chore: remove marketing routes and align app-first navigation

This commit is contained in:
Stanley Cheung 2026-02-09 14:57:09 +08:00
parent 464a6e83b9
commit a36dbaaf1f
24 changed files with 410 additions and 1194 deletions

215
AGENTS.md
View File

@ -119,3 +119,218 @@ Each command extends `Command` from `@/lib/commands/base-command` and implements
- `undo()` - restores the saved state
Actions and commands work together: actions are "what triggered this", commands are "how to do it (and undo it)".
# Frontend Agent Guide (React + Clean UI)
This repository uses **test-first development** and **clean UI/code** principles.
AI agents must follow these rules when planning, coding, and refactoring.
---
## 1) Core Principles
### Test-first is mandatory
Follow **Red → Green → Refactor** where practical:
1. **Red:** Write a failing test that captures the behavior.
2. **Green:** Implement the simplest code to pass.
3. **Refactor:** Improve structure/readability with tests green.
UI work that is purely presentational may start with a minimal component and then tests, but behavior changes must be test-first.
### Clean code and clean UI always
- Prefer **clarity over cleverness**.
- Use **small components**, **meaningful names**, **single responsibility**.
- Keep UI structure simple; avoid deeply nested layouts.
- Avoid over-engineering; introduce abstractions only when pressure appears.
### Minimal scope
- Implement only whats needed for the current task/tests.
- No premature “future-proofing.”
### Never use `any`
- **Do not use `any` as a type.**
- Prefer precise types or generics.
---
## 2) Workflow Rules (How the agent works)
### Before coding
- Identify the smallest behavior to add.
- Decide where it belongs (component, hook, util, store).
- Write/adjust tests first for behavior changes.
### While coding
- Keep changes small and incremental.
- Run tests frequently and keep them green.
### After coding
- Refactor for readability and structure.
- Ensure linting/formatting pass.
- Update docs only if behavior/usage changed.
---
## 3) Project Architecture Expectations (React)
### Component structure
- **UI components**: presentational, no side effects.
- **Feature components**: orchestrate UI + hooks/state.
- **Hooks**: encapsulate shared logic.
- **Utilities**: pure functions.
### Dependency direction
- Features can use shared UI/components/utils.
- Shared UI must not depend on feature-specific logic.
### State
- Prefer local state where possible.
- Use a shared store only when state must be shared across major areas.
---
## 4) Testing Strategy (React)
### Preferred test types
1. **Unit tests**: pure utils, hooks (default).
2. **Component tests**: React Testing Library for behavior.
3. **E2E tests**: only for high-level flows.
### What to test
- Behavior and user interactions, not implementation details.
- Edge cases and failure paths.
### Testing rules
- Every new behavior must include tests.
- Tests must be deterministic.
- Avoid snapshot tests for logic-heavy components.
---
## 5) Clean Code Rules (Non-negotiable)
### Naming
- Use intention-revealing names.
- Avoid ambiguous abbreviations.
### Functions & components
- Keep functions small.
- Keep components focused.
- Avoid prop drilling if a context or store is more appropriate.
### Error handling
- Fail fast; show user-friendly messages where needed.
- No silent failures.
### Comments
- Use comments sparingly, only for _why_, not _what_.
### Formatting
- Follow repo lint/format rules.
- No unused variables or dead code.
---
## 6) React Conventions
### Props & types
- Use typed props and shared types in `types/` or local `types.ts`.
- Avoid widening types; keep them as narrow as possible.
### Accessibility
- Use semantic HTML.
- Ensure keyboard navigability for interactive elements.
### Styling
- Follow repo styling approach consistently.
- Keep styles co-located with components when possible.
---
## 7) Implementation Guidelines
### When adding new UI behavior
1. Write a test describing the interaction.
2. Implement the smallest change to pass.
3. Refactor for clarity and reusability.
### When fixing a bug
1. Add a failing test.
2. Fix with minimal change.
3. Refactor if needed.
---
## 8) Agent Output Expectations
When producing changes, the agent should:
- Include tests with each behavior change.
- Keep patches focused and minimal.
- Summarize what changed and why.
---
## 9) Quality Gate Checklist (must pass)
- [ ] All tests pass (`unit`, `component`, `e2e` as applicable)
- [ ] Lint/format checks pass
- [ ] No flaky or time-dependent tests
- [ ] No new unused exports or dead code
- [ ] Accessibility reviewed for interactive changes
---
## 10) Default Commands (adjust if repo differs)
Prefer these commands if they exist:
- `npm test` / `pnpm test`
- `npm run test:watch`
- `npm run test:e2e`
- `npm run lint`
- `npm run format`
Follow `package.json` scripts if different.
---
## 11) Anti-Patterns to Avoid
- Writing behavior before tests (unless purely presentational).
- Mixing unrelated refactors with feature work.
- Over-mocking your own code.
- Snapshot tests for logic-heavy UI.
- Large shared “utils” dumping grounds.
---
## 12) When uncertain
- Choose the simplest interpretation.
- Write tests that document the behavior.
- Keep changes minimal and reversible.

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

