mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
chore: normalize line endings
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export async function GET() {
|
||||
return new Response("OK", { status: 200 });
|
||||
}
|
||||
export async function GET() {
|
||||
return new Response("OK", { status: 200 });
|
||||
}
|
||||
|
||||
@@ -1,280 +1,280 @@
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
q: z.string().max(500, "Query too long").optional(),
|
||||
type: z.enum(["songs", "effects"]).optional(),
|
||||
page: z.coerce.number().int().min(1).max(1000).default(1),
|
||||
page_size: z.coerce.number().int().min(1).max(150).default(20),
|
||||
sort: z
|
||||
.enum(["downloads", "rating", "created", "score"])
|
||||
.default("downloads"),
|
||||
min_rating: z.coerce.number().min(0).max(5).default(3),
|
||||
commercial_only: z.coerce.boolean().default(true),
|
||||
});
|
||||
|
||||
const freesoundResultSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string().url(),
|
||||
previews: z
|
||||
.object({
|
||||
"preview-hq-mp3": z.string().url(),
|
||||
"preview-lq-mp3": z.string().url(),
|
||||
"preview-hq-ogg": z.string().url(),
|
||||
"preview-lq-ogg": z.string().url(),
|
||||
})
|
||||
.optional(),
|
||||
download: z.string().url().optional(),
|
||||
duration: z.number(),
|
||||
filesize: z.number(),
|
||||
type: z.string(),
|
||||
channels: z.number(),
|
||||
bitrate: z.number(),
|
||||
bitdepth: z.number(),
|
||||
samplerate: z.number(),
|
||||
username: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
license: z.string(),
|
||||
created: z.string(),
|
||||
num_downloads: z.number().optional(),
|
||||
avg_rating: z.number().optional(),
|
||||
num_ratings: z.number().optional(),
|
||||
});
|
||||
|
||||
const freesoundResponseSchema = z.object({
|
||||
count: z.number(),
|
||||
next: z.string().url().nullable(),
|
||||
previous: z.string().url().nullable(),
|
||||
results: z.array(freesoundResultSchema),
|
||||
});
|
||||
|
||||
const transformedResultSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string(),
|
||||
previewUrl: z.string().optional(),
|
||||
downloadUrl: z.string().optional(),
|
||||
duration: z.number(),
|
||||
filesize: z.number(),
|
||||
type: z.string(),
|
||||
channels: z.number(),
|
||||
bitrate: z.number(),
|
||||
bitdepth: z.number(),
|
||||
samplerate: z.number(),
|
||||
username: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
license: z.string(),
|
||||
created: z.string(),
|
||||
downloads: z.number().optional(),
|
||||
rating: z.number().optional(),
|
||||
ratingCount: z.number().optional(),
|
||||
});
|
||||
|
||||
const apiResponseSchema = z.object({
|
||||
count: z.number(),
|
||||
next: z.string().nullable(),
|
||||
previous: z.string().nullable(),
|
||||
results: z.array(transformedResultSchema),
|
||||
query: z.string().optional(),
|
||||
type: z.string(),
|
||||
page: z.number(),
|
||||
pageSize: z.number(),
|
||||
sort: z.string(),
|
||||
minRating: z.number().optional(),
|
||||
});
|
||||
|
||||
function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
|
||||
if (!query) return `${sort}_desc`;
|
||||
return sort === "score" ? "score" : `${sort}_desc`;
|
||||
}
|
||||
|
||||
function applyEffectsFilters({
|
||||
params,
|
||||
min_rating,
|
||||
commercial_only,
|
||||
}: {
|
||||
params: URLSearchParams;
|
||||
min_rating: number;
|
||||
commercial_only: boolean;
|
||||
}) {
|
||||
params.append("filter", "duration:[* TO 30.0]");
|
||||
params.append("filter", `avg_rating:[${min_rating} TO *]`);
|
||||
|
||||
if (commercial_only) {
|
||||
params.append(
|
||||
"filter",
|
||||
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
|
||||
);
|
||||
}
|
||||
|
||||
params.append(
|
||||
"filter",
|
||||
"tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
|
||||
);
|
||||
}
|
||||
|
||||
function transformFreesoundResult(
|
||||
result: z.infer<typeof freesoundResultSchema>,
|
||||
) {
|
||||
return {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
description: result.description,
|
||||
url: result.url,
|
||||
previewUrl:
|
||||
result.previews?.["preview-hq-mp3"] ||
|
||||
result.previews?.["preview-lq-mp3"],
|
||||
downloadUrl: result.download,
|
||||
duration: result.duration,
|
||||
filesize: result.filesize,
|
||||
type: result.type,
|
||||
channels: result.channels,
|
||||
bitrate: result.bitrate,
|
||||
bitdepth: result.bitdepth,
|
||||
samplerate: result.samplerate,
|
||||
username: result.username,
|
||||
tags: result.tags,
|
||||
license: result.license,
|
||||
created: result.created,
|
||||
downloads: result.num_downloads || 0,
|
||||
rating: result.avg_rating || 0,
|
||||
ratingCount: result.num_ratings || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { limited } = await checkRateLimit({ request });
|
||||
if (limited) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const validationResult = searchParamsSchema.safeParse({
|
||||
q: searchParams.get("q") || undefined,
|
||||
type: searchParams.get("type") || undefined,
|
||||
page: searchParams.get("page") || undefined,
|
||||
page_size: searchParams.get("page_size") || undefined,
|
||||
sort: searchParams.get("sort") || undefined,
|
||||
min_rating: searchParams.get("min_rating") || undefined,
|
||||
});
|
||||
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid parameters",
|
||||
details: validationResult.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
q: query,
|
||||
type,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
sort,
|
||||
min_rating,
|
||||
commercial_only,
|
||||
} = validationResult.data;
|
||||
|
||||
if (type === "songs") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Songs are not available yet",
|
||||
message:
|
||||
"Song search functionality is coming soon. Try searching for sound effects instead.",
|
||||
},
|
||||
{ status: 501 },
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = "https://freesound.org/apiv2/search/text/";
|
||||
|
||||
const sortParam = buildSortParameter({ query, sort });
|
||||
|
||||
const params = new URLSearchParams({
|
||||
query: query || "",
|
||||
token: webEnv.FREESOUND_API_KEY,
|
||||
page: page.toString(),
|
||||
page_size: pageSize.toString(),
|
||||
sort: sortParam,
|
||||
fields:
|
||||
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
|
||||
});
|
||||
|
||||
const isEffectsSearch = type === "effects" || !type;
|
||||
if (isEffectsSearch) {
|
||||
applyEffectsFilters({ params, min_rating, commercial_only });
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("Freesound API error:", response.status, errorText);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to search sounds" },
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
|
||||
const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
|
||||
if (!freesoundValidation.success) {
|
||||
console.error(
|
||||
"Invalid Freesound API response:",
|
||||
freesoundValidation.error,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid response from Freesound API" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const data = freesoundValidation.data;
|
||||
|
||||
const transformedResults = data.results.map(transformFreesoundResult);
|
||||
|
||||
const responseData = {
|
||||
count: data.count,
|
||||
next: data.next,
|
||||
previous: data.previous,
|
||||
results: transformedResults,
|
||||
query: query || "",
|
||||
type: type || "effects",
|
||||
page,
|
||||
pageSize,
|
||||
sort,
|
||||
minRating: min_rating,
|
||||
};
|
||||
|
||||
const responseValidation = apiResponseSchema.safeParse(responseData);
|
||||
if (!responseValidation.success) {
|
||||
console.error(
|
||||
"Invalid API response structure:",
|
||||
responseValidation.error,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal response formatting error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(responseValidation.data);
|
||||
} catch (error) {
|
||||
console.error("Error searching sounds:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
q: z.string().max(500, "Query too long").optional(),
|
||||
type: z.enum(["songs", "effects"]).optional(),
|
||||
page: z.coerce.number().int().min(1).max(1000).default(1),
|
||||
page_size: z.coerce.number().int().min(1).max(150).default(20),
|
||||
sort: z
|
||||
.enum(["downloads", "rating", "created", "score"])
|
||||
.default("downloads"),
|
||||
min_rating: z.coerce.number().min(0).max(5).default(3),
|
||||
commercial_only: z.coerce.boolean().default(true),
|
||||
});
|
||||
|
||||
const freesoundResultSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string().url(),
|
||||
previews: z
|
||||
.object({
|
||||
"preview-hq-mp3": z.string().url(),
|
||||
"preview-lq-mp3": z.string().url(),
|
||||
"preview-hq-ogg": z.string().url(),
|
||||
"preview-lq-ogg": z.string().url(),
|
||||
})
|
||||
.optional(),
|
||||
download: z.string().url().optional(),
|
||||
duration: z.number(),
|
||||
filesize: z.number(),
|
||||
type: z.string(),
|
||||
channels: z.number(),
|
||||
bitrate: z.number(),
|
||||
bitdepth: z.number(),
|
||||
samplerate: z.number(),
|
||||
username: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
license: z.string(),
|
||||
created: z.string(),
|
||||
num_downloads: z.number().optional(),
|
||||
avg_rating: z.number().optional(),
|
||||
num_ratings: z.number().optional(),
|
||||
});
|
||||
|
||||
const freesoundResponseSchema = z.object({
|
||||
count: z.number(),
|
||||
next: z.string().url().nullable(),
|
||||
previous: z.string().url().nullable(),
|
||||
results: z.array(freesoundResultSchema),
|
||||
});
|
||||
|
||||
const transformedResultSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string(),
|
||||
previewUrl: z.string().optional(),
|
||||
downloadUrl: z.string().optional(),
|
||||
duration: z.number(),
|
||||
filesize: z.number(),
|
||||
type: z.string(),
|
||||
channels: z.number(),
|
||||
bitrate: z.number(),
|
||||
bitdepth: z.number(),
|
||||
samplerate: z.number(),
|
||||
username: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
license: z.string(),
|
||||
created: z.string(),
|
||||
downloads: z.number().optional(),
|
||||
rating: z.number().optional(),
|
||||
ratingCount: z.number().optional(),
|
||||
});
|
||||
|
||||
const apiResponseSchema = z.object({
|
||||
count: z.number(),
|
||||
next: z.string().nullable(),
|
||||
previous: z.string().nullable(),
|
||||
results: z.array(transformedResultSchema),
|
||||
query: z.string().optional(),
|
||||
type: z.string(),
|
||||
page: z.number(),
|
||||
pageSize: z.number(),
|
||||
sort: z.string(),
|
||||
minRating: z.number().optional(),
|
||||
});
|
||||
|
||||
function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
|
||||
if (!query) return `${sort}_desc`;
|
||||
return sort === "score" ? "score" : `${sort}_desc`;
|
||||
}
|
||||
|
||||
function applyEffectsFilters({
|
||||
params,
|
||||
min_rating,
|
||||
commercial_only,
|
||||
}: {
|
||||
params: URLSearchParams;
|
||||
min_rating: number;
|
||||
commercial_only: boolean;
|
||||
}) {
|
||||
params.append("filter", "duration:[* TO 30.0]");
|
||||
params.append("filter", `avg_rating:[${min_rating} TO *]`);
|
||||
|
||||
if (commercial_only) {
|
||||
params.append(
|
||||
"filter",
|
||||
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
|
||||
);
|
||||
}
|
||||
|
||||
params.append(
|
||||
"filter",
|
||||
"tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
|
||||
);
|
||||
}
|
||||
|
||||
function transformFreesoundResult(
|
||||
result: z.infer<typeof freesoundResultSchema>,
|
||||
) {
|
||||
return {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
description: result.description,
|
||||
url: result.url,
|
||||
previewUrl:
|
||||
result.previews?.["preview-hq-mp3"] ||
|
||||
result.previews?.["preview-lq-mp3"],
|
||||
downloadUrl: result.download,
|
||||
duration: result.duration,
|
||||
filesize: result.filesize,
|
||||
type: result.type,
|
||||
channels: result.channels,
|
||||
bitrate: result.bitrate,
|
||||
bitdepth: result.bitdepth,
|
||||
samplerate: result.samplerate,
|
||||
username: result.username,
|
||||
tags: result.tags,
|
||||
license: result.license,
|
||||
created: result.created,
|
||||
downloads: result.num_downloads || 0,
|
||||
rating: result.avg_rating || 0,
|
||||
ratingCount: result.num_ratings || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { limited } = await checkRateLimit({ request });
|
||||
if (limited) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const validationResult = searchParamsSchema.safeParse({
|
||||
q: searchParams.get("q") || undefined,
|
||||
type: searchParams.get("type") || undefined,
|
||||
page: searchParams.get("page") || undefined,
|
||||
page_size: searchParams.get("page_size") || undefined,
|
||||
sort: searchParams.get("sort") || undefined,
|
||||
min_rating: searchParams.get("min_rating") || undefined,
|
||||
});
|
||||
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid parameters",
|
||||
details: validationResult.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
q: query,
|
||||
type,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
sort,
|
||||
min_rating,
|
||||
commercial_only,
|
||||
} = validationResult.data;
|
||||
|
||||
if (type === "songs") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Songs are not available yet",
|
||||
message:
|
||||
"Song search functionality is coming soon. Try searching for sound effects instead.",
|
||||
},
|
||||
{ status: 501 },
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = "https://freesound.org/apiv2/search/text/";
|
||||
|
||||
const sortParam = buildSortParameter({ query, sort });
|
||||
|
||||
const params = new URLSearchParams({
|
||||
query: query || "",
|
||||
token: webEnv.FREESOUND_API_KEY,
|
||||
page: page.toString(),
|
||||
page_size: pageSize.toString(),
|
||||
sort: sortParam,
|
||||
fields:
|
||||
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
|
||||
});
|
||||
|
||||
const isEffectsSearch = type === "effects" || !type;
|
||||
if (isEffectsSearch) {
|
||||
applyEffectsFilters({ params, min_rating, commercial_only });
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("Freesound API error:", response.status, errorText);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to search sounds" },
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
|
||||
const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
|
||||
if (!freesoundValidation.success) {
|
||||
console.error(
|
||||
"Invalid Freesound API response:",
|
||||
freesoundValidation.error,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid response from Freesound API" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const data = freesoundValidation.data;
|
||||
|
||||
const transformedResults = data.results.map(transformFreesoundResult);
|
||||
|
||||
const responseData = {
|
||||
count: data.count,
|
||||
next: data.next,
|
||||
previous: data.previous,
|
||||
results: transformedResults,
|
||||
query: query || "",
|
||||
type: type || "effects",
|
||||
page,
|
||||
pageSize,
|
||||
sort,
|
||||
minRating: min_rating,
|
||||
};
|
||||
|
||||
const responseValidation = apiResponseSchema.safeParse(responseData);
|
||||
if (!responseValidation.success) {
|
||||
console.error(
|
||||
"Invalid API response structure:",
|
||||
responseValidation.error,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal response formatting error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(responseValidation.data);
|
||||
} catch (error) {
|
||||
console.error("Error searching sounds:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
interface BasePageProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
mainClassName?: string;
|
||||
maxWidth?: "3xl" | "6xl" | "full";
|
||||
title?: string;
|
||||
description?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function BasePage({
|
||||
children,
|
||||
className = "",
|
||||
mainClassName = "",
|
||||
maxWidth = "3xl",
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: BasePageProps) {
|
||||
const maxWidthClass = {
|
||||
"3xl": "max-w-3xl",
|
||||
"6xl": "max-w-6xl",
|
||||
full: "max-w-full",
|
||||
}[maxWidth];
|
||||
|
||||
return (
|
||||
<section className={cn("bg-background min-h-screen", className)}>
|
||||
<Header />
|
||||
<main
|
||||
className={cn(
|
||||
"relative container mx-auto flex flex-col gap-12 px-6 pt-12 pb-24 md:pt-24",
|
||||
maxWidthClass,
|
||||
mainClassName,
|
||||
)}
|
||||
>
|
||||
{title && description && (
|
||||
<div className="flex flex-col gap-8 text-center">
|
||||
<h1 className="text-5xl font-bold tracking-tight md:text-6xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
interface BasePageProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
mainClassName?: string;
|
||||
maxWidth?: "3xl" | "6xl" | "full";
|
||||
title?: string;
|
||||
description?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function BasePage({
|
||||
children,
|
||||
className = "",
|
||||
mainClassName = "",
|
||||
maxWidth = "3xl",
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: BasePageProps) {
|
||||
const maxWidthClass = {
|
||||
"3xl": "max-w-3xl",
|
||||
"6xl": "max-w-6xl",
|
||||
full: "max-w-full",
|
||||
}[maxWidth];
|
||||
|
||||
return (
|
||||
<section className={cn("bg-background min-h-screen", className)}>
|
||||
<Header />
|
||||
<main
|
||||
className={cn(
|
||||
"relative container mx-auto flex flex-col gap-12 px-6 pt-12 pb-24 md:pt-24",
|
||||
maxWidthClass,
|
||||
mainClassName,
|
||||
)}
|
||||
>
|
||||
{title && description && (
|
||||
<div className="flex flex-col gap-8 text-center">
|
||||
<h1 className="text-5xl font-bold tracking-tight md:text-6xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,135 +1,135 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import Prose from "@/components/ui/prose";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
|
||||
import type { Author, Post } from "@/lib/blog/types";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: PageProps): Promise<Metadata> {
|
||||
const slug = (await params).slug;
|
||||
|
||||
const data = await getSinglePost({ slug });
|
||||
|
||||
if (!data || !data.post) return {};
|
||||
|
||||
return {
|
||||
title: data.post.title,
|
||||
description: data.post.description,
|
||||
twitter: {
|
||||
title: `${data.post.title}`,
|
||||
description: `${data.post.description}`,
|
||||
card: "summary_large_image",
|
||||
images: [
|
||||
{
|
||||
url: data.post.coverImage,
|
||||
width: "1200",
|
||||
height: "630",
|
||||
alt: data.post.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
openGraph: {
|
||||
type: "article",
|
||||
images: [
|
||||
{
|
||||
url: data.post.coverImage,
|
||||
width: "1200",
|
||||
height: "630",
|
||||
alt: data.post.title,
|
||||
},
|
||||
],
|
||||
title: data.post.title,
|
||||
description: data.post.description,
|
||||
publishedTime: new Date(data.post.publishedAt).toISOString(),
|
||||
authors: data.post.authors.map((author: Author) => author.name),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const data = await getPosts();
|
||||
if (!data || !data.posts.length) return [];
|
||||
|
||||
return data.posts.map((post) => ({
|
||||
slug: post.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: PageProps) {
|
||||
const slug = (await params).slug;
|
||||
const data = await getSinglePost({ slug });
|
||||
if (!data || !data.post) return notFound();
|
||||
|
||||
const html = await processHtmlContent({ html: data.post.content });
|
||||
|
||||
return (
|
||||
<BasePage>
|
||||
<PostHeader post={data.post} />
|
||||
<Separator />
|
||||
<PostContent html={html} />
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function PostHeader({ post }: { post: Post }) {
|
||||
const formattedDate = new Date(post.publishedAt).toLocaleDateString("en-US", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-8">
|
||||
<PostMeta date={formattedDate} publishedAt={post.publishedAt} />
|
||||
<PostTitle title={post.title} />
|
||||
{post.coverImage && <PostCoverImage post={post} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostCoverImage({ post }: { post: Post }) {
|
||||
return (
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg w-full mt-4">
|
||||
<Image
|
||||
src={post.coverImage}
|
||||
alt={post.title}
|
||||
loading="eager"
|
||||
fill
|
||||
className="rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<time dateTime={publishedAt.toString()}>{date}</time>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostTitle({ title }: { title: string }) {
|
||||
return (
|
||||
<h1 className="text-5xl font-bold tracking-tight md:text-4xl text-center">
|
||||
{title}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
function PostContent({ html }: { html: string }) {
|
||||
return (
|
||||
<section className="">
|
||||
<Prose html={html} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import Prose from "@/components/ui/prose";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
|
||||
import type { Author, Post } from "@/lib/blog/types";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: PageProps): Promise<Metadata> {
|
||||
const slug = (await params).slug;
|
||||
|
||||
const data = await getSinglePost({ slug });
|
||||
|
||||
if (!data || !data.post) return {};
|
||||
|
||||
return {
|
||||
title: data.post.title,
|
||||
description: data.post.description,
|
||||
twitter: {
|
||||
title: `${data.post.title}`,
|
||||
description: `${data.post.description}`,
|
||||
card: "summary_large_image",
|
||||
images: [
|
||||
{
|
||||
url: data.post.coverImage,
|
||||
width: "1200",
|
||||
height: "630",
|
||||
alt: data.post.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
openGraph: {
|
||||
type: "article",
|
||||
images: [
|
||||
{
|
||||
url: data.post.coverImage,
|
||||
width: "1200",
|
||||
height: "630",
|
||||
alt: data.post.title,
|
||||
},
|
||||
],
|
||||
title: data.post.title,
|
||||
description: data.post.description,
|
||||
publishedTime: new Date(data.post.publishedAt).toISOString(),
|
||||
authors: data.post.authors.map((author: Author) => author.name),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const data = await getPosts();
|
||||
if (!data || !data.posts.length) return [];
|
||||
|
||||
return data.posts.map((post) => ({
|
||||
slug: post.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: PageProps) {
|
||||
const slug = (await params).slug;
|
||||
const data = await getSinglePost({ slug });
|
||||
if (!data || !data.post) return notFound();
|
||||
|
||||
const html = await processHtmlContent({ html: data.post.content });
|
||||
|
||||
return (
|
||||
<BasePage>
|
||||
<PostHeader post={data.post} />
|
||||
<Separator />
|
||||
<PostContent html={html} />
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function PostHeader({ post }: { post: Post }) {
|
||||
const formattedDate = new Date(post.publishedAt).toLocaleDateString("en-US", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-8">
|
||||
<PostMeta date={formattedDate} publishedAt={post.publishedAt} />
|
||||
<PostTitle title={post.title} />
|
||||
{post.coverImage && <PostCoverImage post={post} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostCoverImage({ post }: { post: Post }) {
|
||||
return (
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg w-full mt-4">
|
||||
<Image
|
||||
src={post.coverImage}
|
||||
alt={post.title}
|
||||
loading="eager"
|
||||
fill
|
||||
className="rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<time dateTime={publishedAt.toString()}>{date}</time>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostTitle({ title }: { title: string }) {
|
||||
return (
|
||||
<h1 className="text-5xl font-bold tracking-tight md:text-4xl text-center">
|
||||
{title}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
function PostContent({ html }: { html: string }) {
|
||||
return (
|
||||
<section className="">
|
||||
<Prose html={html} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import type { Post } from "@/lib/blog/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog - OpenCut",
|
||||
description:
|
||||
"Read the latest news and updates about OpenCut, the free and open-source video editor.",
|
||||
openGraph: {
|
||||
title: "Blog - OpenCut",
|
||||
description:
|
||||
"Read the latest news and updates about OpenCut, the free and open-source video editor.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default async function BlogPage() {
|
||||
const data = await getPosts().catch(() => null);
|
||||
if (!data || !data.posts) return <div>No posts yet</div>;
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
title="Blog"
|
||||
description="Read the latest news and updates about OpenCut, the free and open-source video editor."
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{data.posts.map((post) => (
|
||||
<div key={post.id} className="flex flex-col">
|
||||
<BlogPostItem post={post} />
|
||||
<Separator />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function BlogPostItem({ post }: { post: Post }) {
|
||||
return (
|
||||
<Link href={`/blog/${post.slug}`}>
|
||||
<div className="flex h-auto w-full items-center justify-between py-6 opacity-100 hover:opacity-75">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xl font-semibold">{post.title}</h2>
|
||||
<p className="text-muted-foreground">{post.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import type { Post } from "@/lib/blog/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog - OpenCut",
|
||||
description:
|
||||
"Read the latest news and updates about OpenCut, the free and open-source video editor.",
|
||||
openGraph: {
|
||||
title: "Blog - OpenCut",
|
||||
description:
|
||||
"Read the latest news and updates about OpenCut, the free and open-source video editor.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default async function BlogPage() {
|
||||
const data = await getPosts().catch(() => null);
|
||||
if (!data || !data.posts) return <div>No posts yet</div>;
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
title="Blog"
|
||||
description="Read the latest news and updates about OpenCut, the free and open-source video editor."
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{data.posts.map((post) => (
|
||||
<div key={post.id} className="flex flex-col">
|
||||
<BlogPostItem post={post} />
|
||||
<Separator />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function BlogPostItem({ post }: { post: Post }) {
|
||||
return (
|
||||
<Link href={`/blog/${post.slug}`}>
|
||||
<div className="flex h-auto w-full items-center justify-between py-6 opacity-100 hover:opacity-75">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xl font-semibold">{post.title}</h2>
|
||||
<p className="text-muted-foreground">{post.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
+268
-268
@@ -1,268 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Check, Copy, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
function downloadAsset(src: string) {
|
||||
const filename = src.split("/").pop() ?? "asset.svg";
|
||||
const a = document.createElement("a");
|
||||
a.href = src;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function copyAsset(src: string) {
|
||||
const res = await fetch(src);
|
||||
const text = await res.text();
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
|
||||
|
||||
type AssetTheme = "dark" | "light" | "icon";
|
||||
|
||||
interface AssetVariant {
|
||||
src: string;
|
||||
theme: AssetTheme;
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface AssetSection {
|
||||
title: string;
|
||||
description: string;
|
||||
cols: "1" | "2";
|
||||
assets: AssetVariant[];
|
||||
}
|
||||
|
||||
const ASSET_SECTIONS: AssetSection[] = [
|
||||
{
|
||||
title: "Symbol",
|
||||
description:
|
||||
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/symbol.svg",
|
||||
theme: "dark",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/symbol-light.svg",
|
||||
theme: "light",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Lockup",
|
||||
description:
|
||||
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/logo.svg",
|
||||
theme: "dark",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/logo-light.svg",
|
||||
theme: "light",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text.svg",
|
||||
theme: "dark",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text-light.svg",
|
||||
theme: "light",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function BrandPage() {
|
||||
return (
|
||||
<BasePage
|
||||
maxWidth="6xl"
|
||||
title="Brand"
|
||||
description={
|
||||
<>
|
||||
Download OpenCut brand assets for use in your projects.{" "}
|
||||
<Link
|
||||
href="#guidelines"
|
||||
className="underline underline-offset-4"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("guidelines")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
Read the brand guidelines.
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="mx-auto gap-2"
|
||||
onClick={() => {
|
||||
ALL_ASSETS().forEach((asset, i) => {
|
||||
setTimeout(() => downloadAsset(asset.src), i * 200);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Download />
|
||||
Download all
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-10">
|
||||
{ASSET_SECTIONS.map((section) => (
|
||||
<div key={section.title} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="font-semibold text-lg">{section.title}</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3",
|
||||
section.cols === "2"
|
||||
? "grid-cols-1 sm:grid-cols-2"
|
||||
: "grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{section.assets.map((variant) => (
|
||||
<AssetCard key={variant.src} variant={variant} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div id="guidelines" className="flex flex-col gap-8 text-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">Usage</h2>
|
||||
<p className="text-muted-foreground text-base leading-relaxed">
|
||||
OpenCut is open source — the code is free to use under its license.
|
||||
That license does not cover the name or logo. You can say you use
|
||||
OpenCut, that your project integrates with OpenCut, or that it was
|
||||
built on top of OpenCut. You cannot name your product OpenCut, imply
|
||||
we made or endorse your product, or use the marks commercially
|
||||
without asking first. For anything unclear, reach out at{" "}
|
||||
<Link
|
||||
href="mailto:brand@opencut.app"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
brand@opencut.app
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">What's not allowed</h2>
|
||||
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
|
||||
{[
|
||||
"Using OpenCut in the name of your product, service, or domain.",
|
||||
"Implying that OpenCut made, sponsors, or endorses your work.",
|
||||
"Using the logo or name on merchandise or commercial marketing.",
|
||||
"Modifying the marks.",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="mt-0.5 shrink-0">-</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
|
||||
light: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#000",
|
||||
},
|
||||
dark: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
};
|
||||
|
||||
function AssetCard({ variant }: { variant: AssetVariant }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
await copyAsset(variant.src);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="group relative overflow-hidden"
|
||||
style={
|
||||
variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
|
||||
}
|
||||
>
|
||||
<div className="flex h-56 items-center justify-center px-12 py-8">
|
||||
<Image
|
||||
src={variant.src}
|
||||
alt={variant.label}
|
||||
width={variant.width}
|
||||
height={variant.height}
|
||||
className="max-h-16 w-auto select-none object-contain"
|
||||
draggable={false}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Check, Copy, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
function downloadAsset(src: string) {
|
||||
const filename = src.split("/").pop() ?? "asset.svg";
|
||||
const a = document.createElement("a");
|
||||
a.href = src;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function copyAsset(src: string) {
|
||||
const res = await fetch(src);
|
||||
const text = await res.text();
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
|
||||
|
||||
type AssetTheme = "dark" | "light" | "icon";
|
||||
|
||||
interface AssetVariant {
|
||||
src: string;
|
||||
theme: AssetTheme;
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface AssetSection {
|
||||
title: string;
|
||||
description: string;
|
||||
cols: "1" | "2";
|
||||
assets: AssetVariant[];
|
||||
}
|
||||
|
||||
const ASSET_SECTIONS: AssetSection[] = [
|
||||
{
|
||||
title: "Symbol",
|
||||
description:
|
||||
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/symbol.svg",
|
||||
theme: "dark",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/symbol-light.svg",
|
||||
theme: "light",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Lockup",
|
||||
description:
|
||||
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/logo.svg",
|
||||
theme: "dark",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/logo-light.svg",
|
||||
theme: "light",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text.svg",
|
||||
theme: "dark",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text-light.svg",
|
||||
theme: "light",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function BrandPage() {
|
||||
return (
|
||||
<BasePage
|
||||
maxWidth="6xl"
|
||||
title="Brand"
|
||||
description={
|
||||
<>
|
||||
Download OpenCut brand assets for use in your projects.{" "}
|
||||
<Link
|
||||
href="#guidelines"
|
||||
className="underline underline-offset-4"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("guidelines")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
Read the brand guidelines.
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="mx-auto gap-2"
|
||||
onClick={() => {
|
||||
ALL_ASSETS().forEach((asset, i) => {
|
||||
setTimeout(() => downloadAsset(asset.src), i * 200);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Download />
|
||||
Download all
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-10">
|
||||
{ASSET_SECTIONS.map((section) => (
|
||||
<div key={section.title} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="font-semibold text-lg">{section.title}</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3",
|
||||
section.cols === "2"
|
||||
? "grid-cols-1 sm:grid-cols-2"
|
||||
: "grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{section.assets.map((variant) => (
|
||||
<AssetCard key={variant.src} variant={variant} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div id="guidelines" className="flex flex-col gap-8 text-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">Usage</h2>
|
||||
<p className="text-muted-foreground text-base leading-relaxed">
|
||||
OpenCut is open source — the code is free to use under its license.
|
||||
That license does not cover the name or logo. You can say you use
|
||||
OpenCut, that your project integrates with OpenCut, or that it was
|
||||
built on top of OpenCut. You cannot name your product OpenCut, imply
|
||||
we made or endorse your product, or use the marks commercially
|
||||
without asking first. For anything unclear, reach out at{" "}
|
||||
<Link
|
||||
href="mailto:brand@opencut.app"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
brand@opencut.app
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">What's not allowed</h2>
|
||||
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
|
||||
{[
|
||||
"Using OpenCut in the name of your product, service, or domain.",
|
||||
"Implying that OpenCut made, sponsors, or endorses your work.",
|
||||
"Using the logo or name on merchandise or commercial marketing.",
|
||||
"Modifying the marks.",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="mt-0.5 shrink-0">-</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
|
||||
light: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#000",
|
||||
},
|
||||
dark: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
};
|
||||
|
||||
function AssetCard({ variant }: { variant: AssetVariant }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
await copyAsset(variant.src);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="group relative overflow-hidden"
|
||||
style={
|
||||
variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
|
||||
}
|
||||
>
|
||||
<div className="flex h-56 items-center justify-center px-12 py-8">
|
||||
<Image
|
||||
src={variant.src}
|
||||
alt={variant.label}
|
||||
width={variant.width}
|
||||
height={variant.height}
|
||||
className="max-h-16 w-auto select-none object-contain"
|
||||
draggable={false}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,105 +1,105 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { allChangelogs } from "content-collections";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { getSortedReleases } from "../utils";
|
||||
import {
|
||||
ReleaseArticle,
|
||||
ReleaseMeta,
|
||||
ReleaseTitle,
|
||||
ReleaseDescription,
|
||||
ReleaseChanges,
|
||||
} from "../components/release";
|
||||
import { CopyMarkdownButton } from "../components/copy-markdown-button";
|
||||
|
||||
type Props = { params: Promise<{ version: string }> };
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return allChangelogs.map((r) => ({ version: r.version }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { version } = await params;
|
||||
const release = allChangelogs.find((r) => r.version === version);
|
||||
if (!release) return {};
|
||||
return {
|
||||
title: `${release.title} (${release.version}) - OpenCut Changelog`,
|
||||
description: release.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ReleaseDetailPage({ params }: Props) {
|
||||
const { version } = await params;
|
||||
const releases = getSortedReleases();
|
||||
const index = releases.findIndex((r) => r.version === version);
|
||||
|
||||
if (index === -1) notFound();
|
||||
|
||||
const release = releases[index];
|
||||
const newer = index > 0 ? releases[index - 1] : null;
|
||||
const older = index < releases.length - 1 ? releases[index + 1] : null;
|
||||
|
||||
return (
|
||||
<BasePage>
|
||||
<div className="mx-auto w-full max-w-3xl flex flex-col gap-12">
|
||||
<Link
|
||||
href="/changelog"
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 w-fit"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
All releases
|
||||
</Link>
|
||||
|
||||
<ReleaseArticle variant="detail">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<ReleaseMeta release={release} />
|
||||
<CopyMarkdownButton
|
||||
description={release.description}
|
||||
changes={release.changes}
|
||||
/>
|
||||
</div>
|
||||
<ReleaseTitle as="h1">{release.title}</ReleaseTitle>
|
||||
{release.description && (
|
||||
<ReleaseDescription>{release.description}</ReleaseDescription>
|
||||
)}
|
||||
</div>
|
||||
<ReleaseChanges release={release} />
|
||||
</ReleaseArticle>
|
||||
|
||||
<nav className="flex items-center justify-between border-t pt-8">
|
||||
{older ? (
|
||||
<Link
|
||||
href={`/changelog/${older.version}`}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground/60">Older</span>
|
||||
<span className="font-medium">{older.title}</span>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{newer ? (
|
||||
<Link
|
||||
href={`/changelog/${newer.version}`}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group text-right"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground/60">Newer</span>
|
||||
<span className="font-medium">{newer.title}</span>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { allChangelogs } from "content-collections";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { getSortedReleases } from "../utils";
|
||||
import {
|
||||
ReleaseArticle,
|
||||
ReleaseMeta,
|
||||
ReleaseTitle,
|
||||
ReleaseDescription,
|
||||
ReleaseChanges,
|
||||
} from "../components/release";
|
||||
import { CopyMarkdownButton } from "../components/copy-markdown-button";
|
||||
|
||||
type Props = { params: Promise<{ version: string }> };
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return allChangelogs.map((r) => ({ version: r.version }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { version } = await params;
|
||||
const release = allChangelogs.find((r) => r.version === version);
|
||||
if (!release) return {};
|
||||
return {
|
||||
title: `${release.title} (${release.version}) - OpenCut Changelog`,
|
||||
description: release.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ReleaseDetailPage({ params }: Props) {
|
||||
const { version } = await params;
|
||||
const releases = getSortedReleases();
|
||||
const index = releases.findIndex((r) => r.version === version);
|
||||
|
||||
if (index === -1) notFound();
|
||||
|
||||
const release = releases[index];
|
||||
const newer = index > 0 ? releases[index - 1] : null;
|
||||
const older = index < releases.length - 1 ? releases[index + 1] : null;
|
||||
|
||||
return (
|
||||
<BasePage>
|
||||
<div className="mx-auto w-full max-w-3xl flex flex-col gap-12">
|
||||
<Link
|
||||
href="/changelog"
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 w-fit"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
All releases
|
||||
</Link>
|
||||
|
||||
<ReleaseArticle variant="detail">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<ReleaseMeta release={release} />
|
||||
<CopyMarkdownButton
|
||||
description={release.description}
|
||||
changes={release.changes}
|
||||
/>
|
||||
</div>
|
||||
<ReleaseTitle as="h1">{release.title}</ReleaseTitle>
|
||||
{release.description && (
|
||||
<ReleaseDescription>{release.description}</ReleaseDescription>
|
||||
)}
|
||||
</div>
|
||||
<ReleaseChanges release={release} />
|
||||
</ReleaseArticle>
|
||||
|
||||
<nav className="flex items-center justify-between border-t pt-8">
|
||||
{older ? (
|
||||
<Link
|
||||
href={`/changelog/${older.version}`}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground/60">Older</span>
|
||||
<span className="font-medium">{older.title}</span>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{newer ? (
|
||||
<Link
|
||||
href={`/changelog/${newer.version}`}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group text-right"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground/60">Newer</span>
|
||||
<span className="font-medium">{newer.title}</span>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckIcon, ClipboardIcon } from "lucide-react";
|
||||
import { getSectionTitle, groupAndOrderChanges } from "../utils";
|
||||
import type { Change } from "../utils";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function buildMarkdown({
|
||||
description,
|
||||
changes,
|
||||
}: {
|
||||
description?: string;
|
||||
changes: Change[];
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (description) {
|
||||
lines.push(description, "");
|
||||
}
|
||||
|
||||
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
|
||||
|
||||
for (const type of orderedTypes) {
|
||||
lines.push(`## ${getSectionTitle(type)}`);
|
||||
for (const change of grouped[type]) {
|
||||
lines.push(`- ${change.text}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n").trimEnd();
|
||||
}
|
||||
|
||||
export function CopyMarkdownButton({
|
||||
description,
|
||||
changes,
|
||||
}: {
|
||||
description?: string;
|
||||
changes: Change[];
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const markdown = buildMarkdown({ description, changes });
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
onClick={handleCopy}
|
||||
className={cn("flex items-center gap-1.5", copied && "pointer-events-none")}
|
||||
title="Copy as markdown"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-4" />
|
||||
)}
|
||||
{copied ? "Copied!" : "Copy markdown"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckIcon, ClipboardIcon } from "lucide-react";
|
||||
import { getSectionTitle, groupAndOrderChanges } from "../utils";
|
||||
import type { Change } from "../utils";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function buildMarkdown({
|
||||
description,
|
||||
changes,
|
||||
}: {
|
||||
description?: string;
|
||||
changes: Change[];
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (description) {
|
||||
lines.push(description, "");
|
||||
}
|
||||
|
||||
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
|
||||
|
||||
for (const type of orderedTypes) {
|
||||
lines.push(`## ${getSectionTitle(type)}`);
|
||||
for (const change of grouped[type]) {
|
||||
lines.push(`- ${change.text}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n").trimEnd();
|
||||
}
|
||||
|
||||
export function CopyMarkdownButton({
|
||||
description,
|
||||
changes,
|
||||
}: {
|
||||
description?: string;
|
||||
changes: Change[];
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const markdown = buildMarkdown({ description, changes });
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
onClick={handleCopy}
|
||||
className={cn("flex items-center gap-1.5", copied && "pointer-events-none")}
|
||||
title="Copy as markdown"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-4" />
|
||||
)}
|
||||
{copied ? "Copied!" : "Copy markdown"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,106 +1,106 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { getSectionTitle, groupAndOrderChanges } from "../utils";
|
||||
import type { Release } from "../utils";
|
||||
|
||||
export function ReleaseArticle({
|
||||
variant,
|
||||
isLatest,
|
||||
children,
|
||||
}: {
|
||||
variant: "list" | "detail";
|
||||
isLatest?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<article className="relative sm:pl-10">
|
||||
<div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
|
||||
<div
|
||||
className={cn(
|
||||
"size-[11px] rounded-full border-[1.5px]",
|
||||
isLatest
|
||||
? "border-foreground bg-foreground"
|
||||
: "border-muted-foreground/30 bg-background",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">{children}</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return <article className="flex flex-col gap-8">{children}</article>;
|
||||
}
|
||||
|
||||
export function ReleaseMeta({ release }: { release: Release }) {
|
||||
return (
|
||||
<span className="text-sm font-medium tracking-widest text-muted-foreground">
|
||||
{release.version} — {release.date}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const titleSizes: Record<"h1" | "h2", string> = {
|
||||
h1: "text-4xl",
|
||||
h2: "text-2xl",
|
||||
};
|
||||
|
||||
export function ReleaseTitle({
|
||||
as: As,
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
as: "h1" | "h2";
|
||||
href?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<As className={cn("font-bold tracking-tight", titleSizes[As])}>
|
||||
{href ? (
|
||||
<Link href={href} className="hover:underline underline-offset-4">
|
||||
{children}
|
||||
</Link>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</As>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseDescription({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="text-base text-foreground leading-relaxed max-w-xl">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseChanges({ release }: { release: Release }) {
|
||||
const { grouped, orderedTypes } = groupAndOrderChanges({
|
||||
changes: release.changes,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{orderedTypes.map((type) => (
|
||||
<div key={type} className="flex flex-col gap-1.5">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{getSectionTitle(type)}:
|
||||
</h3>
|
||||
<ul className="list-disc pl-5 space-y-1.5">
|
||||
{grouped[type].map((change) => (
|
||||
<li
|
||||
key={change.text}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
>
|
||||
{change.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { getSectionTitle, groupAndOrderChanges } from "../utils";
|
||||
import type { Release } from "../utils";
|
||||
|
||||
export function ReleaseArticle({
|
||||
variant,
|
||||
isLatest,
|
||||
children,
|
||||
}: {
|
||||
variant: "list" | "detail";
|
||||
isLatest?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<article className="relative sm:pl-10">
|
||||
<div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
|
||||
<div
|
||||
className={cn(
|
||||
"size-[11px] rounded-full border-[1.5px]",
|
||||
isLatest
|
||||
? "border-foreground bg-foreground"
|
||||
: "border-muted-foreground/30 bg-background",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">{children}</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return <article className="flex flex-col gap-8">{children}</article>;
|
||||
}
|
||||
|
||||
export function ReleaseMeta({ release }: { release: Release }) {
|
||||
return (
|
||||
<span className="text-sm font-medium tracking-widest text-muted-foreground">
|
||||
{release.version} — {release.date}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const titleSizes: Record<"h1" | "h2", string> = {
|
||||
h1: "text-4xl",
|
||||
h2: "text-2xl",
|
||||
};
|
||||
|
||||
export function ReleaseTitle({
|
||||
as: As,
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
as: "h1" | "h2";
|
||||
href?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<As className={cn("font-bold tracking-tight", titleSizes[As])}>
|
||||
{href ? (
|
||||
<Link href={href} className="hover:underline underline-offset-4">
|
||||
{children}
|
||||
</Link>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</As>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseDescription({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="text-base text-foreground leading-relaxed max-w-xl">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseChanges({ release }: { release: Release }) {
|
||||
const { grouped, orderedTypes } = groupAndOrderChanges({
|
||||
changes: release.changes,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{orderedTypes.map((type) => (
|
||||
<div key={type} className="flex flex-col gap-1.5">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{getSectionTitle(type)}:
|
||||
</h3>
|
||||
<ul className="list-disc pl-5 space-y-1.5">
|
||||
{grouped[type].map((change) => (
|
||||
<li
|
||||
key={change.text}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
>
|
||||
{change.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
import { allChangelogs } from "content-collections";
|
||||
|
||||
export type Change = { type: string; text: string };
|
||||
export type Release = (typeof allChangelogs)[number];
|
||||
|
||||
const knownSectionOrder = ["new", "improved", "fixed", "breaking"];
|
||||
|
||||
const knownSectionTitles: Record<string, string> = {
|
||||
new: "Features",
|
||||
improved: "Improvements",
|
||||
fixed: "Fixes",
|
||||
breaking: "Breaking Changes",
|
||||
};
|
||||
|
||||
export function getSectionTitle(type: string): string {
|
||||
return (
|
||||
knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
|
||||
);
|
||||
}
|
||||
|
||||
export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
|
||||
const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
|
||||
if (!acc[change.type]) acc[change.type] = [];
|
||||
acc[change.type].push(change);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const customTypes = Object.keys(grouped).filter(
|
||||
(type) => !knownSectionOrder.includes(type),
|
||||
);
|
||||
const orderedTypes = [
|
||||
...knownSectionOrder.filter((type) => grouped[type]?.length > 0),
|
||||
...customTypes,
|
||||
];
|
||||
|
||||
return { grouped, orderedTypes };
|
||||
}
|
||||
|
||||
export function getSortedReleases() {
|
||||
return [...allChangelogs].sort((a, b) =>
|
||||
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
||||
);
|
||||
}
|
||||
import { allChangelogs } from "content-collections";
|
||||
|
||||
export type Change = { type: string; text: string };
|
||||
export type Release = (typeof allChangelogs)[number];
|
||||
|
||||
const knownSectionOrder = ["new", "improved", "fixed", "breaking"];
|
||||
|
||||
const knownSectionTitles: Record<string, string> = {
|
||||
new: "Features",
|
||||
improved: "Improvements",
|
||||
fixed: "Fixes",
|
||||
breaking: "Breaking Changes",
|
||||
};
|
||||
|
||||
export function getSectionTitle(type: string): string {
|
||||
return (
|
||||
knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
|
||||
);
|
||||
}
|
||||
|
||||
export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
|
||||
const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
|
||||
if (!acc[change.type]) acc[change.type] = [];
|
||||
acc[change.type].push(change);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const customTypes = Object.keys(grouped).filter(
|
||||
(type) => !knownSectionOrder.includes(type),
|
||||
);
|
||||
const orderedTypes = [
|
||||
...knownSectionOrder.filter((type) => grouped[type]?.length > 0),
|
||||
...customTypes,
|
||||
];
|
||||
|
||||
return { grouped, orderedTypes };
|
||||
}
|
||||
|
||||
export function getSortedReleases() {
|
||||
return [...allChangelogs].sort((a, b) =>
|
||||
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,242 +1,242 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EXTERNAL_TOOLS } from "@/constants/site-constants";
|
||||
import { BasePage } from "../base-page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Contributors - OpenCut",
|
||||
description:
|
||||
"Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
|
||||
openGraph: {
|
||||
title: "Contributors - OpenCut",
|
||||
description:
|
||||
"Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
interface Contributor {
|
||||
id: number;
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
contributions: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
async function getContributors(): Promise<Contributor[]> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/OpenCut-app/OpenCut/contributors?per_page=100",
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "OpenCut-Web-App",
|
||||
},
|
||||
next: { revalidate: 600 }, // 10 minutes
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Failed to fetch contributors");
|
||||
return [];
|
||||
}
|
||||
|
||||
const contributors = (await response.json()) as Contributor[];
|
||||
|
||||
const filteredContributors = contributors.filter(
|
||||
(contributor) => contributor.type === "User",
|
||||
);
|
||||
|
||||
return filteredContributors;
|
||||
} catch (error) {
|
||||
console.error("Error fetching contributors:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ContributorsPage() {
|
||||
const contributors = await getContributors();
|
||||
const topContributors = contributors.slice(0, 2);
|
||||
const otherContributors = contributors.slice(2);
|
||||
const totalContributions = contributors.reduce(
|
||||
(sum, c) => sum + c.contributions,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
title="Contributors"
|
||||
description="Meet the amazing people who contribute to OpenCut, the free and open-source video editor."
|
||||
>
|
||||
<div className="-mt-4 flex items-center justify-center gap-8 text-sm">
|
||||
<StatItem value={contributors.length} label="contributors" />
|
||||
<StatItem value={totalContributions} label="contributions" />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-20">
|
||||
{topContributors.length > 0 && (
|
||||
<TopContributorsSection contributors={topContributors} />
|
||||
)}
|
||||
{otherContributors.length > 0 && (
|
||||
<AllContributorsSection contributors={otherContributors} />
|
||||
)}
|
||||
<ExternalToolsSection />
|
||||
<GitHubContributeSection
|
||||
title="Join the community"
|
||||
description="OpenCut is built by developers like you. Every contribution, no matter how small, helps make video editing more accessible for everyone."
|
||||
/>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ value, label }: { value: number; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-foreground size-2 rounded-full" />
|
||||
<span className="font-medium">{value}</span>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopContributorsSection({
|
||||
contributors,
|
||||
}: {
|
||||
contributors: Contributor[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<h2 className="text-2xl font-semibold">Top contributors</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Leading the way in contributions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex w-full max-w-xl flex-col justify-center gap-6 md:flex-row">
|
||||
{contributors.map((contributor) => (
|
||||
<TopContributorCard key={contributor.id} contributor={contributor} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopContributorCard({ contributor }: { contributor: Contributor }) {
|
||||
return (
|
||||
<Link
|
||||
href={contributor.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full"
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-6 p-8 text-center">
|
||||
<Avatar className="mx-auto size-28">
|
||||
<AvatarImage
|
||||
src={contributor.avatar_url}
|
||||
alt={`${contributor.login}'s avatar`}
|
||||
/>
|
||||
<AvatarFallback className="text-lg font-semibold">
|
||||
{contributor.login.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-xl font-semibold">{contributor.login}</h3>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="font-medium">{contributor.contributions}</span>
|
||||
<span className="text-muted-foreground">contributions</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function AllContributorsSection({
|
||||
contributors,
|
||||
}: {
|
||||
contributors: Contributor[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-12">
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<h2 className="text-2xl font-semibold">All contributors</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Everyone who makes OpenCut better
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{contributors.map((contributor) => (
|
||||
<Link
|
||||
key={contributor.id}
|
||||
href={contributor.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="opacity-100 hover:opacity-70"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 p-2">
|
||||
<Avatar className="size-16">
|
||||
<AvatarImage
|
||||
src={contributor.avatar_url}
|
||||
alt={`${contributor.login}'s avatar`}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{contributor.login.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="text-center">
|
||||
<h3 className="text-sm font-medium">{contributor.login}</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{contributor.contributions}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalToolsSection() {
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<h2 className="text-2xl font-semibold">External tools</h2>
|
||||
<p className="text-muted-foreground">Tools we use to build OpenCut</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{EXTERNAL_TOOLS.map((tool, index) => (
|
||||
<Link
|
||||
key={tool.url}
|
||||
href={tool.url}
|
||||
target="_blank"
|
||||
className="block"
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
>
|
||||
<Card className="h-full">
|
||||
<CardContent className="flex items-center justify-center h-full flex-col gap-4 p-6 text-center">
|
||||
<tool.icon className="size-8" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<h3 className="text-lg font-semibold">{tool.name}</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EXTERNAL_TOOLS } from "@/constants/site-constants";
|
||||
import { BasePage } from "../base-page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Contributors - OpenCut",
|
||||
description:
|
||||
"Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
|
||||
openGraph: {
|
||||
title: "Contributors - OpenCut",
|
||||
description:
|
||||
"Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
interface Contributor {
|
||||
id: number;
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
contributions: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
async function getContributors(): Promise<Contributor[]> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/OpenCut-app/OpenCut/contributors?per_page=100",
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "OpenCut-Web-App",
|
||||
},
|
||||
next: { revalidate: 600 }, // 10 minutes
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Failed to fetch contributors");
|
||||
return [];
|
||||
}
|
||||
|
||||
const contributors = (await response.json()) as Contributor[];
|
||||
|
||||
const filteredContributors = contributors.filter(
|
||||
(contributor) => contributor.type === "User",
|
||||
);
|
||||
|
||||
return filteredContributors;
|
||||
} catch (error) {
|
||||
console.error("Error fetching contributors:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ContributorsPage() {
|
||||
const contributors = await getContributors();
|
||||
const topContributors = contributors.slice(0, 2);
|
||||
const otherContributors = contributors.slice(2);
|
||||
const totalContributions = contributors.reduce(
|
||||
(sum, c) => sum + c.contributions,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
title="Contributors"
|
||||
description="Meet the amazing people who contribute to OpenCut, the free and open-source video editor."
|
||||
>
|
||||
<div className="-mt-4 flex items-center justify-center gap-8 text-sm">
|
||||
<StatItem value={contributors.length} label="contributors" />
|
||||
<StatItem value={totalContributions} label="contributions" />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-20">
|
||||
{topContributors.length > 0 && (
|
||||
<TopContributorsSection contributors={topContributors} />
|
||||
)}
|
||||
{otherContributors.length > 0 && (
|
||||
<AllContributorsSection contributors={otherContributors} />
|
||||
)}
|
||||
<ExternalToolsSection />
|
||||
<GitHubContributeSection
|
||||
title="Join the community"
|
||||
description="OpenCut is built by developers like you. Every contribution, no matter how small, helps make video editing more accessible for everyone."
|
||||
/>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ value, label }: { value: number; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-foreground size-2 rounded-full" />
|
||||
<span className="font-medium">{value}</span>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopContributorsSection({
|
||||
contributors,
|
||||
}: {
|
||||
contributors: Contributor[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<h2 className="text-2xl font-semibold">Top contributors</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Leading the way in contributions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex w-full max-w-xl flex-col justify-center gap-6 md:flex-row">
|
||||
{contributors.map((contributor) => (
|
||||
<TopContributorCard key={contributor.id} contributor={contributor} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopContributorCard({ contributor }: { contributor: Contributor }) {
|
||||
return (
|
||||
<Link
|
||||
href={contributor.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full"
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-6 p-8 text-center">
|
||||
<Avatar className="mx-auto size-28">
|
||||
<AvatarImage
|
||||
src={contributor.avatar_url}
|
||||
alt={`${contributor.login}'s avatar`}
|
||||
/>
|
||||
<AvatarFallback className="text-lg font-semibold">
|
||||
{contributor.login.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-xl font-semibold">{contributor.login}</h3>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="font-medium">{contributor.contributions}</span>
|
||||
<span className="text-muted-foreground">contributions</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function AllContributorsSection({
|
||||
contributors,
|
||||
}: {
|
||||
contributors: Contributor[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-12">
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<h2 className="text-2xl font-semibold">All contributors</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Everyone who makes OpenCut better
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{contributors.map((contributor) => (
|
||||
<Link
|
||||
key={contributor.id}
|
||||
href={contributor.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="opacity-100 hover:opacity-70"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 p-2">
|
||||
<Avatar className="size-16">
|
||||
<AvatarImage
|
||||
src={contributor.avatar_url}
|
||||
alt={`${contributor.login}'s avatar`}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{contributor.login.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="text-center">
|
||||
<h3 className="text-sm font-medium">{contributor.login}</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{contributor.contributions}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalToolsSection() {
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<h2 className="text-2xl font-semibold">External tools</h2>
|
||||
<p className="text-muted-foreground">Tools we use to build OpenCut</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{EXTERNAL_TOOLS.map((tool, index) => (
|
||||
<Link
|
||||
key={tool.url}
|
||||
href={tool.url}
|
||||
target="_blank"
|
||||
className="block"
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
>
|
||||
<Card className="h-full">
|
||||
<CardContent className="flex items-center justify-center h-full flex-col gap-4 p-6 text-center">
|
||||
<tool.icon className="size-8" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<h3 className="text-lg font-semibold">{tool.name}</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,113 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { AssetsPanel } from "@/components/editor/panels/assets";
|
||||
import { PropertiesPanel } from "@/components/editor/panels/properties";
|
||||
import { Timeline } from "@/components/editor/panels/timeline";
|
||||
import { PreviewPanel } from "@/components/editor/panels/preview";
|
||||
import { EditorHeader } from "@/components/editor/editor-header";
|
||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||
import { Onboarding } from "@/components/editor/onboarding";
|
||||
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { usePasteMedia } from "@/hooks/use-paste-media";
|
||||
import { MobileGate } from "@/components/editor/mobile-gate";
|
||||
|
||||
export default function Editor() {
|
||||
const params = useParams();
|
||||
const projectId = params.project_id as string;
|
||||
|
||||
return (
|
||||
<MobileGate>
|
||||
<EditorProvider projectId={projectId}>
|
||||
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
|
||||
<EditorHeader />
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<EditorLayout />
|
||||
</div>
|
||||
<Onboarding />
|
||||
<MigrationDialog />
|
||||
</div>
|
||||
</EditorProvider>
|
||||
</MobileGate>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorLayout() {
|
||||
usePasteMedia();
|
||||
const { panels, setPanel } = usePanelStore();
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="size-full gap-[0.18rem]"
|
||||
onLayout={(sizes) => {
|
||||
setPanel("mainContent", sizes[0] ?? panels.mainContent);
|
||||
setPanel("timeline", sizes[1] ?? panels.timeline);
|
||||
}}
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={panels.mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="size-full gap-[0.19rem] px-3"
|
||||
onLayout={(sizes) => {
|
||||
setPanel("tools", sizes[0] ?? panels.tools);
|
||||
setPanel("preview", sizes[1] ?? panels.preview);
|
||||
setPanel("properties", sizes[2] ?? panels.properties);
|
||||
}}
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={panels.tools}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
className="min-w-0"
|
||||
>
|
||||
<AssetsPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={panels.preview}
|
||||
minSize={30}
|
||||
className="min-h-0 min-w-0 flex-1"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={panels.properties}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
className="min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={panels.timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
className="min-h-0 px-3 pb-3"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { AssetsPanel } from "@/components/editor/panels/assets";
|
||||
import { PropertiesPanel } from "@/components/editor/panels/properties";
|
||||
import { Timeline } from "@/components/editor/panels/timeline";
|
||||
import { PreviewPanel } from "@/components/editor/panels/preview";
|
||||
import { EditorHeader } from "@/components/editor/editor-header";
|
||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||
import { Onboarding } from "@/components/editor/onboarding";
|
||||
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { usePasteMedia } from "@/hooks/use-paste-media";
|
||||
import { MobileGate } from "@/components/editor/mobile-gate";
|
||||
|
||||
export default function Editor() {
|
||||
const params = useParams();
|
||||
const projectId = params.project_id as string;
|
||||
|
||||
return (
|
||||
<MobileGate>
|
||||
<EditorProvider projectId={projectId}>
|
||||
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
|
||||
<EditorHeader />
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<EditorLayout />
|
||||
</div>
|
||||
<Onboarding />
|
||||
<MigrationDialog />
|
||||
</div>
|
||||
</EditorProvider>
|
||||
</MobileGate>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorLayout() {
|
||||
usePasteMedia();
|
||||
const { panels, setPanel } = usePanelStore();
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="size-full gap-[0.18rem]"
|
||||
onLayout={(sizes) => {
|
||||
setPanel("mainContent", sizes[0] ?? panels.mainContent);
|
||||
setPanel("timeline", sizes[1] ?? panels.timeline);
|
||||
}}
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={panels.mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="size-full gap-[0.19rem] px-3"
|
||||
onLayout={(sizes) => {
|
||||
setPanel("tools", sizes[0] ?? panels.tools);
|
||||
setPanel("preview", sizes[1] ?? panels.preview);
|
||||
setPanel("properties", sizes[2] ?? panels.properties);
|
||||
}}
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={panels.tools}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
className="min-w-0"
|
||||
>
|
||||
<AssetsPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={panels.preview}
|
||||
minSize={30}
|
||||
className="min-h-0 min-w-0 flex-1"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={panels.properties}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
className="min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={panels.timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
className="min-h-0 px-3 pb-3"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
+284
-284
@@ -1,284 +1,284 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Custom variant for dark mode */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Plugins */
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
:root {
|
||||
--background: hsl(0, 0%, 100%);
|
||||
--foreground: hsl(0 0% 11%);
|
||||
--card: hsl(0, 0%, 100%);
|
||||
--card-foreground: hsl(0 0% 11%);
|
||||
--popover: hsl(0, 0%, 100%);
|
||||
--popover-hover: hsl(0, 0%, 96%);
|
||||
--popover-foreground: hsl(0 0% 2%);
|
||||
--primary: hsl(200, 90%, 52%);
|
||||
--primary-foreground: hsl(0, 0%, 100%);
|
||||
--secondary: hsl(204, 100%, 97%);
|
||||
--secondary-border: hsl(204, 100%, 94%);
|
||||
--secondary-foreground: hsl(200, 98%, 39%);
|
||||
--muted: hsl(0 0% 85.1%);
|
||||
--muted-foreground: hsl(0 0% 50%);
|
||||
--accent: hsl(0, 0%, 96%);
|
||||
--accent-foreground: hsl(0 0% 2%);
|
||||
--destructive: hsl(0, 83%, 50%);
|
||||
--destructive-foreground: hsl(0, 0%, 100%);
|
||||
--constructive: hsl(141, 71%, 48%);
|
||||
--constructive-foreground: hsl(0, 0%, 100%);
|
||||
--border: hsl(0 0% 91%);
|
||||
--input: hsl(0, 0%, 100%);
|
||||
--ring: hsl(0, 0%, 55%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 96.1%);
|
||||
--sidebar-foreground: hsl(0 0% 2%);
|
||||
--sidebar-primary: hsl(0 0% 2%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 91%);
|
||||
--sidebar-accent: hsl(0 0% 85.1%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 2%);
|
||||
--sidebar-border: hsl(0 0% 85.1%);
|
||||
--sidebar-ring: hsl(0 0% 16.9%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
}
|
||||
|
||||
.panel {
|
||||
--background: hsl(210, 20%, 98%);
|
||||
--foreground: hsl(0 0% 13%);
|
||||
--card: hsl(0, 0%, 98%);
|
||||
--card-foreground: hsl(0 0% 13%);
|
||||
--primary-foreground: hsl(0, 0%, 98%);
|
||||
--secondary: hsl(204, 100%, 95%);
|
||||
--secondary-border: hsl(204, 100%, 92%);
|
||||
--secondary-foreground: hsl(200, 98%, 37%);
|
||||
--muted: hsl(0 0% 83.1%);
|
||||
--muted-foreground: hsl(0 0% 48%);
|
||||
--accent: hsl(0, 0%, 93%);
|
||||
--accent-foreground: hsl(0 0% 5%);
|
||||
--destructive-foreground: hsl(0, 0%, 98%);
|
||||
--constructive-foreground: hsl(0, 0%, 98%);
|
||||
--border: hsl(0 0% 87%);
|
||||
--input: hsl(0 0% 93%);
|
||||
--ring: hsl(0, 0%, 53%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(0, 0%, 5%);
|
||||
--foreground: hsl(0 0% 87%);
|
||||
--card: hsl(0, 0%, 5%);
|
||||
--card-foreground: hsl(0 0% 87%);
|
||||
--popover: hsl(0, 0%, 5%);
|
||||
--popover-hover: hsl(0, 0%, 13%);
|
||||
--popover-foreground: hsl(0 0% 95%);
|
||||
--secondary: hsl(204, 100%, 12%);
|
||||
--secondary-border: hsl(204, 100%, 15%);
|
||||
--secondary-foreground: hsl(200, 98%, 61%);
|
||||
--muted: hsl(0 0% 20%);
|
||||
--accent: hsl(0, 0%, 14%);
|
||||
--accent-foreground: hsl(0 0% 95%);
|
||||
--border: hsl(0 0% 16%);
|
||||
--input: hsl(0 0% 5%);
|
||||
--ring: hsl(0, 0%, 50%);
|
||||
--sidebar-background: hsl(0 0% 8%);
|
||||
--sidebar-foreground: hsl(0 0% 95%);
|
||||
--sidebar-primary: hsl(0 0% 95%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 15%);
|
||||
--sidebar-accent: hsl(0 0% 20%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 95%);
|
||||
--sidebar-border: hsl(0 0% 20%);
|
||||
--sidebar-ring: hsl(0 0% 83.1%);
|
||||
--sidebar: hsl(0 0% 6%);
|
||||
}
|
||||
|
||||
.dark .panel {
|
||||
--background: hsl(0 0% 10%);
|
||||
--foreground: hsl(0 0% 85%);
|
||||
--card: hsl(0, 0%, 10%);
|
||||
--card-foreground: hsl(0 0% 85%);
|
||||
--secondary: hsl(204, 67%, 9%);
|
||||
--secondary-border: hsl(204, 100%, 14%);
|
||||
--secondary-foreground: hsl(200, 98%, 63%);
|
||||
--muted: hsl(0 0% 22%);
|
||||
--accent: hsl(0, 0%, 15%);
|
||||
--accent-foreground: hsl(0 0% 93%);
|
||||
--border: hsl(0 0% 18%);
|
||||
--input: hsl(0 0% 22%);
|
||||
--ring: hsl(0, 0%, 52%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
/* Other default base styles */
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-primary/35 selection:text-primary-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Responsive breakpoints */
|
||||
--breakpoint-xs: 30rem;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: var(--font-inter), sans-serif;
|
||||
|
||||
/* Font sizes */
|
||||
--text-xs: 0.72rem;
|
||||
--text-sm: 0.79rem;
|
||||
--text-base: 0.92rem;
|
||||
--text-base--line-height: calc(1.5 / 0.95);
|
||||
--text-xs--line-height: calc(1 / 0.8);
|
||||
|
||||
/* Border radius */
|
||||
--radius-lg: 0.82rem;
|
||||
--radius-md: 0.65rem;
|
||||
--radius-sm: 0.35rem;
|
||||
|
||||
/* Palette mapped to root design tokens */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-hover: var(--popover-hover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-border: var(--secondary-border);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-constructive: var(--constructive);
|
||||
--color-constructive-foreground: var(--constructive-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Chart colors */
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
/* Sidebar */
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
/* Animations */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-x-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:horizontal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-y-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:vertical {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-thin {
|
||||
&::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Custom variant for dark mode */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Plugins */
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
:root {
|
||||
--background: hsl(0, 0%, 100%);
|
||||
--foreground: hsl(0 0% 11%);
|
||||
--card: hsl(0, 0%, 100%);
|
||||
--card-foreground: hsl(0 0% 11%);
|
||||
--popover: hsl(0, 0%, 100%);
|
||||
--popover-hover: hsl(0, 0%, 96%);
|
||||
--popover-foreground: hsl(0 0% 2%);
|
||||
--primary: hsl(200, 90%, 52%);
|
||||
--primary-foreground: hsl(0, 0%, 100%);
|
||||
--secondary: hsl(204, 100%, 97%);
|
||||
--secondary-border: hsl(204, 100%, 94%);
|
||||
--secondary-foreground: hsl(200, 98%, 39%);
|
||||
--muted: hsl(0 0% 85.1%);
|
||||
--muted-foreground: hsl(0 0% 50%);
|
||||
--accent: hsl(0, 0%, 96%);
|
||||
--accent-foreground: hsl(0 0% 2%);
|
||||
--destructive: hsl(0, 83%, 50%);
|
||||
--destructive-foreground: hsl(0, 0%, 100%);
|
||||
--constructive: hsl(141, 71%, 48%);
|
||||
--constructive-foreground: hsl(0, 0%, 100%);
|
||||
--border: hsl(0 0% 91%);
|
||||
--input: hsl(0, 0%, 100%);
|
||||
--ring: hsl(0, 0%, 55%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 96.1%);
|
||||
--sidebar-foreground: hsl(0 0% 2%);
|
||||
--sidebar-primary: hsl(0 0% 2%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 91%);
|
||||
--sidebar-accent: hsl(0 0% 85.1%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 2%);
|
||||
--sidebar-border: hsl(0 0% 85.1%);
|
||||
--sidebar-ring: hsl(0 0% 16.9%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
}
|
||||
|
||||
.panel {
|
||||
--background: hsl(210, 20%, 98%);
|
||||
--foreground: hsl(0 0% 13%);
|
||||
--card: hsl(0, 0%, 98%);
|
||||
--card-foreground: hsl(0 0% 13%);
|
||||
--primary-foreground: hsl(0, 0%, 98%);
|
||||
--secondary: hsl(204, 100%, 95%);
|
||||
--secondary-border: hsl(204, 100%, 92%);
|
||||
--secondary-foreground: hsl(200, 98%, 37%);
|
||||
--muted: hsl(0 0% 83.1%);
|
||||
--muted-foreground: hsl(0 0% 48%);
|
||||
--accent: hsl(0, 0%, 93%);
|
||||
--accent-foreground: hsl(0 0% 5%);
|
||||
--destructive-foreground: hsl(0, 0%, 98%);
|
||||
--constructive-foreground: hsl(0, 0%, 98%);
|
||||
--border: hsl(0 0% 87%);
|
||||
--input: hsl(0 0% 93%);
|
||||
--ring: hsl(0, 0%, 53%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(0, 0%, 5%);
|
||||
--foreground: hsl(0 0% 87%);
|
||||
--card: hsl(0, 0%, 5%);
|
||||
--card-foreground: hsl(0 0% 87%);
|
||||
--popover: hsl(0, 0%, 5%);
|
||||
--popover-hover: hsl(0, 0%, 13%);
|
||||
--popover-foreground: hsl(0 0% 95%);
|
||||
--secondary: hsl(204, 100%, 12%);
|
||||
--secondary-border: hsl(204, 100%, 15%);
|
||||
--secondary-foreground: hsl(200, 98%, 61%);
|
||||
--muted: hsl(0 0% 20%);
|
||||
--accent: hsl(0, 0%, 14%);
|
||||
--accent-foreground: hsl(0 0% 95%);
|
||||
--border: hsl(0 0% 16%);
|
||||
--input: hsl(0 0% 5%);
|
||||
--ring: hsl(0, 0%, 50%);
|
||||
--sidebar-background: hsl(0 0% 8%);
|
||||
--sidebar-foreground: hsl(0 0% 95%);
|
||||
--sidebar-primary: hsl(0 0% 95%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 15%);
|
||||
--sidebar-accent: hsl(0 0% 20%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 95%);
|
||||
--sidebar-border: hsl(0 0% 20%);
|
||||
--sidebar-ring: hsl(0 0% 83.1%);
|
||||
--sidebar: hsl(0 0% 6%);
|
||||
}
|
||||
|
||||
.dark .panel {
|
||||
--background: hsl(0 0% 10%);
|
||||
--foreground: hsl(0 0% 85%);
|
||||
--card: hsl(0, 0%, 10%);
|
||||
--card-foreground: hsl(0 0% 85%);
|
||||
--secondary: hsl(204, 67%, 9%);
|
||||
--secondary-border: hsl(204, 100%, 14%);
|
||||
--secondary-foreground: hsl(200, 98%, 63%);
|
||||
--muted: hsl(0 0% 22%);
|
||||
--accent: hsl(0, 0%, 15%);
|
||||
--accent-foreground: hsl(0 0% 93%);
|
||||
--border: hsl(0 0% 18%);
|
||||
--input: hsl(0 0% 22%);
|
||||
--ring: hsl(0, 0%, 52%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
/* Other default base styles */
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-primary/35 selection:text-primary-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Responsive breakpoints */
|
||||
--breakpoint-xs: 30rem;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: var(--font-inter), sans-serif;
|
||||
|
||||
/* Font sizes */
|
||||
--text-xs: 0.72rem;
|
||||
--text-sm: 0.79rem;
|
||||
--text-base: 0.92rem;
|
||||
--text-base--line-height: calc(1.5 / 0.95);
|
||||
--text-xs--line-height: calc(1 / 0.8);
|
||||
|
||||
/* Border radius */
|
||||
--radius-lg: 0.82rem;
|
||||
--radius-md: 0.65rem;
|
||||
--radius-sm: 0.35rem;
|
||||
|
||||
/* Palette mapped to root design tokens */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-hover: var(--popover-hover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-border: var(--secondary-border);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-constructive: var(--constructive);
|
||||
--color-constructive-foreground: var(--constructive-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Chart colors */
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
/* Sidebar */
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
/* Animations */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-x-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:horizontal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-y-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:vertical {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-thin {
|
||||
&::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,86 +1,86 @@
|
||||
import type { Metadata } from "next";
|
||||
import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
|
||||
|
||||
export const baseMetaData: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: SITE_INFO.title,
|
||||
description: SITE_INFO.description,
|
||||
openGraph: {
|
||||
title: SITE_INFO.title,
|
||||
description: SITE_INFO.description,
|
||||
url: SITE_URL,
|
||||
siteName: SITE_INFO.title,
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: SITE_INFO.openGraphImage,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "OpenCut Wordmark",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: SITE_INFO.title,
|
||||
description: SITE_INFO.description,
|
||||
creator: "@opencutapp",
|
||||
images: [SITE_INFO.twitterImage],
|
||||
},
|
||||
pinterest: {
|
||||
richPin: false,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.ico" },
|
||||
{ url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" },
|
||||
{ url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
||||
{ url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" },
|
||||
],
|
||||
apple: [
|
||||
{ url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" },
|
||||
{
|
||||
url: "/icons/apple-icon-114x114.png",
|
||||
sizes: "114x114",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/icons/apple-icon-120x120.png",
|
||||
sizes: "120x120",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/icons/apple-icon-144x144.png",
|
||||
sizes: "144x144",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/icons/apple-icon-152x152.png",
|
||||
sizes: "152x152",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/icons/apple-icon-180x180.png",
|
||||
sizes: "180x180",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
shortcut: ["/favicon.ico"],
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: SITE_INFO.title,
|
||||
},
|
||||
manifest: "/manifest.json",
|
||||
other: {
|
||||
"msapplication-config": "/browserconfig.xml",
|
||||
},
|
||||
};
|
||||
import type { Metadata } from "next";
|
||||
import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
|
||||
|
||||
export const baseMetaData: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: SITE_INFO.title,
|
||||
description: SITE_INFO.description,
|
||||
openGraph: {
|
||||
title: SITE_INFO.title,
|
||||
description: SITE_INFO.description,
|
||||
url: SITE_URL,
|
||||
siteName: SITE_INFO.title,
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: SITE_INFO.openGraphImage,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "OpenCut Wordmark",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: SITE_INFO.title,
|
||||
description: SITE_INFO.description,
|
||||
creator: "@opencutapp",
|
||||
images: [SITE_INFO.twitterImage],
|
||||
},
|
||||
pinterest: {
|
||||
richPin: false,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.ico" },
|
||||
{ url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" },
|
||||
{ url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
||||
{ url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" },
|
||||
],
|
||||
apple: [
|
||||
{ url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" },
|
||||
{
|
||||
url: "/icons/apple-icon-114x114.png",
|
||||
sizes: "114x114",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/icons/apple-icon-120x120.png",
|
||||
sizes: "120x120",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/icons/apple-icon-144x144.png",
|
||||
sizes: "144x144",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/icons/apple-icon-152x152.png",
|
||||
sizes: "152x152",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/icons/apple-icon-180x180.png",
|
||||
sizes: "180x180",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
shortcut: ["/favicon.ico"],
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: SITE_INFO.title,
|
||||
},
|
||||
manifest: "/manifest.json",
|
||||
other: {
|
||||
"msapplication-config": "/browserconfig.xml",
|
||||
},
|
||||
};
|
||||
|
||||
+21
-21
@@ -1,21 +1,21 @@
|
||||
import { Hero } from "@/components/landing/hero";
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import type { Metadata } from "next";
|
||||
import { SITE_URL } from "@/constants/site-constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
alternates: {
|
||||
canonical: SITE_URL,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function Home() {
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<Hero />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Hero } from "@/components/landing/hero";
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import type { Metadata } from "next";
|
||||
import { SITE_URL } from "@/constants/site-constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
alternates: {
|
||||
canonical: SITE_URL,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function Home() {
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<Hero />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1015
-1015
File diff suppressed because it is too large
Load Diff
+135
-135
@@ -1,135 +1,135 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { TProjectSortKey } from "@/lib/project/types";
|
||||
|
||||
export type ProjectsViewMode = "grid" | "list";
|
||||
|
||||
interface ProjectsState {
|
||||
searchQuery: string;
|
||||
sortKey: TProjectSortKey;
|
||||
sortOrder: "asc" | "desc";
|
||||
viewMode: ProjectsViewMode;
|
||||
selectedProjectIds: string[];
|
||||
lastSelectedProjectId: string | null;
|
||||
isHydrated: boolean;
|
||||
setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void;
|
||||
setSearchQuery: ({ query }: { query: string }) => void;
|
||||
setSortKey: ({ sortKey }: { sortKey: TProjectSortKey }) => void;
|
||||
setSortOrder: ({ sortOrder }: { sortOrder: "asc" | "desc" }) => void;
|
||||
toggleSortOrder: () => void;
|
||||
setViewMode: ({ viewMode }: { viewMode: ProjectsViewMode }) => void;
|
||||
setSelectedProjects: ({ projectIds }: { projectIds: string[] }) => void;
|
||||
clearSelectedProjects: () => void;
|
||||
setProjectSelected: ({
|
||||
projectId,
|
||||
isSelected,
|
||||
}: {
|
||||
projectId: string;
|
||||
isSelected: boolean;
|
||||
}) => void;
|
||||
selectProjectRange: ({
|
||||
projectId,
|
||||
allProjectIds,
|
||||
}: {
|
||||
projectId: string;
|
||||
allProjectIds: string[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const getNextSelectedProjectIds = ({
|
||||
selectedProjectIds,
|
||||
projectId,
|
||||
isSelected,
|
||||
}: {
|
||||
selectedProjectIds: string[];
|
||||
projectId: string;
|
||||
isSelected: boolean;
|
||||
}): string[] => {
|
||||
const selectedProjectIdSet = new Set(selectedProjectIds);
|
||||
|
||||
if (isSelected) {
|
||||
selectedProjectIdSet.add(projectId);
|
||||
return Array.from(selectedProjectIdSet);
|
||||
}
|
||||
|
||||
selectedProjectIdSet.delete(projectId);
|
||||
return Array.from(selectedProjectIdSet);
|
||||
};
|
||||
|
||||
export const useProjectsStore = create<ProjectsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
searchQuery: "",
|
||||
sortKey: "updatedAt",
|
||||
sortOrder: "desc",
|
||||
viewMode: "grid",
|
||||
selectedProjectIds: [],
|
||||
lastSelectedProjectId: null,
|
||||
isHydrated: false,
|
||||
setIsHydrated: ({ isHydrated }) => set({ isHydrated }),
|
||||
setSearchQuery: ({ query }) => set({ searchQuery: query }),
|
||||
setSortKey: ({ sortKey }) => set({ sortKey }),
|
||||
setSortOrder: ({ sortOrder }) => set({ sortOrder }),
|
||||
toggleSortOrder: () =>
|
||||
set((state) => ({
|
||||
sortOrder: state.sortOrder === "asc" ? "desc" : "asc",
|
||||
})),
|
||||
setViewMode: ({ viewMode }) => set({ viewMode }),
|
||||
setSelectedProjects: ({ projectIds }) =>
|
||||
set({ selectedProjectIds: projectIds }),
|
||||
clearSelectedProjects: () =>
|
||||
set({ selectedProjectIds: [], lastSelectedProjectId: null }),
|
||||
setProjectSelected: ({ projectId, isSelected }) =>
|
||||
set((state) => ({
|
||||
selectedProjectIds: getNextSelectedProjectIds({
|
||||
selectedProjectIds: state.selectedProjectIds,
|
||||
projectId,
|
||||
isSelected,
|
||||
}),
|
||||
lastSelectedProjectId: isSelected
|
||||
? projectId
|
||||
: state.lastSelectedProjectId,
|
||||
})),
|
||||
selectProjectRange: ({ projectId, allProjectIds }) =>
|
||||
set((state) => {
|
||||
const anchorId = state.lastSelectedProjectId;
|
||||
if (!anchorId) {
|
||||
return {
|
||||
selectedProjectIds: [projectId],
|
||||
lastSelectedProjectId: projectId,
|
||||
};
|
||||
}
|
||||
|
||||
const anchorIndex = allProjectIds.indexOf(anchorId);
|
||||
const targetIndex = allProjectIds.indexOf(projectId);
|
||||
|
||||
if (anchorIndex === -1 || targetIndex === -1) {
|
||||
return {
|
||||
selectedProjectIds: [projectId],
|
||||
lastSelectedProjectId: projectId,
|
||||
};
|
||||
}
|
||||
|
||||
const startIndex = Math.min(anchorIndex, targetIndex);
|
||||
const endIndex = Math.max(anchorIndex, targetIndex);
|
||||
const rangeIds = allProjectIds.slice(startIndex, endIndex + 1);
|
||||
|
||||
const merged = new Set([...state.selectedProjectIds, ...rangeIds]);
|
||||
return {
|
||||
selectedProjectIds: Array.from(merged),
|
||||
};
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "projects-view-mode",
|
||||
partialize: (state) => ({
|
||||
viewMode: state.viewMode,
|
||||
sortKey: state.sortKey,
|
||||
sortOrder: state.sortOrder,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
state?.setIsHydrated({ isHydrated: true });
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { TProjectSortKey } from "@/lib/project/types";
|
||||
|
||||
export type ProjectsViewMode = "grid" | "list";
|
||||
|
||||
interface ProjectsState {
|
||||
searchQuery: string;
|
||||
sortKey: TProjectSortKey;
|
||||
sortOrder: "asc" | "desc";
|
||||
viewMode: ProjectsViewMode;
|
||||
selectedProjectIds: string[];
|
||||
lastSelectedProjectId: string | null;
|
||||
isHydrated: boolean;
|
||||
setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void;
|
||||
setSearchQuery: ({ query }: { query: string }) => void;
|
||||
setSortKey: ({ sortKey }: { sortKey: TProjectSortKey }) => void;
|
||||
setSortOrder: ({ sortOrder }: { sortOrder: "asc" | "desc" }) => void;
|
||||
toggleSortOrder: () => void;
|
||||
setViewMode: ({ viewMode }: { viewMode: ProjectsViewMode }) => void;
|
||||
setSelectedProjects: ({ projectIds }: { projectIds: string[] }) => void;
|
||||
clearSelectedProjects: () => void;
|
||||
setProjectSelected: ({
|
||||
projectId,
|
||||
isSelected,
|
||||
}: {
|
||||
projectId: string;
|
||||
isSelected: boolean;
|
||||
}) => void;
|
||||
selectProjectRange: ({
|
||||
projectId,
|
||||
allProjectIds,
|
||||
}: {
|
||||
projectId: string;
|
||||
allProjectIds: string[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const getNextSelectedProjectIds = ({
|
||||
selectedProjectIds,
|
||||
projectId,
|
||||
isSelected,
|
||||
}: {
|
||||
selectedProjectIds: string[];
|
||||
projectId: string;
|
||||
isSelected: boolean;
|
||||
}): string[] => {
|
||||
const selectedProjectIdSet = new Set(selectedProjectIds);
|
||||
|
||||
if (isSelected) {
|
||||
selectedProjectIdSet.add(projectId);
|
||||
return Array.from(selectedProjectIdSet);
|
||||
}
|
||||
|
||||
selectedProjectIdSet.delete(projectId);
|
||||
return Array.from(selectedProjectIdSet);
|
||||
};
|
||||
|
||||
export const useProjectsStore = create<ProjectsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
searchQuery: "",
|
||||
sortKey: "updatedAt",
|
||||
sortOrder: "desc",
|
||||
viewMode: "grid",
|
||||
selectedProjectIds: [],
|
||||
lastSelectedProjectId: null,
|
||||
isHydrated: false,
|
||||
setIsHydrated: ({ isHydrated }) => set({ isHydrated }),
|
||||
setSearchQuery: ({ query }) => set({ searchQuery: query }),
|
||||
setSortKey: ({ sortKey }) => set({ sortKey }),
|
||||
setSortOrder: ({ sortOrder }) => set({ sortOrder }),
|
||||
toggleSortOrder: () =>
|
||||
set((state) => ({
|
||||
sortOrder: state.sortOrder === "asc" ? "desc" : "asc",
|
||||
})),
|
||||
setViewMode: ({ viewMode }) => set({ viewMode }),
|
||||
setSelectedProjects: ({ projectIds }) =>
|
||||
set({ selectedProjectIds: projectIds }),
|
||||
clearSelectedProjects: () =>
|
||||
set({ selectedProjectIds: [], lastSelectedProjectId: null }),
|
||||
setProjectSelected: ({ projectId, isSelected }) =>
|
||||
set((state) => ({
|
||||
selectedProjectIds: getNextSelectedProjectIds({
|
||||
selectedProjectIds: state.selectedProjectIds,
|
||||
projectId,
|
||||
isSelected,
|
||||
}),
|
||||
lastSelectedProjectId: isSelected
|
||||
? projectId
|
||||
: state.lastSelectedProjectId,
|
||||
})),
|
||||
selectProjectRange: ({ projectId, allProjectIds }) =>
|
||||
set((state) => {
|
||||
const anchorId = state.lastSelectedProjectId;
|
||||
if (!anchorId) {
|
||||
return {
|
||||
selectedProjectIds: [projectId],
|
||||
lastSelectedProjectId: projectId,
|
||||
};
|
||||
}
|
||||
|
||||
const anchorIndex = allProjectIds.indexOf(anchorId);
|
||||
const targetIndex = allProjectIds.indexOf(projectId);
|
||||
|
||||
if (anchorIndex === -1 || targetIndex === -1) {
|
||||
return {
|
||||
selectedProjectIds: [projectId],
|
||||
lastSelectedProjectId: projectId,
|
||||
};
|
||||
}
|
||||
|
||||
const startIndex = Math.min(anchorIndex, targetIndex);
|
||||
const endIndex = Math.max(anchorIndex, targetIndex);
|
||||
const rangeIds = allProjectIds.slice(startIndex, endIndex + 1);
|
||||
|
||||
const merged = new Set([...state.selectedProjectIds, ...rangeIds]);
|
||||
return {
|
||||
selectedProjectIds: Array.from(merged),
|
||||
};
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "projects-view-mode",
|
||||
partialize: (state) => ({
|
||||
viewMode: state.viewMode,
|
||||
sortKey: state.sortKey,
|
||||
sortOrder: state.sortOrder,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
state?.setIsHydrated({ isHydrated: true });
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
+146
-146
@@ -1,146 +1,146 @@
|
||||
import type { Metadata } from "next";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const LAST_UPDATED = "February 25, 2026";
|
||||
|
||||
type StatusType = "complete" | "pending" | "default" | "info";
|
||||
|
||||
interface Status {
|
||||
text: string;
|
||||
type: StatusType;
|
||||
}
|
||||
|
||||
interface RoadmapItem {
|
||||
title: string;
|
||||
description: string;
|
||||
status: Status;
|
||||
}
|
||||
|
||||
const roadmapItems: RoadmapItem[] = [
|
||||
{
|
||||
title: "Start",
|
||||
description:
|
||||
"This is where it all started. Repository created, initial project structure, and the vision for a free, open-source video editor. [Check out the first tweet](https://x.com/mazeincoding/status/1936706642512388188) to see where it started.",
|
||||
status: {
|
||||
text: "Completed",
|
||||
type: "complete",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Core UI",
|
||||
description:
|
||||
"Build the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.",
|
||||
status: {
|
||||
text: "Completed",
|
||||
type: "complete",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Essential functionality",
|
||||
description:
|
||||
"Everything that makes a video editor **useful**. Timeline interactivity, storage, effects, transitions, etc.",
|
||||
status: {
|
||||
text: "In progress",
|
||||
type: "pending",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Native app (mobile/desktop)",
|
||||
description:
|
||||
"Native OpenCut apps for Mac, Windows, Linux, and iOS/Android.",
|
||||
status: {
|
||||
text: "Not started",
|
||||
type: "default",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Roadmap - OpenCut",
|
||||
description:
|
||||
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
|
||||
openGraph: {
|
||||
title: "OpenCut Roadmap - What's Coming Next",
|
||||
description:
|
||||
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "/open-graph/roadmap.jpg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "OpenCut Roadmap",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "OpenCut Roadmap - What's Coming Next",
|
||||
description:
|
||||
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
|
||||
images: ["/open-graph/roadmap.jpg"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function RoadmapPage() {
|
||||
return (
|
||||
<BasePage
|
||||
title="Roadmap"
|
||||
description={`What's coming next for OpenCut (last updated: ${LAST_UPDATED})`}
|
||||
>
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-16">
|
||||
<div className="flex flex-col gap-6">
|
||||
{roadmapItems.map((item, index) => (
|
||||
<RoadmapItem key={item.title} item={item} index={index} />
|
||||
))}
|
||||
</div>
|
||||
<GitHubContributeSection
|
||||
title="Want to help?"
|
||||
description="OpenCut is open source and built by the community. Every contribution,
|
||||
no matter how small, helps us build the best free video editor
|
||||
possible."
|
||||
/>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function RoadmapItem({ item, index }: { item: RoadmapItem; index: number }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-lg font-medium">
|
||||
<span className="leading-normal select-none">{index + 1}</span>
|
||||
<h3>{item.title}</h3>
|
||||
<StatusBadge status={item.status} className="ml-1" />
|
||||
</div>
|
||||
<div className="text-foreground/70 leading-relaxed">
|
||||
<ReactMarkdownWrapper>{item.description}</ReactMarkdownWrapper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({
|
||||
status,
|
||||
className,
|
||||
}: {
|
||||
status: Status;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
className={cn("shadow-none", className, {
|
||||
"bg-green-500! text-white": status.type === "complete",
|
||||
"bg-yellow-500! text-white": status.type === "pending",
|
||||
"bg-blue-500! text-white": status.type === "info",
|
||||
"bg-foreground/10! text-accent-foreground": status.type === "default",
|
||||
})}
|
||||
>
|
||||
{status.text}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
import type { Metadata } from "next";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const LAST_UPDATED = "February 25, 2026";
|
||||
|
||||
type StatusType = "complete" | "pending" | "default" | "info";
|
||||
|
||||
interface Status {
|
||||
text: string;
|
||||
type: StatusType;
|
||||
}
|
||||
|
||||
interface RoadmapItem {
|
||||
title: string;
|
||||
description: string;
|
||||
status: Status;
|
||||
}
|
||||
|
||||
const roadmapItems: RoadmapItem[] = [
|
||||
{
|
||||
title: "Start",
|
||||
description:
|
||||
"This is where it all started. Repository created, initial project structure, and the vision for a free, open-source video editor. [Check out the first tweet](https://x.com/mazeincoding/status/1936706642512388188) to see where it started.",
|
||||
status: {
|
||||
text: "Completed",
|
||||
type: "complete",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Core UI",
|
||||
description:
|
||||
"Build the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.",
|
||||
status: {
|
||||
text: "Completed",
|
||||
type: "complete",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Essential functionality",
|
||||
description:
|
||||
"Everything that makes a video editor **useful**. Timeline interactivity, storage, effects, transitions, etc.",
|
||||
status: {
|
||||
text: "In progress",
|
||||
type: "pending",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Native app (mobile/desktop)",
|
||||
description:
|
||||
"Native OpenCut apps for Mac, Windows, Linux, and iOS/Android.",
|
||||
status: {
|
||||
text: "Not started",
|
||||
type: "default",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Roadmap - OpenCut",
|
||||
description:
|
||||
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
|
||||
openGraph: {
|
||||
title: "OpenCut Roadmap - What's Coming Next",
|
||||
description:
|
||||
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "/open-graph/roadmap.jpg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "OpenCut Roadmap",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "OpenCut Roadmap - What's Coming Next",
|
||||
description:
|
||||
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
|
||||
images: ["/open-graph/roadmap.jpg"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function RoadmapPage() {
|
||||
return (
|
||||
<BasePage
|
||||
title="Roadmap"
|
||||
description={`What's coming next for OpenCut (last updated: ${LAST_UPDATED})`}
|
||||
>
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-16">
|
||||
<div className="flex flex-col gap-6">
|
||||
{roadmapItems.map((item, index) => (
|
||||
<RoadmapItem key={item.title} item={item} index={index} />
|
||||
))}
|
||||
</div>
|
||||
<GitHubContributeSection
|
||||
title="Want to help?"
|
||||
description="OpenCut is open source and built by the community. Every contribution,
|
||||
no matter how small, helps us build the best free video editor
|
||||
possible."
|
||||
/>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function RoadmapItem({ item, index }: { item: RoadmapItem; index: number }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-lg font-medium">
|
||||
<span className="leading-normal select-none">{index + 1}</span>
|
||||
<h3>{item.title}</h3>
|
||||
<StatusBadge status={item.status} className="ml-1" />
|
||||
</div>
|
||||
<div className="text-foreground/70 leading-relaxed">
|
||||
<ReactMarkdownWrapper>{item.description}</ReactMarkdownWrapper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({
|
||||
status,
|
||||
className,
|
||||
}: {
|
||||
status: Status;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
className={cn("shadow-none", className, {
|
||||
"bg-green-500! text-white": status.type === "complete",
|
||||
"bg-yellow-500! text-white": status.type === "pending",
|
||||
"bg-blue-500! text-white": status.type === "info",
|
||||
"bg-foreground/10! text-accent-foreground": status.type === "default",
|
||||
})}
|
||||
>
|
||||
{status.text}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
+13
-13
@@ -1,13 +1,13 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/constants/site-constants";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/_next/", "/projects/", "/editor/"],
|
||||
},
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/constants/site-constants";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/_next/", "/projects/", "/editor/"],
|
||||
},
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
import { Feed } from "feed";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { posts } = await getPosts();
|
||||
|
||||
const feed = new Feed({
|
||||
title: `${SITE_INFO.title} Blog`,
|
||||
description: SITE_INFO.description,
|
||||
id: `${SITE_URL}`,
|
||||
link: `${SITE_URL}/blog/`,
|
||||
language: "en",
|
||||
image: `${SITE_INFO.openGraphImage}`,
|
||||
favicon: `${SITE_INFO.favicon}`,
|
||||
copyright: `All rights reserved ${new Date().getFullYear()}, ${
|
||||
SITE_INFO.title
|
||||
}`,
|
||||
});
|
||||
|
||||
for (const post of posts) {
|
||||
feed.addItem({
|
||||
title: post.title,
|
||||
id: `${SITE_URL}/blog/${post.slug}`,
|
||||
link: `${SITE_URL}/blog/${post.slug}`,
|
||||
description: post.description,
|
||||
author: post.authors.map((author) => ({
|
||||
name: author.name,
|
||||
})),
|
||||
date: new Date(post.publishedAt),
|
||||
image: post.coverImage || SITE_INFO.openGraphImage,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(feed.rss2(), {
|
||||
headers: {
|
||||
"Content-Type": "text/xml",
|
||||
"Cache-Control": "public, max-age=86400, stale-while-revalidate",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating RSS feed", error);
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
import { Feed } from "feed";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { posts } = await getPosts();
|
||||
|
||||
const feed = new Feed({
|
||||
title: `${SITE_INFO.title} Blog`,
|
||||
description: SITE_INFO.description,
|
||||
id: `${SITE_URL}`,
|
||||
link: `${SITE_URL}/blog/`,
|
||||
language: "en",
|
||||
image: `${SITE_INFO.openGraphImage}`,
|
||||
favicon: `${SITE_INFO.favicon}`,
|
||||
copyright: `All rights reserved ${new Date().getFullYear()}, ${
|
||||
SITE_INFO.title
|
||||
}`,
|
||||
});
|
||||
|
||||
for (const post of posts) {
|
||||
feed.addItem({
|
||||
title: post.title,
|
||||
id: `${SITE_URL}/blog/${post.slug}`,
|
||||
link: `${SITE_URL}/blog/${post.slug}`,
|
||||
description: post.description,
|
||||
author: post.authors.map((author) => ({
|
||||
name: author.name,
|
||||
})),
|
||||
date: new Date(post.publishedAt),
|
||||
image: post.coverImage || SITE_INFO.openGraphImage,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(feed.rss2(), {
|
||||
headers: {
|
||||
"Content-Type": "text/xml",
|
||||
"Cache-Control": "public, max-age=86400, stale-while-revalidate",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating RSS feed", error);
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
+61
-61
@@ -1,61 +1,61 @@
|
||||
import { SITE_URL } from "@/constants/site-constants";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const data = await getPosts();
|
||||
|
||||
const postPages: MetadataRoute.Sitemap =
|
||||
data?.posts?.map((post) => ({
|
||||
url: `${SITE_URL}/blog/${post.slug}`,
|
||||
lastModified: new Date(post.publishedAt),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.8,
|
||||
})) ?? [];
|
||||
|
||||
return [
|
||||
{
|
||||
url: SITE_URL,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/contributors`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/roadmap`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/privacy`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/terms`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/why-not-capcut`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "yearly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/blog`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
...postPages,
|
||||
];
|
||||
}
|
||||
import { SITE_URL } from "@/constants/site-constants";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const data = await getPosts();
|
||||
|
||||
const postPages: MetadataRoute.Sitemap =
|
||||
data?.posts?.map((post) => ({
|
||||
url: `${SITE_URL}/blog/${post.slug}`,
|
||||
lastModified: new Date(post.publishedAt),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.8,
|
||||
})) ?? [];
|
||||
|
||||
return [
|
||||
{
|
||||
url: SITE_URL,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/contributors`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/roadmap`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/privacy`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/terms`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/why-not-capcut`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "yearly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/blog`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
...postPages,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,86 +1,86 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { SPONSORS, type Sponsor } from "@/constants/site-constants";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sponsors - OpenCut",
|
||||
description:
|
||||
"Support OpenCut and help us build the future of free and open-source video editing.",
|
||||
openGraph: {
|
||||
title: "Sponsors - OpenCut",
|
||||
description:
|
||||
"Support OpenCut and help us build the future of free and open-source video editing.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default function SponsorsPage() {
|
||||
return (
|
||||
<BasePage>
|
||||
<div className="flex flex-col gap-8 text-center">
|
||||
<h1 className="text-5xl font-bold tracking-tight md:text-6xl">
|
||||
Sponsors
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed text-pretty">
|
||||
Support OpenCut and help us build the future of privacy-first video
|
||||
editing.
|
||||
</p>
|
||||
</div>
|
||||
<SponsorsGrid />
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function SponsorsGrid() {
|
||||
return (
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{SPONSORS.map((sponsor) => (
|
||||
<SponsorCard key={sponsor.name} sponsor={sponsor} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SponsorCard({ sponsor }: { sponsor: Sponsor }) {
|
||||
return (
|
||||
<Link
|
||||
href={sponsor.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="size-full"
|
||||
>
|
||||
<Card className="h-full">
|
||||
<CardContent className="flex h-full flex-col justify-center gap-8 p-8">
|
||||
<Image
|
||||
src={sponsor.logo}
|
||||
alt={`${sponsor.name} logo`}
|
||||
width={50}
|
||||
height={50}
|
||||
className={cn(
|
||||
"object-contain",
|
||||
sponsor.invertOnDark && "invert-0 dark:invert",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-xl font-semibold group-hover:underline">
|
||||
{sponsor.name}
|
||||
</h3>
|
||||
<HugeiconsIcon
|
||||
icon={LinkSquare02Icon}
|
||||
className="text-muted-foreground size-4"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{sponsor.description}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { SPONSORS, type Sponsor } from "@/constants/site-constants";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sponsors - OpenCut",
|
||||
description:
|
||||
"Support OpenCut and help us build the future of free and open-source video editing.",
|
||||
openGraph: {
|
||||
title: "Sponsors - OpenCut",
|
||||
description:
|
||||
"Support OpenCut and help us build the future of free and open-source video editing.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default function SponsorsPage() {
|
||||
return (
|
||||
<BasePage>
|
||||
<div className="flex flex-col gap-8 text-center">
|
||||
<h1 className="text-5xl font-bold tracking-tight md:text-6xl">
|
||||
Sponsors
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed text-pretty">
|
||||
Support OpenCut and help us build the future of privacy-first video
|
||||
editing.
|
||||
</p>
|
||||
</div>
|
||||
<SponsorsGrid />
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function SponsorsGrid() {
|
||||
return (
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{SPONSORS.map((sponsor) => (
|
||||
<SponsorCard key={sponsor.name} sponsor={sponsor} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SponsorCard({ sponsor }: { sponsor: Sponsor }) {
|
||||
return (
|
||||
<Link
|
||||
href={sponsor.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="size-full"
|
||||
>
|
||||
<Card className="h-full">
|
||||
<CardContent className="flex h-full flex-col justify-center gap-8 p-8">
|
||||
<Image
|
||||
src={sponsor.logo}
|
||||
alt={`${sponsor.name} logo`}
|
||||
width={50}
|
||||
height={50}
|
||||
className={cn(
|
||||
"object-contain",
|
||||
sponsor.invertOnDark && "invert-0 dark:invert",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-xl font-semibold group-hover:underline">
|
||||
{sponsor.name}
|
||||
</h3>
|
||||
<HugeiconsIcon
|
||||
icon={LinkSquare02Icon}
|
||||
className="text-muted-foreground size-4"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{sponsor.description}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user