Merge branch 'main' into split-issue-83
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -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.
|
|
70
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@ -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
|
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -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.
|
|
42
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@ -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
|
4
.github/workflows/bun-ci.yml
vendored
@ -31,13 +31,13 @@ jobs:
|
|||||||
- name: Install Bun
|
- name: Install Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
|
||||||
with:
|
with:
|
||||||
bun-version: 1.2.2
|
bun-version: 1.2.17
|
||||||
|
|
||||||
- name: Cache Bun modules
|
- name: Cache Bun modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
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
|
- name: Install dependencies
|
||||||
working-directory: apps/web
|
working-directory: apps/web
|
||||||
|
4
.gitignore
vendored
@ -28,3 +28,7 @@ node_modules
|
|||||||
.turbo
|
.turbo
|
||||||
|
|
||||||
*.env
|
*.env
|
||||||
|
|
||||||
|
# cursor
|
||||||
|
|
||||||
|
.cursor/
|
15
README.md
@ -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:
|
Navigate into the web app's directory and create a `.env` file from the example:
|
||||||
```bash
|
```bash
|
||||||
cd apps/web
|
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.*
|
*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).
|
The application will be available at [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
=======
|
---
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
||||||
=======
|
---
|
||||||
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
|
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
|
||||||
|
|
||||||
Quick start for contributors:
|
Quick start for contributors:
|
||||||
|
@ -19,7 +19,8 @@ COPY apps/web/ apps/web/
|
|||||||
COPY packages/db/ packages/db/
|
COPY packages/db/ packages/db/
|
||||||
COPY packages/auth/ packages/auth/
|
COPY packages/auth/ packages/auth/
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NODE_ENV production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
WORKDIR /app/apps/web
|
WORKDIR /app/apps/web
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
@ -28,7 +29,8 @@ RUN bun run build
|
|||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NODE_ENV production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
11
apps/web/public/browserconfig.xml
Normal file
@ -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>
|
BIN
apps/web/public/favicon.ico
Normal file
After Width: | Height: | Size: 1.1 KiB |
10
apps/web/public/frame.svg
Normal file
@ -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>
|
After Width: | Height: | Size: 716 B |
BIN
apps/web/public/icons/android-icon-144x144.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/web/public/icons/android-icon-192x192.png
Normal file
After Width: | Height: | Size: 741 B |
BIN
apps/web/public/icons/android-icon-36x36.png
Normal file
After Width: | Height: | Size: 768 B |
BIN
apps/web/public/icons/android-icon-48x48.png
Normal file
After Width: | Height: | Size: 802 B |
BIN
apps/web/public/icons/android-icon-72x72.png
Normal file
After Width: | Height: | Size: 826 B |
BIN
apps/web/public/icons/android-icon-96x96.png
Normal file
After Width: | Height: | Size: 906 B |
BIN
apps/web/public/icons/apple-icon-114x114.png
Normal file
After Width: | Height: | Size: 985 B |
BIN
apps/web/public/icons/apple-icon-120x120.png
Normal file
After Width: | Height: | Size: 998 B |
BIN
apps/web/public/icons/apple-icon-144x144.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/web/public/icons/apple-icon-152x152.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/web/public/icons/apple-icon-180x180.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
apps/web/public/icons/apple-icon-57x57.png
Normal file
After Width: | Height: | Size: 809 B |
BIN
apps/web/public/icons/apple-icon-60x60.png
Normal file
After Width: | Height: | Size: 843 B |
BIN
apps/web/public/icons/apple-icon-72x72.png
Normal file
After Width: | Height: | Size: 826 B |
BIN
apps/web/public/icons/apple-icon-76x76.png
Normal file
After Width: | Height: | Size: 820 B |
BIN
apps/web/public/icons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 670 B |
BIN
apps/web/public/icons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 747 B |
BIN
apps/web/public/icons/favicon-96x96.png
Normal file
After Width: | Height: | Size: 906 B |
BIN
apps/web/public/icons/ms-icon-144x144.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/web/public/icons/ms-icon-150x150.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/web/public/icons/ms-icon-310x310.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
apps/web/public/icons/ms-icon-70x70.png
Normal file
After Width: | Height: | Size: 814 B |
BIN
apps/web/public/landing-page-bg.png
Normal file
After Width: | Height: | Size: 225 KiB |
10
apps/web/public/logo.svg
Normal file
@ -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>
|
After Width: | Height: | Size: 362 B |
44
apps/web/public/manifest.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { GithubIcon } from "@/components/icons";
|
import { GithubIcon } from "@/components/icons";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Contributors - OpenCut",
|
title: "Contributors - OpenCut",
|
||||||
@ -49,7 +50,7 @@ async function getContributors(): Promise<Contributor[]> {
|
|||||||
const contributors = await response.json();
|
const contributors = await response.json();
|
||||||
|
|
||||||
const filteredContributors = contributors.filter(
|
const filteredContributors = contributors.filter(
|
||||||
(contributor: any) => contributor.type === "User"
|
(contributor: Contributor) => contributor.type === "User"
|
||||||
);
|
);
|
||||||
|
|
||||||
return filteredContributors;
|
return filteredContributors;
|
||||||
@ -61,8 +62,8 @@ async function getContributors(): Promise<Contributor[]> {
|
|||||||
|
|
||||||
export default async function ContributorsPage() {
|
export default async function ContributorsPage() {
|
||||||
const contributors = await getContributors();
|
const contributors = await getContributors();
|
||||||
const topContributor = contributors[0];
|
const topContributors = contributors.slice(0, 2);
|
||||||
const otherContributors = contributors.slice(1);
|
const otherContributors = contributors.slice(2);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<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="relative container mx-auto px-4 py-16">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="text-center mb-20">
|
<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" />
|
<GithubIcon className="h-3 w-3" />
|
||||||
Open Source
|
Open Source
|
||||||
</div>
|
</Badge>
|
||||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
||||||
Contributors
|
Contributors
|
||||||
</h1>
|
</h1>
|
||||||
@ -105,54 +106,59 @@ export default async function ContributorsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{topContributor && (
|
{topContributors.length > 0 && (
|
||||||
<div className="mb-20">
|
<div className="mb-20">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-2xl font-semibold mb-2">
|
<h2 className="text-2xl font-semibold mb-2">
|
||||||
Top Contributor
|
Top Contributors
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Leading the way in contributions
|
Leading the way in contributions
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<div className="flex flex-col md:flex-row gap-6 justify-center max-w-4xl mx-auto">
|
||||||
href={topContributor.html_url}
|
{topContributors.map((contributor, index) => (
|
||||||
target="_blank"
|
<Link
|
||||||
rel="noopener noreferrer"
|
key={contributor.id}
|
||||||
className="group block"
|
href={contributor.html_url}
|
||||||
>
|
target="_blank"
|
||||||
<div className="relative mx-auto max-w-md">
|
rel="noopener noreferrer"
|
||||||
<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" />
|
className="group block flex-1"
|
||||||
<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 mx-auto max-w-md">
|
||||||
<div className="relative mb-6">
|
<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" />
|
||||||
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
|
<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">
|
||||||
<AvatarImage
|
<CardContent className="p-8 text-center">
|
||||||
src={topContributor.avatar_url}
|
<div className="relative mb-6">
|
||||||
alt={`${topContributor.login}'s avatar`}
|
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
|
||||||
/>
|
<AvatarImage
|
||||||
<AvatarFallback className="text-lg font-semibold">
|
src={contributor.avatar_url}
|
||||||
{topContributor.login.charAt(0).toUpperCase()}
|
alt={`${contributor.login}'s avatar`}
|
||||||
</AvatarFallback>
|
/>
|
||||||
</Avatar>
|
<AvatarFallback className="text-lg font-semibold">
|
||||||
<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">
|
{contributor.login.charAt(0).toUpperCase()}
|
||||||
1
|
</AvatarFallback>
|
||||||
</div>
|
</Avatar>
|
||||||
</div>
|
<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">
|
||||||
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
|
{index + 1}
|
||||||
{topContributor.login}
|
</div>
|
||||||
</h3>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
|
||||||
<span className="font-medium text-foreground">
|
{contributor.login}
|
||||||
{topContributor.contributions}
|
</h3>
|
||||||
</span>
|
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||||
<span>contributions</span>
|
<span className="font-medium text-foreground">
|
||||||
</div>
|
{contributor.contributions}
|
||||||
</CardContent>
|
</span>
|
||||||
</Card>
|
<span>contributions</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -167,7 +173,7 @@ export default async function ContributorsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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) => (
|
{otherContributors.map((contributor, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={contributor.id}
|
key={contributor.id}
|
||||||
@ -179,8 +185,8 @@ export default async function ContributorsPage() {
|
|||||||
animationDelay: `${index * 50}ms`,
|
animationDelay: `${index * 50}ms`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-center p-4 rounded-xl hover:bg-muted/50 transition-all duration-300 group-hover:scale-105">
|
<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 ring-2 ring-transparent group-hover:ring-muted-foreground/20 transition-all duration-300">
|
<Avatar className="h-16 w-16 mx-auto mb-3">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={contributor.avatar_url}
|
src={contributor.avatar_url}
|
||||||
alt={`${contributor.login}'s avatar`}
|
alt={`${contributor.login}'s avatar`}
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -39,7 +39,7 @@
|
|||||||
--sidebar-ring: 0 0% 3.9%;
|
--sidebar-ring: 0 0% 3.9%;
|
||||||
}
|
}
|
||||||
.dark {
|
.dark {
|
||||||
--background: 0 0% 8%;
|
--background: 0 0% 6%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
--card: 0 0% 3.9%;
|
--card: 0 0% 3.9%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 0 0% 98%;
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import { Analytics } from "@vercel/analytics/react";
|
import { Analytics } from "@vercel/analytics/react";
|
||||||
@ -6,46 +5,15 @@ import Script from "next/script";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
import { TooltipProvider } from "../components/ui/tooltip";
|
import { TooltipProvider } from "../components/ui/tooltip";
|
||||||
|
import { DevelopmentDebug } from "../components/development-debug";
|
||||||
|
import { baseMetaData } from "./metadata";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata = baseMetaData;
|
||||||
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 default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
@ -60,6 +28,7 @@ export default function RootLayout({
|
|||||||
{children}
|
{children}
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
<DevelopmentDebug />
|
||||||
<Script
|
<Script
|
||||||
src="https://app.databuddy.cc/databuddy.js"
|
src="https://app.databuddy.cc/databuddy.js"
|
||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
|
66
apps/web/src/app/metadata.ts
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
};
|
@ -1,6 +1,7 @@
|
|||||||
import { Hero } from "@/components/landing/hero";
|
import { Hero } from "@/components/landing/hero";
|
||||||
import { Header } from "@/components/header";
|
import { Header } from "@/components/header";
|
||||||
import { getWaitlistCount } from "@/lib/waitlist";
|
import { getWaitlistCount } from "@/lib/waitlist";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
// Force dynamic rendering so waitlist count updates in real-time
|
// Force dynamic rendering so waitlist count updates in real-time
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@ -10,6 +11,13 @@ export default async function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 />
|
<Header />
|
||||||
<Hero signupCount={signupCount} />
|
<Hero signupCount={signupCount} />
|
||||||
</div>
|
</div>
|
||||||
|
107
apps/web/src/components/development-debug.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -3,13 +3,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ChevronLeft, Download } from "lucide-react";
|
import { ChevronLeft, Download } from "lucide-react";
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
import { HeaderBase } from "./header-base";
|
import { HeaderBase } from "./header-base";
|
||||||
import { ProjectNameEditor } from "./editor/project-name-editor";
|
import { ProjectNameEditor } from "./editor/project-name-editor";
|
||||||
|
|
||||||
export function EditorHeader() {
|
export function EditorHeader() {
|
||||||
const { activeProject } = useProjectStore();
|
|
||||||
const { getTotalDuration } = useTimelineStore();
|
const { getTotalDuration } = useTimelineStore();
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { AspectRatio } from "../ui/aspect-ratio";
|
import { AspectRatio } from "../ui/aspect-ratio";
|
||||||
import { DragOverlay } from "../ui/drag-overlay";
|
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 { processMediaFiles } from "@/lib/media-processing";
|
||||||
import { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react";
|
import { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react";
|
||||||
import { useDragDrop } from "@/hooks/use-drag-drop";
|
import { useDragDrop } from "@/hooks/use-drag-drop";
|
||||||
@ -17,27 +17,28 @@ export function MediaPanel() {
|
|||||||
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [mediaFilter, setMediaFilter] = useState("all");
|
const [mediaFilter, setMediaFilter] = useState("all");
|
||||||
|
|
||||||
const processFiles = async (files: FileList | File[]) => {
|
const processFiles = async (files: FileList | File[]) => {
|
||||||
// If no files, do nothing
|
if (!files || files.length === 0) return;
|
||||||
if (!files?.length) return;
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
setProgress(0);
|
||||||
try {
|
try {
|
||||||
// Process files (extract metadata, generate thumbnails, etc.)
|
// 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
|
// Add each processed media item to the store
|
||||||
items.forEach((item) => {
|
processedItems.forEach((item) => addMediaItem(item));
|
||||||
addMediaItem(item);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Show error if processing fails
|
// Show error toast if processing fails
|
||||||
console.error("File processing failed:", error);
|
console.error("Error processing files:", error);
|
||||||
toast.error("Failed to process files");
|
toast.error("Failed to process files");
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
setProgress(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -67,7 +68,7 @@ export function MediaPanel() {
|
|||||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
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
|
// When dragging a media item, set drag data for timeline to read
|
||||||
e.dataTransfer.setData(
|
e.dataTransfer.setData(
|
||||||
"application/x-media-item",
|
"application/x-media-item",
|
||||||
@ -101,7 +102,7 @@ export function MediaPanel() {
|
|||||||
setFilteredMediaItems(filtered);
|
setFilteredMediaItems(filtered);
|
||||||
}, [mediaItems, mediaFilter, searchQuery]);
|
}, [mediaItems, mediaFilter, searchQuery]);
|
||||||
|
|
||||||
const renderPreview = (item: any) => {
|
const renderPreview = (item: MediaItem) => {
|
||||||
// Render a preview for each media type (image, video, audio, unknown)
|
// Render a preview for each media type (image, video, audio, unknown)
|
||||||
// Each preview is draggable to the timeline
|
// Each preview is draggable to the timeline
|
||||||
const baseDragProps = {
|
const baseDragProps = {
|
||||||
@ -241,15 +242,12 @@ export function MediaPanel() {
|
|||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<>
|
<>
|
||||||
<Upload className="h-4 w-4 animate-spin" />
|
<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" />
|
<Plus className="h-4 w-4" />
|
||||||
<span
|
<span className="hidden sm:inline ml-2" aria-label="Add file">
|
||||||
className="hidden sm:inline ml-2"
|
|
||||||
aria-label="Add file"
|
|
||||||
>
|
|
||||||
Add
|
Add
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
@ -1,32 +1,98 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import {
|
||||||
import { useMediaStore } from "@/stores/media-store";
|
useTimelineStore,
|
||||||
|
type TimelineClip,
|
||||||
|
type TimelineTrack,
|
||||||
|
} from "@/stores/timeline-store";
|
||||||
|
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
import { VideoPlayer } from "@/components/ui/video-player";
|
import { VideoPlayer } from "@/components/ui/video-player";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Play, Pause, Volume2, VolumeX } from "lucide-react";
|
import { Play, Pause, Volume2, VolumeX, Plus } from "lucide-react";
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
// Debug flag - set to false to hide active clips info
|
interface ActiveClip {
|
||||||
const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
|
clip: TimelineClip;
|
||||||
|
track: TimelineTrack;
|
||||||
|
mediaItem: MediaItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function PreviewPanel() {
|
export function PreviewPanel() {
|
||||||
const { tracks } = useTimelineStore();
|
const { tracks } = useTimelineStore();
|
||||||
const { mediaItems } = useMediaStore();
|
const { mediaItems } = useMediaStore();
|
||||||
const { isPlaying, toggle, currentTime, muted, toggleMute, volume } =
|
const { currentTime, muted, toggleMute, volume } = usePlaybackStore();
|
||||||
usePlaybackStore();
|
|
||||||
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
|
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
|
||||||
const [showDebug, setShowDebug] = useState(SHOW_DEBUG_INFO);
|
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
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
|
// Get active clips at current time
|
||||||
const getActiveClips = () => {
|
const getActiveClips = (): ActiveClip[] => {
|
||||||
const activeClips: Array<{
|
const activeClips: ActiveClip[] = [];
|
||||||
clip: any;
|
|
||||||
track: any;
|
|
||||||
mediaItem: any;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
tracks.forEach((track) => {
|
tracks.forEach((track) => {
|
||||||
track.clips.forEach((clip) => {
|
track.clips.forEach((clip) => {
|
||||||
@ -37,12 +103,10 @@ export function PreviewPanel() {
|
|||||||
if (currentTime >= clipStart && currentTime < clipEnd) {
|
if (currentTime >= clipStart && currentTime < clipEnd) {
|
||||||
const mediaItem =
|
const mediaItem =
|
||||||
clip.mediaId === "test"
|
clip.mediaId === "test"
|
||||||
? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
|
? null // Test clips don't have a real media item
|
||||||
: mediaItems.find((item) => item.id === clip.mediaId);
|
: mediaItems.find((item) => item.id === clip.mediaId) || null;
|
||||||
|
|
||||||
if (mediaItem || clip.mediaId === "test") {
|
activeClips.push({ clip, track, mediaItem });
|
||||||
activeClips.push({ clip, track, mediaItem });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -51,10 +115,9 @@ export function PreviewPanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const activeClips = getActiveClips();
|
const activeClips = getActiveClips();
|
||||||
const aspectRatio = canvasSize.width / canvasSize.height;
|
|
||||||
|
|
||||||
// Render a clip
|
// Render a clip
|
||||||
const renderClip = (clipData: any, index: number) => {
|
const renderClip = (clipData: ActiveClip, index: number) => {
|
||||||
const { clip, mediaItem } = clipData;
|
const { clip, mediaItem } = clipData;
|
||||||
|
|
||||||
// Test clips
|
// Test clips
|
||||||
@ -155,18 +218,6 @@ export function PreviewPanel() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -180,30 +231,23 @@ export function PreviewPanel() {
|
|||||||
)}
|
)}
|
||||||
{muted || volume === 0 ? "Unmute" : "Mute"}
|
{muted || volume === 0 ? "Unmute" : "Mute"}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Preview Area */}
|
{/* 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={containerRef}
|
||||||
|
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref={previewRef}
|
ref={previewRef}
|
||||||
className="relative overflow-hidden rounded-sm max-w-full max-h-full bg-black border border-gray-600"
|
className="relative overflow-hidden rounded-sm bg-black border"
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: aspectRatio.toString(),
|
width: previewDimensions.width,
|
||||||
width: "100%",
|
height: previewDimensions.height,
|
||||||
height: "100%",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activeClips.length === 0 ? (
|
{activeClips.length === 0 ? (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-white/50">
|
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||||
{tracks.length === 0
|
{tracks.length === 0
|
||||||
? "Drop media to start editing"
|
? "Drop media to start editing"
|
||||||
: "No clips at current time"}
|
: "No clips at current time"}
|
||||||
@ -212,35 +256,28 @@ export function PreviewPanel() {
|
|||||||
activeClips.map((clipData, index) => renderClip(clipData, index))
|
activeClips.map((clipData, index) => renderClip(clipData, index))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Debug Info Panel - Conditionally rendered */}
|
<PreviewToolbar />
|
||||||
{showDebug && (
|
</div>
|
||||||
<div className="border-t bg-background p-2 flex-shrink-0">
|
</div>
|
||||||
<div className="text-xs font-medium mb-1">
|
);
|
||||||
Debug: Active Clips ({activeClips.length})
|
}
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 overflow-x-auto">
|
function PreviewToolbar() {
|
||||||
{activeClips.map((clipData, index) => (
|
const { isPlaying, toggle } = usePlaybackStore();
|
||||||
<div
|
|
||||||
key={clipData.clip.id}
|
return (
|
||||||
className="flex items-center gap-1 px-2 py-1 bg-muted rounded text-xs whitespace-nowrap"
|
<div
|
||||||
>
|
data-toolbar
|
||||||
<span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4">
|
className="flex items-center justify-center gap-2 px-4 pt-2 bg-background-500 w-full"
|
||||||
{index + 1}
|
>
|
||||||
</span>
|
<Button variant="text" size="icon" onClick={toggle}>
|
||||||
<span>{clipData.clip.name}</span>
|
{isPlaying ? (
|
||||||
<span className="text-muted-foreground">
|
<Pause className="h-3 w-3" />
|
||||||
({clipData.mediaItem?.type || "test"})
|
) : (
|
||||||
</span>
|
<Play className="h-3 w-3" />
|
||||||
</div>
|
)}
|
||||||
))}
|
</Button>
|
||||||
{activeClips.length === 0 && (
|
|
||||||
<span className="text-muted-foreground">No active clips</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -17,13 +17,12 @@ import { useMediaStore } from "@/stores/media-store";
|
|||||||
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
|
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SpeedControl } from "./speed-control";
|
import { SpeedControl } from "./speed-control";
|
||||||
|
import type { BackgroundType } from "@/types/editor";
|
||||||
|
|
||||||
export function PropertiesPanel() {
|
export function PropertiesPanel() {
|
||||||
const { tracks } = useTimelineStore();
|
const { tracks } = useTimelineStore();
|
||||||
const { mediaItems } = useMediaStore();
|
const { mediaItems } = useMediaStore();
|
||||||
const [backgroundType, setBackgroundType] = useState<
|
const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur");
|
||||||
"blur" | "mirror" | "color"
|
|
||||||
>("blur");
|
|
||||||
const [backgroundColor, setBackgroundColor] = useState("#000000");
|
const [backgroundColor, setBackgroundColor] = useState("#000000");
|
||||||
|
|
||||||
// Get the first video clip for preview (simplified)
|
// Get the first video clip for preview (simplified)
|
||||||
@ -78,7 +77,9 @@ export function PropertiesPanel() {
|
|||||||
<Label htmlFor="bg-type">Background Type</Label>
|
<Label htmlFor="bg-type">Background Type</Label>
|
||||||
<Select
|
<Select
|
||||||
value={backgroundType}
|
value={backgroundType}
|
||||||
onValueChange={(value: any) => setBackgroundType(value)}
|
onValueChange={(value: BackgroundType) =>
|
||||||
|
setBackgroundType(value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select background type" />
|
<SelectValue placeholder="Select background type" />
|
||||||
|
276
apps/web/src/components/editor/timeline-clip.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -1860,3 +1860,4 @@ function TimelineTrackContent({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,7 +29,7 @@ export function HeaderBase({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<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>}
|
{leftContent && <div className="flex items-center">{leftContent}</div>}
|
||||||
{centerContent && (
|
{centerContent && (
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { HeaderBase } from "./header-base";
|
import { HeaderBase } from "./header-base";
|
||||||
import { useSession } from "@opencut/auth/client";
|
import { useSession } from "@opencut/auth/client";
|
||||||
import { getStars } from "@/lib/fetchGhStars";
|
import { getStars } from "@/lib/fetchGhStars";
|
||||||
import { Star } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
@ -29,15 +28,15 @@ export function Header() {
|
|||||||
|
|
||||||
const leftContent = (
|
const leftContent = (
|
||||||
<Link href="/" className="flex items-center gap-3">
|
<Link href="/" className="flex items-center gap-3">
|
||||||
<Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
|
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
|
||||||
<span className="font-medium tracking-tight">OpenCut</span>
|
<span className="text-xl font-medium hidden md:block">OpenCut</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
||||||
const rightContent = (
|
const rightContent = (
|
||||||
<nav className="flex items-center">
|
<nav className="flex items-center gap-3">
|
||||||
<Link href="/contributors">
|
<Link href="/contributors">
|
||||||
<Button variant="text" className="text-sm">
|
<Button variant="text" className="text-sm p-0">
|
||||||
Contributors
|
Contributors
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@ -59,5 +58,13 @@ export function Header() {
|
|||||||
</nav>
|
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,7 @@ export function GithubIcon({ className }: { className?: string }) {
|
|||||||
viewBox="0 -3.5 256 256"
|
viewBox="0 -3.5 256 256"
|
||||||
preserveAspectRatio="xMinYMin meet"
|
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="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" />
|
<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" />
|
||||||
|
@ -8,6 +8,7 @@ import Link from "next/link";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { getStars } from "@/lib/fetchGhStars";
|
import { getStars } from "@/lib/fetchGhStars";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
interface HeroProps {
|
interface HeroProps {
|
||||||
signupCount: number;
|
signupCount: number;
|
||||||
@ -82,7 +83,7 @@ export function Hero({ signupCount }: HeroProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
@ -93,18 +94,21 @@ export function Hero({ signupCount }: HeroProps) {
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.2, duration: 0.8 }}
|
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">
|
<h1>The Open Source</h1>
|
||||||
The open source
|
<div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
|
||||||
</h1>
|
<div className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2">
|
||||||
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter mt-2">
|
<Image src="/frame.svg" height={79} width={459} alt="frame" />
|
||||||
video editor
|
<span className="absolute inset-0 flex items-center justify-center">
|
||||||
</h1>
|
Video Editor
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.p
|
<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 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: 0.4, duration: 0.8 }}
|
transition={{ delay: 0.4, duration: 0.8 }}
|
||||||
@ -119,7 +123,10 @@ export function Hero({ signupCount }: HeroProps) {
|
|||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: 0.6, duration: 0.8 }}
|
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
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Enter your email"
|
placeholder="Enter your email"
|
||||||
|
@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { BackgroundType } from "@/types/editor";
|
||||||
|
|
||||||
interface ImageTimelineTreatmentProps {
|
interface ImageTimelineTreatmentProps {
|
||||||
src: string;
|
src: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
targetAspectRatio?: number; // Default to 16:9 for video
|
targetAspectRatio?: number; // Default to 16:9 for video
|
||||||
className?: string;
|
className?: string;
|
||||||
backgroundType?: "blur" | "mirror" | "color";
|
backgroundType?: BackgroundType;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
226
apps/web/src/hooks/use-drag-clip.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
}
|
@ -11,11 +11,15 @@ import {
|
|||||||
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
|
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
|
||||||
|
|
||||||
export async function processMediaFiles(
|
export async function processMediaFiles(
|
||||||
files: FileList | File[]
|
files: FileList | File[],
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
): Promise<ProcessedMediaItem[]> {
|
): Promise<ProcessedMediaItem[]> {
|
||||||
const fileArray = Array.from(files);
|
const fileArray = Array.from(files);
|
||||||
const processedItems: ProcessedMediaItem[] = [];
|
const processedItems: ProcessedMediaItem[] = [];
|
||||||
|
|
||||||
|
const total = fileArray.length;
|
||||||
|
let completed = 0;
|
||||||
|
|
||||||
for (const file of fileArray) {
|
for (const file of fileArray) {
|
||||||
const fileType = getFileType(file);
|
const fileType = getFileType(file);
|
||||||
|
|
||||||
@ -57,6 +61,15 @@ export async function processMediaFiles(
|
|||||||
duration,
|
duration,
|
||||||
aspectRatio,
|
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) {
|
} catch (error) {
|
||||||
console.error("Error processing file:", file.name, error);
|
console.error("Error processing file:", file.name, error);
|
||||||
toast.error(`Failed to process ${file.name}`);
|
toast.error(`Failed to process ${file.name}`);
|
||||||
|
@ -8,7 +8,7 @@ interface PlaybackStore extends PlaybackState, PlaybackControls {
|
|||||||
|
|
||||||
let playbackTimer: number | null = null;
|
let playbackTimer: number | null = null;
|
||||||
|
|
||||||
const startTimer = (store: any) => {
|
const startTimer = (store: () => PlaybackStore) => {
|
||||||
if (playbackTimer) cancelAnimationFrame(playbackTimer);
|
if (playbackTimer) cancelAnimationFrame(playbackTimer);
|
||||||
|
|
||||||
// Use requestAnimationFrame for smoother updates
|
// Use requestAnimationFrame for smoother updates
|
||||||
|
@ -30,6 +30,27 @@ interface TimelineStore {
|
|||||||
clearSelectedClips: () => void;
|
clearSelectedClips: () => void;
|
||||||
setSelectedClips: (clips: { trackId: string; clipId: string }[]) => 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
|
// Actions
|
||||||
addTrack: (type: "video" | "audio" | "effects") => string;
|
addTrack: (type: "video" | "audio" | "effects") => string;
|
||||||
removeTrack: (trackId: string) => void;
|
removeTrack: (trackId: string) => void;
|
||||||
@ -73,7 +94,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
// Deep copy tracks
|
// Deep copy tracks
|
||||||
set({
|
set({
|
||||||
history: [...history, JSON.parse(JSON.stringify(tracks))],
|
history: [...history, JSON.parse(JSON.stringify(tracks))],
|
||||||
redoStack: [] // Clear redo stack when new action is performed
|
redoStack: [], // Clear redo stack when new action is performed
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -84,7 +105,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
set({
|
set({
|
||||||
tracks: prev,
|
tracks: prev,
|
||||||
history: history.slice(0, -1),
|
history: history.slice(0, -1),
|
||||||
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack
|
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))], // Add current state to redo stack
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -96,7 +117,11 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
if (multi) {
|
if (multi) {
|
||||||
// Toggle selection
|
// Toggle selection
|
||||||
return exists
|
return exists
|
||||||
? { selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)) }
|
? {
|
||||||
|
selectedClips: state.selectedClips.filter(
|
||||||
|
(c) => !(c.trackId === trackId && c.clipId === clipId)
|
||||||
|
),
|
||||||
|
}
|
||||||
: { selectedClips: [...state.selectedClips, { trackId, clipId }] };
|
: { selectedClips: [...state.selectedClips, { trackId, clipId }] };
|
||||||
} else {
|
} else {
|
||||||
return { selectedClips: [{ trackId, clipId }] };
|
return { selectedClips: [{ trackId, clipId }] };
|
||||||
@ -105,7 +130,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
deselectClip: (trackId, clipId) => {
|
deselectClip: (trackId, clipId) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)),
|
selectedClips: state.selectedClips.filter(
|
||||||
|
(c) => !(c.trackId === trackId && c.clipId === clipId)
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
clearSelectedClips: () => {
|
clearSelectedClips: () => {
|
||||||
@ -161,7 +188,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
tracks: state.tracks
|
tracks: state.tracks
|
||||||
.map((track) =>
|
.map((track) =>
|
||||||
track.id === trackId
|
track.id === trackId
|
||||||
? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) }
|
? {
|
||||||
|
...track,
|
||||||
|
clips: track.clips.filter((clip) => clip.id !== clipId),
|
||||||
|
}
|
||||||
: track
|
: track
|
||||||
)
|
)
|
||||||
// Remove track if it becomes empty
|
// Remove track if it becomes empty
|
||||||
@ -261,4 +291,56 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
const next = redoStack[redoStack.length - 1];
|
const next = redoStack[redoStack.length - 1];
|
||||||
set({ tracks: next, redoStack: redoStack.slice(0, -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
1
apps/web/src/types/editor.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export type BackgroundType = "blur" | "mirror" | "color";
|
27
apps/web/src/types/timeline.ts
Normal file
@ -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;
|
||||||
|
}
|
@ -1,11 +1,8 @@
|
|||||||
[build]
|
# Next.js plugin
|
||||||
base = "."
|
|
||||||
command = "bun install && bunx turbo build --filter=opencut"
|
|
||||||
publish = "apps/web/.next"
|
|
||||||
|
|
||||||
[[plugins]]
|
[[plugins]]
|
||||||
package = "@netlify/plugin-nextjs"
|
package = "@netlify/plugin-nextjs"
|
||||||
|
|
||||||
|
# Redirects for domain migration
|
||||||
[[redirects]]
|
[[redirects]]
|
||||||
from = "https://appcut.app/*"
|
from = "https://appcut.app/*"
|
||||||
to = "https://opencut.app/:splat"
|
to = "https://opencut.app/:splat"
|
||||||
|