View File

@ -1,152 +0,0 @@
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 "@/types/blog";
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 (
<>
{post.coverImage && <PostCoverImage post={post} />}
<PostMeta date={formattedDate} publishedAt={post.publishedAt} />
<PostTitle title={post.title} />
<PostAuthor author={post.authors[0]} />
</>
);
}
function PostCoverImage({ post }: { post: Post }) {
return (
<div className="relative aspect-video overflow-hidden rounded-lg">
<Image
src={post.coverImage}
alt={post.title}
loading="eager"
fill
className="rounded-lg object-cover"
/>
</div>
);
}
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">{title}</h1>
);
}
function PostAuthor({ author }: { author?: Author }) {
if (!author) return null;
return (
<div className="flex items-center justify-center gap-2">
<Image
src={author.image}
alt={author.name}
width={36}
height={36}
loading="eager"
className="aspect-square size-8 shrink-0 rounded-full"
/>
<p className="text-muted-foreground">{author.name}</p>
</div>
);
}
function PostContent({ html }: { html: string }) {
return (
<section className="pt-8">
<Prose html={html} />
</section>
);
}

View File

@ -1,73 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { BasePage } from "@/app/base-page";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import { getPosts } from "@/lib/blog/query";
import type { Author, Post } from "@/types/blog";
export const metadata: Metadata = {
title: "Blog - OpenCut",
description:
"Read the latest news and updates about OpenCut, the free and open-source video editor.",
openGraph: {
title: "Blog - OpenCut",
description:
"Read the latest news and updates about OpenCut, the free and open-source video editor.",
type: "website",
},
};
export default async function BlogPage() {
const data = await getPosts();
if (!data || !data.posts) return <div>No posts yet</div>;
return (
<BasePage
title="Blog"
description="Read the latest news and updates about OpenCut, the free and open-source video editor."
>
<div className="flex flex-col">
{data.posts.map((post) => (
<div key={post.id} className="flex flex-col">
<BlogPostItem post={post} />
<Separator />
</div>
))}
</div>
</BasePage>
);
}
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>
{post.authors && post.authors.length > 0 && (
<AuthorList authors={post.authors} />
)}
</div>
</Link>
);
}
function AuthorList({ authors }: { authors: Author[] }) {
return (
<div className="flex items-center gap-2">
{authors.map((author) => (
<div key={author.id} className="flex items-center gap-2">
<Avatar className="size-6 shadow-sm">
<AvatarImage src={author.image} alt={author.name} />
<AvatarFallback className="text-xs">
{author.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
))}
</div>
);
}

View File

@ -1,242 +0,0 @@
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>
);
}

View File

