chore: remove marketing routes and align app-first navigation
This commit is contained in:
parent
464a6e83b9
commit
a36dbaaf1f
215
AGENTS.md
215
AGENTS.md
|
|
@ -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 what’s 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.
|
||||
|
|
@ -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 |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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` },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
1
bun.lock
1
bun.lock
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "opencut",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue