diff options
61 files changed, 1682 insertions, 702 deletions
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 85cc4b23..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '[BUG] ' -labels: bug -assignees: '' ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] - -**Additional context** -Add any other context about the problem here.
\ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..c9684d1f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,70 @@ +name: Bug report +description: Create a report to help us improve +title: '[BUG] ' +labels: bug +body: + - type: input + id: Platform + attributes: + label: Platform + description: Please enter the platform on which you encountered the bug. + placeholder: e.g. Windows 11, Ubuntu 14.04 + validations: + required: true + - type: input + id: Browser + attributes: + label: Browser + description: Please enter the browser on which you encountered the bug. + placeholder: e.g. Chrome 137, Firefox 137, Safari 17 + validations: + required: true + - type: textarea + id: current-behavior + attributes: + label: Current Behavior + description: A concise description of what you're experiencing. + validations: + required: true + - type: textarea + id: expected-behavior + attributes: + label: Expected Behavior + description: A concise description of what you expected to happen. + validations: + required: false + - type: dropdown + id: recurrence-probability + attributes: + label: Recurrence Probability + description: How often does this bug occur? + options: + - Always + - Usually + - Sometimes + - Seldom + default: 0 + validations: + required: true + - type: textarea + id: steps-to-reproduce + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + - type: textarea + id: additional-context + attributes: + label: Anything else? + description: | + Links? References? Anything that will give us more context about the issue you are encountering! + + Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. + validations: + required: false
\ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 1d528f25..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '[FEATURE] ' -labels: enhancement -assignees: '' ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here.
\ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..49a00eb6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,42 @@ +name: Feature request +description: Suggest an idea for OpenCut +title: '[FEATURE] ' +labels: enhancement +body: + - type: markdown + attributes: + value: Please make sure that no duplicated issues has already been delivered. + - type: textarea + id: problem + attributes: + label: Problem + placeholder: Is your feature request related to a problem? Please describe. + description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + validations: + required: true + - type: textarea + id: solution + attributes: + label: Solution + placeholder: Describe the solution you'd like. + description: A clear and concise description of what you want to happen. + validations: + required: true + - type: textarea + id: alternative + attributes: + label: Alternative + placeholder: Describe alternatives you've considered. + description: A clear and concise description of any alternative solutions or features you've considered. + validations: + required: true + - type: textarea + id: additional-context + attributes: + label: Anything else? + description: | + Links? References? Anything that will give us more context about the issue you are encountering! + + Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. + validations: + required: false
\ No newline at end of file diff --git a/.github/workflows/bun-ci.yml b/.github/workflows/bun-ci.yml index 4df6212a..34b6d516 100644 --- a/.github/workflows/bun-ci.yml +++ b/.github/workflows/bun-ci.yml @@ -31,13 +31,13 @@ jobs: - name: Install Bun uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 with: - bun-version: 1.2.2 + bun-version: 1.2.17 - name: Cache Bun modules uses: actions/cache@v4 with: path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('apps/web/bun.lock') }} + key: ${{ runner.os }}-bun-1.2.17-${{ hashFiles('apps/web/bun.lock') }} - name: Install dependencies working-directory: apps/web @@ -27,4 +27,8 @@ node_modules .cursorignore .turbo -*.env
\ No newline at end of file +*.env + +# cursor + +.cursor/
\ No newline at end of file @@ -59,7 +59,16 @@ Before you begin, ensure you have the following installed on your system: Navigate into the web app's directory and create a `.env` file from the example: ```bash cd apps/web - cp .env.example .env + + + # Unix/Linux/Mac + cp .env.example .env.local + + # Windows Command Prompt + copy .env.example .env.local + + # Windows PowerShell + Copy-Item .env.example .env.local ``` *The default values in the `.env` file should work for local development.* @@ -94,13 +103,13 @@ Before you begin, ensure you have the following installed on your system: The application will be available at [http://localhost:3000](http://localhost:3000). -======= +--- ## Contributing Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md) -======= +--- We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines. Quick start for contributors: diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 8b13ce38..46eeb102 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -19,7 +19,8 @@ COPY apps/web/ apps/web/ COPY packages/db/ packages/db/
COPY packages/auth/ packages/auth/
-ENV NEXT_TELEMETRY_DISABLED=1
+ENV NODE_ENV production
+ENV NEXT_TELEMETRY_DISABLED 1
WORKDIR /app/apps/web
RUN bun run build
@@ -28,7 +29,8 @@ RUN bun run build FROM base AS runner
WORKDIR /app
-ENV NEXT_TELEMETRY_DISABLED=1
+ENV NODE_ENV production
+ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
diff --git a/apps/web/public/browserconfig.xml b/apps/web/public/browserconfig.xml new file mode 100644 index 00000000..39060b21 --- /dev/null +++ b/apps/web/public/browserconfig.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<browserconfig> + <msapplication> + <tile> + <square70x70logo src="/icons/ms-icon-70x70.png"/> + <square150x150logo src="/icons/ms-icon-150x150.png"/> + <square310x310logo src="/icons/ms-icon-310x310.png"/> + <TileColor>#ffffff</TileColor> + </tile> + </msapplication> +</browserconfig>
\ No newline at end of file diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico Binary files differnew file mode 100644 index 00000000..9b2cba8f --- /dev/null +++ b/apps/web/public/favicon.ico diff --git a/apps/web/public/frame.svg b/apps/web/public/frame.svg new file mode 100644 index 00000000..4dac68e9 --- /dev/null +++ b/apps/web/public/frame.svg @@ -0,0 +1,10 @@ +<svg width="459" height="77" viewBox="0 0 459 77" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect x="0.5" y="1.15625" width="458" height="75" rx="15.5" fill="#101010"/> +<rect x="0.5" y="1.15625" width="458" height="75" rx="15.5" stroke="#FFCC00"/> +<rect x="0.5" y="1.15625" width="31" height="75" rx="15.5" fill="#2A2A2A"/> +<rect x="0.5" y="1.15625" width="31" height="75" rx="15.5" stroke="#FFCC00"/> +<rect x="13" y="20.6562" width="6" height="36" rx="3" fill="#FFCC00"/> +<rect x="427.5" y="1.15625" width="31" height="75" rx="15.5" fill="#2A2A2A"/> +<rect x="427.5" y="1.15625" width="31" height="75" rx="15.5" stroke="#FFCC00"/> +<rect x="440" y="20.6562" width="6" height="36" rx="3" fill="#FFCC00"/> +</svg> diff --git a/apps/web/public/icons/android-icon-144x144.png b/apps/web/public/icons/android-icon-144x144.png Binary files differnew file mode 100644 index 00000000..7320ece7 --- /dev/null +++ b/apps/web/public/icons/android-icon-144x144.png diff --git a/apps/web/public/icons/android-icon-192x192.png b/apps/web/public/icons/android-icon-192x192.png Binary files differnew file mode 100644 index 00000000..f534f429 --- /dev/null +++ b/apps/web/public/icons/android-icon-192x192.png diff --git a/apps/web/public/icons/android-icon-36x36.png b/apps/web/public/icons/android-icon-36x36.png Binary files differnew file mode 100644 index 00000000..6b8db020 --- /dev/null +++ b/apps/web/public/icons/android-icon-36x36.png diff --git a/apps/web/public/icons/android-icon-48x48.png b/apps/web/public/icons/android-icon-48x48.png Binary files differnew file mode 100644 index 00000000..30a68fa5 --- /dev/null +++ b/apps/web/public/icons/android-icon-48x48.png diff --git a/apps/web/public/icons/android-icon-72x72.png b/apps/web/public/icons/android-icon-72x72.png Binary files differnew file mode 100644 index 00000000..e4019c84 --- /dev/null +++ b/apps/web/public/icons/android-icon-72x72.png diff --git a/apps/web/public/icons/android-icon-96x96.png b/apps/web/public/icons/android-icon-96x96.png Binary files differnew file mode 100644 index 00000000..85591ab4 --- /dev/null +++ b/apps/web/public/icons/android-icon-96x96.png diff --git a/apps/web/public/icons/apple-icon-114x114.png b/apps/web/public/icons/apple-icon-114x114.png Binary files differnew file mode 100644 index 00000000..c776e3f9 --- /dev/null +++ b/apps/web/public/icons/apple-icon-114x114.png diff --git a/apps/web/public/icons/apple-icon-120x120.png b/apps/web/public/icons/apple-icon-120x120.png Binary files differnew file mode 100644 index 00000000..76604df8 --- /dev/null +++ b/apps/web/public/icons/apple-icon-120x120.png diff --git a/apps/web/public/icons/apple-icon-144x144.png b/apps/web/public/icons/apple-icon-144x144.png Binary files differnew file mode 100644 index 00000000..7320ece7 --- /dev/null +++ b/apps/web/public/icons/apple-icon-144x144.png diff --git a/apps/web/public/icons/apple-icon-152x152.png b/apps/web/public/icons/apple-icon-152x152.png Binary files differnew file mode 100644 index 00000000..eb5ef0df --- /dev/null +++ b/apps/web/public/icons/apple-icon-152x152.png diff --git a/apps/web/public/icons/apple-icon-180x180.png b/apps/web/public/icons/apple-icon-180x180.png Binary files differnew file mode 100644 index 00000000..b90e62fb --- /dev/null +++ b/apps/web/public/icons/apple-icon-180x180.png diff --git a/apps/web/public/icons/apple-icon-57x57.png b/apps/web/public/icons/apple-icon-57x57.png Binary files differnew file mode 100644 index 00000000..7cfb2e56 --- /dev/null +++ b/apps/web/public/icons/apple-icon-57x57.png diff --git a/apps/web/public/icons/apple-icon-60x60.png b/apps/web/public/icons/apple-icon-60x60.png Binary files differnew file mode 100644 index 00000000..1d90a370 --- /dev/null +++ b/apps/web/public/icons/apple-icon-60x60.png diff --git a/apps/web/public/icons/apple-icon-72x72.png b/apps/web/public/icons/apple-icon-72x72.png Binary files differnew file mode 100644 index 00000000..e4019c84 --- /dev/null +++ b/apps/web/public/icons/apple-icon-72x72.png diff --git a/apps/web/public/icons/apple-icon-76x76.png b/apps/web/public/icons/apple-icon-76x76.png Binary files differnew file mode 100644 index 00000000..34575f42 --- /dev/null +++ b/apps/web/public/icons/apple-icon-76x76.png diff --git a/apps/web/public/icons/favicon-16x16.png b/apps/web/public/icons/favicon-16x16.png Binary files differnew file mode 100644 index 00000000..96db3a2f --- /dev/null +++ b/apps/web/public/icons/favicon-16x16.png diff --git a/apps/web/public/icons/favicon-32x32.png b/apps/web/public/icons/favicon-32x32.png Binary files differnew file mode 100644 index 00000000..f8f589dc --- /dev/null +++ b/apps/web/public/icons/favicon-32x32.png diff --git a/apps/web/public/icons/favicon-96x96.png b/apps/web/public/icons/favicon-96x96.png Binary files differnew file mode 100644 index 00000000..85591ab4 --- /dev/null +++ b/apps/web/public/icons/favicon-96x96.png diff --git a/apps/web/public/icons/ms-icon-144x144.png b/apps/web/public/icons/ms-icon-144x144.png Binary files differnew file mode 100644 index 00000000..7320ece7 --- /dev/null +++ b/apps/web/public/icons/ms-icon-144x144.png diff --git a/apps/web/public/icons/ms-icon-150x150.png b/apps/web/public/icons/ms-icon-150x150.png Binary files differnew file mode 100644 index 00000000..248ef48a --- /dev/null +++ b/apps/web/public/icons/ms-icon-150x150.png diff --git a/apps/web/public/icons/ms-icon-310x310.png b/apps/web/public/icons/ms-icon-310x310.png Binary files differnew file mode 100644 index 00000000..950a3d6b --- /dev/null +++ b/apps/web/public/icons/ms-icon-310x310.png diff --git a/apps/web/public/icons/ms-icon-70x70.png b/apps/web/public/icons/ms-icon-70x70.png Binary files differnew file mode 100644 index 00000000..7b9c2694 --- /dev/null +++ b/apps/web/public/icons/ms-icon-70x70.png diff --git a/apps/web/public/landing-page-bg.png b/apps/web/public/landing-page-bg.png Binary files differnew file mode 100644 index 00000000..4f873945 --- /dev/null +++ b/apps/web/public/landing-page-bg.png diff --git a/apps/web/public/logo.svg b/apps/web/public/logo.svg new file mode 100644 index 00000000..f4079a5e --- /dev/null +++ b/apps/web/public/logo.svg @@ -0,0 +1,10 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_10_2)"> +<path d="M32 9.37305V22.627L22.627 32H9.37305L0 22.627V9.37305L9.37305 0H22.627L32 9.37305ZM8 8V24H24V8H8Z" fill="white"/> +</g> +<defs> +<clipPath id="clip0_10_2"> +<rect width="32" height="32" fill="white"/> +</clipPath> +</defs> +</svg> diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json new file mode 100644 index 00000000..fcbfad59 --- /dev/null +++ b/apps/web/public/manifest.json @@ -0,0 +1,44 @@ +{ + "name": "OpenCut", + "description": "A simple but powerful video editor that gets the job done. In your browser.", + "display": "standalone", + "start_url": "/", + "icons": [ + { + "src": "/icons/android-icon-36x36.png", + "sizes": "36x36", + "type": "image\/png", + "density": "0.75" + }, + { + "src": "/icons/android-icon-48x48.png", + "sizes": "48x48", + "type": "image\/png", + "density": "1.0" + }, + { + "src": "/icons/android-icon-72x72.png", + "sizes": "72x72", + "type": "image\/png", + "density": "1.5" + }, + { + "src": "/icons/android-icon-96x96.png", + "sizes": "96x96", + "type": "image\/png", + "density": "2.0" + }, + { + "src": "/icons/android-icon-144x144.png", + "sizes": "144x144", + "type": "image\/png", + "density": "3.0" + }, + { + "src": "/icons/android-icon-192x192.png", + "sizes": "192x192", + "type": "image\/png", + "density": "4.0" + } + ] +}
\ No newline at end of file diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx index f5fa0755..67957c32 100644 --- a/apps/web/src/app/contributors/page.tsx +++ b/apps/web/src/app/contributors/page.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { GithubIcon } from "@/components/icons";
+import { Badge } from "@/components/ui/badge";
export const metadata: Metadata = {
title: "Contributors - OpenCut",
@@ -49,7 +50,7 @@ async function getContributors(): Promise<Contributor[]> { const contributors = await response.json();
const filteredContributors = contributors.filter(
- (contributor: any) => contributor.type === "User"
+ (contributor: Contributor) => contributor.type === "User"
);
return filteredContributors;
@@ -61,8 +62,8 @@ async function getContributors(): Promise<Contributor[]> { export default async function ContributorsPage() {
const contributors = await getContributors();
- const topContributor = contributors[0];
- const otherContributors = contributors.slice(1);
+ const topContributors = contributors.slice(0, 2);
+ const otherContributors = contributors.slice(2);
return (
<div className="min-h-screen bg-background">
@@ -77,10 +78,10 @@ export default async function ContributorsPage() { <div className="relative container mx-auto px-4 py-16">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-20">
- <div className="inline-flex items-center gap-2 bg-muted/50 text-muted-foreground px-3 py-1 rounded-full text-sm mb-6">
+ <Badge variant="secondary" className="gap-2 mb-6">
<GithubIcon className="h-3 w-3" />
Open Source
- </div>
+ </Badge>
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
Contributors
</h1>
@@ -105,54 +106,59 @@ export default async function ContributorsPage() { </div>
</div>
- {topContributor && (
+ {topContributors.length > 0 && (
<div className="mb-20">
<div className="text-center mb-12">
<h2 className="text-2xl font-semibold mb-2">
- Top Contributor
+ Top Contributors
</h2>
<p className="text-muted-foreground">
Leading the way in contributions
</p>
</div>
- <Link
- href={topContributor.html_url}
- target="_blank"
- rel="noopener noreferrer"
- className="group block"
- >
- <div className="relative mx-auto max-w-md">
- <div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" />
- <Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
- <CardContent className="p-8 text-center">
- <div className="relative mb-6">
- <Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
- <AvatarImage
- src={topContributor.avatar_url}
- alt={`${topContributor.login}'s avatar`}
- />
- <AvatarFallback className="text-lg font-semibold">
- {topContributor.login.charAt(0).toUpperCase()}
- </AvatarFallback>
- </Avatar>
- <div className="absolute -top-2 -right-2 bg-foreground text-background rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold">
- 1
- </div>
- </div>
- <h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
- {topContributor.login}
- </h3>
- <div className="flex items-center justify-center gap-2 text-muted-foreground">
- <span className="font-medium text-foreground">
- {topContributor.contributions}
- </span>
- <span>contributions</span>
- </div>
- </CardContent>
- </Card>
- </div>
- </Link>
+ <div className="flex flex-col md:flex-row gap-6 justify-center max-w-4xl mx-auto">
+ {topContributors.map((contributor, index) => (
+ <Link
+ key={contributor.id}
+ href={contributor.html_url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="group block flex-1"
+ >
+ <div className="relative mx-auto max-w-md">
+ <div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" />
+ <Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
+ <CardContent className="p-8 text-center">
+ <div className="relative mb-6">
+ <Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
+ <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="absolute -top-2 -right-2 bg-foreground text-background rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold">
+ {index + 1}
+ </div>
+ </div>
+ <h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
+ {contributor.login}
+ </h3>
+ <div className="flex items-center justify-center gap-2 text-muted-foreground">
+ <span className="font-medium text-foreground">
+ {contributor.contributions}
+ </span>
+ <span>contributions</span>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ </Link>
+ ))}
+ </div>
</div>
)}
@@ -167,7 +173,7 @@ export default async function ContributorsPage() { </p>
</div>
- <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
{otherContributors.map((contributor, index) => (
<Link
key={contributor.id}
@@ -179,8 +185,8 @@ export default async function ContributorsPage() { animationDelay: `${index * 50}ms`,
}}
>
- <div className="text-center p-4 rounded-xl hover:bg-muted/50 transition-all duration-300 group-hover:scale-105">
- <Avatar className="h-16 w-16 mx-auto mb-3 ring-2 ring-transparent group-hover:ring-muted-foreground/20 transition-all duration-300">
+ <div className="text-center p-2 rounded-xl transition-all duration-300 hover:opacity-50">
+ <Avatar className="h-16 w-16 mx-auto mb-3">
<AvatarImage
src={contributor.avatar_url}
alt={`${contributor.login}'s avatar`}
diff --git a/apps/web/src/app/favicon.ico b/apps/web/src/app/favicon.ico Binary files differdeleted file mode 100644 index cb20127b..00000000 --- a/apps/web/src/app/favicon.ico +++ /dev/null diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 2169dbbc..1c8a7887 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -39,7 +39,7 @@ --sidebar-ring: 0 0% 3.9%; } .dark { - --background: 0 0% 8%; + --background: 0 0% 6%; --foreground: 0 0% 98%; --card: 0 0% 3.9%; --card-foreground: 0 0% 98%; diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 5d33100d..8188d31b 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,4 +1,3 @@ -import type { Metadata } from "next"; import { Inter } from "next/font/google"; import { ThemeProvider } from "next-themes"; import { Analytics } from "@vercel/analytics/react"; @@ -6,46 +5,15 @@ import Script from "next/script"; import "./globals.css"; import { Toaster } from "../components/ui/sonner"; import { TooltipProvider } from "../components/ui/tooltip"; +import { DevelopmentDebug } from "../components/development-debug"; +import { baseMetaData } from "./metadata"; const inter = Inter({ subsets: ["latin"], variable: "--font-inter", }); -export const metadata: Metadata = { - title: "OpenCut", - description: - "A simple but powerful video editor that gets the job done. In your browser.", - openGraph: { - title: "OpenCut", - description: - "A simple but powerful video editor that gets the job done. In your browser.", - url: "https://opencut.app", - siteName: "OpenCut", - locale: "en_US", - type: "website", - images: [ - { - url: "https://opencut.app/opengraph-image.jpg", - width: 1200, - height: 630, - alt: "OpenCut", - }, - ], - }, - twitter: { - card: "summary_large_image", - title: "OpenCut", - description: - "A simple but powerful video editor that gets the job done. In your browser.", - creator: "@opencutapp", - images: ["/opengraph-image.jpg"], - }, - robots: { - index: true, - follow: true, - }, -}; +export const metadata = baseMetaData; export default function RootLayout({ children, @@ -60,6 +28,7 @@ export default function RootLayout({ {children} <Analytics /> <Toaster /> + <DevelopmentDebug /> <Script src="https://app.databuddy.cc/databuddy.js" strategy="afterInteractive" diff --git a/apps/web/src/app/metadata.ts b/apps/web/src/app/metadata.ts new file mode 100644 index 00000000..4e387014 --- /dev/null +++ b/apps/web/src/app/metadata.ts @@ -0,0 +1,66 @@ +import { Metadata } from "next"; + +const title = "OpenCut"; +const description = "A simple but powerful video editor that gets the job done. In your browser."; +const openGraphImageUrl = "https://opencut.app/opengraph-image.jpg"; +const twitterImageUrl = "/opengraph-image.jpg"; + +export const baseMetaData: Metadata = { + title: title, + description: description, + openGraph: { + title: title, + description: description, + url: "https://opencut.app", + siteName: "OpenCut", + locale: "en_US", + type: "website", + images: [ + { + url: openGraphImageUrl, + width: 1200, + height: 630, + alt: "OpenCut", + }, + ], + }, + twitter: { + card: "summary_large_image", + title: title, + description: description, + creator: "@opencutapp", + images: [twitterImageUrl], + }, + robots: { + index: true, + follow: true, + }, + icons: { + icon: [ + { url: "/favicon.ico" }, + { url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" }, + { url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" }, + { url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" }, + ], + apple: [ + { url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" }, + { url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" }, + { url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" }, + { url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" }, + { url: "/icons/apple-icon-114x114.png", sizes: "114x114", type: "image/png" }, + { url: "/icons/apple-icon-120x120.png", sizes: "120x120", type: "image/png" }, + { url: "/icons/apple-icon-144x144.png", sizes: "144x144", type: "image/png" }, + { url: "/icons/apple-icon-152x152.png", sizes: "152x152", type: "image/png" }, + { url: "/icons/apple-icon-180x180.png", sizes: "180x180", type: "image/png" }, + ], + shortcut: ["/favicon.ico"] + }, + appleWebApp: { + capable: true, + title: title, + }, + manifest: "/manifest.json", + other: { + "msapplication-config": "/browserconfig.xml" + } +}; diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index dcedab32..e9957e1b 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,6 +1,7 @@ import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header";
import { getWaitlistCount } from "@/lib/waitlist";
+import Image from "next/image";
// Force dynamic rendering so waitlist count updates in real-time
export const dynamic = "force-dynamic";
@@ -10,6 +11,13 @@ export default async function Home() { return (
<div>
+ <Image
+ className="fixed top-0 left-0 -z-50 size-full object-cover"
+ src="/landing-page-bg.png"
+ height={1903.5}
+ width={1269}
+ alt="landing-page.bg"
+ />
<Header />
<Hero signupCount={signupCount} />
</div>
diff --git a/apps/web/src/components/development-debug.tsx b/apps/web/src/components/development-debug.tsx new file mode 100644 index 00000000..aef69999 --- /dev/null +++ b/apps/web/src/components/development-debug.tsx @@ -0,0 +1,107 @@ +"use client";
+
+import {
+ useTimelineStore,
+ type TimelineClip,
+ type TimelineTrack,
+} from "@/stores/timeline-store";
+import { useMediaStore, type MediaItem } from "@/stores/media-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+import { Button } from "@/components/ui/button";
+import { useState } from "react";
+
+// Only show in development
+const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
+
+interface ActiveClip {
+ clip: TimelineClip;
+ track: TimelineTrack;
+ mediaItem: MediaItem | null;
+}
+
+export function DevelopmentDebug() {
+ const { tracks } = useTimelineStore();
+ const { mediaItems } = useMediaStore();
+ const { currentTime } = usePlaybackStore();
+ const [showDebug, setShowDebug] = useState(false);
+
+ // Don't render anything in production
+ if (!SHOW_DEBUG_INFO) return null;
+
+ // Get active clips at current time
+ const getActiveClips = (): ActiveClip[] => {
+ const activeClips: ActiveClip[] = [];
+
+ tracks.forEach((track) => {
+ track.clips.forEach((clip) => {
+ const clipStart = clip.startTime;
+ const clipEnd =
+ clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+
+ if (currentTime >= clipStart && currentTime < clipEnd) {
+ const mediaItem =
+ clip.mediaId === "test"
+ ? null // Test clips don't have a real media item
+ : mediaItems.find((item) => item.id === clip.mediaId) || null;
+
+ activeClips.push({ clip, track, mediaItem });
+ }
+ });
+ });
+
+ return activeClips;
+ };
+
+ const activeClips = getActiveClips();
+
+ return (
+ <div className="fixed bottom-4 right-4 z-50">
+ <div className="flex flex-col items-end gap-2">
+ {/* Toggle Button */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setShowDebug(!showDebug)}
+ className="text-xs backdrop-blur-md bg-background/80 border-border/50"
+ >
+ Debug {showDebug ? "ON" : "OFF"}
+ </Button>
+
+ {/* Debug Info Panel */}
+ {showDebug && (
+ <div className="backdrop-blur-md bg-background/90 border border-border/50 rounded-lg p-3 max-w-sm">
+ <div className="text-xs font-medium mb-2 text-foreground">
+ Active Clips ({activeClips.length})
+ </div>
+ <div className="space-y-1 max-h-40 overflow-y-auto">
+ {activeClips.map((clipData, index) => (
+ <div
+ key={clipData.clip.id}
+ className="flex items-center gap-2 px-2 py-1 bg-muted/60 rounded text-xs"
+ >
+ <span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4 flex-shrink-0">
+ {index + 1}
+ </span>
+ <div className="min-w-0 flex-1">
+ <div className="truncate">{clipData.clip.name}</div>
+ <div className="text-muted-foreground text-[10px]">
+ {clipData.mediaItem?.type || "test"}
+ </div>
+ </div>
+ </div>
+ ))}
+ {activeClips.length === 0 && (
+ <div className="text-muted-foreground text-xs py-2 text-center">
+ No active clips
+ </div>
+ )}
+ </div>
+ <div className="mt-2 pt-2 border-t border-border/30 text-[10px] text-muted-foreground">
+ Time: {currentTime.toFixed(2)}s
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index 6f9f0b2f..5131230f 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -3,13 +3,11 @@ import Link from "next/link";
import { Button } from "./ui/button";
import { ChevronLeft, Download } from "lucide-react";
-import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base";
import { ProjectNameEditor } from "./editor/project-name-editor";
export function EditorHeader() {
- const { activeProject } = useProjectStore();
const { getTotalDuration } = useTimelineStore();
const handleExport = () => {
diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx index f2b3f657..dfe08365 100644 --- a/apps/web/src/components/editor/media-panel.tsx +++ b/apps/web/src/components/editor/media-panel.tsx @@ -3,7 +3,7 @@ import { Button } from "../ui/button"; import { AspectRatio } from "../ui/aspect-ratio"; import { DragOverlay } from "../ui/drag-overlay"; -import { useMediaStore } from "@/stores/media-store"; +import { useMediaStore, type MediaItem } from "@/stores/media-store"; import { processMediaFiles } from "@/lib/media-processing"; import { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react"; import { useDragDrop } from "@/hooks/use-drag-drop"; @@ -17,27 +17,28 @@ export function MediaPanel() { const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); const fileInputRef = useRef<HTMLInputElement>(null); const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); const [searchQuery, setSearchQuery] = useState(""); const [mediaFilter, setMediaFilter] = useState("all"); const processFiles = async (files: FileList | File[]) => { - // If no files, do nothing - if (!files?.length) return; - + if (!files || files.length === 0) return; setIsProcessing(true); + setProgress(0); try { // Process files (extract metadata, generate thumbnails, etc.) - const items = await processMediaFiles(files); + const processedItems = await processMediaFiles(files, (p) => + setProgress(p) + ); // Add each processed media item to the store - items.forEach((item) => { - addMediaItem(item); - }); + processedItems.forEach((item) => addMediaItem(item)); } catch (error) { - // Show error if processing fails - console.error("File processing failed:", error); + // Show error toast if processing fails + console.error("Error processing files:", error); toast.error("Failed to process files"); } finally { setIsProcessing(false); + setProgress(0); } }; @@ -67,7 +68,7 @@ export function MediaPanel() { return `${min}:${sec.toString().padStart(2, "0")}`; }; - const startDrag = (e: React.DragEvent, item: any) => { + const startDrag = (e: React.DragEvent, item: MediaItem) => { // When dragging a media item, set drag data for timeline to read e.dataTransfer.setData( "application/x-media-item", @@ -101,7 +102,7 @@ export function MediaPanel() { setFilteredMediaItems(filtered); }, [mediaItems, mediaFilter, searchQuery]); - const renderPreview = (item: any) => { + const renderPreview = (item: MediaItem) => { // Render a preview for each media type (image, video, audio, unknown) // Each preview is draggable to the timeline const baseDragProps = { @@ -241,15 +242,12 @@ export function MediaPanel() { {isProcessing ? ( <> <Upload className="h-4 w-4 animate-spin" /> - <span className="hidden md:inline ml-2">Processing...</span> + <span className="hidden md:inline ml-2">{progress}%</span> </> ) : ( <> <Plus className="h-4 w-4" /> - <span - className="hidden sm:inline ml-2" - aria-label="Add file" - > + <span className="hidden sm:inline ml-2" aria-label="Add file"> Add </span> </> diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 33bfe688..e19b4de7 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -1,246 +1,283 @@ -"use client";
-
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { VideoPlayer } from "@/components/ui/video-player";
-import { Button } from "@/components/ui/button";
-import { Play, Pause, Volume2, VolumeX } from "lucide-react";
-import { useState, useRef } from "react";
-
-// Debug flag - set to false to hide active clips info
-const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
-
-export function PreviewPanel() {
- const { tracks } = useTimelineStore();
- const { mediaItems } = useMediaStore();
- const { isPlaying, toggle, currentTime, muted, toggleMute, volume } =
- usePlaybackStore();
- const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
- const [showDebug, setShowDebug] = useState(SHOW_DEBUG_INFO);
- const previewRef = useRef<HTMLDivElement>(null);
-
- // Get active clips at current time
- const getActiveClips = () => {
- const activeClips: Array<{
- clip: any;
- track: any;
- mediaItem: any;
- }> = [];
-
- tracks.forEach((track) => {
- track.clips.forEach((clip) => {
- const clipStart = clip.startTime;
- const clipEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
-
- if (currentTime >= clipStart && currentTime < clipEnd) {
- const mediaItem =
- clip.mediaId === "test"
- ? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
- : mediaItems.find((item) => item.id === clip.mediaId);
-
- if (mediaItem || clip.mediaId === "test") {
- activeClips.push({ clip, track, mediaItem });
- }
- }
- });
- });
-
- return activeClips;
- };
-
- const activeClips = getActiveClips();
- const aspectRatio = canvasSize.width / canvasSize.height;
-
- // Render a clip
- const renderClip = (clipData: any, index: number) => {
- const { clip, mediaItem } = clipData;
-
- // Test clips
- if (!mediaItem || clip.mediaId === "test") {
- return (
- <div
- key={clip.id}
- className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
- >
- <div className="text-center">
- <div className="text-2xl mb-2">🎬</div>
- <p className="text-xs text-white">{clip.name}</p>
- </div>
- </div>
- );
- }
-
- // Video clips
- if (mediaItem.type === "video") {
- return (
- <div key={clip.id} className="absolute inset-0">
- <VideoPlayer
- src={mediaItem.url}
- poster={mediaItem.thumbnailUrl}
- clipStartTime={clip.startTime}
- trimStart={clip.trimStart}
- trimEnd={clip.trimEnd}
- clipDuration={clip.duration}
- />
- </div>
- );
- }
-
- // Image clips
- if (mediaItem.type === "image") {
- return (
- <div key={clip.id} className="absolute inset-0">
- <img
- src={mediaItem.url}
- alt={mediaItem.name}
- className="w-full h-full object-cover"
- draggable={false}
- />
- </div>
- );
- }
-
- // Audio clips (visual representation)
- if (mediaItem.type === "audio") {
- return (
- <div
- key={clip.id}
- className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
- >
- <div className="text-center">
- <div className="text-2xl mb-2">🎵</div>
- <p className="text-xs text-white">{mediaItem.name}</p>
- </div>
- </div>
- );
- }
-
- return null;
- };
-
- // Canvas presets
- const canvasPresets = [
- { name: "16:9 HD", width: 1920, height: 1080 },
- { name: "16:9 4K", width: 3840, height: 2160 },
- { name: "9:16 Mobile", width: 1080, height: 1920 },
- { name: "1:1 Square", width: 1080, height: 1080 },
- { name: "4:3 Standard", width: 1440, height: 1080 },
- ];
-
- return (
- <div className="h-full w-full flex flex-col min-h-0 min-w-0">
- {/* Controls */}
- <div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0">
- <span className="text-muted-foreground">Canvas:</span>
- <select
- value={`${canvasSize.width}x${canvasSize.height}`}
- onChange={(e) => {
- const preset = canvasPresets.find(
- (p) => `${p.width}x${p.height}` === e.target.value
- );
- if (preset)
- setCanvasSize({ width: preset.width, height: preset.height });
- }}
- className="bg-background border rounded px-2 py-1 text-xs"
- >
- {canvasPresets.map((preset) => (
- <option
- key={preset.name}
- value={`${preset.width}x${preset.height}`}
- >
- {preset.name} ({preset.width}×{preset.height})
- </option>
- ))}
- </select>
-
- {/* Debug Toggle - Only show in development */}
- {SHOW_DEBUG_INFO && (
- <Button
- variant="text"
- size="sm"
- onClick={() => setShowDebug(!showDebug)}
- className="text-xs"
- >
- Debug {showDebug ? "ON" : "OFF"}
- </Button>
- )}
-
- <Button
- variant="outline"
- size="sm"
- onClick={toggleMute}
- className="ml-auto"
- >
- {muted || volume === 0 ? (
- <VolumeX className="h-3 w-3 mr-1" />
- ) : (
- <Volume2 className="h-3 w-3 mr-1" />
- )}
- {muted || volume === 0 ? "Unmute" : "Mute"}
- </Button>
-
- <Button variant="outline" size="sm" onClick={toggle}>
- {isPlaying ? (
- <Pause className="h-3 w-3 mr-1" />
- ) : (
- <Play className="h-3 w-3 mr-1" />
- )}
- {isPlaying ? "Pause" : "Play"}
- </Button>
- </div>
-
- {/* Preview Area */}
- <div className="flex-1 flex items-center justify-center p-2 sm:p-4 bg-gray-900 min-h-0 min-w-0">
- <div
- ref={previewRef}
- className="relative overflow-hidden rounded-sm max-w-full max-h-full bg-black border border-gray-600"
- style={{
- aspectRatio: aspectRatio.toString(),
- width: "100%",
- height: "100%",
- }}
- >
- {activeClips.length === 0 ? (
- <div className="absolute inset-0 flex items-center justify-center text-white/50">
- {tracks.length === 0
- ? "Drop media to start editing"
- : "No clips at current time"}
- </div>
- ) : (
- activeClips.map((clipData, index) => renderClip(clipData, index))
- )}
- </div>
- </div>
-
- {/* Debug Info Panel - Conditionally rendered */}
- {showDebug && (
- <div className="border-t bg-background p-2 flex-shrink-0">
- <div className="text-xs font-medium mb-1">
- Debug: Active Clips ({activeClips.length})
- </div>
- <div className="flex gap-2 overflow-x-auto">
- {activeClips.map((clipData, index) => (
- <div
- key={clipData.clip.id}
- className="flex items-center gap-1 px-2 py-1 bg-muted rounded text-xs whitespace-nowrap"
- >
- <span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4">
- {index + 1}
- </span>
- <span>{clipData.clip.name}</span>
- <span className="text-muted-foreground">
- ({clipData.mediaItem?.type || "test"})
- </span>
- </div>
- ))}
- {activeClips.length === 0 && (
- <span className="text-muted-foreground">No active clips</span>
- )}
- </div>
- </div>
- )}
- </div>
- );
-}
+"use client"; + +import { + useTimelineStore, + type TimelineClip, + type TimelineTrack, +} from "@/stores/timeline-store"; +import { useMediaStore, type MediaItem } from "@/stores/media-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { VideoPlayer } from "@/components/ui/video-player"; +import { Button } from "@/components/ui/button"; +import { Play, Pause, Volume2, VolumeX, Plus } from "lucide-react"; +import { useState, useRef, useEffect } from "react"; + +interface ActiveClip { + clip: TimelineClip; + track: TimelineTrack; + mediaItem: MediaItem | null; +} + +export function PreviewPanel() { + const { tracks } = useTimelineStore(); + const { mediaItems } = useMediaStore(); + const { currentTime, muted, toggleMute, volume } = usePlaybackStore(); + const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 }); + const previewRef = useRef<HTMLDivElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const [previewDimensions, setPreviewDimensions] = useState({ + width: 0, + height: 0, + }); + + // Calculate optimal preview size that fits in container while maintaining aspect ratio + useEffect(() => { + const updatePreviewSize = () => { + if (!containerRef.current) return; + + const container = containerRef.current.getBoundingClientRect(); + const computedStyle = getComputedStyle(containerRef.current); + + // Get padding values + const paddingTop = parseFloat(computedStyle.paddingTop); + const paddingBottom = parseFloat(computedStyle.paddingBottom); + const paddingLeft = parseFloat(computedStyle.paddingLeft); + const paddingRight = parseFloat(computedStyle.paddingRight); + + // Get gap value (gap-4 = 1rem = 16px) + const gap = parseFloat(computedStyle.gap) || 16; + + // Get toolbar height if it exists + const toolbar = containerRef.current.querySelector("[data-toolbar]"); + const toolbarHeight = toolbar + ? toolbar.getBoundingClientRect().height + : 0; + + // Calculate available space after accounting for padding, gap, and toolbar + const availableWidth = container.width - paddingLeft - paddingRight; + const availableHeight = + container.height - + paddingTop - + paddingBottom - + toolbarHeight - + (toolbarHeight > 0 ? gap : 0); + + const targetRatio = canvasSize.width / canvasSize.height; + const containerRatio = availableWidth / availableHeight; + + let width, height; + + if (containerRatio > targetRatio) { + // Container is wider - constrain by height + height = availableHeight; + width = height * targetRatio; + } else { + // Container is taller - constrain by width + width = availableWidth; + height = width / targetRatio; + } + + setPreviewDimensions({ width, height }); + }; + + updatePreviewSize(); + + const resizeObserver = new ResizeObserver(updatePreviewSize); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => resizeObserver.disconnect(); + }, [canvasSize.width, canvasSize.height]); + + // Get active clips at current time + const getActiveClips = (): ActiveClip[] => { + const activeClips: ActiveClip[] = []; + + tracks.forEach((track) => { + track.clips.forEach((clip) => { + const clipStart = clip.startTime; + const clipEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (currentTime >= clipStart && currentTime < clipEnd) { + const mediaItem = + clip.mediaId === "test" + ? null // Test clips don't have a real media item + : mediaItems.find((item) => item.id === clip.mediaId) || null; + + activeClips.push({ clip, track, mediaItem }); + } + }); + }); + + return activeClips; + }; + + const activeClips = getActiveClips(); + + // Render a clip + const renderClip = (clipData: ActiveClip, index: number) => { + const { clip, mediaItem } = clipData; + + // Test clips + if (!mediaItem || clip.mediaId === "test") { + return ( + <div + key={clip.id} + className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center" + > + <div className="text-center"> + <div className="text-2xl mb-2">🎬</div> + <p className="text-xs text-white">{clip.name}</p> + </div> + </div> + ); + } + + // Video clips + if (mediaItem.type === "video") { + return ( + <div key={clip.id} className="absolute inset-0"> + <VideoPlayer + src={mediaItem.url} + poster={mediaItem.thumbnailUrl} + clipStartTime={clip.startTime} + trimStart={clip.trimStart} + trimEnd={clip.trimEnd} + clipDuration={clip.duration} + /> + </div> + ); + } + + // Image clips + if (mediaItem.type === "image") { + return ( + <div key={clip.id} className="absolute inset-0"> + <img + src={mediaItem.url} + alt={mediaItem.name} + className="w-full h-full object-cover" + draggable={false} + /> + </div> + ); + } + + // Audio clips (visual representation) + if (mediaItem.type === "audio") { + return ( + <div + key={clip.id} + className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center" + > + <div className="text-center"> + <div className="text-2xl mb-2">🎵</div> + <p className="text-xs text-white">{mediaItem.name}</p> + </div> + </div> + ); + } + + return null; + }; + + // Canvas presets + const canvasPresets = [ + { name: "16:9 HD", width: 1920, height: 1080 }, + { name: "16:9 4K", width: 3840, height: 2160 }, + { name: "9:16 Mobile", width: 1080, height: 1920 }, + { name: "1:1 Square", width: 1080, height: 1080 }, + { name: "4:3 Standard", width: 1440, height: 1080 }, + ]; + + return ( + <div className="h-full w-full flex flex-col min-h-0 min-w-0"> + {/* Controls */} + <div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0"> + <span className="text-muted-foreground">Canvas:</span> + <select + value={`${canvasSize.width}x${canvasSize.height}`} + onChange={(e) => { + const preset = canvasPresets.find( + (p) => `${p.width}x${p.height}` === e.target.value + ); + if (preset) + setCanvasSize({ width: preset.width, height: preset.height }); + }} + className="bg-background border rounded px-2 py-1 text-xs" + > + {canvasPresets.map((preset) => ( + <option + key={preset.name} + value={`${preset.width}x${preset.height}`} + > + {preset.name} ({preset.width}×{preset.height}) + </option> + ))} + </select> + + <Button + variant="outline" + size="sm" + onClick={toggleMute} + className="ml-auto" + > + {muted || volume === 0 ? ( + <VolumeX className="h-3 w-3 mr-1" /> + ) : ( + <Volume2 className="h-3 w-3 mr-1" /> + )} + {muted || volume === 0 ? "Unmute" : "Mute"} + </Button> + </div> + + {/* Preview Area */} + <div + ref={containerRef} + className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4" + > + <div + ref={previewRef} + className="relative overflow-hidden rounded-sm bg-black border" + style={{ + width: previewDimensions.width, + height: previewDimensions.height, + }} + > + {activeClips.length === 0 ? ( + <div className="absolute inset-0 flex items-center justify-center text-muted-foreground"> + {tracks.length === 0 + ? "Drop media to start editing" + : "No clips at current time"} + </div> + ) : ( + activeClips.map((clipData, index) => renderClip(clipData, index)) + )} + </div> + + <PreviewToolbar /> + </div> + </div> + ); +} + +function PreviewToolbar() { + const { isPlaying, toggle } = usePlaybackStore(); + + return ( + <div + data-toolbar + className="flex items-center justify-center gap-2 px-4 pt-2 bg-background-500 w-full" + > + <Button variant="text" size="icon" onClick={toggle}> + {isPlaying ? ( + <Pause className="h-3 w-3" /> + ) : ( + <Play className="h-3 w-3" /> + )} + </Button> + </div> + ); +} diff --git a/apps/web/src/components/editor/properties-panel.tsx b/apps/web/src/components/editor/properties-panel.tsx index c784dab8..96ef66e5 100644 --- a/apps/web/src/components/editor/properties-panel.tsx +++ b/apps/web/src/components/editor/properties-panel.tsx @@ -17,13 +17,12 @@ import { useMediaStore } from "@/stores/media-store"; import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment"; import { useState } from "react"; import { SpeedControl } from "./speed-control"; +import type { BackgroundType } from "@/types/editor"; export function PropertiesPanel() { const { tracks } = useTimelineStore(); const { mediaItems } = useMediaStore(); - const [backgroundType, setBackgroundType] = useState< - "blur" | "mirror" | "color" - >("blur"); + const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur"); const [backgroundColor, setBackgroundColor] = useState("#000000"); // Get the first video clip for preview (simplified) @@ -78,7 +77,9 @@ export function PropertiesPanel() { <Label htmlFor="bg-type">Background Type</Label> <Select value={backgroundType} - onValueChange={(value: any) => setBackgroundType(value)} + onValueChange={(value: BackgroundType) => + setBackgroundType(value) + } > <SelectTrigger> <SelectValue placeholder="Select background type" /> diff --git a/apps/web/src/components/editor/timeline-clip.tsx b/apps/web/src/components/editor/timeline-clip.tsx new file mode 100644 index 00000000..4c0d3b77 --- /dev/null +++ b/apps/web/src/components/editor/timeline-clip.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "../ui/button"; +import { MoreVertical, Scissors, Trash2 } from "lucide-react"; +import { useMediaStore } from "@/stores/media-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { useDragClip } from "@/hooks/use-drag-clip"; +import AudioWaveform from "./audio-waveform"; +import { toast } from "sonner"; +import { TimelineClipProps, ResizeState } from "@/types/timeline"; + +export function TimelineClip({ + clip, + track, + zoomLevel, + isSelected, + onContextMenu, + onClipMouseDown, + onClipClick, +}: TimelineClipProps) { + const { mediaItems } = useMediaStore(); + const { updateClipTrim, addClipToTrack, removeClipFromTrack, dragState } = + useTimelineStore(); + const { currentTime } = usePlaybackStore(); + + const [resizing, setResizing] = useState<ResizeState | null>(null); + const [clipMenuOpen, setClipMenuOpen] = useState(false); + + const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; + const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); + + // Use real-time position during drag, otherwise use stored position + const isBeingDragged = dragState.clipId === clip.id; + const clipStartTime = + isBeingDragged && dragState.isDragging + ? dragState.currentTime + : clip.startTime; + const clipLeft = clipStartTime * 50 * zoomLevel; + + const getTrackColor = (type: string) => { + switch (type) { + case "video": + return "bg-blue-500/20 border-blue-500/30"; + case "audio": + return "bg-green-500/20 border-green-500/30"; + case "effects": + return "bg-purple-500/20 border-purple-500/30"; + default: + return "bg-gray-500/20 border-gray-500/30"; + } + }; + + const handleResizeStart = ( + e: React.MouseEvent, + clipId: string, + side: "left" | "right" + ) => { + e.stopPropagation(); + e.preventDefault(); + + setResizing({ + clipId, + side, + startX: e.clientX, + initialTrimStart: clip.trimStart, + initialTrimEnd: clip.trimEnd, + }); + }; + + const updateTrimFromMouseMove = (e: { clientX: number }) => { + if (!resizing) return; + + const deltaX = e.clientX - resizing.startX; + const deltaTime = deltaX / (50 * zoomLevel); + + if (resizing.side === "left") { + const newTrimStart = Math.max( + 0, + Math.min( + clip.duration - clip.trimEnd - 0.1, + resizing.initialTrimStart + deltaTime + ) + ); + updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd); + } else { + const newTrimEnd = Math.max( + 0, + Math.min( + clip.duration - clip.trimStart - 0.1, + resizing.initialTrimEnd - deltaTime + ) + ); + updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd); + } + }; + + const handleResizeMove = (e: React.MouseEvent) => { + updateTrimFromMouseMove(e); + }; + + const handleResizeEnd = () => { + setResizing(null); + }; + + const handleDeleteClip = () => { + removeClipFromTrack(track.id, clip.id); + setClipMenuOpen(false); + }; + + const handleSplitClip = () => { + // Use current playback time as split point + const splitTime = currentTime; + // Only split if splitTime is within the clip's effective range + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) { + toast.error("Playhead must be within clip to split"); + return; + } + + const firstDuration = splitTime - effectiveStart; + const secondDuration = effectiveEnd - splitTime; + + // First part: adjust original clip + updateClipTrim( + track.id, + clip.id, + clip.trimStart, + clip.trimEnd + secondDuration + ); + + // Second part: add new clip after split + addClipToTrack(track.id, { + mediaId: clip.mediaId, + name: clip.name + " (cut)", + duration: clip.duration, + startTime: splitTime, + trimStart: clip.trimStart + firstDuration, + trimEnd: clip.trimEnd, + }); + + setClipMenuOpen(false); + toast.success("Clip split successfully"); + }; + + const renderClipContent = () => { + const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + + if (!mediaItem) { + return ( + <span className="text-xs text-foreground/80 truncate">{clip.name}</span> + ); + } + + if (mediaItem.type === "image") { + return ( + <div className="w-full h-full flex items-center justify-center"> + <img + src={mediaItem.url} + alt={mediaItem.name} + className="w-full h-full object-cover" + draggable={false} + /> + </div> + ); + } + + if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { + return ( + <div className="w-full h-full flex items-center gap-2"> + <div className="w-8 h-8 flex-shrink-0"> + <img + src={mediaItem.thumbnailUrl} + alt={mediaItem.name} + className="w-full h-full object-cover rounded-sm" + draggable={false} + /> + </div> + <span className="text-xs text-foreground/80 truncate flex-1"> + {clip.name} + </span> + </div> + ); + } + + if (mediaItem.type === "audio") { + return ( + <div className="w-full h-full flex items-center gap-2"> + <div className="flex-1 min-w-0"> + <AudioWaveform + audioUrl={mediaItem.url} + height={24} + className="w-full" + /> + </div> + </div> + ); + } + + // Fallback for videos without thumbnails + return ( + <span className="text-xs text-foreground/80 truncate">{clip.name}</span> + ); + }; + + return ( + <div + className={`timeline-clip absolute h-full border ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg ${isSelected ? "ring-2 ring-blue-500 z-10" : ""} ${isBeingDragged ? "shadow-lg z-20" : ""}`} + style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }} + onMouseDown={(e) => onClipMouseDown(e, clip)} + onClick={(e) => onClipClick(e, clip)} + onMouseMove={handleResizeMove} + onMouseUp={handleResizeEnd} + onMouseLeave={handleResizeEnd} + tabIndex={0} + onContextMenu={(e) => onContextMenu(e, clip.id)} + > + {/* Left trim handle */} + <div + className={`absolute left-0 top-0 bottom-0 w-2 cursor-w-resize transition-opacity bg-blue-500/50 hover:bg-blue-500 ${isSelected ? "opacity-100" : "opacity-0"}`} + onMouseDown={(e) => handleResizeStart(e, clip.id, "left")} + /> + + {/* Clip content */} + <div className="flex-1 relative"> + {renderClipContent()} + + {/* Clip options menu */} + <div className="absolute top-1 right-1 z-10"> + <Button + variant="text" + size="icon" + className="opacity-0 group-hover:opacity-100 transition-opacity" + onClick={(e) => { + e.stopPropagation(); + setClipMenuOpen(!clipMenuOpen); + }} + onMouseDown={(e) => e.stopPropagation()} + > + <MoreVertical className="h-4 w-4" /> + </Button> + + {clipMenuOpen && ( + <div + className="absolute right-0 mt-2 w-32 bg-white border rounded shadow z-50" + onMouseDown={(e) => e.stopPropagation()} + > + <button + className="flex items-center w-full px-3 py-2 text-sm hover:bg-muted/30" + onClick={handleSplitClip} + > + <Scissors className="h-4 w-4 mr-2" /> Split + </button> + <button + className="flex items-center w-full px-3 py-2 text-sm text-red-600 hover:bg-red-50" + onClick={handleDeleteClip} + > + <Trash2 className="h-4 w-4 mr-2" /> Delete + </button> + </div> + )} + </div> + </div> + + {/* Right trim handle */} + <div + className={`absolute right-0 top-0 bottom-0 w-2 cursor-e-resize transition-opacity bg-blue-500/50 hover:bg-blue-500 ${isSelected ? "opacity-100" : "opacity-0"}`} + onMouseDown={(e) => handleResizeStart(e, clip.id, "right")} + /> + </div> + ); +} diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 591eac89..ae6b98d3 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1860,3 +1860,4 @@ function TimelineTrackContent({ </div>
);
}
+
diff --git a/apps/web/src/components/header-base.tsx b/apps/web/src/components/header-base.tsx index 5f7b2b8d..f27eb7fc 100644 --- a/apps/web/src/components/header-base.tsx +++ b/apps/web/src/components/header-base.tsx @@ -29,7 +29,7 @@ export function HeaderBase({ return (
<header
- className={cn("px-6 h-16 flex justify-between items-center", className)}
+ className={cn("px-6 h-14 flex justify-between items-center", className)}
>
{leftContent && <div className="flex items-center">{leftContent}</div>}
{centerContent && (
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 7366dd31..ff15774e 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -1,14 +1,13 @@ "use client";
import Link from "next/link";
-import Image from "next/image";
import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base";
import { useSession } from "@opencut/auth/client";
import { getStars } from "@/lib/fetchGhStars";
-import { Star } from "lucide-react";
import { useEffect, useState } from "react";
+import Image from "next/image";
export function Header() {
const { data: session } = useSession();
@@ -29,15 +28,15 @@ export function Header() { const leftContent = (
<Link href="/" className="flex items-center gap-3">
- <Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
- <span className="font-medium tracking-tight">OpenCut</span>
+ <Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
+ <span className="text-xl font-medium hidden md:block">OpenCut</span>
</Link>
);
const rightContent = (
- <nav className="flex items-center">
+ <nav className="flex items-center gap-3">
<Link href="/contributors">
- <Button variant="text" className="text-sm">
+ <Button variant="text" className="text-sm p-0">
Contributors
</Button>
</Link>
@@ -59,5 +58,13 @@ export function Header() { </nav>
);
- return <HeaderBase leftContent={leftContent} rightContent={rightContent} />;
+ return (
+ <div className="mx-4 md:mx-0">
+ <HeaderBase
+ className="bg-[#1D1D1D] border border-white/10 rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
+ leftContent={leftContent}
+ rightContent={rightContent}
+ />
+ </div>
+ );
}
diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx index e63b6c79..563c92a9 100644 --- a/apps/web/src/components/icons.tsx +++ b/apps/web/src/components/icons.tsx @@ -29,7 +29,7 @@ export function GithubIcon({ className }: { className?: string }) { viewBox="0 -3.5 256 256" preserveAspectRatio="xMinYMin meet" > - <g fill="#161614"> + <g fill="currentColor"> <path d="M127.505 0C57.095 0 0 57.085 0 127.505c0 56.336 36.534 104.13 87.196 120.99 6.372 1.18 8.712-2.766 8.712-6.134 0-3.04-.119-13.085-.173-23.739-35.473 7.713-42.958-15.044-42.958-15.044-5.8-14.738-14.157-18.656-14.157-18.656-11.568-7.914.872-7.752.872-7.752 12.804.9 19.546 13.14 19.546 13.14 11.372 19.493 29.828 13.857 37.104 10.6 1.144-8.242 4.449-13.866 8.095-17.05-28.32-3.225-58.092-14.158-58.092-63.014 0-13.92 4.981-25.295 13.138-34.224-1.324-3.212-5.688-16.18 1.235-33.743 0 0 10.707-3.427 35.073 13.07 10.17-2.826 21.078-4.242 31.914-4.29 10.836.048 21.752 1.464 31.942 4.29 24.337-16.497 35.029-13.07 35.029-13.07 6.94 17.563 2.574 30.531 1.25 33.743 8.175 8.929 13.122 20.303 13.122 34.224 0 48.972-29.828 59.756-58.22 62.912 4.573 3.957 8.648 11.717 8.648 23.612 0 17.06-.148 30.791-.148 34.991 0 3.393 2.295 7.369 8.759 6.117 50.634-16.879 87.122-64.656 87.122-120.973C255.009 57.085 197.922 0 127.505 0" /> <path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398" /> diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index 7b4588c8..d5c5ed92 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -8,6 +8,7 @@ import Link from "next/link"; import { useEffect, useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { getStars } from "@/lib/fetchGhStars";
+import Image from "next/image";
interface HeroProps {
signupCount: number;
@@ -82,7 +83,7 @@ export function Hero({ signupCount }: HeroProps) { };
return (
- <div className="min-h-[calc(100vh-4rem)] flex flex-col justify-between items-center text-center px-4">
+ <div className="min-h-[calc(100vh-6rem)] supports-[height:100dvh]:min-h-[calc(100dvh-6rem)] flex flex-col justify-between items-center text-center px-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -93,18 +94,21 @@ export function Hero({ signupCount }: HeroProps) { initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.8 }}
- className="inline-block"
+ className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
>
- <h1 className="text-5xl sm:text-7xl font-bold tracking-tighter">
- The open source
- </h1>
- <h1 className="text-5xl sm:text-7xl font-bold tracking-tighter mt-2">
- video editor
- </h1>
+ <h1>The Open Source</h1>
+ <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
+ <div className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2">
+ <Image src="/frame.svg" height={79} width={459} alt="frame" />
+ <span className="absolute inset-0 flex items-center justify-center">
+ Video Editor
+ </span>
+ </div>
+ </div>
</motion.div>
<motion.p
- className="mt-10 text-lg sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
+ className="mt-10 text-base sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4, duration: 0.8 }}
@@ -119,7 +123,10 @@ export function Hero({ signupCount }: HeroProps) { animate={{ opacity: 1 }}
transition={{ delay: 0.6, duration: 0.8 }}
>
- <form onSubmit={handleSubmit} className="flex gap-3 w-full max-w-lg">
+ <form
+ onSubmit={handleSubmit}
+ className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
+ >
<Input
type="email"
placeholder="Enter your email"
diff --git a/apps/web/src/components/ui/image-timeline-treatment.tsx b/apps/web/src/components/ui/image-timeline-treatment.tsx index 600dd243..31847df5 100644 --- a/apps/web/src/components/ui/image-timeline-treatment.tsx +++ b/apps/web/src/components/ui/image-timeline-treatment.tsx @@ -2,13 +2,14 @@ import { useState } from "react";
import { cn } from "@/lib/utils";
+import type { BackgroundType } from "@/types/editor";
interface ImageTimelineTreatmentProps {
src: string;
alt: string;
targetAspectRatio?: number; // Default to 16:9 for video
className?: string;
- backgroundType?: "blur" | "mirror" | "color";
+ backgroundType?: BackgroundType;
backgroundColor?: string;
}
diff --git a/apps/web/src/hooks/use-drag-clip.ts b/apps/web/src/hooks/use-drag-clip.ts new file mode 100644 index 00000000..eb4d7435 --- /dev/null +++ b/apps/web/src/hooks/use-drag-clip.ts @@ -0,0 +1,226 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useTimelineStore } from "@/stores/timeline-store"; + +interface DragState { + isDragging: boolean; + clipId: string | null; + trackId: string | null; + startMouseX: number; + startClipTime: number; + clickOffsetTime: number; + currentTime: number; +} + +export function useDragClip(zoomLevel: number) { + const { tracks, updateClipStartTime, moveClipToTrack } = useTimelineStore(); + + const [dragState, setDragState] = useState<DragState>({ + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }); + + const timelineRef = useRef<HTMLDivElement>(null); + const dragStateRef = useRef(dragState); + + // Keep ref in sync with state + dragStateRef.current = dragState; + + const startDrag = useCallback( + ( + e: React.MouseEvent, + clipId: string, + trackId: string, + clipStartTime: number, + clickOffsetTime: number + ) => { + e.preventDefault(); + e.stopPropagation(); + + setDragState({ + isDragging: true, + clipId, + trackId, + startMouseX: e.clientX, + startClipTime: clipStartTime, + clickOffsetTime, + currentTime: clipStartTime, + }); + }, + [] + ); + + const updateDrag = useCallback( + (e: MouseEvent) => { + if (!dragState.isDragging || !timelineRef.current) { + return; + } + + const timelineRect = timelineRef.current.getBoundingClientRect(); + const mouseX = e.clientX - timelineRect.left; + const mouseTime = Math.max(0, mouseX / (50 * zoomLevel)); + const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); + const snappedTime = Math.round(adjustedTime * 10) / 10; + + setDragState((prev) => ({ + ...prev, + currentTime: snappedTime, + })); + }, + [dragState.isDragging, dragState.clickOffsetTime, zoomLevel] + ); + + const endDrag = useCallback( + (targetTrackId?: string) => { + if (!dragState.isDragging || !dragState.clipId || !dragState.trackId) + return; + + const finalTrackId = targetTrackId || dragState.trackId; + const finalTime = dragState.currentTime; + + // Check for overlaps + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const targetTrack = tracks.find((t) => t.id === finalTrackId); + const movingClip = sourceTrack?.clips.find( + (c) => c.id === dragState.clipId + ); + + if (!movingClip || !targetTrack) { + setDragState((prev) => ({ ...prev, isDragging: false })); + return; + } + + const movingClipDuration = + movingClip.duration - movingClip.trimStart - movingClip.trimEnd; + const movingClipEnd = finalTime + movingClipDuration; + + const hasOverlap = targetTrack.clips.some((existingClip) => { + // Skip the clip being moved if it's on the same track + if ( + dragState.trackId === finalTrackId && + existingClip.id === dragState.clipId + ) { + return false; + } + + const existingStart = existingClip.startTime; + const existingEnd = + existingClip.startTime + + (existingClip.duration - + existingClip.trimStart - + existingClip.trimEnd); + + return finalTime < existingEnd && movingClipEnd > existingStart; + }); + + if (!hasOverlap) { + if (dragState.trackId === finalTrackId) { + // Moving within same track + updateClipStartTime(finalTrackId, dragState.clipId!, finalTime); + } else { + // Moving to different track + moveClipToTrack(dragState.trackId!, finalTrackId, dragState.clipId!); + requestAnimationFrame(() => { + updateClipStartTime(finalTrackId, dragState.clipId!, finalTime); + }); + } + } + + setDragState({ + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }); + }, + [dragState, tracks, updateClipStartTime, moveClipToTrack] + ); + + const cancelDrag = useCallback(() => { + setDragState({ + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }); + }, []); + + // Global mouse events + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseMove = (e: MouseEvent) => updateDrag(e); + const handleMouseUp = () => endDrag(); + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") cancelDrag(); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.addEventListener("keydown", handleEscape); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.removeEventListener("keydown", handleEscape); + }; + }, [dragState.isDragging, updateDrag, endDrag, cancelDrag]); + + const getDraggedClipPosition = useCallback( + (clipId: string) => { + // Use ref to get current state, not stale closure + const currentDragState = dragStateRef.current; + const isMatch = + currentDragState.isDragging && currentDragState.clipId === clipId; + + if (isMatch) { + return currentDragState.currentTime; + } + return null; + }, + [] // No dependencies needed since we use ref + ); + + const isValidDropTarget = useCallback( + (trackId: string) => { + if (!dragState.isDragging) return false; + + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const targetTrack = tracks.find((t) => t.id === trackId); + + if (!sourceTrack || !targetTrack) return false; + + // For now, allow drops on same track type + return sourceTrack.type === targetTrack.type; + }, + [dragState.isDragging, dragState.trackId, tracks] + ); + + return { + // State + isDragging: dragState.isDragging, + draggedClipId: dragState.clipId, + currentDragTime: dragState.currentTime, + clickOffsetTime: dragState.clickOffsetTime, + + // Methods + startDrag, + endDrag, + cancelDrag, + getDraggedClipPosition, + isValidDropTarget, + + // Refs + timelineRef, + }; +} diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts index d241f411..daf22b34 100644 --- a/apps/web/src/lib/media-processing.ts +++ b/apps/web/src/lib/media-processing.ts @@ -11,11 +11,15 @@ import { export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
export async function processMediaFiles(
- files: FileList | File[]
+ files: FileList | File[],
+ onProgress?: (progress: number) => void
): Promise<ProcessedMediaItem[]> {
const fileArray = Array.from(files);
const processedItems: ProcessedMediaItem[] = [];
+ const total = fileArray.length;
+ let completed = 0;
+
for (const file of fileArray) {
const fileType = getFileType(file);
@@ -57,6 +61,15 @@ export async function processMediaFiles( duration,
aspectRatio,
});
+
+ // Yield back to the event loop to keep the UI responsive
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ completed += 1;
+ if (onProgress) {
+ const percent = Math.round((completed / total) * 100);
+ onProgress(percent);
+ }
} catch (error) {
console.error("Error processing file:", file.name, error);
toast.error(`Failed to process ${file.name}`);
diff --git a/apps/web/src/stores/playback-store.ts b/apps/web/src/stores/playback-store.ts index 0cc01cc1..543865f9 100644 --- a/apps/web/src/stores/playback-store.ts +++ b/apps/web/src/stores/playback-store.ts @@ -8,7 +8,7 @@ interface PlaybackStore extends PlaybackState, PlaybackControls { let playbackTimer: number | null = null; -const startTimer = (store: any) => { +const startTimer = (store: () => PlaybackStore) => { if (playbackTimer) cancelAnimationFrame(playbackTimer); // Use requestAnimationFrame for smoother updates diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index e1474bae..3b068d7c 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -1,264 +1,346 @@ -import { create } from "zustand";
-
-export interface TimelineClip {
- id: string;
- mediaId: string;
- name: string;
- duration: number;
- startTime: number;
- trimStart: number;
- trimEnd: number;
-}
-
-export interface TimelineTrack {
- id: string;
- name: string;
- type: "video" | "audio" | "effects";
- clips: TimelineClip[];
- muted?: boolean;
-}
-
-interface TimelineStore {
- tracks: TimelineTrack[];
- history: TimelineTrack[][];
- redoStack: TimelineTrack[][];
-
- // Multi-selection
- selectedClips: { trackId: string; clipId: string }[];
- selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
- deselectClip: (trackId: string, clipId: string) => void;
- clearSelectedClips: () => void;
- setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
-
- // Actions
- addTrack: (type: "video" | "audio" | "effects") => string;
- removeTrack: (trackId: string) => void;
- addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
- removeClipFromTrack: (trackId: string, clipId: string) => void;
- moveClipToTrack: (
- fromTrackId: string,
- toTrackId: string,
- clipId: string
- ) => void;
- updateClipTrim: (
- trackId: string,
- clipId: string,
- trimStart: number,
- trimEnd: number
- ) => void;
- updateClipStartTime: (
- trackId: string,
- clipId: string,
- startTime: number
- ) => void;
- toggleTrackMute: (trackId: string) => void;
-
- // Computed values
- getTotalDuration: () => number;
-
- // New actions
- undo: () => void;
- redo: () => void;
- pushHistory: () => void;
-}
-
-export const useTimelineStore = create<TimelineStore>((set, get) => ({
- tracks: [],
- history: [],
- redoStack: [],
- selectedClips: [],
-
- pushHistory: () => {
- const { tracks, history, redoStack } = get();
- // Deep copy tracks
- set({
- history: [...history, JSON.parse(JSON.stringify(tracks))],
- redoStack: [] // Clear redo stack when new action is performed
- });
- },
-
- undo: () => {
- const { history, redoStack, tracks } = get();
- if (history.length === 0) return;
- const prev = history[history.length - 1];
- set({
- tracks: prev,
- history: history.slice(0, -1),
- redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack
- });
- },
-
- selectClip: (trackId, clipId, multi = false) => {
- set((state) => {
- const exists = state.selectedClips.some(
- (c) => c.trackId === trackId && c.clipId === clipId
- );
- if (multi) {
- // Toggle selection
- return exists
- ? { selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)) }
- : { selectedClips: [...state.selectedClips, { trackId, clipId }] };
- } else {
- return { selectedClips: [{ trackId, clipId }] };
- }
- });
- },
- deselectClip: (trackId, clipId) => {
- set((state) => ({
- selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)),
- }));
- },
- clearSelectedClips: () => {
- set({ selectedClips: [] });
- },
-
- setSelectedClips: (clips) => set({ selectedClips: clips }),
-
- addTrack: (type) => {
- get().pushHistory();
- const newTrack: TimelineTrack = {
- id: crypto.randomUUID(),
- name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
- type,
- clips: [],
- muted: false,
- };
- set((state) => ({
- tracks: [...state.tracks, newTrack],
- }));
- return newTrack.id;
- },
-
- removeTrack: (trackId) => {
- get().pushHistory();
- set((state) => ({
- tracks: state.tracks.filter((track) => track.id !== trackId),
- }));
- },
-
- addClipToTrack: (trackId, clipData) => {
- get().pushHistory();
- const newClip: TimelineClip = {
- ...clipData,
- id: crypto.randomUUID(),
- startTime: clipData.startTime || 0,
- trimStart: 0,
- trimEnd: 0,
- };
-
- set((state) => ({
- tracks: state.tracks.map((track) =>
- track.id === trackId
- ? { ...track, clips: [...track.clips, newClip] }
- : track
- ),
- }));
- },
-
- removeClipFromTrack: (trackId, clipId) => {
- get().pushHistory();
- set((state) => ({
- tracks: state.tracks
- .map((track) =>
- track.id === trackId
- ? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) }
- : track
- )
- // Remove track if it becomes empty
- .filter((track) => track.clips.length > 0),
- }));
- },
-
- moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
- get().pushHistory();
- set((state) => {
- const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
- const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
-
- if (!clipToMove) return state;
-
- return {
- tracks: state.tracks
- .map((track) => {
- if (track.id === fromTrackId) {
- return {
- ...track,
- clips: track.clips.filter((clip) => clip.id !== clipId),
- };
- } else if (track.id === toTrackId) {
- return {
- ...track,
- clips: [...track.clips, clipToMove],
- };
- }
- return track;
- })
- // Remove track if it becomes empty
- .filter((track) => track.clips.length > 0),
- };
- });
- },
-
- updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
- get().pushHistory();
- set((state) => ({
- tracks: state.tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- clips: track.clips.map((clip) =>
- clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip
- ),
- }
- : track
- ),
- }));
- },
-
- updateClipStartTime: (trackId, clipId, startTime) => {
- get().pushHistory();
- set((state) => ({
- tracks: state.tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- clips: track.clips.map((clip) =>
- clip.id === clipId ? { ...clip, startTime } : clip
- ),
- }
- : track
- ),
- }));
- },
-
- toggleTrackMute: (trackId) => {
- get().pushHistory();
- set((state) => ({
- tracks: state.tracks.map((track) =>
- track.id === trackId ? { ...track, muted: !track.muted } : track
- ),
- }));
- },
-
- getTotalDuration: () => {
- const { tracks } = get();
- if (tracks.length === 0) return 0;
-
- const trackEndTimes = tracks.map((track) =>
- track.clips.reduce((maxEnd, clip) => {
- const clipEnd =
- clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
- return Math.max(maxEnd, clipEnd);
- }, 0)
- );
-
- return Math.max(...trackEndTimes, 0);
- },
-
- redo: () => {
- const { redoStack } = get();
- if (redoStack.length === 0) return;
- const next = redoStack[redoStack.length - 1];
- set({ tracks: next, redoStack: redoStack.slice(0, -1) });
- },
-}));
+import { create } from "zustand"; + +export interface TimelineClip { + id: string; + mediaId: string; + name: string; + duration: number; + startTime: number; + trimStart: number; + trimEnd: number; +} + +export interface TimelineTrack { + id: string; + name: string; + type: "video" | "audio" | "effects"; + clips: TimelineClip[]; + muted?: boolean; +} + +interface TimelineStore { + tracks: TimelineTrack[]; + history: TimelineTrack[][]; + redoStack: TimelineTrack[][]; + + // Multi-selection + selectedClips: { trackId: string; clipId: string }[]; + selectClip: (trackId: string, clipId: string, multi?: boolean) => void; + deselectClip: (trackId: string, clipId: string) => void; + clearSelectedClips: () => void; + setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void; + + // Drag state + dragState: { + isDragging: boolean; + clipId: string | null; + trackId: string | null; + startMouseX: number; + startClipTime: number; + clickOffsetTime: number; + currentTime: number; + }; + setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void; + startDrag: ( + clipId: string, + trackId: string, + startMouseX: number, + startClipTime: number, + clickOffsetTime: number + ) => void; + updateDragTime: (currentTime: number) => void; + endDrag: () => void; + + // Actions + addTrack: (type: "video" | "audio" | "effects") => string; + removeTrack: (trackId: string) => void; + addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void; + removeClipFromTrack: (trackId: string, clipId: string) => void; + moveClipToTrack: ( + fromTrackId: string, + toTrackId: string, + clipId: string + ) => void; + updateClipTrim: ( + trackId: string, + clipId: string, + trimStart: number, + trimEnd: number + ) => void; + updateClipStartTime: ( + trackId: string, + clipId: string, + startTime: number + ) => void; + toggleTrackMute: (trackId: string) => void; + + // Computed values + getTotalDuration: () => number; + + // New actions + undo: () => void; + redo: () => void; + pushHistory: () => void; +} + +export const useTimelineStore = create<TimelineStore>((set, get) => ({ + tracks: [], + history: [], + redoStack: [], + selectedClips: [], + + pushHistory: () => { + const { tracks, history, redoStack } = get(); + // Deep copy tracks + set({ + history: [...history, JSON.parse(JSON.stringify(tracks))], + redoStack: [], // Clear redo stack when new action is performed + }); + }, + + undo: () => { + const { history, redoStack, tracks } = get(); + if (history.length === 0) return; + const prev = history[history.length - 1]; + set({ + tracks: prev, + history: history.slice(0, -1), + redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))], // Add current state to redo stack + }); + }, + + selectClip: (trackId, clipId, multi = false) => { + set((state) => { + const exists = state.selectedClips.some( + (c) => c.trackId === trackId && c.clipId === clipId + ); + if (multi) { + // Toggle selection + return exists + ? { + selectedClips: state.selectedClips.filter( + (c) => !(c.trackId === trackId && c.clipId === clipId) + ), + } + : { selectedClips: [...state.selectedClips, { trackId, clipId }] }; + } else { + return { selectedClips: [{ trackId, clipId }] }; + } + }); + }, + deselectClip: (trackId, clipId) => { + set((state) => ({ + selectedClips: state.selectedClips.filter( + (c) => !(c.trackId === trackId && c.clipId === clipId) + ), + })); + }, + clearSelectedClips: () => { + set({ selectedClips: [] }); + }, + + setSelectedClips: (clips) => set({ selectedClips: clips }), + + addTrack: (type) => { + get().pushHistory(); + const newTrack: TimelineTrack = { + id: crypto.randomUUID(), + name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`, + type, + clips: [], + muted: false, + }; + set((state) => ({ + tracks: [...state.tracks, newTrack], + })); + return newTrack.id; + }, + + removeTrack: (trackId) => { + get().pushHistory(); + set((state) => ({ + tracks: state.tracks.filter((track) => track.id !== trackId), + })); + }, + + addClipToTrack: (trackId, clipData) => { + get().pushHistory(); + const newClip: TimelineClip = { + ...clipData, + id: crypto.randomUUID(), + startTime: clipData.startTime || 0, + trimStart: 0, + trimEnd: 0, + }; + + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { ...track, clips: [...track.clips, newClip] } + : track + ), + })); + }, + + removeClipFromTrack: (trackId, clipId) => { + get().pushHistory(); + set((state) => ({ + tracks: state.tracks + .map((track) => + track.id === trackId + ? { + ...track, + clips: track.clips.filter((clip) => clip.id !== clipId), + } + : track + ) + // Remove track if it becomes empty + .filter((track) => track.clips.length > 0), + })); + }, + + moveClipToTrack: (fromTrackId, toTrackId, clipId) => { + get().pushHistory(); + set((state) => { + const fromTrack = state.tracks.find((track) => track.id === fromTrackId); + const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId); + + if (!clipToMove) return state; + + return { + tracks: state.tracks + .map((track) => { + if (track.id === fromTrackId) { + return { + ...track, + clips: track.clips.filter((clip) => clip.id !== clipId), + }; + } else if (track.id === toTrackId) { + return { + ...track, + clips: [...track.clips, clipToMove], + }; + } + return track; + }) + // Remove track if it becomes empty + .filter((track) => track.clips.length > 0), + }; + }); + }, + + updateClipTrim: (trackId, clipId, trimStart, trimEnd) => { + get().pushHistory(); + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { + ...track, + clips: track.clips.map((clip) => + clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip + ), + } + : track + ), + })); + }, + + updateClipStartTime: (trackId, clipId, startTime) => { + get().pushHistory(); + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { + ...track, + clips: track.clips.map((clip) => + clip.id === clipId ? { ...clip, startTime } : clip + ), + } + : track + ), + })); + }, + + toggleTrackMute: (trackId) => { + get().pushHistory(); + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId ? { ...track, muted: !track.muted } : track + ), + })); + }, + + getTotalDuration: () => { + const { tracks } = get(); + if (tracks.length === 0) return 0; + + const trackEndTimes = tracks.map((track) => + track.clips.reduce((maxEnd, clip) => { + const clipEnd = + clip.startTime + clip.duration - clip.trimStart - clip.trimEnd; + return Math.max(maxEnd, clipEnd); + }, 0) + ); + + return Math.max(...trackEndTimes, 0); + }, + + redo: () => { + const { redoStack } = get(); + if (redoStack.length === 0) return; + const next = redoStack[redoStack.length - 1]; + set({ tracks: next, redoStack: redoStack.slice(0, -1) }); + }, + + dragState: { + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }, + + setDragState: (dragState) => + set((state) => ({ + dragState: { ...state.dragState, ...dragState }, + })), + + startDrag: (clipId, trackId, startMouseX, startClipTime, clickOffsetTime) => { + set({ + dragState: { + isDragging: true, + clipId, + trackId, + startMouseX, + startClipTime, + clickOffsetTime, + currentTime: startClipTime, + }, + }); + }, + + updateDragTime: (currentTime) => { + set((state) => ({ + dragState: { + ...state.dragState, + currentTime, + }, + })); + }, + + endDrag: () => { + set({ + dragState: { + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }, + }); + }, +})); diff --git a/apps/web/src/types/editor.ts b/apps/web/src/types/editor.ts new file mode 100644 index 00000000..bf1dae2f --- /dev/null +++ b/apps/web/src/types/editor.ts @@ -0,0 +1 @@ +export type BackgroundType = "blur" | "mirror" | "color";
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts new file mode 100644 index 00000000..21be520e --- /dev/null +++ b/apps/web/src/types/timeline.ts @@ -0,0 +1,27 @@ +import { TimelineTrack, TimelineClip } from "@/stores/timeline-store";
+
+export interface TimelineClipProps {
+ clip: TimelineClip;
+ track: TimelineTrack;
+ zoomLevel: number;
+ isSelected: boolean;
+ onContextMenu: (e: React.MouseEvent, clipId: string) => void;
+ onClipMouseDown: (e: React.MouseEvent, clip: TimelineClip) => void;
+ onClipClick: (e: React.MouseEvent, clip: TimelineClip) => void;
+}
+
+export interface ResizeState {
+ clipId: string;
+ side: "left" | "right";
+ startX: number;
+ initialTrimStart: number;
+ initialTrimEnd: number;
+}
+
+export interface ContextMenuState {
+ type: "track" | "clip";
+ trackId: string;
+ clipId?: string;
+ x: number;
+ y: number;
+}
diff --git a/netlify.toml b/netlify.toml index a895edd5..ec820937 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,13 +1,10 @@ -[build]
- base = "."
- command = "bun install && bunx turbo build --filter=opencut"
- publish = "apps/web/.next"
-
+# Next.js plugin
[[plugins]]
package = "@netlify/plugin-nextjs"
+# Redirects for domain migration
[[redirects]]
from = "https://appcut.app/*"
to = "https://opencut.app/:splat"
status = 301
- force = true
\ No newline at end of file + force = true
\ No newline at end of file |