@ -1,21 +1,5 @@
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";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
alternates: {
canonical: SITE_URL,
},
};
export default async function Home() {
return (
<div>
<Header />
<Hero />
<Footer />
</div>
);
export default function Home() {
redirect("/projects");
}

View File

@ -1,144 +0,0 @@
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";
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: "Badge (potentially)",
description:
'An "Edit with OpenCut" badge web apps can integrate. Shows on video players.',
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: July 14, 2025)"
>
<div className="mx-auto flex max-w-4xl flex-col gap-16">
<div className="flex flex-col gap-6">
{roadmapItems.map((item, index) => (
<RoadmapItem key={item.title} item={item} index={index} />
))}
</div>
<GitHubContributeSection
title="Want to help?"
description="OpenCut is open source and built by the community. Every contribution,
no matter how small, helps us build the best free video editor
possible."
/>
</div>
</BasePage>
);
}
function RoadmapItem({ item, index }: { item: RoadmapItem; index: number }) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-lg font-medium">
<span className="leading-normal select-none">{index + 1}</span>
<h3>{item.title}</h3>
<StatusBadge status={item.status} className="ml-1" />
</div>
<div className="text-foreground/70 leading-relaxed">
<ReactMarkdownWrapper>{item.description}</ReactMarkdownWrapper>
</div>
</div>
);
}
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>
);
}

View File

@ -1,46 +0,0 @@
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 });
}
}

View File

@ -1,36 +1,19 @@
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",
changeFrequency: "daily",
priority: 1,
},
{
url: `${SITE_URL}/contributors`,
url: `${SITE_URL}/projects`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.5,
},
{
url: `${SITE_URL}/roadmap`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
priority: 0.9,
},
{
url: `${SITE_URL}/privacy`,
@ -44,18 +27,5 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
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,
];
}

View File

@ -1,82 +0,0 @@
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";
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="object-contain"
/>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<h3 className="text-xl font-semibold group-hover:underline">
{sponsor.name}
</h3>
<HugeiconsIcon
icon={LinkSquare02Icon}
className="text-muted-foreground size-4"
/>
</div>
<p className="text-muted-foreground">{sponsor.description}</p>
</div>
</CardContent>
</Card>
</Link>
);
}

View File

@ -57,7 +57,7 @@ export function Onboarding() {
<Title title={getStepTitle()} />
<Description description="There's still a ton of things to do to make this editor amazing." />
<Description description="A lot of features are still missing. We're working hard to build them out!" />
<Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" />
<Description description="If you're curious, check out our latest updates in [GitHub discussions](https://github.com/OpenCut-app/OpenCut/discussions)." />
</div>
<NextButton onClick={handleNext}>Next</NextButton>
</div>

View File

@ -16,15 +16,13 @@ type CategoryLinks = Record<Category, FooterLink[]>;
const links: CategoryLinks = {
resources: [
{ label: "Roadmap", href: "/roadmap" },
{ label: "Blog", href: "/blog" },
{ label: "Projects", href: "/projects" },
{ label: "Privacy", href: "/privacy" },
{ label: "Terms of use", href: "/terms" },
],
company: [
{ label: "Contributors", href: "/contributors" },
{ label: "Sponsors", href: "/sponsors" },
{ label: "Branding", href: "/branding" },
{ label: "GitHub", href: SOCIAL_LINKS.github },
{ label: "Discord", href: SOCIAL_LINKS.discord },
{ label: "About", href: `${SOCIAL_LINKS.github}/blob/main/README.md` },
],
};

View File

@ -17,18 +17,8 @@ export function Header() {
const closeMenu = () => setIsMenuOpen(false);
const links = [
{
label: "Contributors",
href: "/contributors",
},
{
label: "Sponsors",
href: "/sponsors",
},
{
label: "Blog",
href: "/blog",
},
{ label: "Privacy", href: "/privacy" },
{ label: "Terms", href: "/terms" },
];
return (

View File

@ -1,173 +0,0 @@
"use client";
import { type PropsWithChildren, useEffect, useRef, useState } from "react";
type HandlebarsProps = PropsWithChildren;
export function Handlebars({ children }: HandlebarsProps) {
const containerRef = useRef<HTMLDivElement>(null);
const leftHandleRef = useRef<HTMLDivElement>(null);
const rightHandleRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
const [leftHandle, setLeftHandle] = useState(0);
const [rightHandle, setRightHandle] = useState(0);
const widthRef = useRef(0);
const leftHandlePositionRef = useRef(0);
const rightHandlePositionRef = useRef(0);
const dragRef = useRef<{
isDragging: boolean;
side: "left" | "right" | null;
pointerId: number | null;
startX: number;
initialPosition: number;
}>({
isDragging: false,
side: null,
pointerId: null,
startX: 0,
initialPosition: 0,
});
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const updateWidth = () => {
const newWidth = el.offsetWidth;
setWidth(newWidth);
setRightHandle(newWidth);
};
const observer = new ResizeObserver(updateWidth);
observer.observe(el);
updateWidth();
return () => observer.disconnect();
}, []);
useEffect(() => {
widthRef.current = width;
leftHandlePositionRef.current = leftHandle;
rightHandlePositionRef.current = rightHandle;
}, [leftHandle, rightHandle, width]);
useEffect(() => {
const handlePointerMove = (event: PointerEvent) => {
const { isDragging, side, pointerId, startX, initialPosition } =
dragRef.current;
if (!isDragging) return;
if (pointerId !== null && event.pointerId !== pointerId) return;
if (!side) return;
const deltaX = event.clientX - startX;
if (side === "left") {
const maxLeft = Math.max(0, rightHandlePositionRef.current - 60);
const nextLeftHandle = Math.max(
0,
Math.min(maxLeft, initialPosition + deltaX),
);
setLeftHandle(nextLeftHandle);
return;
}
const minRight = Math.min(
widthRef.current,
leftHandlePositionRef.current + 60,
);
const nextRightHandle = Math.max(
minRight,
Math.min(widthRef.current, initialPosition + deltaX),
);
setRightHandle(nextRightHandle);
};
const handlePointerEnd = (event: PointerEvent) => {
const { pointerId } = dragRef.current;
if (pointerId !== null && event.pointerId !== pointerId) return;
dragRef.current.isDragging = false;
dragRef.current.side = null;
dragRef.current.pointerId = null;
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerEnd);
window.addEventListener("pointercancel", handlePointerEnd);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerEnd);
window.removeEventListener("pointercancel", handlePointerEnd);
};
}, []);
const leftGradientPercent = width > 0 ? (leftHandle / (width - 10)) * 100 : 0;
const rightGradientPercent =
width > 0 ? (rightHandle / (width + 10)) * 100 : 0;
return (
<div className="flex justify-center gap-4 leading-16">
<div ref={containerRef} className="relative mt-0.5 -rotate-[2.76deg]">
<div className="absolute inset-0 z-10 flex size-full justify-between rounded-2xl border border-yellow-500">
<div
ref={leftHandleRef}
className="bg-background absolute left-0 z-20 flex h-full w-7 cursor-ew-resize touch-none items-center justify-center rounded-full border border-yellow-500 select-none"
style={{
translate: `${leftHandle}px 0`,
}}
onPointerDown={(event) => {
event.preventDefault();
leftHandleRef.current?.setPointerCapture(event.pointerId);
dragRef.current.isDragging = true;
dragRef.current.side = "left";
dragRef.current.pointerId = event.pointerId;
dragRef.current.startX = event.clientX;
dragRef.current.initialPosition = leftHandlePositionRef.current;
}}
>
<div className="h-8 w-2 rounded-full bg-yellow-500" />
</div>
<div
ref={rightHandleRef}
className="bg-background absolute -left-[30px] z-20 flex h-full w-7 cursor-ew-resize touch-none items-center justify-center rounded-full border border-yellow-500 select-none"
style={{
translate: `${rightHandle}px 0`,
}}
onPointerDown={(event) => {
event.preventDefault();
rightHandleRef.current?.setPointerCapture(event.pointerId);
dragRef.current.isDragging = true;
dragRef.current.side = "right";
dragRef.current.pointerId = event.pointerId;
dragRef.current.startX = event.clientX;
dragRef.current.initialPosition = rightHandlePositionRef.current;
}}
>
<div className="h-8 w-2 rounded-full bg-yellow-500" />
</div>
</div>
<span
className="relative z-0 inline-flex size-full items-center justify-center rounded-2xl px-9 will-change-auto"
style={{
mask: `linear-gradient(90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) ${leftGradientPercent}%,
rgba(0, 0, 0) ${leftGradientPercent}%,
rgba(0, 0, 0) ${rightGradientPercent}%,
rgba(255, 255, 255, 0) ${rightGradientPercent}%,
rgba(255, 255, 255, 0) 100%)`,
}}
>
{children}
</span>
</div>
</div>
);
}

View File

@ -1,46 +0,0 @@
"use client";
import { Button } from "../ui/button";
import { ArrowRight } from "lucide-react";
import Image from "next/image";
import { Handlebars } from "./handlebars";
import Link from "next/link";
export function Hero() {
return (
<div className="flex min-h-[calc(100svh-4.5rem)] flex-col items-center justify-between px-4 text-center">
<Image
className="absolute top-0 left-0 -z-50 size-full object-cover opacity-85 invert dark:invert-0"
src="/landing-page-dark.png"
height={1903.5}
width={1269}
alt="landing-page.bg"
/>
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center">
<div className="inline-block text-4xl font-bold tracking-tighter md:text-[4rem]">
<h1>The open source</h1>
<Handlebars>Video editor</Handlebars>
</div>
<p className="text-muted-foreground mx-auto mt-10 max-w-xl text-base font-light tracking-wide sm:text-xl">
A simple but powerful video editor that gets the job done. Works on
any platform.
</p>
<div className="mt-8 flex justify-center gap-8">
<Link href="/projects">
<Button
variant="foreground"
type="submit"
size="lg"
className="h-11 text-base"
>
Try early beta
<ArrowRight className="ml-0.5" />
</Button>
</Link>
</div>
</div>
</div>
);
}

View File

@ -1,72 +0,0 @@
import type {
MarbleAuthorList,
MarbleCategoryList,
MarblePost,
MarblePostList,
MarbleTagList,
} from "@/types/blog";
import { unified } from "unified";
import rehypeParse from "rehype-parse";
import rehypeStringify from "rehype-stringify";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeSanitize from "rehype-sanitize";
const url =
process.env.NEXT_PUBLIC_MARBLE_API_URL ?? "https://api.marblecms.com";
const key = process.env.MARBLE_WORKSPACE_KEY ?? "cmd4iw9mm0006l804kwqv0k46";
async function fetchFromMarble<T>({
endpoint,
}: {
endpoint: string;
}): Promise<T> {
try {
const response = await fetch(`${url}/${key}/${endpoint}`);
if (!response.ok) {
throw new Error(
`Failed to fetch ${endpoint}: ${response.status} ${response.statusText}`,
);
}
return (await response.json()) as T;
} catch (error) {
console.error(`Error fetching ${endpoint}:`, error);
throw error;
}
}
export async function getPosts() {
return fetchFromMarble<MarblePostList>({ endpoint: "posts" });
}
export async function getTags() {
return fetchFromMarble<MarbleTagList>({ endpoint: "tags" });
}
export async function getSinglePost({ slug }: { slug: string }) {
return fetchFromMarble<MarblePost>({ endpoint: `posts/${slug}` });
}
export async function getCategories() {
return fetchFromMarble<MarbleCategoryList>({ endpoint: "categories" });
}
export async function getAuthors() {
return fetchFromMarble<MarbleAuthorList>({ endpoint: "authors" });
}
export async function processHtmlContent({
html,
}: {
html: string;
}): Promise<string> {
const processor = unified()
.use(rehypeSanitize)
.use(rehypeParse, { fragment: true })
.use(rehypeSlug)
.use(rehypeAutolinkHeadings, { behavior: "append" })
.use(rehypeStringify);
const file = await processor.process({ value: html, type: "html" });
return String(file);
}

View File

@ -1,92 +0,0 @@
export type Post = {
id: string;
slug: string;
title: string;
content: string;
description: string;
coverImage: string;
publishedAt: Date;
updatedAt: Date;
authors: {
id: string;
name: string;
image: string;
}[];
category: {
id: string;
slug: string;
name: string;
};
tags: {
id: string;
slug: string;
name: string;
}[];
attribution: {
author: string;
url: string;
} | null;
};
export type Pagination = {
limit: number;
currpage: number;
nextPage: number | null;
prevPage: number | null;
totalItems: number;
totalPages: number;
};
export type MarblePostList = {
posts: Post[];
pagination: Pagination;
};
export type MarblePost = {
post: Post;
};
export type Tag = {
id: string;
name: string;
slug: string;
};
export type MarbleTag = {
tag: Tag;
};
export type MarbleTagList = {
tags: Tag[];
pagination: Pagination;
};
export type Category = {
id: string;
name: string;
slug: string;
};
export type MarbleCategory = {
category: Category;
};
export type MarbleCategoryList = {
categories: Category[];
pagination: Pagination;
};
export type Author = {
id: string;
name: string;
image: string;
};
export type MarbleAuthor = {
author: Author;
};
export type MarbleAuthorList = {
authors: Author[];
pagination: Pagination;
};

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "opencut",

View File

@ -0,0 +1,47 @@
# Clean Up Repo and Remove Home Page
## Goal
Reduce surface area for the initial release by removing the public marketing/home page and keeping only product-critical routes.
## User Stories
- As a product owner, I want only core editor routes enabled so maintenance burden is lower.
- As a developer, I want a simplified app structure so new features are easier to ship.
- As a user, I want to land directly on the app flow instead of a marketing page
## Scope
- Remove or deprecate current home page route (`/`).
- Redirect `/` to the primary product entry point (`/projects`).
- Remove non-product marketing/content routes from app runtime:
- `/blog`
- `/blog/[slug]`
- `/contributors`
- `/roadmap`
- `/sponsors`
- `/rss.xml`
- Remove unused landing-page components/assets tied only to home page.
- Update header/footer links to avoid removed pages.
- Update sitemap entries to include only active product/legal routes.
## Technical Plan
1. Identify root route implementation under `apps/web/src/app/page.tsx` (or equivalent).
2. Replace with server redirect using Next.js `redirect()`.
3. Remove non-product routes and related content modules.
4. Remove dead UI blocks/components no longer referenced.
5. Update header/footer links that still point to removed sections.
6. Update sitemap and route metadata to avoid deleted pages.
7. Run lint/build and smoke test key routes.
## Acceptance Criteria
- Visiting `/` redirects to chosen product entry route.
- Visiting removed marketing routes returns `404`.
- No broken imports from removed home-page files.
- No dead links in top-level navigation.
- Build succeeds without route-level errors.
## Risks and Mitigations
- Risk: Breaking SEO or legal content discoverability.
- Mitigation: Keep explicit routes for `/privacy`, `/terms`, etc.
## Deliverables
- PR removing home page and related dead files.
- Short migration note in README/changelog.

View File

@ -0,0 +1,38 @@
# Add Storybook and Test Coverage Baseline
## Goal
Establish UI review and automated quality checks with minimum viable Storybook + test coverage.
## User Stories
- As a designer/developer, I want isolated component previews to review UI quickly.
- As a maintainer, I want basic automated tests to prevent regressions.
- As a team lead, I want coverage visibility to track test health.
## Scope
- Add Storybook configuration for `apps/web` UI components.
- Create initial stories for core reusable UI components.
- Add unit tests for critical domain logic (project save/migrations, timeline helpers).
- Add coverage output and CI reporting.
## Technical Plan
1. Install Storybook for Next.js app and create `.storybook` config.
2. Add stories for top-priority components (button/input/dialog/editor panel shell).
3. Standardize test runner (Bun test or Vitest) and add scripts.
4. Add coverage reporting (`lcov` + text summary).
5. Add CI job for tests and coverage threshold checks.
## Acceptance Criteria
- `storybook` runs locally and stories load.
- At least 10 representative stories exist for shared UI.
- Tests run via one command from root.
- Coverage report generated in CI artifacts.
- Initial minimum threshold enforced (for example 30% lines, then ratchet upward).
## Risks and Mitigations
- Risk: Tooling drift with Bun/Next setup.
- Mitigation: Keep scripts simple and documented in README.
## Deliverables
- Storybook setup + starter stories.
- Test/coverage scripts and CI workflow update.
- Testing strategy doc for future contributors.

View File

@ -0,0 +1,39 @@
# Add Auth Pages and Auth Flow
## Goal
Enable optional account login/signup UX so users can authenticate for cloud features.
## User Stories
- As a user, I want to sign up/sign in with email and password.
- As a returning user, I want session persistence across visits.
- As a product owner, I want routes that require account features to enforce auth.
## Scope
- Add auth screens (Sign In, Sign Up, optional Forgot Password).
- Wire screens to existing Better Auth endpoints (`/api/auth/[...all]`).
- Add protected route handling for account-required pages.
- Add user menu/session indicator in app shell.
## Technical Plan
1. Build pages under `apps/web/src/app` (for example `/sign-in`, `/sign-up`).
2. Use `signIn`, `signUp`, `useSession` from `apps/web/src/lib/auth/client.ts`.
3. Add route guard strategy:
- Middleware for protected paths, or
- Server checks at page/layout level.
4. Define post-login redirect target and logout behavior.
5. Add form validation, loading, and error states.
## Acceptance Criteria
- New users can create account from UI.
- Existing users can log in and maintain session.
- Protected routes redirect unauthenticated users to sign-in.
- Auth errors display clear, actionable messages.
## Risks and Mitigations
- Risk: Breaking existing anonymous/local-only editing flow.
- Mitigation: Keep editor usable without auth unless cloud-only actions are triggered.
## Deliverables
- Auth screens and route protection.
- Updated navigation/session UI.
- Basic auth E2E tests.

View File

@ -0,0 +1,56 @@
# Cloud Sync for Projects and Assets
## Goal
Provide reliable sync of project state and media assets across devices/accounts.
## User Stories
- As a logged-in user, I want my projects available on another device.
- As a user, I want large media files to upload reliably and resume when interrupted.
- As a user, I want local edits to sync automatically without losing data.
## Scope
- Sync project JSON snapshots to backend storage.
- Sync media assets to object storage.
- Add conflict handling and sync status UI.
- Keep offline-first local save as primary immediate write path.
## Data Model (Proposed)
- `projects` table: `id`, `user_id`, `name`, `project_json`, `version`, timestamps.
- `project_media` table: `project_id`, `asset_id`, `storage_key`, `checksum`, `size`, `mime`, timestamps.
## Technical Plan
1. Backend API
- `GET /api/projects`
- `GET /api/projects/:id`
- `PUT /api/projects/:id` (optimistic concurrency)
- `POST /api/projects/:id/media/presign`
2. Client Sync Service
- Hook into existing autosave flow after local save success.
- Queue sync tasks (project snapshot, then missing media uploads).
- Retry with backoff when offline/errors occur.
3. Upload Strategy
- Use presigned multipart uploads for large files.
- Track checksum to avoid re-upload unchanged assets.
4. Conflict Strategy (v1)
- Version-based conflict detect.
- If conflict, fetch latest and prompt user to choose local or remote.
## Acceptance Criteria
- Logged-in user can create project on device A and open on device B.
- Media assets referenced by timeline are available after sync completes.
- Failed uploads retry automatically and expose status.
- No loss of local data when cloud is unavailable.
## Risks and Mitigations
- Risk: Cost and latency for large asset storage.
- Mitigation: Incremental uploads, dedupe by checksum, optional upload limits.
- Risk: Conflict complexity.
- Mitigation: Start with simple version conflict policy before merge logic.
## Deliverables
- DB schema migrations and API endpoints.
- Client cloud sync manager integrated with save lifecycle.
- Sync status indicators and error recovery UX.