Compare commits
186 Commits
mazeincodi
...
mazeincodi
Author | SHA1 | Date | |
---|---|---|---|
1466dd42e2 | |||
b461234c65 | |||
822323d883 | |||
ca29be23ff | |||
796308e68e | |||
507d6a6a7e | |||
c414b83bc4 | |||
e4f2ce9221 | |||
bfba482098 | |||
3bc00f8e40 | |||
02d7a92e06 | |||
4e0352d4d6 | |||
8aa0aeb6e3 | |||
306c2885f1 | |||
5a6872537e | |||
8fe09c83b1 | |||
901d0baafd | |||
8934cc610f | |||
cdfb5ea7b0 | |||
dc99516a3f | |||
1f257d30dc | |||
f53f2802eb | |||
c32daa4f2e | |||
87e90a5e24 | |||
089f7f8d71 | |||
01140fd7bb | |||
dfde7592bb | |||
6391147a96 | |||
679ebc02b5 | |||
d5db470150 | |||
f3c45ee892 | |||
d9809c70c3 | |||
17ef810074 | |||
ebb3bc89c3 | |||
6d98c2af24 | |||
0bdbd7e2b3 | |||
6262f2b379 | |||
3f0fe9d20e | |||
0383000ada | |||
a816cc503f | |||
f74bebeb8b | |||
7ca5bcfa50 | |||
efdd2aa6ed | |||
12a2ec59fd | |||
b799615654 | |||
3224dd974a | |||
49787bdfe5 | |||
fff95afbc6 | |||
f76555dae5 | |||
53184217bf | |||
1284c232a3 | |||
b428a28aea | |||
23f118969b | |||
b10bb8ed55 | |||
d4048772ad | |||
063094e70a | |||
cd88a92c6d | |||
70352ced92 | |||
896f2ee554 | |||
b66e85cf4d | |||
46614d4008 | |||
2d7f2f8503 | |||
449673b79e | |||
4f74018648 | |||
4ed9858725 | |||
2f5bde1051 | |||
6626a8c413 | |||
ebb3456c10 | |||
a8d6bbd03a | |||
0f470ba7a7 | |||
029152dd5b | |||
d6a2de21d0 | |||
53e88df0d7 | |||
dd80064be6 | |||
e225272ec3 | |||
b01421f115 | |||
75eede20af | |||
e23cf66373 | |||
2f3a148dd4 | |||
e5fc3f9bbb | |||
0c97bc8c3f | |||
3eac1bcb0b | |||
3ea6b00254 | |||
e7dabd1444 | |||
389a546478 | |||
4fb0d014af | |||
c0684ca62d | |||
ef806ceab8 | |||
515be65bc4 | |||
4fe0ff3010 | |||
e1481391c7 | |||
777b0f7000 | |||
926aebe004 | |||
2775ac427d | |||
f5c546d416 | |||
d1e313450d | |||
27b0e51265 | |||
ebcb898e4e | |||
ffb6a35e20 | |||
2ddd8cb0d7 | |||
baa5c43e7c | |||
bf64ec1133 | |||
de3d3e210e | |||
3bb640c453 | |||
0f343dfbda | |||
ba14d65605 | |||
94d39dfa7e | |||
00d7b3ba8d | |||
266d3bc0a2 | |||
b94cc121d2 | |||
181d3fca06 | |||
57bfb7610f | |||
5977a5d253 | |||
0723623eaf | |||
f9d0be20d0 | |||
a511c57729 | |||
76c3293ac4 | |||
a969f3df86 | |||
cca25a6103 | |||
c322ad1124 | |||
2e5b623d90 | |||
e786d437a5 | |||
0ae852c185 | |||
db60354349 | |||
ba9870a908 | |||
e7d35c667f | |||
067b6e3d73 | |||
2326faf526 | |||
9ba57d86b6 | |||
92a8466368 | |||
56a8098442 | |||
6a666efa39 | |||
07351ca7e6 | |||
d9fcef9385 | |||
7f0bcea4ca | |||
903cdf5fff | |||
354dbbd0f9 | |||
51a83f9f21 | |||
e2a0f745c8 | |||
c18b173bc9 | |||
a4201c8be6 | |||
1722055a72 | |||
01b9dbccc2 | |||
d39edd8521 | |||
3a6cfd73e4 | |||
e14e137212 | |||
fe1a2542cc | |||
66eb6f13d5 | |||
4db515df51 | |||
992b0cbf23 | |||
7444ab2894 | |||
fd2cf591b7 | |||
d8494185e0 | |||
3989da93c3 | |||
10d11a5baa | |||
998ee086e5 | |||
402b004ef3 | |||
affa239f7b | |||
151b3dfc51 | |||
4a8ff91ae3 | |||
411a9a5f1c | |||
b8057cdd30 | |||
e01142b798 | |||
7461b763b1 | |||
1f2c505efe | |||
547f566fc2 | |||
7de09adce6 | |||
84d206c01e | |||
22ae5d0e37 | |||
bc28ce09cb | |||
a9293ffb48 | |||
274d03185b | |||
9bb19712e5 | |||
bc3fbabfb2 | |||
9354d69acf | |||
f8a4cd4ac6 | |||
5fc205fc57 | |||
8f583c25e0 | |||
27471ba532 | |||
1d13e9a67f | |||
9b29122a2c | |||
dcbae4554c | |||
33916d4e91 | |||
595f4c5f88 | |||
77560dc5aa | |||
e8a22d99cb |
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
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
|
||||
with:
|
||||
bun-version: 1.2.2
|
||||
bun-version: 1.2.17
|
||||
|
||||
- name: Cache Bun modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('apps/web/bun.lock') }}
|
||||
key: ${{ runner.os }}-bun-1.2.17-${{ hashFiles('apps/web/bun.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/web
|
||||
|
7
.gitignore
vendored
@ -27,4 +27,9 @@ node_modules
|
||||
.cursorignore
|
||||
.turbo
|
||||
|
||||
*.env
|
||||
*.env
|
||||
|
||||
# cursor
|
||||
|
||||
.cursor/
|
||||
bun.lockb
|
34
README.md
@ -1,12 +1,18 @@
|
||||
<img src="apps/web/public/logo.png" align="left" width="130" height="130">
|
||||
|
||||
<div align="right">
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="left" width="120">
|
||||
<img src="apps/web/public/logo.png" alt="OpenCut Logo" width="100" />
|
||||
</td>
|
||||
<td align="right">
|
||||
<h1>OpenCut <span style="font-size: 0.7em; font-weight: normal;">(prev AppCut)</span></h1>
|
||||
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
|
||||
# OpenCut (prev AppCut)
|
||||
### A free, open-source video editor for web, desktop, and mobile.
|
||||
</div>
|
||||
|
||||
## Why?
|
||||
|
||||
@ -20,6 +26,7 @@
|
||||
- Multi-track support
|
||||
- Real-time preview
|
||||
- No watermarks or subscriptions
|
||||
- Analytics provided by [Databuddy](https://www.databuddy.cc?utm_source=opencut), 100% Anonymized & Non-invasive.
|
||||
|
||||
## Project Structure
|
||||
|
||||
@ -58,7 +65,16 @@ Before you begin, ensure you have the following installed on your system:
|
||||
Navigate into the web app's directory and create a `.env` file from the example:
|
||||
```bash
|
||||
cd apps/web
|
||||
cp .env.example .env
|
||||
|
||||
|
||||
# Unix/Linux/Mac
|
||||
cp .env.example .env.local
|
||||
|
||||
# Windows Command Prompt
|
||||
copy .env.example .env.local
|
||||
|
||||
# Windows PowerShell
|
||||
Copy-Item .env.example .env.local
|
||||
```
|
||||
*The default values in the `.env` file should work for local development.*
|
||||
|
||||
@ -93,13 +109,13 @@ Before you begin, ensure you have the following installed on your system:
|
||||
|
||||
The application will be available at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
=======
|
||||
---
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
||||
=======
|
||||
---
|
||||
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
|
||||
|
||||
Quick start for contributors:
|
||||
|
@ -1,30 +1,45 @@
|
||||
FROM oven/bun:latest AS base
|
||||
FROM oven/bun:alpine AS base
|
||||
|
||||
# Install dependencies
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Build the application
|
||||
# Install dependencies and build the application
|
||||
FROM base AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
COPY package.json package.json
|
||||
COPY bun.lock bun.lock
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
COPY packages/auth/package.json packages/auth/package.json
|
||||
|
||||
RUN bun install
|
||||
|
||||
COPY apps/web/ apps/web/
|
||||
COPY packages/db/ packages/db/
|
||||
COPY packages/auth/ packages/auth/
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
WORKDIR /app/apps/web
|
||||
RUN bun run build
|
||||
|
||||
# Production image
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
|
||||
RUN chown nextjs:nodejs apps
|
||||
|
||||
USER nextjs
|
||||
|
||||
@ -33,4 +48,4 @@ EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["bun", "server.js"]
|
||||
CMD ["bun", "apps/web/server.js"]
|
@ -13,7 +13,7 @@ if (!process.env.DATABASE_URL) {
|
||||
}
|
||||
|
||||
export default {
|
||||
schema: "./src/lib/db/schema.ts",
|
||||
schema: "../../packages/db/src/schema.ts",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL,
|
||||
|
@ -6,6 +6,19 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
reactStrictMode: true,
|
||||
productionBrowserSourceMaps: true,
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "plus.unsplash.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
@ -21,7 +21,6 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@opencut/auth": "workspace:*",
|
||||
"@opencut/db": "workspace:*",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@upstash/ratelimit": "^2.0.5",
|
||||
"@upstash/redis": "^1.35.0",
|
||||
"@vercel/analytics": "^1.4.1",
|
||||
@ -57,6 +56,7 @@
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.15.4",
|
||||
"@types/bun": "latest",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
@ -68,4 +68,4 @@
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
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"
|
||||
}
|
||||
]
|
||||
}
|
BIN
apps/web/public/opengraph-image.jpg
Normal file
After Width: | Height: | Size: 54 KiB |
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "@opencut/auth/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@ -10,7 +9,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Suspense, useState } from "react";
|
||||
import { memo, Suspense } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@ -18,121 +17,22 @@ import Link from "next/link";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { GoogleIcon } from "@/components/icons";
|
||||
import { useLogin } from "@/hooks/auth/useLogin";
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError(null);
|
||||
setIsEmailLoading(true);
|
||||
|
||||
const { error } = await signIn.email({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message || "An unexpected error occurred.");
|
||||
setIsEmailLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/editor");
|
||||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
setError(null);
|
||||
setIsGoogleLoading(true);
|
||||
|
||||
try {
|
||||
await signIn.social({
|
||||
provider: "google",
|
||||
});
|
||||
router.push("/editor");
|
||||
} catch (error) {
|
||||
setError("Failed to sign in with Google. Please try again.");
|
||||
setIsGoogleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isAnyLoading = isEmailLoading || isGoogleLoading;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
disabled={isAnyLoading}
|
||||
>
|
||||
{isGoogleLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<GoogleIcon />
|
||||
)}{" "}
|
||||
Continue with Google
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator className="w-full" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
disabled={isAnyLoading || !email || !password}
|
||||
className="w-full h-11"
|
||||
size="lg"
|
||||
>
|
||||
{isEmailLoading ? <Loader2 className="animate-spin" /> : "Sign in"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const LoginPage = () => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
error,
|
||||
isAnyLoading,
|
||||
isEmailLoading,
|
||||
isGoogleLoading,
|
||||
handleLogin,
|
||||
handleGoogleLogin,
|
||||
} = useLogin();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center relative">
|
||||
@ -158,19 +58,85 @@ export default function LoginPage() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LoginForm />
|
||||
<div className="flex flex-col space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
disabled={isAnyLoading}
|
||||
>
|
||||
{isGoogleLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<GoogleIcon />
|
||||
)}{" "}
|
||||
Continue with Google
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator className="w-full" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
disabled={isAnyLoading || !email || !password}
|
||||
className="w-full h-11"
|
||||
size="lg"
|
||||
>
|
||||
{isEmailLoading ? <Loader2 className="animate-spin" /> : "Sign in"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/signup"
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</Suspense>
|
||||
<div className="mt-6 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/signup"
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(LoginPage);
|
||||
|
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signUp, signIn } from "@opencut/auth/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@ -10,151 +9,32 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Suspense, useState } from "react";
|
||||
import { memo, Suspense } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import Link from "next/link";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Loader2, ArrowLeft } from "lucide-react";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { GoogleIcon } from "@/components/icons";
|
||||
import { useSignUp } from "@/hooks/auth/useSignUp";
|
||||
|
||||
function SignUpForm() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
|
||||
const handleSignUp = async () => {
|
||||
setError(null);
|
||||
setIsEmailLoading(true);
|
||||
|
||||
const { error } = await signUp.email({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message || "An unexpected error occurred.");
|
||||
setIsEmailLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
const handleGoogleSignUp = async () => {
|
||||
setError(null);
|
||||
setIsGoogleLoading(true);
|
||||
|
||||
try {
|
||||
await signIn.social({
|
||||
provider: "google",
|
||||
});
|
||||
|
||||
router.push("/editor");
|
||||
} catch (error) {
|
||||
setError("Failed to sign up with Google. Please try again.");
|
||||
setIsGoogleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isAnyLoading = isEmailLoading || isGoogleLoading;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleGoogleSignUp}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
disabled={isAnyLoading}
|
||||
>
|
||||
{isGoogleLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<GoogleIcon />
|
||||
)}{" "}
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator className="w-full" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Create a strong password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSignUp}
|
||||
disabled={isAnyLoading || !name || !email || !password}
|
||||
className="w-full h-11"
|
||||
size="lg"
|
||||
>
|
||||
{isEmailLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
"Create account"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SignUpPage() {
|
||||
const SignUpPage = () => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
name,
|
||||
setName,
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
error,
|
||||
isAnyLoading,
|
||||
isEmailLoading,
|
||||
isGoogleLoading,
|
||||
handleSignUp,
|
||||
handleGoogleSignUp,
|
||||
} = useSignUp();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center relative">
|
||||
@ -165,7 +45,6 @@ export default function SignUpPage() {
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" /> Back
|
||||
</Button>
|
||||
|
||||
<Card className="w-[400px] shadow-lg border-0">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-2xl font-semibold">
|
||||
@ -183,19 +62,101 @@ export default function SignUpPage() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SignUpForm />
|
||||
<div className="flex flex-col space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleGoogleSignUp}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
disabled={isAnyLoading}
|
||||
>
|
||||
{isGoogleLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<GoogleIcon />
|
||||
)}{" "}
|
||||
Continue with Google
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator className="w-full" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Create a strong password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSignUp}
|
||||
disabled={isAnyLoading || !name || !email || !password}
|
||||
className="w-full h-11"
|
||||
size="lg"
|
||||
>
|
||||
{isEmailLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
"Create account"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</Suspense>
|
||||
<div className="mt-6 text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SignUpPage);
|
||||
|
5
apps/web/src/app/api/health/route.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return new Response("OK", { status: 200 });
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@opencut/db";
|
||||
import { db, eq } from "@opencut/db";
|
||||
import { waitlist } from "@opencut/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { waitlistRateLimit } from "@/lib/rate-limit";
|
||||
import { z } from "zod";
|
||||
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { GithubIcon } from "@/components/icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Contributors - OpenCut",
|
||||
@ -49,7 +50,7 @@ async function getContributors(): Promise<Contributor[]> {
|
||||
const contributors = await response.json();
|
||||
|
||||
const filteredContributors = contributors.filter(
|
||||
(contributor: any) => contributor.type === "User"
|
||||
(contributor: Contributor) => contributor.type === "User"
|
||||
);
|
||||
|
||||
return filteredContributors;
|
||||
@ -61,8 +62,8 @@ async function getContributors(): Promise<Contributor[]> {
|
||||
|
||||
export default async function ContributorsPage() {
|
||||
const contributors = await getContributors();
|
||||
const topContributor = contributors[0];
|
||||
const otherContributors = contributors.slice(1);
|
||||
const topContributors = contributors.slice(0, 2);
|
||||
const otherContributors = contributors.slice(2);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
@ -77,10 +78,10 @@ export default async function ContributorsPage() {
|
||||
<div className="relative container mx-auto px-4 py-16">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-20">
|
||||
<div className="inline-flex items-center gap-2 bg-muted/50 text-muted-foreground px-3 py-1 rounded-full text-sm mb-6">
|
||||
<Badge variant="secondary" className="gap-2 mb-6">
|
||||
<GithubIcon className="h-3 w-3" />
|
||||
Open Source
|
||||
</div>
|
||||
</Badge>
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
||||
Contributors
|
||||
</h1>
|
||||
@ -105,54 +106,56 @@ export default async function ContributorsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{topContributor && (
|
||||
{topContributors.length > 0 && (
|
||||
<div className="mb-20">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-2xl font-semibold mb-2">
|
||||
Top Contributor
|
||||
Top Contributors
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Leading the way in contributions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={topContributor.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group block"
|
||||
>
|
||||
<div className="relative mx-auto max-w-md">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" />
|
||||
<Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className="relative mb-6">
|
||||
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
|
||||
<AvatarImage
|
||||
src={topContributor.avatar_url}
|
||||
alt={`${topContributor.login}'s avatar`}
|
||||
/>
|
||||
<AvatarFallback className="text-lg font-semibold">
|
||||
{topContributor.login.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="absolute -top-2 -right-2 bg-foreground text-background rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold">
|
||||
1
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
|
||||
{topContributor.login}
|
||||
</h3>
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{topContributor.contributions}
|
||||
</span>
|
||||
<span>contributions</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex flex-col md:flex-row gap-6 justify-center max-w-4xl mx-auto">
|
||||
{topContributors.map((contributor, index) => (
|
||||
<Link
|
||||
key={contributor.id}
|
||||
href={contributor.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group block flex-1"
|
||||
>
|
||||
<div className="relative mx-auto max-w-md">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" />
|
||||
<Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className="relative mb-6">
|
||||
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
|
||||
<AvatarImage
|
||||
src={contributor.avatar_url}
|
||||
alt={`${contributor.login}'s avatar`}
|
||||
/>
|
||||
<AvatarFallback className="text-lg font-semibold">
|
||||
{contributor.login.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
|
||||
{contributor.login}
|
||||
</h3>
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{contributor.contributions}
|
||||
</span>
|
||||
<span>contributions</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -167,7 +170,7 @@ export default async function ContributorsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||
{otherContributors.map((contributor, index) => (
|
||||
<Link
|
||||
key={contributor.id}
|
||||
@ -179,8 +182,8 @@ export default async function ContributorsPage() {
|
||||
animationDelay: `${index * 50}ms`,
|
||||
}}
|
||||
>
|
||||
<div className="text-center p-4 rounded-xl hover:bg-muted/50 transition-all duration-300 group-hover:scale-105">
|
||||
<Avatar className="h-16 w-16 mx-auto mb-3 ring-2 ring-transparent group-hover:ring-muted-foreground/20 transition-all duration-300">
|
||||
<div className="text-center p-2 rounded-xl transition-all duration-300 hover:opacity-50">
|
||||
<Avatar className="h-16 w-16 mx-auto mb-3">
|
||||
<AvatarImage
|
||||
src={contributor.avatar_url}
|
||||
alt={`${contributor.login}'s avatar`}
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -43,7 +43,7 @@
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover: 0 0% 14.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
@ -55,7 +55,7 @@
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 100% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--border: 0 0% 17%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
|
@ -1,4 +1,3 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
@ -6,17 +5,15 @@ import Script from "next/script";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { TooltipProvider } from "../components/ui/tooltip";
|
||||
import { DevelopmentDebug } from "../components/development-debug";
|
||||
import { baseMetaData } from "./metadata";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "OpenCut",
|
||||
description:
|
||||
"A simple but powerful video editor that gets the job done. In your browser.",
|
||||
};
|
||||
export const metadata = baseMetaData;
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
@ -31,6 +28,7 @@ export default function RootLayout({
|
||||
{children}
|
||||
<Analytics />
|
||||
<Toaster />
|
||||
<DevelopmentDebug />
|
||||
<Script
|
||||
src="https://app.databuddy.cc/databuddy.js"
|
||||
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,17 +1,20 @@
|
||||
import { Hero } from "@/components/landing/hero";
|
||||
import { Header } from "@/components/header";
|
||||
import { getWaitlistCount } from "@/lib/waitlist";
|
||||
|
||||
// Force dynamic rendering so waitlist count updates in real-time
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Home() {
|
||||
const signupCount = await getWaitlistCount();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<Hero signupCount={signupCount} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Hero } from "@/components/landing/hero";
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { getWaitlistCount } from "@/lib/waitlist";
|
||||
import Image from "next/image";
|
||||
|
||||
// Force dynamic rendering so waitlist count updates in real-time
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Home() {
|
||||
const signupCount = await getWaitlistCount();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<Hero signupCount={signupCount} />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
228
apps/web/src/app/projects/page.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
ChevronLeft,
|
||||
Plus,
|
||||
Calendar,
|
||||
MoreHorizontal,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import { TProject } from "@/types/project";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
// Hard-coded project data
|
||||
const mockProjects: TProject[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Summer Vacation Highlights",
|
||||
createdAt: new Date("2024-12-15"),
|
||||
updatedAt: new Date("2024-12-20"),
|
||||
thumbnail:
|
||||
"https://plus.unsplash.com/premium_photo-1750854354243-81f40af63a73?q=80&w=687&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Product Demo Video",
|
||||
createdAt: new Date("2024-12-10"),
|
||||
updatedAt: new Date("2024-12-18"),
|
||||
thumbnail:
|
||||
"https://images.unsplash.com/photo-1750875936215-0c35c1742cd6?q=80&w=688&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Wedding Ceremony Edit",
|
||||
createdAt: new Date("2024-12-05"),
|
||||
updatedAt: new Date("2024-12-16"),
|
||||
thumbnail:
|
||||
"https://images.unsplash.com/photo-1750967991618-7b64a3025381?q=80&w=687&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "Travel Vlog - Japan",
|
||||
createdAt: new Date("2024-11-28"),
|
||||
updatedAt: new Date("2024-12-14"),
|
||||
thumbnail:
|
||||
"https://images.unsplash.com/photo-1750639258774-9a714379a093?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
},
|
||||
];
|
||||
|
||||
// Mock duration data (in seconds)
|
||||
const mockDurations: Record<string, number> = {
|
||||
"1": 245, // 4:05
|
||||
"2": 120, // 2:00
|
||||
"3": 1800, // 30:00
|
||||
"4": 780, // 13:00
|
||||
"5": 360, // 6:00
|
||||
"6": 180, // 3:00
|
||||
};
|
||||
|
||||
export default function ProjectsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="pt-6 px-6 flex items-center justify-between w-full h-16">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-1 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<ChevronLeft className="!size-5 shrink-0" />
|
||||
<span className="text-sm font-medium">Back</span>
|
||||
</Link>
|
||||
<div className="block md:hidden">
|
||||
<CreateButton />
|
||||
</div>
|
||||
</div>
|
||||
<main className="max-w-6xl mx-auto px-6 pt-6 pb-6">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
||||
Your Projects
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{mockProjects.length}{" "}
|
||||
{mockProjects.length === 1 ? "project" : "projects"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<CreateButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mockProjects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
|
||||
<Video className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">No projects yet</h3>
|
||||
<p className="text-muted-foreground mb-6 max-w-md">
|
||||
Start creating your first video project. Import media, edit, and
|
||||
export professional videos.
|
||||
</p>
|
||||
<Link href="/editor">
|
||||
<Button size="lg" className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Your First Project
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{mockProjects.map((project, index) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCard({ project }: { project: TProject }) {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={`/editor/${project.id}`} className="block group">
|
||||
<Card className="overflow-hidden bg-background border-none p-0">
|
||||
<div
|
||||
className={`relative aspect-square bg-muted transition-opacity ${
|
||||
isDropdownOpen ? "opacity-65" : "opacity-100 group-hover:opacity-65"
|
||||
}`}
|
||||
>
|
||||
{/* Thumbnail preview */}
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
src={project.thumbnail}
|
||||
alt="Project thumbnail"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Duration badge */}
|
||||
<div className="absolute bottom-3 right-3 bg-background text-foreground text-xs px-2 py-1 rounded">
|
||||
{formatDuration(mockDurations[project.id] || 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="px-0 pt-5">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
|
||||
{project.name}
|
||||
</h3>
|
||||
<DropdownMenu onOpenChange={setIsDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className={`size-6 p-0 transition-all shrink-0 ml-2 ${
|
||||
isDropdownOpen
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log("close");
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem>Rename</DropdownMenuItem>
|
||||
<DropdownMenuItem>Duplicate</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar className="!size-4" />
|
||||
<span>Created {formatDate(project.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateButton() {
|
||||
return (
|
||||
<Button className="flex">
|
||||
<Plus className="!size-4" />
|
||||
<span className="text-sm font-medium">New project</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
@ -1,398 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signUp, signIn } from "@opencut/auth/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import Link from "next/link";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { GoogleIcon } from "@/components/icons";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
|
||||
// Zod schemas
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email("Please enter a valid email address"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
});
|
||||
|
||||
const signupSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.string().email("Please enter a valid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
});
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>;
|
||||
type SignupFormData = z.infer<typeof signupSchema>;
|
||||
|
||||
interface AuthFormProps {
|
||||
mode: "login" | "signup";
|
||||
}
|
||||
|
||||
const authConfig = {
|
||||
login: {
|
||||
title: "Welcome back",
|
||||
description: "Sign in to your account to continue",
|
||||
buttonText: "Sign in",
|
||||
linkText: "Don't have an account?",
|
||||
linkHref: "/signup",
|
||||
linkLabel: "Sign up",
|
||||
successRedirect: "/editor",
|
||||
},
|
||||
signup: {
|
||||
title: "Create your account",
|
||||
description: "Get started with your free account today",
|
||||
buttonText: "Create account",
|
||||
linkText: "Already have an account?",
|
||||
linkHref: "/login",
|
||||
linkLabel: "Sign in",
|
||||
successRedirect: "/login",
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface AuthFormContentProps {
|
||||
error: string | null;
|
||||
setError: (error: string | null) => void;
|
||||
isGoogleLoading: boolean;
|
||||
config: typeof authConfig.login | typeof authConfig.signup;
|
||||
router: ReturnType<typeof useRouter>;
|
||||
}
|
||||
|
||||
function LoginFormContent({
|
||||
error,
|
||||
setError,
|
||||
isGoogleLoading,
|
||||
config,
|
||||
router,
|
||||
}: AuthFormContentProps) {
|
||||
const form = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: { email: "", password: "" },
|
||||
});
|
||||
|
||||
const { isSubmitting } = form.formState;
|
||||
const isAnyLoading = isSubmitting || isGoogleLoading;
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { error } = await signIn.email({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message || "An unexpected error occurred.");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(config.successRedirect);
|
||||
} catch (error) {
|
||||
setError("An unexpected error occurred. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isAnyLoading}
|
||||
className="w-full h-11"
|
||||
size="lg"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
config.buttonText
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function SignupFormContent({
|
||||
error,
|
||||
setError,
|
||||
isGoogleLoading,
|
||||
config,
|
||||
router,
|
||||
}: AuthFormContentProps) {
|
||||
const form = useForm<SignupFormData>({
|
||||
resolver: zodResolver(signupSchema),
|
||||
defaultValues: { email: "", password: "", name: "" },
|
||||
});
|
||||
|
||||
const { isSubmitting } = form.formState;
|
||||
const isAnyLoading = isSubmitting || isGoogleLoading;
|
||||
|
||||
const onSubmit = async (data: SignupFormData) => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { error } = await signUp.email({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message || "An unexpected error occurred.");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(config.successRedirect);
|
||||
} catch (error) {
|
||||
setError("An unexpected error occurred. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="John Doe"
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Create a strong password"
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isAnyLoading}
|
||||
className="w-full h-11"
|
||||
size="lg"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
config.buttonText
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthForm({ mode }: AuthFormProps) {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
const config = authConfig[mode];
|
||||
|
||||
const handleGoogleAuth = async () => {
|
||||
setError(null);
|
||||
setIsGoogleLoading(true);
|
||||
|
||||
try {
|
||||
await signIn.social({
|
||||
provider: "google",
|
||||
});
|
||||
|
||||
router.push(config.successRedirect);
|
||||
} catch (error) {
|
||||
setError(
|
||||
`Failed to ${mode === "login" ? "sign in" : "sign up"} with Google. Please try again.`
|
||||
);
|
||||
setIsGoogleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center relative">
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => router.back()}
|
||||
className="absolute top-6 left-6"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" /> Back
|
||||
</Button>
|
||||
|
||||
<Card className="w-[400px] shadow-lg border-0">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-2xl font-semibold">
|
||||
{config.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
{config.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex flex-col space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleGoogleAuth}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
disabled={isGoogleLoading}
|
||||
>
|
||||
{isGoogleLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<GoogleIcon />
|
||||
)}
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator className="w-full" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "login" ? (
|
||||
<LoginFormContent
|
||||
error={error}
|
||||
setError={setError}
|
||||
isGoogleLoading={isGoogleLoading}
|
||||
config={config}
|
||||
router={router}
|
||||
/>
|
||||
) : (
|
||||
<SignupFormContent
|
||||
error={error}
|
||||
setError={setError}
|
||||
isGoogleLoading={isGoogleLoading}
|
||||
config={config}
|
||||
router={router}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
{config.linkText}{" "}
|
||||
<Link
|
||||
href={config.linkHref}
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{config.linkLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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,12 +3,11 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "./ui/button";
|
||||
import { ChevronLeft, Download } from "lucide-react";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { HeaderBase } from "./header-base";
|
||||
import { ProjectNameEditor } from "./editor/project-name-editor";
|
||||
|
||||
export function EditorHeader() {
|
||||
const { activeProject } = useProjectStore();
|
||||
const { getTotalDuration } = useTimelineStore();
|
||||
|
||||
const handleExport = () => {
|
||||
@ -24,13 +23,15 @@ export function EditorHeader() {
|
||||
};
|
||||
|
||||
const leftContent = (
|
||||
<Link
|
||||
href="/"
|
||||
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="text-sm">{activeProject?.name || "Loading..."}</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
<ProjectNameEditor />
|
||||
</div>
|
||||
);
|
||||
|
||||
const centerContent = (
|
||||
|
115
apps/web/src/components/editor/audio-waveform.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
|
||||
interface AudioWaveformProps {
|
||||
audioUrl: string;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
||||
audioUrl,
|
||||
height = 32,
|
||||
className = ''
|
||||
}) => {
|
||||
const waveformRef = useRef<HTMLDivElement>(null);
|
||||
const wavesurfer = useRef<WaveSurfer | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const initWaveSurfer = async () => {
|
||||
if (!waveformRef.current || !audioUrl) return;
|
||||
|
||||
try {
|
||||
// Clean up any existing instance
|
||||
if (wavesurfer.current) {
|
||||
try {
|
||||
wavesurfer.current.destroy();
|
||||
} catch (e) {
|
||||
// Silently ignore destroy errors
|
||||
}
|
||||
wavesurfer.current = null;
|
||||
}
|
||||
|
||||
wavesurfer.current = WaveSurfer.create({
|
||||
container: waveformRef.current,
|
||||
waveColor: 'rgba(255, 255, 255, 0.6)',
|
||||
progressColor: 'rgba(255, 255, 255, 0.9)',
|
||||
cursorColor: 'transparent',
|
||||
barWidth: 2,
|
||||
barGap: 1,
|
||||
height: height,
|
||||
normalize: true,
|
||||
interact: false,
|
||||
});
|
||||
|
||||
// Event listeners
|
||||
wavesurfer.current.on('ready', () => {
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
setError(false);
|
||||
}
|
||||
});
|
||||
|
||||
wavesurfer.current.on('error', (err) => {
|
||||
console.error('WaveSurfer error:', err);
|
||||
if (mounted) {
|
||||
setError(true);
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
await wavesurfer.current.load(audioUrl);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize WaveSurfer:', err);
|
||||
if (mounted) {
|
||||
setError(true);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initWaveSurfer();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (wavesurfer.current) {
|
||||
try {
|
||||
wavesurfer.current.destroy();
|
||||
} catch (e) {
|
||||
// Silently ignore destroy errors
|
||||
}
|
||||
wavesurfer.current = null;
|
||||
}
|
||||
};
|
||||
}, [audioUrl, height]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${className}`} style={{ height }}>
|
||||
<span className="text-xs text-foreground/60">Audio unavailable</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs text-foreground/60">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={waveformRef}
|
||||
className={`w-full transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`}
|
||||
style={{ height }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioWaveform;
|
@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import { AspectRatio } from "../ui/aspect-ratio";
|
||||
import { DragOverlay } from "../ui/drag-overlay";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { processMediaFiles } from "@/lib/media-processing";
|
||||
import { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react";
|
||||
import { useDragDrop } from "@/hooks/use-drag-drop";
|
||||
import { processMediaFiles } from "@/lib/media-processing";
|
||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { Image, Music, Plus, Trash2, Upload, Video } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AspectRatio } from "../ui/aspect-ratio";
|
||||
import { Button } from "../ui/button";
|
||||
import { DragOverlay } from "../ui/drag-overlay";
|
||||
|
||||
// MediaPanel lets users add, view, and drag media (images, videos, audio) into the project.
|
||||
// You can upload files or drag them from your computer. Dragging from here to the timeline adds them to your video project.
|
||||
@ -17,27 +18,28 @@ export function MediaPanel() {
|
||||
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [mediaFilter, setMediaFilter] = useState("all");
|
||||
|
||||
const processFiles = async (files: FileList | File[]) => {
|
||||
// If no files, do nothing
|
||||
if (!files?.length) return;
|
||||
|
||||
if (!files || files.length === 0) return;
|
||||
setIsProcessing(true);
|
||||
setProgress(0);
|
||||
try {
|
||||
// Process files (extract metadata, generate thumbnails, etc.)
|
||||
const items = await processMediaFiles(files);
|
||||
const processedItems = await processMediaFiles(files, (p) =>
|
||||
setProgress(p)
|
||||
);
|
||||
// Add each processed media item to the store
|
||||
items.forEach((item) => {
|
||||
addMediaItem(item);
|
||||
});
|
||||
processedItems.forEach((item) => addMediaItem(item));
|
||||
} catch (error) {
|
||||
// Show error if processing fails
|
||||
console.error("File processing failed:", error);
|
||||
// Show error toast if processing fails
|
||||
console.error("Error processing files:", error);
|
||||
toast.error("Failed to process files");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
@ -57,6 +59,21 @@ export function MediaPanel() {
|
||||
const handleRemove = (e: React.MouseEvent, id: string) => {
|
||||
// Remove a media item from the store
|
||||
e.stopPropagation();
|
||||
|
||||
|
||||
// Remove tracks automatically when delete media
|
||||
const { tracks, removeTrack } = useTimelineStore.getState();
|
||||
tracks.forEach((track) => {
|
||||
const clipsToRemove = track.clips.filter((clip) => clip.mediaId === id);
|
||||
clipsToRemove.forEach((clip) => {
|
||||
useTimelineStore.getState().removeClipFromTrack(track.id, clip.id);
|
||||
});
|
||||
// Only remove track if it becomes empty and has no other clips
|
||||
const updatedTrack = useTimelineStore.getState().tracks.find(t => t.id === track.id);
|
||||
if (updatedTrack && updatedTrack.clips.length === 0) {
|
||||
removeTrack(track.id);
|
||||
}
|
||||
});
|
||||
removeMediaItem(id);
|
||||
};
|
||||
|
||||
@ -67,7 +84,7 @@ export function MediaPanel() {
|
||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const startDrag = (e: React.DragEvent, item: any) => {
|
||||
const startDrag = (e: React.DragEvent, item: MediaItem) => {
|
||||
// When dragging a media item, set drag data for timeline to read
|
||||
e.dataTransfer.setData(
|
||||
"application/x-media-item",
|
||||
@ -84,21 +101,24 @@ export function MediaPanel() {
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = mediaItems.filter((item) => {
|
||||
if (mediaFilter && mediaFilter !== 'all' && item.type !== mediaFilter) {
|
||||
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (searchQuery && !item.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
|
||||
if (
|
||||
searchQuery &&
|
||||
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
setFilteredMediaItems(filtered);
|
||||
}, [mediaItems, mediaFilter, searchQuery]);
|
||||
|
||||
const renderPreview = (item: any) => {
|
||||
const renderPreview = (item: MediaItem) => {
|
||||
// Render a preview for each media type (image, video, audio, unknown)
|
||||
// Each preview is draggable to the timeline
|
||||
const baseDragProps = {
|
||||
@ -209,23 +229,23 @@ export function MediaPanel() {
|
||||
{/* Button to add/upload media */}
|
||||
<div className="flex gap-2">
|
||||
{/* Search and filter controls */}
|
||||
<select
|
||||
value={mediaFilter}
|
||||
onChange={(e) => setMediaFilter(e.target.value)}
|
||||
className="px-2 py-1 text-xs border rounded bg-background"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="audio">Audio</option>
|
||||
<option value="image">Image</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search media..."
|
||||
className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
value={mediaFilter}
|
||||
onChange={(e) => setMediaFilter(e.target.value)}
|
||||
className="px-2 py-1 text-xs border rounded bg-background"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="audio">Audio</option>
|
||||
<option value="image">Image</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search media..."
|
||||
className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Add media button */}
|
||||
<Button
|
||||
@ -233,21 +253,23 @@ export function MediaPanel() {
|
||||
size="sm"
|
||||
onClick={handleFileSelect}
|
||||
disabled={isProcessing}
|
||||
className="flex-none min-w-[80px] whitespace-nowrap"
|
||||
className="flex-none min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Upload className="h-4 w-4 mr-2 animate-spin" />
|
||||
Processing...
|
||||
<Upload className="h-4 w-4 animate-spin" />
|
||||
<span className="hidden md:inline ml-2">{progress}%</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add
|
||||
<span className="hidden sm:inline ml-2" aria-label="Add file">
|
||||
Add
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
@ -276,7 +298,15 @@ export function MediaPanel() {
|
||||
<AspectRatio ratio={item.aspectRatio}>
|
||||
{renderPreview(item)}
|
||||
</AspectRatio>
|
||||
<span className="text-xs truncate px-1">{item.name}</span>
|
||||
<span
|
||||
className="text-xs truncate px-1 max-w-full"
|
||||
aria-label={item.name}
|
||||
title={item.name}
|
||||
>
|
||||
{item.name.length > 8
|
||||
? `${item.name.slice(0, 4)}...${item.name.slice(-3)}`
|
||||
: item.name}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{/* Show remove button on hover */}
|
||||
|
@ -1,213 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { VideoPlayer } from "@/components/ui/video-player";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
// Debug flag - set to false to hide active clips info
|
||||
const SHOW_DEBUG_INFO = process.env.NODE_ENV === 'development';
|
||||
|
||||
export function PreviewPanel() {
|
||||
const { tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const { isPlaying, toggle, currentTime } = usePlaybackStore();
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
|
||||
const [showDebug, setShowDebug] = useState(SHOW_DEBUG_INFO);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get active clips at current time
|
||||
const getActiveClips = () => {
|
||||
const activeClips: Array<{
|
||||
clip: any;
|
||||
track: any;
|
||||
mediaItem: any;
|
||||
}> = [];
|
||||
|
||||
tracks.forEach((track) => {
|
||||
track.clips.forEach((clip) => {
|
||||
const clipStart = clip.startTime;
|
||||
const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime >= clipStart && currentTime < clipEnd) {
|
||||
const mediaItem = clip.mediaId === "test"
|
||||
? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
|
||||
: mediaItems.find((item) => item.id === clip.mediaId);
|
||||
|
||||
if (mediaItem || clip.mediaId === "test") {
|
||||
activeClips.push({ clip, track, mediaItem });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return activeClips;
|
||||
};
|
||||
|
||||
const activeClips = getActiveClips();
|
||||
const aspectRatio = canvasSize.width / canvasSize.height;
|
||||
|
||||
// Render a clip
|
||||
const renderClip = (clipData: any, index: number) => {
|
||||
const { clip, mediaItem } = clipData;
|
||||
|
||||
// Test clips
|
||||
if (!mediaItem || clip.mediaId === "test") {
|
||||
return (
|
||||
<div
|
||||
key={clip.id}
|
||||
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">🎬</div>
|
||||
<p className="text-xs text-white">{clip.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Video clips
|
||||
if (mediaItem.type === "video") {
|
||||
return (
|
||||
<div key={clip.id} className="absolute inset-0">
|
||||
<VideoPlayer
|
||||
src={mediaItem.url}
|
||||
poster={mediaItem.thumbnailUrl}
|
||||
clipStartTime={clip.startTime}
|
||||
trimStart={clip.trimStart}
|
||||
trimEnd={clip.trimEnd}
|
||||
clipDuration={clip.duration}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Image clips
|
||||
if (mediaItem.type === "image") {
|
||||
return (
|
||||
<div key={clip.id} className="absolute inset-0">
|
||||
<img
|
||||
src={mediaItem.url}
|
||||
alt={mediaItem.name}
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Audio clips (visual representation)
|
||||
if (mediaItem.type === "audio") {
|
||||
return (
|
||||
<div
|
||||
key={clip.id}
|
||||
className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">🎵</div>
|
||||
<p className="text-xs text-white">{mediaItem.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Canvas presets
|
||||
const canvasPresets = [
|
||||
{ name: "16:9 HD", width: 1920, height: 1080 },
|
||||
{ name: "16:9 4K", width: 3840, height: 2160 },
|
||||
{ name: "9:16 Mobile", width: 1080, height: 1920 },
|
||||
{ name: "1:1 Square", width: 1080, height: 1080 },
|
||||
{ name: "4:3 Standard", width: 1440, height: 1080 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col min-h-0 min-w-0">
|
||||
{/* Controls */}
|
||||
<div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0">
|
||||
<span className="text-muted-foreground">Canvas:</span>
|
||||
<select
|
||||
value={`${canvasSize.width}x${canvasSize.height}`}
|
||||
onChange={(e) => {
|
||||
const preset = canvasPresets.find(p => `${p.width}x${p.height}` === e.target.value);
|
||||
if (preset) setCanvasSize({ width: preset.width, height: preset.height });
|
||||
}}
|
||||
className="bg-background border rounded px-2 py-1 text-xs"
|
||||
>
|
||||
{canvasPresets.map(preset => (
|
||||
<option key={preset.name} value={`${preset.width}x${preset.height}`}>
|
||||
{preset.name} ({preset.width}×{preset.height})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Debug Toggle - Only show in development */}
|
||||
{SHOW_DEBUG_INFO && (
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onClick={() => setShowDebug(!showDebug)}
|
||||
className="text-xs"
|
||||
>
|
||||
Debug {showDebug ? 'ON' : 'OFF'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={toggle} className="ml-auto">
|
||||
{isPlaying ? <Pause className="h-3 w-3 mr-1" /> : <Play className="h-3 w-3 mr-1" />}
|
||||
{isPlaying ? "Pause" : "Play"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Preview Area */}
|
||||
<div className="flex-1 flex items-center justify-center p-2 sm:p-4 bg-gray-900 min-h-0 min-w-0">
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="relative overflow-hidden rounded-sm max-w-full max-h-full bg-black border border-gray-600"
|
||||
style={{
|
||||
aspectRatio: aspectRatio.toString(),
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{activeClips.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-white/50">
|
||||
{tracks.length === 0 ? "Drop media to start editing" : "No clips at current time"}
|
||||
</div>
|
||||
) : (
|
||||
activeClips.map((clipData, index) => renderClip(clipData, index))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Debug Info Panel - Conditionally rendered */}
|
||||
{showDebug && (
|
||||
<div className="border-t bg-background p-2 flex-shrink-0">
|
||||
<div className="text-xs font-medium mb-1">Debug: Active Clips ({activeClips.length})</div>
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{activeClips.map((clipData, index) => (
|
||||
<div
|
||||
key={clipData.clip.id}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-muted rounded text-xs whitespace-nowrap"
|
||||
>
|
||||
<span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span>{clipData.clip.name}</span>
|
||||
<span className="text-muted-foreground">({clipData.mediaItem?.type || 'test'})</span>
|
||||
</div>
|
||||
))}
|
||||
{activeClips.length === 0 && (
|
||||
<span className="text-muted-foreground">No active clips</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useTimelineStore,
|
||||
type TimelineClip,
|
||||
type TimelineTrack,
|
||||
} from "@/stores/timeline-store";
|
||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { VideoPlayer } from "@/components/ui/video-player";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Play, Pause, Volume2, VolumeX, Plus } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
|
||||
interface ActiveClip {
|
||||
clip: TimelineClip;
|
||||
track: TimelineTrack;
|
||||
mediaItem: MediaItem | null;
|
||||
}
|
||||
|
||||
export function PreviewPanel() {
|
||||
const { tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const { currentTime, muted, toggleMute, volume } = usePlaybackStore();
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [previewDimensions, setPreviewDimensions] = useState({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
// Calculate optimal preview size that fits in container while maintaining aspect ratio
|
||||
useEffect(() => {
|
||||
const updatePreviewSize = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const container = containerRef.current.getBoundingClientRect();
|
||||
const computedStyle = getComputedStyle(containerRef.current);
|
||||
|
||||
// Get padding values
|
||||
const paddingTop = parseFloat(computedStyle.paddingTop);
|
||||
const paddingBottom = parseFloat(computedStyle.paddingBottom);
|
||||
const paddingLeft = parseFloat(computedStyle.paddingLeft);
|
||||
const paddingRight = parseFloat(computedStyle.paddingRight);
|
||||
|
||||
// Get gap value (gap-4 = 1rem = 16px)
|
||||
const gap = parseFloat(computedStyle.gap) || 16;
|
||||
|
||||
// Get toolbar height if it exists
|
||||
const toolbar = containerRef.current.querySelector("[data-toolbar]");
|
||||
const toolbarHeight = toolbar
|
||||
? toolbar.getBoundingClientRect().height
|
||||
: 0;
|
||||
|
||||
// Calculate available space after accounting for padding, gap, and toolbar
|
||||
const availableWidth = container.width - paddingLeft - paddingRight;
|
||||
const availableHeight =
|
||||
container.height -
|
||||
paddingTop -
|
||||
paddingBottom -
|
||||
toolbarHeight -
|
||||
(toolbarHeight > 0 ? gap : 0);
|
||||
|
||||
const targetRatio = canvasSize.width / canvasSize.height;
|
||||
const containerRatio = availableWidth / availableHeight;
|
||||
|
||||
let width, height;
|
||||
|
||||
if (containerRatio > targetRatio) {
|
||||
// Container is wider - constrain by height
|
||||
height = availableHeight;
|
||||
width = height * targetRatio;
|
||||
} else {
|
||||
// Container is taller - constrain by width
|
||||
width = availableWidth;
|
||||
height = width / targetRatio;
|
||||
}
|
||||
|
||||
setPreviewDimensions({ width, height });
|
||||
};
|
||||
|
||||
updatePreviewSize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updatePreviewSize);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [canvasSize.width, canvasSize.height]);
|
||||
|
||||
// Get active clips at current time
|
||||
const getActiveClips = (): ActiveClip[] => {
|
||||
const activeClips: ActiveClip[] = [];
|
||||
|
||||
tracks.forEach((track) => {
|
||||
track.clips.forEach((clip) => {
|
||||
const clipStart = clip.startTime;
|
||||
const clipEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime >= clipStart && currentTime < clipEnd) {
|
||||
const mediaItem =
|
||||
clip.mediaId === "test"
|
||||
? null // Test clips don't have a real media item
|
||||
: mediaItems.find((item) => item.id === clip.mediaId) || null;
|
||||
|
||||
activeClips.push({ clip, track, mediaItem });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return activeClips;
|
||||
};
|
||||
|
||||
const activeClips = getActiveClips();
|
||||
|
||||
// Render a clip
|
||||
const renderClip = (clipData: ActiveClip, index: number) => {
|
||||
const { clip, mediaItem } = clipData;
|
||||
|
||||
// Test clips
|
||||
if (!mediaItem || clip.mediaId === "test") {
|
||||
return (
|
||||
<div
|
||||
key={clip.id}
|
||||
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">🎬</div>
|
||||
<p className="text-xs text-white">{clip.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Video clips
|
||||
if (mediaItem.type === "video") {
|
||||
return (
|
||||
<div key={clip.id} className="absolute inset-0">
|
||||
<VideoPlayer
|
||||
src={mediaItem.url}
|
||||
poster={mediaItem.thumbnailUrl}
|
||||
clipStartTime={clip.startTime}
|
||||
trimStart={clip.trimStart}
|
||||
trimEnd={clip.trimEnd}
|
||||
clipDuration={clip.duration}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Image clips
|
||||
if (mediaItem.type === "image") {
|
||||
return (
|
||||
<div key={clip.id} className="absolute inset-0">
|
||||
<img
|
||||
src={mediaItem.url}
|
||||
alt={mediaItem.name}
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Audio clips (visual representation)
|
||||
if (mediaItem.type === "audio") {
|
||||
return (
|
||||
<div
|
||||
key={clip.id}
|
||||
className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">🎵</div>
|
||||
<p className="text-xs text-white">{mediaItem.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Canvas presets
|
||||
const canvasPresets = [
|
||||
{ name: "16:9 HD", width: 1920, height: 1080 },
|
||||
{ name: "16:9 4K", width: 3840, height: 2160 },
|
||||
{ name: "9:16 Mobile", width: 1080, height: 1920 },
|
||||
{ name: "1:1 Square", width: 1080, height: 1080 },
|
||||
{ name: "4:3 Standard", width: 1440, height: 1080 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col min-h-0 min-w-0">
|
||||
{/* Controls */}
|
||||
<div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0">
|
||||
<span className="text-muted-foreground">Canvas:</span>
|
||||
<select
|
||||
value={`${canvasSize.width}x${canvasSize.height}`}
|
||||
onChange={(e) => {
|
||||
const preset = canvasPresets.find(
|
||||
(p) => `${p.width}x${p.height}` === e.target.value
|
||||
);
|
||||
if (preset)
|
||||
setCanvasSize({ width: preset.width, height: preset.height });
|
||||
}}
|
||||
className="bg-background border rounded px-2 py-1 text-xs"
|
||||
>
|
||||
{canvasPresets.map((preset) => (
|
||||
<option
|
||||
key={preset.name}
|
||||
value={`${preset.width}x${preset.height}`}
|
||||
>
|
||||
{preset.name} ({preset.width}×{preset.height})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleMute}
|
||||
className="ml-auto"
|
||||
>
|
||||
{muted || volume === 0 ? (
|
||||
<VolumeX className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<Volume2 className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{muted || volume === 0 ? "Unmute" : "Mute"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Preview Area */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4"
|
||||
>
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="relative overflow-hidden rounded-sm bg-black border"
|
||||
style={{
|
||||
width: previewDimensions.width,
|
||||
height: previewDimensions.height,
|
||||
}}
|
||||
>
|
||||
{activeClips.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||
{tracks.length === 0
|
||||
? "No media added to timeline"
|
||||
: "No clips at current time"}
|
||||
</div>
|
||||
) : (
|
||||
activeClips.map((clipData, index) => renderClip(clipData, index))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PreviewToolbar />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewToolbar() {
|
||||
const { isPlaying, toggle } = usePlaybackStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-toolbar
|
||||
className="flex items-center justify-center gap-2 px-4 pt-2 bg-background-500 w-full"
|
||||
>
|
||||
<Button variant="text" size="icon" onClick={toggle}>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-3 w-3" />
|
||||
) : (
|
||||
<Play className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
110
apps/web/src/components/editor/project-name-editor.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Input } from "../ui/input";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { Edit2, Check, X } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface ProjectNameEditorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProjectNameEditor({ className }: ProjectNameEditorProps) {
|
||||
const { activeProject, updateProjectName } = useProjectStore();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProject) {
|
||||
setEditValue(activeProject.name);
|
||||
}
|
||||
}, [activeProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (activeProject) {
|
||||
setEditValue(activeProject.name);
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (editValue.trim()) {
|
||||
updateProjectName(editValue.trim());
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (activeProject) {
|
||||
setEditValue(activeProject.name);
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeProject) {
|
||||
return <span className="text-sm text-muted-foreground">Loading...</span>;
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="h-7 text-sm px-3 py-1 min-w-[200px]"
|
||||
size={1}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
onClick={handleSave}
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={!editValue.trim()}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
onClick={handleCancel}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 group">
|
||||
<span className="text-sm font-medium">{activeProject.name}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
onClick={handleStartEdit}
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -17,13 +17,12 @@ import { useMediaStore } from "@/stores/media-store";
|
||||
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
|
||||
import { useState } from "react";
|
||||
import { SpeedControl } from "./speed-control";
|
||||
import type { BackgroundType } from "@/types/editor";
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const [backgroundType, setBackgroundType] = useState<
|
||||
"blur" | "mirror" | "color"
|
||||
>("blur");
|
||||
const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur");
|
||||
const [backgroundColor, setBackgroundColor] = useState("#000000");
|
||||
|
||||
// Get the first video clip for preview (simplified)
|
||||
@ -78,7 +77,9 @@ export function PropertiesPanel() {
|
||||
<Label htmlFor="bg-type">Background Type</Label>
|
||||
<Select
|
||||
value={backgroundType}
|
||||
onValueChange={(value: any) => setBackgroundType(value)}
|
||||
onValueChange={(value: BackgroundType) =>
|
||||
setBackgroundType(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select background type" />
|
||||
|
380
apps/web/src/components/editor/timeline-clip.tsx
Normal file
@ -0,0 +1,380 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
MoreVertical,
|
||||
Scissors,
|
||||
Trash2,
|
||||
SplitSquareHorizontal,
|
||||
Music,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
} from "lucide-react";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import AudioWaveform from "./audio-waveform";
|
||||
import { toast } from "sonner";
|
||||
import { TimelineClipProps, ResizeState } from "@/types/timeline";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { isDragging } from "motion/react";
|
||||
|
||||
export function TimelineClip({
|
||||
clip,
|
||||
track,
|
||||
zoomLevel,
|
||||
isSelected,
|
||||
onContextMenu,
|
||||
onClipMouseDown,
|
||||
onClipClick,
|
||||
}: TimelineClipProps) {
|
||||
const { mediaItems } = useMediaStore();
|
||||
const {
|
||||
updateClipTrim,
|
||||
addClipToTrack,
|
||||
removeClipFromTrack,
|
||||
dragState,
|
||||
splitClip,
|
||||
splitAndKeepLeft,
|
||||
splitAndKeepRight,
|
||||
separateAudio,
|
||||
} = 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";
|
||||
}
|
||||
};
|
||||
|
||||
// Resize handles for trimming clips
|
||||
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);
|
||||
toast.success("Clip deleted");
|
||||
};
|
||||
|
||||
const handleSplitClip = () => {
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within clip to split");
|
||||
return;
|
||||
}
|
||||
|
||||
const secondClipId = splitClip(track.id, clip.id, currentTime);
|
||||
if (secondClipId) {
|
||||
toast.success("Clip split successfully");
|
||||
} else {
|
||||
toast.error("Failed to split clip");
|
||||
}
|
||||
setClipMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleSplitAndKeepLeft = () => {
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within clip");
|
||||
return;
|
||||
}
|
||||
|
||||
splitAndKeepLeft(track.id, clip.id, currentTime);
|
||||
toast.success("Split and kept left portion");
|
||||
setClipMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleSplitAndKeepRight = () => {
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within clip");
|
||||
return;
|
||||
}
|
||||
|
||||
splitAndKeepRight(track.id, clip.id, currentTime);
|
||||
toast.success("Split and kept right portion");
|
||||
setClipMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleSeparateAudio = () => {
|
||||
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
||||
|
||||
if (!mediaItem || mediaItem.type !== "video") {
|
||||
toast.error("Audio separation only available for video clips");
|
||||
return;
|
||||
}
|
||||
|
||||
const audioClipId = separateAudio(track.id, clip.id);
|
||||
if (audioClipId) {
|
||||
toast.success("Audio separated to audio track");
|
||||
} else {
|
||||
toast.error("Failed to separate audio");
|
||||
}
|
||||
setClipMenuOpen(false);
|
||||
};
|
||||
|
||||
const canSplitAtPlayhead = () => {
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
return currentTime > effectiveStart && currentTime < effectiveEnd;
|
||||
};
|
||||
|
||||
const canSeparateAudio = () => {
|
||||
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
||||
return mediaItem?.type === "video" && track.type === "video";
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
|
||||
);
|
||||
};
|
||||
|
||||
const handleClipMouseDown = (e: React.MouseEvent) => {
|
||||
if (onClipMouseDown) {
|
||||
onClipMouseDown(e, clip);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute top-0 h-full select-none transition-all duration-75 ${
|
||||
isBeingDragged ? "z-50" : "z-10"
|
||||
} ${isSelected ? "ring-2 ring-primary" : ""}`}
|
||||
style={{
|
||||
left: `${clipLeft}px`,
|
||||
width: `${clipWidth}px`,
|
||||
}}
|
||||
onMouseMove={resizing ? handleResizeMove : undefined}
|
||||
onMouseUp={resizing ? handleResizeEnd : undefined}
|
||||
onMouseLeave={resizing ? handleResizeEnd : undefined}
|
||||
>
|
||||
<div
|
||||
className={`relative h-full rounded border cursor-pointer overflow-hidden ${getTrackColor(
|
||||
track.type
|
||||
)} ${isSelected ? "ring-2 ring-primary ring-offset-1" : ""}`}
|
||||
onClick={(e) => onClipClick && onClipClick(e, clip)}
|
||||
onMouseDown={handleClipMouseDown}
|
||||
onContextMenu={(e) => onContextMenu && onContextMenu(e, clip.id)}
|
||||
>
|
||||
<div className="absolute inset-1 flex items-center p-1">
|
||||
{renderClipContent()}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart(e, clip.id, "left")}
|
||||
/>
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart(e, clip.id, "right")}
|
||||
/>
|
||||
|
||||
<div className="absolute top-1 right-1">
|
||||
<DropdownMenu open={clipMenuOpen} onOpenChange={setClipMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80 hover:bg-background"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setClipMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{/* Split operations - only available when playhead is within clip */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger disabled={!canSplitAtPlayhead()}>
|
||||
<Scissors className="mr-2 h-4 w-4" />
|
||||
Split
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onClick={handleSplitClip}>
|
||||
<SplitSquareHorizontal className="mr-2 h-4 w-4" />
|
||||
Split at Playhead
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleSplitAndKeepLeft}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Split and Keep Left
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleSplitAndKeepRight}>
|
||||
<ChevronRight className="mr-2 h-4 w-4" />
|
||||
Split and Keep Right
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Audio separation - only available for video clips */}
|
||||
{canSeparateAudio() && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSeparateAudio}>
|
||||
<Music className="mr-2 h-4 w-4" />
|
||||
Separate Audio
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleDeleteClip}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Clip
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
219
apps/web/src/components/editor/timeline-toolbar.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import type { TrackType } from "@/types/timeline";
|
||||
import {
|
||||
ArrowLeftToLine,
|
||||
ArrowRightToLine,
|
||||
Copy,
|
||||
Pause,
|
||||
Play,
|
||||
Scissors,
|
||||
Snowflake,
|
||||
SplitSquareHorizontal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "../ui/tooltip";
|
||||
|
||||
interface TimelineToolbarProps {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
speed: number;
|
||||
tracks: any[];
|
||||
toggle: () => void;
|
||||
setSpeed: (speed: number) => void;
|
||||
addTrack: (type: TrackType) => string;
|
||||
addClipToTrack: (trackId: string, clip: any) => void;
|
||||
handleSplitSelected: () => void;
|
||||
handleDuplicateSelected: () => void;
|
||||
handleFreezeSelected: () => void;
|
||||
handleDeleteSelected: () => void;
|
||||
}
|
||||
|
||||
export function TimelineToolbar({
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
speed,
|
||||
tracks,
|
||||
toggle,
|
||||
setSpeed,
|
||||
addTrack,
|
||||
addClipToTrack,
|
||||
handleSplitSelected,
|
||||
handleDuplicateSelected,
|
||||
handleFreezeSelected,
|
||||
handleDeleteSelected,
|
||||
}: TimelineToolbarProps) {
|
||||
return (
|
||||
<div className="border-b flex items-center px-2 py-1 gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
{/* Play/Pause Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={toggle}
|
||||
className="mr-2"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isPlaying ? "Pause (Space)" : "Play (Space)"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
|
||||
{/* Time Display */}
|
||||
<div
|
||||
className="text-xs text-muted-foreground font-mono px-2"
|
||||
style={{ minWidth: "18ch", textAlign: "center" }}
|
||||
>
|
||||
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
|
||||
</div>
|
||||
|
||||
{/* Test Clip Button - for debugging */}
|
||||
{tracks.length === 0 && (
|
||||
<>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const trackId = addTrack("video");
|
||||
addClipToTrack(trackId, {
|
||||
mediaId: "test",
|
||||
name: "Test Clip",
|
||||
duration: 5,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Add Test Clip
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add a test clip to try playback</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={handleSplitSelected}>
|
||||
<Scissors className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split clip (S)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon">
|
||||
<ArrowLeftToLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep left (A)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon">
|
||||
<ArrowRightToLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep right (D)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon">
|
||||
<SplitSquareHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Separate audio (E)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleDuplicateSelected}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Duplicate clip (Ctrl+D)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={handleFreezeSelected}>
|
||||
<Snowflake className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Freeze frame (F)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={handleDeleteSelected}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete clip (Delete)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
|
||||
{/* Speed Control */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Select
|
||||
value={speed.toFixed(1)}
|
||||
onValueChange={(value) => setSpeed(parseFloat(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[90px] h-8">
|
||||
<SelectValue placeholder="1.0x" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0.5">0.5x</SelectItem>
|
||||
<SelectItem value="1.0">1.0x</SelectItem>
|
||||
<SelectItem value="1.5">1.5x</SelectItem>
|
||||
<SelectItem value="2.0">2.0x</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Playback Speed</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
660
apps/web/src/components/editor/timeline-track.tsx
Normal file
@ -0,0 +1,660 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { toast } from "sonner";
|
||||
import { Copy, Scissors, Trash2 } from "lucide-react";
|
||||
import { TimelineClip } from "./timeline-clip";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "../ui/context-menu";
|
||||
import {
|
||||
TimelineTrack,
|
||||
TimelineClip as TypeTimelineClip,
|
||||
} from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
|
||||
export function TimelineTrackContent({
|
||||
track,
|
||||
zoomLevel,
|
||||
}: {
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
}) {
|
||||
const { mediaItems } = useMediaStore();
|
||||
const {
|
||||
tracks,
|
||||
moveClipToTrack,
|
||||
updateClipStartTime,
|
||||
addClipToTrack,
|
||||
selectedClips,
|
||||
selectClip,
|
||||
deselectClip,
|
||||
dragState,
|
||||
startDrag: startDragAction,
|
||||
updateDragTime,
|
||||
endDrag: endDragAction,
|
||||
} = useTimelineStore();
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const [isDropping, setIsDropping] = useState(false);
|
||||
const [dropPosition, setDropPosition] = useState<number | null>(null);
|
||||
const [wouldOverlap, setWouldOverlap] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const [mouseDownLocation, setMouseDownLocation] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
// Set up mouse event listeners for drag
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!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;
|
||||
|
||||
updateDragTime(snappedTime);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!dragState.clipId || !dragState.trackId) return;
|
||||
|
||||
const finalTime = dragState.currentTime;
|
||||
|
||||
// Check for overlaps and update position
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const movingClip = sourceTrack?.clips.find(
|
||||
(c) => c.id === dragState.clipId
|
||||
);
|
||||
|
||||
if (movingClip) {
|
||||
const movingClipDuration =
|
||||
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
|
||||
const movingClipEnd = finalTime + movingClipDuration;
|
||||
|
||||
const targetTrack = tracks.find((t) => t.id === track.id);
|
||||
const hasOverlap = targetTrack?.clips.some((existingClip) => {
|
||||
if (
|
||||
dragState.trackId === track.id &&
|
||||
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 === track.id) {
|
||||
updateClipStartTime(track.id, dragState.clipId, finalTime);
|
||||
} else {
|
||||
moveClipToTrack(dragState.trackId, track.id, dragState.clipId);
|
||||
requestAnimationFrame(() => {
|
||||
updateClipStartTime(track.id, dragState.clipId!, finalTime);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endDragAction();
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.clickOffsetTime,
|
||||
dragState.clipId,
|
||||
dragState.trackId,
|
||||
dragState.currentTime,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
track.id,
|
||||
updateDragTime,
|
||||
updateClipStartTime,
|
||||
moveClipToTrack,
|
||||
endDragAction,
|
||||
]);
|
||||
|
||||
const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => {
|
||||
setMouseDownLocation({ x: e.clientX, y: e.clientY });
|
||||
// Handle multi-selection only in mousedown
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
selectClip(track.id, clip.id, true);
|
||||
}
|
||||
|
||||
// Calculate the offset from the left edge of the clip to where the user clicked
|
||||
const clipElement = e.currentTarget as HTMLElement;
|
||||
const clipRect = clipElement.getBoundingClientRect();
|
||||
const clickOffsetX = e.clientX - clipRect.left;
|
||||
const clickOffsetTime = clickOffsetX / (50 * zoomLevel);
|
||||
|
||||
startDragAction(
|
||||
clip.id,
|
||||
track.id,
|
||||
e.clientX,
|
||||
clip.startTime,
|
||||
clickOffsetTime
|
||||
);
|
||||
};
|
||||
|
||||
const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Check if mouse moved significantly
|
||||
if (mouseDownLocation) {
|
||||
const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
|
||||
const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
|
||||
// If it moved more than a few pixels, consider it a drag and not a click.
|
||||
if (deltaX > 5 || deltaY > 5) {
|
||||
setMouseDownLocation(null); // Reset for next interaction
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip selection logic for multi-selection (handled in mousedown)
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle single selection/deselection
|
||||
const isSelected = selectedClips.some(
|
||||
(c) => c.trackId === track.id && c.clipId === clip.id
|
||||
);
|
||||
|
||||
if (isSelected) {
|
||||
// If clip is selected, deselect it
|
||||
deselectClip(track.id, clip.id);
|
||||
} else {
|
||||
// If clip is not selected, select it (replacing other selections)
|
||||
selectClip(track.id, clip.id, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrackDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Handle both timeline clips and media items
|
||||
const hasTimelineClip = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item"
|
||||
);
|
||||
|
||||
if (!hasTimelineClip && !hasMediaItem) return;
|
||||
|
||||
if (hasMediaItem) {
|
||||
try {
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item"
|
||||
);
|
||||
if (mediaItemData) {
|
||||
const { type } = JSON.parse(mediaItemData);
|
||||
const isCompatible =
|
||||
(track.type === "video" &&
|
||||
(type === "video" || type === "image")) ||
|
||||
(track.type === "audio" && type === "audio");
|
||||
|
||||
if (!isCompatible) {
|
||||
e.dataTransfer.dropEffect = "none";
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing dropped media item:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate drop position for overlap checking
|
||||
const trackContainer = e.currentTarget.querySelector(
|
||||
".track-clips-container"
|
||||
) as HTMLElement;
|
||||
let dropTime = 0;
|
||||
if (trackContainer) {
|
||||
const rect = trackContainer.getBoundingClientRect();
|
||||
const mouseX = Math.max(0, e.clientX - rect.left);
|
||||
dropTime = mouseX / (50 * zoomLevel);
|
||||
}
|
||||
|
||||
// Check for potential overlaps and show appropriate feedback
|
||||
let wouldOverlap = false;
|
||||
|
||||
if (hasMediaItem) {
|
||||
try {
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item"
|
||||
);
|
||||
if (mediaItemData) {
|
||||
const { id } = JSON.parse(mediaItemData);
|
||||
const mediaItem = mediaItems.find((item) => item.id === id);
|
||||
if (mediaItem) {
|
||||
const newClipDuration = mediaItem.duration || 5;
|
||||
const snappedTime = Math.round(dropTime * 10) / 10;
|
||||
const newClipEnd = snappedTime + newClipDuration;
|
||||
|
||||
wouldOverlap = track.clips.some((existingClip) => {
|
||||
const existingStart = existingClip.startTime;
|
||||
const existingEnd =
|
||||
existingClip.startTime +
|
||||
(existingClip.duration -
|
||||
existingClip.trimStart -
|
||||
existingClip.trimEnd);
|
||||
return snappedTime < existingEnd && newClipEnd > existingStart;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue with default behavior
|
||||
}
|
||||
} else if (hasTimelineClip) {
|
||||
try {
|
||||
const timelineClipData = e.dataTransfer.getData(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
if (timelineClipData) {
|
||||
const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData);
|
||||
const sourceTrack = tracks.find(
|
||||
(t: TimelineTrack) => t.id === fromTrackId
|
||||
);
|
||||
const movingClip = sourceTrack?.clips.find(
|
||||
(c: any) => c.id === clipId
|
||||
);
|
||||
|
||||
if (movingClip) {
|
||||
const movingClipDuration =
|
||||
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
|
||||
const snappedTime = Math.round(dropTime * 10) / 10;
|
||||
const movingClipEnd = snappedTime + movingClipDuration;
|
||||
|
||||
wouldOverlap = track.clips.some((existingClip) => {
|
||||
if (fromTrackId === track.id && existingClip.id === clipId)
|
||||
return false;
|
||||
|
||||
const existingStart = existingClip.startTime;
|
||||
const existingEnd =
|
||||
existingClip.startTime +
|
||||
(existingClip.duration -
|
||||
existingClip.trimStart -
|
||||
existingClip.trimEnd);
|
||||
return snappedTime < existingEnd && movingClipEnd > existingStart;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue with default behavior
|
||||
}
|
||||
}
|
||||
|
||||
if (wouldOverlap) {
|
||||
e.dataTransfer.dropEffect = "none";
|
||||
setWouldOverlap(true);
|
||||
setDropPosition(Math.round(dropTime * 10) / 10);
|
||||
return;
|
||||
}
|
||||
|
||||
e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy";
|
||||
setWouldOverlap(false);
|
||||
setDropPosition(Math.round(dropTime * 10) / 10);
|
||||
};
|
||||
|
||||
const handleTrackDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const hasTimelineClip = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item"
|
||||
);
|
||||
|
||||
if (!hasTimelineClip && !hasMediaItem) return;
|
||||
|
||||
dragCounterRef.current++;
|
||||
setIsDropping(true);
|
||||
};
|
||||
|
||||
const handleTrackDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const hasTimelineClip = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item"
|
||||
);
|
||||
|
||||
if (!hasTimelineClip && !hasMediaItem) return;
|
||||
|
||||
dragCounterRef.current--;
|
||||
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDropping(false);
|
||||
setWouldOverlap(false);
|
||||
setDropPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrackDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Reset all drag states
|
||||
dragCounterRef.current = 0;
|
||||
setIsDropping(false);
|
||||
setWouldOverlap(false);
|
||||
const currentDropPosition = dropPosition;
|
||||
setDropPosition(null);
|
||||
|
||||
const hasTimelineClip = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item"
|
||||
);
|
||||
|
||||
if (!hasTimelineClip && !hasMediaItem) return;
|
||||
|
||||
const trackContainer = e.currentTarget.querySelector(
|
||||
".track-clips-container"
|
||||
) as HTMLElement;
|
||||
if (!trackContainer) return;
|
||||
|
||||
const rect = trackContainer.getBoundingClientRect();
|
||||
const mouseX = Math.max(0, e.clientX - rect.left);
|
||||
const newStartTime = mouseX / (50 * zoomLevel);
|
||||
const snappedTime = Math.round(newStartTime * 10) / 10;
|
||||
|
||||
try {
|
||||
if (hasTimelineClip) {
|
||||
// Handle timeline clip movement
|
||||
const timelineClipData = e.dataTransfer.getData(
|
||||
"application/x-timeline-clip"
|
||||
);
|
||||
if (!timelineClipData) return;
|
||||
|
||||
const {
|
||||
clipId,
|
||||
trackId: fromTrackId,
|
||||
clickOffsetTime = 0,
|
||||
} = JSON.parse(timelineClipData);
|
||||
|
||||
// Find the clip being moved
|
||||
const sourceTrack = tracks.find(
|
||||
(t: TimelineTrack) => t.id === fromTrackId
|
||||
);
|
||||
const movingClip = sourceTrack?.clips.find(
|
||||
(c: TypeTimelineClip) => c.id === clipId
|
||||
);
|
||||
|
||||
if (!movingClip) {
|
||||
toast.error("Clip not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Adjust position based on where user clicked on the clip
|
||||
const adjustedStartTime = snappedTime - clickOffsetTime;
|
||||
const finalStartTime = Math.max(
|
||||
0,
|
||||
Math.round(adjustedStartTime * 10) / 10
|
||||
);
|
||||
|
||||
// Check for overlaps with existing clips (excluding the moving clip itself)
|
||||
const movingClipDuration =
|
||||
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
|
||||
const movingClipEnd = finalStartTime + movingClipDuration;
|
||||
|
||||
const hasOverlap = track.clips.some((existingClip) => {
|
||||
// Skip the clip being moved if it's on the same track
|
||||
if (fromTrackId === track.id && existingClip.id === clipId)
|
||||
return false;
|
||||
|
||||
const existingStart = existingClip.startTime;
|
||||
const existingEnd =
|
||||
existingClip.startTime +
|
||||
(existingClip.duration -
|
||||
existingClip.trimStart -
|
||||
existingClip.trimEnd);
|
||||
|
||||
// Check if clips overlap
|
||||
return finalStartTime < existingEnd && movingClipEnd > existingStart;
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
toast.error(
|
||||
"Cannot move clip here - it would overlap with existing clips"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromTrackId === track.id) {
|
||||
// Moving within same track
|
||||
updateClipStartTime(track.id, clipId, finalStartTime);
|
||||
} else {
|
||||
// Moving to different track
|
||||
moveClipToTrack(fromTrackId, track.id, clipId);
|
||||
requestAnimationFrame(() => {
|
||||
updateClipStartTime(track.id, clipId, finalStartTime);
|
||||
});
|
||||
}
|
||||
} else if (hasMediaItem) {
|
||||
// Handle media item drop
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item"
|
||||
);
|
||||
if (!mediaItemData) return;
|
||||
|
||||
const { id, type } = JSON.parse(mediaItemData);
|
||||
const mediaItem = mediaItems.find((item) => item.id === id);
|
||||
|
||||
if (!mediaItem) {
|
||||
toast.error("Media item not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if track type is compatible
|
||||
const isCompatible =
|
||||
(track.type === "video" && (type === "video" || type === "image")) ||
|
||||
(track.type === "audio" && type === "audio");
|
||||
|
||||
if (!isCompatible) {
|
||||
toast.error(`Cannot add ${type} to ${track.type} track`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for overlaps with existing clips
|
||||
const newClipDuration = mediaItem.duration || 5;
|
||||
const newClipEnd = snappedTime + newClipDuration;
|
||||
|
||||
const hasOverlap = track.clips.some((existingClip) => {
|
||||
const existingStart = existingClip.startTime;
|
||||
const existingEnd =
|
||||
existingClip.startTime +
|
||||
(existingClip.duration -
|
||||
existingClip.trimStart -
|
||||
existingClip.trimEnd);
|
||||
|
||||
// Check if clips overlap
|
||||
return snappedTime < existingEnd && newClipEnd > existingStart;
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
toast.error(
|
||||
"Cannot place clip here - it would overlap with existing clips"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
addClipToTrack(track.id, {
|
||||
mediaId: mediaItem.id,
|
||||
name: mediaItem.name,
|
||||
duration: mediaItem.duration || 5,
|
||||
startTime: snappedTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
|
||||
toast.success(`Added ${mediaItem.name} to ${track.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling drop:", error);
|
||||
toast.error("Failed to add media to track");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full hover:bg-muted/20"
|
||||
onClick={(e) => {
|
||||
// If clicking empty area (not on a clip), deselect all clips
|
||||
if (!(e.target as HTMLElement).closest(".timeline-clip")) {
|
||||
const { clearSelectedClips } = useTimelineStore.getState();
|
||||
clearSelectedClips();
|
||||
}
|
||||
}}
|
||||
onDragOver={handleTrackDragOver}
|
||||
onDragEnter={handleTrackDragEnter}
|
||||
onDragLeave={handleTrackDragLeave}
|
||||
onDrop={handleTrackDrop}
|
||||
>
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className="h-full relative track-clips-container min-w-full"
|
||||
>
|
||||
{track.clips.length === 0 ? (
|
||||
<div
|
||||
className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
|
||||
isDropping
|
||||
? wouldOverlap
|
||||
? "border-red-500 bg-red-500/10 text-red-600"
|
||||
: "border-blue-500 bg-blue-500/10 text-blue-600"
|
||||
: "border-muted/30"
|
||||
}`}
|
||||
>
|
||||
{isDropping
|
||||
? wouldOverlap
|
||||
? "Cannot drop - would overlap"
|
||||
: "Drop clip here"
|
||||
: "Drop media here"}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{track.clips.map((clip) => {
|
||||
const isSelected = selectedClips.some(
|
||||
(c) => c.trackId === track.id && c.clipId === clip.id
|
||||
);
|
||||
|
||||
const handleClipSplit = () => {
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const { updateClipTrim, addClipToTrack } = useTimelineStore();
|
||||
const splitTime = currentTime;
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime +
|
||||
(clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
|
||||
updateClipTrim(
|
||||
track.id,
|
||||
clip.id,
|
||||
clip.trimStart,
|
||||
clip.trimEnd + (effectiveEnd - splitTime)
|
||||
);
|
||||
addClipToTrack(track.id, {
|
||||
mediaId: clip.mediaId,
|
||||
name: clip.name + " (split)",
|
||||
duration: clip.duration,
|
||||
startTime: splitTime,
|
||||
trimStart: clip.trimStart + (splitTime - effectiveStart),
|
||||
trimEnd: clip.trimEnd,
|
||||
});
|
||||
toast.success("Clip split successfully");
|
||||
} else {
|
||||
toast.error("Playhead must be within clip to split");
|
||||
}
|
||||
};
|
||||
|
||||
const handleClipDuplicate = () => {
|
||||
const { addClipToTrack } = useTimelineStore.getState();
|
||||
addClipToTrack(track.id, {
|
||||
mediaId: clip.mediaId,
|
||||
name: clip.name + " (copy)",
|
||||
duration: clip.duration,
|
||||
startTime:
|
||||
clip.startTime +
|
||||
(clip.duration - clip.trimStart - clip.trimEnd) +
|
||||
0.1,
|
||||
trimStart: clip.trimStart,
|
||||
trimEnd: clip.trimEnd,
|
||||
});
|
||||
toast.success("Clip duplicated");
|
||||
};
|
||||
|
||||
const handleClipDelete = () => {
|
||||
const { removeClipFromTrack } = useTimelineStore.getState();
|
||||
removeClipFromTrack(track.id, clip.id);
|
||||
toast.success("Clip deleted");
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu key={clip.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div>
|
||||
<TimelineClip
|
||||
clip={clip}
|
||||
track={track}
|
||||
zoomLevel={zoomLevel}
|
||||
isSelected={isSelected}
|
||||
onClipMouseDown={handleClipMouseDown}
|
||||
onClipClick={handleClipClick}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={handleClipSplit}>
|
||||
<Scissors className="h-4 w-4 mr-2" />
|
||||
Split at Playhead
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleClipDuplicate}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Duplicate Clip
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={handleClipDelete}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Clip
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
124
apps/web/src/components/footer.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RiGithubLine, RiTwitterXLine } from "react-icons/ri";
|
||||
import { getStars } from "@/lib/fetchGhStars";
|
||||
import Image from "next/image";
|
||||
|
||||
export function Footer() {
|
||||
const [star, setStar] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStars = async () => {
|
||||
try {
|
||||
const data = await getStars();
|
||||
setStar(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch GitHub stars", err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStars();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<motion.footer
|
||||
className="bg-background border-t"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.8, duration: 0.8 }}
|
||||
>
|
||||
<div className="max-w-5xl mx-auto px-8 py-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8">
|
||||
{/* Brand Section */}
|
||||
<div className="md:col-span-1 max-w-sm">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Image src="/logo.svg" alt="OpenCut" width={24} height={24} />
|
||||
<span className="font-bold text-lg">OpenCut</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-5">
|
||||
The open source video editor that gets the job done. Simple,
|
||||
powerful, and works on any platform.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href="https://github.com/OpenCut-app/OpenCut"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<RiGithubLine className="h-5 w-5" />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://x.com/OpenCutApp"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<RiTwitterXLine className="h-5 w-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-12 justify-end items-start py-2">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground mb-4">Resources</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Privacy policy
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Terms of use
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Company Links */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground mb-4">Company</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<Link
|
||||
href="/contributors"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Contributors
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="https://github.com/OpenCut-app/OpenCut/blob/main/README.md"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Section */}
|
||||
<div className="pt-2 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>© 2025 OpenCut, All Rights Reserved</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.footer>
|
||||
);
|
||||
}
|
@ -29,7 +29,7 @@ export function HeaderBase({
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn("px-6 h-16 flex justify-between items-center", className)}
|
||||
className={cn("px-6 h-14 flex justify-between items-center", className)}
|
||||
>
|
||||
{leftContent && <div className="flex items-center">{leftContent}</div>}
|
||||
{centerContent && (
|
||||
|
@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Button } from "./ui/button";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { HeaderBase } from "./header-base";
|
||||
import { useSession } from "@opencut/auth/client";
|
||||
import { getStars } from "@/lib/fetchGhStars";
|
||||
import { Star } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
export function Header() {
|
||||
const { data: session } = useSession();
|
||||
@ -29,31 +28,43 @@ export function Header() {
|
||||
|
||||
const leftContent = (
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
|
||||
<span className="font-medium tracking-tight">OpenCut</span>
|
||||
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
|
||||
<span className="text-xl font-medium hidden md:block">OpenCut</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const rightContent = (
|
||||
<nav className="flex items-center">
|
||||
<nav className="flex items-center gap-3">
|
||||
<Link href="/contributors">
|
||||
<Button variant="text" className="text-sm">
|
||||
<Button variant="text" className="text-sm p-0">
|
||||
Contributors
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
|
||||
<Button variant="text" className="text-sm">
|
||||
GitHub
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={session ? "/editor" : "/auth/login"}>
|
||||
<Button size="sm" className="text-sm ml-4">
|
||||
Start editing
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{process.env.NODE_ENV === "development" ? (
|
||||
<Link href="/editor">
|
||||
<Button size="sm" className="text-sm ml-4">
|
||||
Editor
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
|
||||
<Button size="sm" className="text-sm ml-4">
|
||||
GitHub {star}+
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
|
||||
return <HeaderBase leftContent={leftContent} rightContent={rightContent} />;
|
||||
return (
|
||||
<div className="mx-4 md:mx-0">
|
||||
<HeaderBase
|
||||
className="bg-accent border 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"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
>
|
||||
<g fill="#161614">
|
||||
<g fill="currentColor">
|
||||
<path d="M127.505 0C57.095 0 0 57.085 0 127.505c0 56.336 36.534 104.13 87.196 120.99 6.372 1.18 8.712-2.766 8.712-6.134 0-3.04-.119-13.085-.173-23.739-35.473 7.713-42.958-15.044-42.958-15.044-5.8-14.738-14.157-18.656-14.157-18.656-11.568-7.914.872-7.752.872-7.752 12.804.9 19.546 13.14 19.546 13.14 11.372 19.493 29.828 13.857 37.104 10.6 1.144-8.242 4.449-13.866 8.095-17.05-28.32-3.225-58.092-14.158-58.092-63.014 0-13.92 4.981-25.295 13.138-34.224-1.324-3.212-5.688-16.18 1.235-33.743 0 0 10.707-3.427 35.073 13.07 10.17-2.826 21.078-4.242 31.914-4.29 10.836.048 21.752 1.464 31.942 4.29 24.337-16.497 35.029-13.07 35.029-13.07 6.94 17.563 2.574 30.531 1.25 33.743 8.175 8.929 13.122 20.303 13.122 34.224 0 48.972-29.828 59.756-58.22 62.912 4.573 3.957 8.648 11.717 8.648 23.612 0 17.06-.148 30.791-.148 34.991 0 3.393 2.295 7.369 8.759 6.117 50.634-16.879 87.122-64.656 87.122-120.973C255.009 57.085 197.922 0 127.505 0" />
|
||||
|
||||
<path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398" />
|
||||
|
@ -1,175 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { getStars } from "@/lib/fetchGhStars";
|
||||
|
||||
interface HeroProps {
|
||||
signupCount: number;
|
||||
}
|
||||
|
||||
export function Hero({ signupCount }: HeroProps) {
|
||||
const [star, setStar] = useState<string>();
|
||||
const [email, setEmail] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStars = async () => {
|
||||
try {
|
||||
const data = await getStars();
|
||||
setStar(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch GitHub stars", err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStars();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email.trim()) {
|
||||
toast({
|
||||
title: "Email required",
|
||||
description: "Please enter your email address.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/waitlist", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email: email.trim() }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: "Welcome to the waitlist! 🎉",
|
||||
description: "You'll be notified when we launch.",
|
||||
});
|
||||
setEmail("");
|
||||
} else {
|
||||
toast({
|
||||
title: "Oops!",
|
||||
description: data.error || "Something went wrong. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Network error",
|
||||
description: "Please check your connection and try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-[calc(100vh-4rem)] flex flex-col items-center justify-center text-center px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="max-w-3xl mx-auto"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.8 }}
|
||||
className="inline-block"
|
||||
>
|
||||
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter">
|
||||
The open source
|
||||
</h1>
|
||||
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter mt-2">
|
||||
video editor
|
||||
</h1>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
className="mt-10 text-lg sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4, duration: 0.8 }}
|
||||
>
|
||||
A simple but powerful video editor that gets the job done. Works on
|
||||
any platform.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
className="mt-12 flex gap-8 justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6, duration: 0.8 }}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="flex gap-3 w-full max-w-lg">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
className="h-11 text-base flex-1"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="px-6 h-11 text-base"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span className="relative z-10">
|
||||
{isSubmitting ? "Joining..." : "Join waitlist"}
|
||||
</span>
|
||||
<ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" />
|
||||
</Button>
|
||||
</form>
|
||||
</motion.div>
|
||||
|
||||
{signupCount > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8, duration: 0.6 }}
|
||||
className="mt-6 inline-flex items-center gap-2 bg-muted/30 px-4 py-2 rounded-full text-sm text-muted-foreground"
|
||||
>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
<span>{signupCount.toLocaleString()} people already joined</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="absolute bottom-12 left-0 right-0 text-center text-sm text-muted-foreground/60"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.8, duration: 0.8 }}
|
||||
>
|
||||
Currently in beta • Open source on{" "}
|
||||
<Link
|
||||
href="https://github.com/OpenCut-app/OpenCut"
|
||||
className="text-foreground underline"
|
||||
>
|
||||
GitHub {star}+
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
interface HeroProps {
|
||||
signupCount: number;
|
||||
}
|
||||
|
||||
export function Hero({ signupCount }: HeroProps) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email.trim()) {
|
||||
toast({
|
||||
title: "Email required",
|
||||
description: "Please enter your email address.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/waitlist", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email: email.trim() }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: "Welcome to the waitlist! 🎉",
|
||||
description: "You'll be notified when we launch.",
|
||||
});
|
||||
setEmail("");
|
||||
} else {
|
||||
toast({
|
||||
title: "Oops!",
|
||||
description: data.error || "Something went wrong. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Network error",
|
||||
description: "Please check your connection and try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4">
|
||||
<Image
|
||||
className="absolute top-0 left-0 -z-50 size-full object-cover"
|
||||
src="/landing-page-bg.png"
|
||||
height={1903.5}
|
||||
width={1269}
|
||||
alt="landing-page.bg"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.8 }}
|
||||
className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
|
||||
>
|
||||
<h1>The Open Source</h1>
|
||||
<div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
|
||||
<div className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2">
|
||||
<Image src="/frame.svg" height={79} width={459} alt="frame" />
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
Video Editor
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
className="mt-10 text-base sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4, duration: 0.8 }}
|
||||
>
|
||||
A simple but powerful video editor that gets the job done. Works on
|
||||
any platform.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
className="mt-12 flex gap-8 justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6, duration: 0.8 }}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
className="h-11 text-base flex-1"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="px-6 h-11 text-base"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span className="relative z-10">
|
||||
{isSubmitting ? "Joining..." : "Join waitlist"}
|
||||
</span>
|
||||
<ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" />
|
||||
</Button>
|
||||
</form>
|
||||
</motion.div>
|
||||
|
||||
{signupCount > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8, duration: 0.6 }}
|
||||
className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center"
|
||||
>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
<span>{signupCount.toLocaleString()} people already joined</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
"rounded-xl border bg-card text-card-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
@ -3,6 +3,7 @@
|
||||
import * as React from "react";
|
||||
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
@ -18,23 +19,40 @@ const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
const contextMenuItemVariants = cva(
|
||||
"relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "focus:opacity-65 focus:text-accent-foreground",
|
||||
destructive: "text-destructive focus:text-destructive/80",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
>(({ className, inset, children, variant = "default", ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
contextMenuItemVariants({ variant }),
|
||||
"data-[state=open]:bg-accent data-[state=open]:opacity-65",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
<ChevronRight className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
@ -62,7 +80,8 @@ const ContextMenuContent = React.forwardRef<
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -75,12 +94,13 @@ const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
>(({ className, inset, variant = "default", ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
contextMenuItemVariants({ variant }),
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
@ -91,14 +111,13 @@ ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
|
||||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, children, checked, variant = "default", ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
@ -115,19 +134,18 @@ ContextMenuCheckboxItem.displayName =
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & {
|
||||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, children, variant = "default", ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-4 w-4 fill-current" />
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@ -144,7 +162,7 @@ const ContextMenuLabel = React.forwardRef<
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
@ -159,7 +177,7 @@ const ContextMenuSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
className={cn("-mx-1 my-1 h-px bg-foreground/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@ -171,10 +189,7 @@ const ContextMenuShortcut = ({
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
@ -19,16 +19,33 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const dropdownMenuItemVariants = cva(
|
||||
"relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "focus:opacity-65 focus:text-accent-foreground",
|
||||
destructive: "text-destructive focus:text-destructive/80",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
>(({ className, inset, children, variant = "default", ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
dropdownMenuItemVariants({ variant }),
|
||||
"data-[state=open]:bg-accent data-[state=open]:opacity-65",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
@ -66,7 +83,7 @@ const DropdownMenuContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
@ -76,22 +93,6 @@ const DropdownMenuContent = React.forwardRef<
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const dropdownMenuItemVariants = cva(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "focus:bg-accent focus:text-accent-foreground",
|
||||
destructive:
|
||||
"text-destructive focus:bg-destructive focus:text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
@ -113,12 +114,15 @@ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, children, checked, variant = "default", ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
dropdownMenuItemVariants({ variant }),
|
||||
"pl-8 pr-2",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
@ -137,12 +141,15 @@ DropdownMenuCheckboxItem.displayName =
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, children, variant = "default", ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
dropdownMenuItemVariants({ variant }),
|
||||
"pl-8 pr-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -181,7 +188,7 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
className={cn("-mx-1 my-1 h-px bg-foreground/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
@ -2,13 +2,14 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BackgroundType } from "@/types/editor";
|
||||
|
||||
interface ImageTimelineTreatmentProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
targetAspectRatio?: number; // Default to 16:9 for video
|
||||
className?: string;
|
||||
backgroundType?: "blur" | "mirror" | "color";
|
||||
backgroundType?: BackgroundType;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
|
@ -4,108 +4,128 @@ import { useRef, useEffect } from "react";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
|
||||
interface VideoPlayerProps {
|
||||
src: string;
|
||||
poster?: string;
|
||||
className?: string;
|
||||
clipStartTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
clipDuration: number;
|
||||
src: string;
|
||||
poster?: string;
|
||||
className?: string;
|
||||
clipStartTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
clipDuration: number;
|
||||
}
|
||||
|
||||
export function VideoPlayer({
|
||||
src,
|
||||
poster,
|
||||
className = "",
|
||||
clipStartTime,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
clipDuration
|
||||
src,
|
||||
poster,
|
||||
className = "",
|
||||
clipStartTime,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
clipDuration,
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const { isPlaying, currentTime, volume, speed } = usePlaybackStore();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
|
||||
|
||||
// Calculate if we're within this clip's timeline range
|
||||
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
|
||||
const isInClipRange = currentTime >= clipStartTime && currentTime < clipEndTime;
|
||||
// Calculate if we're within this clip's timeline range
|
||||
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
|
||||
const isInClipRange =
|
||||
currentTime >= clipStartTime && currentTime < clipEndTime;
|
||||
|
||||
// Sync playback events
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !isInClipRange) return;
|
||||
// Sync playback events
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !isInClipRange) return;
|
||||
|
||||
const handleSeekEvent = (e: CustomEvent) => {
|
||||
// Always update video time, even if outside clip range
|
||||
const timelineTime = e.detail.time;
|
||||
const videoTime = Math.max(trimStart, Math.min(
|
||||
clipDuration - trimEnd,
|
||||
timelineTime - clipStartTime + trimStart
|
||||
));
|
||||
video.currentTime = videoTime;
|
||||
};
|
||||
const handleSeekEvent = (e: CustomEvent) => {
|
||||
// Always update video time, even if outside clip range
|
||||
const timelineTime = e.detail.time;
|
||||
const videoTime = Math.max(
|
||||
trimStart,
|
||||
Math.min(
|
||||
clipDuration - trimEnd,
|
||||
timelineTime - clipStartTime + trimStart
|
||||
)
|
||||
);
|
||||
video.currentTime = videoTime;
|
||||
};
|
||||
|
||||
const handleUpdateEvent = (e: CustomEvent) => {
|
||||
// Always update video time, even if outside clip range
|
||||
const timelineTime = e.detail.time;
|
||||
const targetTime = Math.max(trimStart, Math.min(
|
||||
clipDuration - trimEnd,
|
||||
timelineTime - clipStartTime + trimStart
|
||||
));
|
||||
const handleUpdateEvent = (e: CustomEvent) => {
|
||||
// Always update video time, even if outside clip range
|
||||
const timelineTime = e.detail.time;
|
||||
const targetTime = Math.max(
|
||||
trimStart,
|
||||
Math.min(
|
||||
clipDuration - trimEnd,
|
||||
timelineTime - clipStartTime + trimStart
|
||||
)
|
||||
);
|
||||
|
||||
if (Math.abs(video.currentTime - targetTime) > 0.5) {
|
||||
video.currentTime = targetTime;
|
||||
}
|
||||
};
|
||||
if (Math.abs(video.currentTime - targetTime) > 0.5) {
|
||||
video.currentTime = targetTime;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSpeed = (e: CustomEvent) => {
|
||||
video.playbackRate = e.detail.speed;
|
||||
};
|
||||
const handleSpeed = (e: CustomEvent) => {
|
||||
video.playbackRate = e.detail.speed;
|
||||
};
|
||||
|
||||
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
|
||||
window.addEventListener("playback-update", handleUpdateEvent as EventListener);
|
||||
window.addEventListener("playback-speed", handleSpeed as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("playback-seek", handleSeekEvent as EventListener);
|
||||
window.removeEventListener("playback-update", handleUpdateEvent as EventListener);
|
||||
window.removeEventListener("playback-speed", handleSpeed as EventListener);
|
||||
};
|
||||
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
|
||||
|
||||
// Sync playback state
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying && isInClipRange) {
|
||||
video.play().catch(() => { });
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [isPlaying, isInClipRange]);
|
||||
|
||||
// Sync volume and speed
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.volume = volume;
|
||||
video.playbackRate = speed;
|
||||
}, [volume, speed]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
poster={poster}
|
||||
className={`w-full h-full object-cover ${className}`}
|
||||
playsInline
|
||||
preload="auto"
|
||||
controls={false}
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
style={{ pointerEvents: 'none' }}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
|
||||
window.addEventListener(
|
||||
"playback-update",
|
||||
handleUpdateEvent as EventListener
|
||||
);
|
||||
}
|
||||
window.addEventListener("playback-speed", handleSpeed as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"playback-seek",
|
||||
handleSeekEvent as EventListener
|
||||
);
|
||||
window.removeEventListener(
|
||||
"playback-update",
|
||||
handleUpdateEvent as EventListener
|
||||
);
|
||||
window.removeEventListener(
|
||||
"playback-speed",
|
||||
handleSpeed as EventListener
|
||||
);
|
||||
};
|
||||
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
|
||||
|
||||
// Sync playback state
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying && isInClipRange) {
|
||||
video.play().catch(() => {});
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [isPlaying, isInClipRange]);
|
||||
|
||||
// Sync volume and speed
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.volume = volume;
|
||||
video.muted = muted;
|
||||
video.playbackRate = speed;
|
||||
}, [volume, speed, muted]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
poster={poster}
|
||||
className={`w-full h-full object-cover ${className}`}
|
||||
playsInline
|
||||
preload="auto"
|
||||
controls={false}
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
style={{ pointerEvents: "none" }}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
60
apps/web/src/hooks/auth/useLogin.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "@opencut/auth/client";
|
||||
|
||||
export function useLogin() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
|
||||
const handleLogin = useCallback(async () => {
|
||||
setError(null);
|
||||
setIsEmailLoading(true);
|
||||
|
||||
const { error } = await signIn.email({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message || "An unexpected error occurred.");
|
||||
setIsEmailLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/editor");
|
||||
}, [router, email, password]);
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
setError(null);
|
||||
setIsGoogleLoading(true);
|
||||
|
||||
try {
|
||||
await signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: "/editor",
|
||||
});
|
||||
} catch (error) {
|
||||
setError("Failed to sign in with Google. Please try again.");
|
||||
setIsGoogleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isAnyLoading = isEmailLoading || isGoogleLoading;
|
||||
|
||||
return {
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
error,
|
||||
isEmailLoading,
|
||||
isGoogleLoading,
|
||||
isAnyLoading,
|
||||
handleLogin,
|
||||
handleGoogleLogin,
|
||||
};
|
||||
}
|
65
apps/web/src/hooks/auth/useSignUp.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signUp, signIn } from "@opencut/auth/client";
|
||||
|
||||
export function useSignUp() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
|
||||
const handleSignUp = useCallback(async () => {
|
||||
setError(null);
|
||||
setIsEmailLoading(true);
|
||||
|
||||
const { error } = await signUp.email({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message || "An unexpected error occurred.");
|
||||
setIsEmailLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/login");
|
||||
}, [name, email, password, router]);
|
||||
|
||||
const handleGoogleSignUp = useCallback(async () => {
|
||||
setError(null);
|
||||
setIsGoogleLoading(true);
|
||||
|
||||
try {
|
||||
await signIn.social({
|
||||
provider: "google",
|
||||
});
|
||||
|
||||
router.push("/editor");
|
||||
} catch (error) {
|
||||
setError("Failed to sign up with Google. Please try again.");
|
||||
setIsGoogleLoading(false);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const isAnyLoading = isEmailLoading || isGoogleLoading;
|
||||
|
||||
return {
|
||||
name,
|
||||
setName,
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
error,
|
||||
isEmailLoading,
|
||||
isGoogleLoading,
|
||||
isAnyLoading,
|
||||
handleSignUp,
|
||||
handleGoogleSignUp,
|
||||
};
|
||||
}
|
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,
|
||||
};
|
||||
}
|
@ -1,18 +1,174 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function usePlaybackControls() {
|
||||
const { toggle } = usePlaybackStore();
|
||||
export const usePlaybackControls = () => {
|
||||
const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore();
|
||||
|
||||
const {
|
||||
selectedClips,
|
||||
tracks,
|
||||
splitClip,
|
||||
splitAndKeepLeft,
|
||||
splitAndKeepRight,
|
||||
separateAudio,
|
||||
} = useTimelineStore();
|
||||
|
||||
const handleSplitSelectedClip = useCallback(() => {
|
||||
if (selectedClips.length !== 1) {
|
||||
toast.error("Select exactly one clip to split");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, clipId } = selectedClips[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const clip = track?.clips.find((c) => c.id === clipId);
|
||||
|
||||
if (!clip) return;
|
||||
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within selected clip");
|
||||
return;
|
||||
}
|
||||
|
||||
splitClip(trackId, clipId, currentTime);
|
||||
toast.success("Clip split at playhead");
|
||||
}, [selectedClips, tracks, currentTime, splitClip]);
|
||||
|
||||
const handleSplitAndKeepLeftCallback = useCallback(() => {
|
||||
if (selectedClips.length !== 1) {
|
||||
toast.error("Select exactly one clip");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, clipId } = selectedClips[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const clip = track?.clips.find((c) => c.id === clipId);
|
||||
|
||||
if (!clip) return;
|
||||
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within selected clip");
|
||||
return;
|
||||
}
|
||||
|
||||
splitAndKeepLeft(trackId, clipId, currentTime);
|
||||
toast.success("Split and kept left portion");
|
||||
}, [selectedClips, tracks, currentTime, splitAndKeepLeft]);
|
||||
|
||||
const handleSplitAndKeepRightCallback = useCallback(() => {
|
||||
if (selectedClips.length !== 1) {
|
||||
toast.error("Select exactly one clip");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, clipId } = selectedClips[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const clip = track?.clips.find((c) => c.id === clipId);
|
||||
|
||||
if (!clip) return;
|
||||
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within selected clip");
|
||||
return;
|
||||
}
|
||||
|
||||
splitAndKeepRight(trackId, clipId, currentTime);
|
||||
toast.success("Split and kept right portion");
|
||||
}, [selectedClips, tracks, currentTime, splitAndKeepRight]);
|
||||
|
||||
const handleSeparateAudioCallback = useCallback(() => {
|
||||
if (selectedClips.length !== 1) {
|
||||
toast.error("Select exactly one video clip to separate audio");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, clipId } = selectedClips[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
|
||||
if (!track || track.type !== "video") {
|
||||
toast.error("Select a video clip to separate audio");
|
||||
return;
|
||||
}
|
||||
|
||||
separateAudio(trackId, clipId);
|
||||
toast.success("Audio separated to audio track");
|
||||
}, [selectedClips, tracks, separateAudio]);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
if (isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
break;
|
||||
|
||||
case "s":
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleSplitSelectedClip();
|
||||
}
|
||||
break;
|
||||
|
||||
case "q":
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleSplitAndKeepLeftCallback();
|
||||
}
|
||||
break;
|
||||
|
||||
case "w":
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleSplitAndKeepRightCallback();
|
||||
}
|
||||
break;
|
||||
|
||||
case "d":
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleSeparateAudioCallback();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
isPlaying,
|
||||
play,
|
||||
pause,
|
||||
handleSplitSelectedClip,
|
||||
handleSplitAndKeepLeftCallback,
|
||||
handleSplitAndKeepRightCallback,
|
||||
handleSeparateAudioCallback,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === "Space" && e.target === document.body) {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggle]);
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyPress);
|
||||
return () => document.removeEventListener("keydown", handleKeyPress);
|
||||
}, [handleKeyPress]);
|
||||
};
|
||||
|
@ -11,11 +11,15 @@ import {
|
||||
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
|
||||
|
||||
export async function processMediaFiles(
|
||||
files: FileList | File[]
|
||||
files: FileList | File[],
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ProcessedMediaItem[]> {
|
||||
const fileArray = Array.from(files);
|
||||
const processedItems: ProcessedMediaItem[] = [];
|
||||
|
||||
const total = fileArray.length;
|
||||
let completed = 0;
|
||||
|
||||
for (const file of fileArray) {
|
||||
const fileType = getFileType(file);
|
||||
|
||||
@ -57,6 +61,15 @@ export async function processMediaFiles(
|
||||
duration,
|
||||
aspectRatio,
|
||||
});
|
||||
|
||||
// Yield back to the event loop to keep the UI responsive
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
completed += 1;
|
||||
if (onProgress) {
|
||||
const percent = Math.round((completed / total) * 100);
|
||||
onProgress(percent);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing file:", file.name, error);
|
||||
toast.error(`Failed to process ${file.name}`);
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { db } from "@opencut/db";
|
||||
import { db, sql } from "@opencut/db";
|
||||
import { waitlist } from "@opencut/db/schema";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export async function getWaitlistCount() {
|
||||
try {
|
||||
|
@ -8,9 +8,9 @@ interface PlaybackStore extends PlaybackState, PlaybackControls {
|
||||
|
||||
let playbackTimer: number | null = null;
|
||||
|
||||
const startTimer = (store: any) => {
|
||||
const startTimer = (store: () => PlaybackStore) => {
|
||||
if (playbackTimer) cancelAnimationFrame(playbackTimer);
|
||||
|
||||
|
||||
// Use requestAnimationFrame for smoother updates
|
||||
const updateTime = () => {
|
||||
const state = store();
|
||||
@ -18,14 +18,22 @@ const startTimer = (store: any) => {
|
||||
const now = performance.now();
|
||||
const delta = (now - lastUpdate) / 1000; // Convert to seconds
|
||||
lastUpdate = now;
|
||||
|
||||
const newTime = state.currentTime + (delta * state.speed);
|
||||
|
||||
const newTime = state.currentTime + delta * state.speed;
|
||||
if (newTime >= state.duration) {
|
||||
// When video completes, pause and reset playhead to start
|
||||
state.pause();
|
||||
state.setCurrentTime(0);
|
||||
// Notify video elements to sync with reset
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playback-seek", { detail: { time: 0 } })
|
||||
);
|
||||
} else {
|
||||
state.setCurrentTime(newTime);
|
||||
// Notify video elements to sync
|
||||
window.dispatchEvent(new CustomEvent('playback-update', { detail: { time: newTime } }));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playback-update", { detail: { time: newTime } })
|
||||
);
|
||||
}
|
||||
}
|
||||
playbackTimer = requestAnimationFrame(updateTime);
|
||||
@ -47,6 +55,8 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: 1,
|
||||
muted: false,
|
||||
previousVolume: 1,
|
||||
speed: 1.0,
|
||||
|
||||
play: () => {
|
||||
@ -72,22 +82,53 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
|
||||
const { duration } = get();
|
||||
const clampedTime = Math.max(0, Math.min(duration, time));
|
||||
set({ currentTime: clampedTime });
|
||||
|
||||
// Notify video elements to seek
|
||||
const event = new CustomEvent('playback-seek', { detail: { time: clampedTime } });
|
||||
|
||||
const event = new CustomEvent("playback-seek", {
|
||||
detail: { time: clampedTime },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
|
||||
setVolume: (volume: number) => set({ volume: Math.max(0, Math.min(1, volume)) }),
|
||||
|
||||
|
||||
setVolume: (volume: number) =>
|
||||
set((state) => ({
|
||||
volume: Math.max(0, Math.min(1, volume)),
|
||||
muted: volume === 0,
|
||||
previousVolume: volume > 0 ? volume : state.previousVolume,
|
||||
})),
|
||||
|
||||
setSpeed: (speed: number) => {
|
||||
const newSpeed = Math.max(0.1, Math.min(2.0, speed));
|
||||
set({ speed: newSpeed });
|
||||
|
||||
const event = new CustomEvent('playback-speed', { detail: { speed: newSpeed } });
|
||||
|
||||
const event = new CustomEvent("playback-speed", {
|
||||
detail: { speed: newSpeed },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
|
||||
setDuration: (duration: number) => set({ duration }),
|
||||
setCurrentTime: (time: number) => set({ currentTime: time }),
|
||||
}));
|
||||
|
||||
mute: () => {
|
||||
const { volume, previousVolume } = get();
|
||||
set({
|
||||
muted: true,
|
||||
previousVolume: volume > 0 ? volume : previousVolume,
|
||||
volume: 0,
|
||||
});
|
||||
},
|
||||
|
||||
unmute: () => {
|
||||
const { previousVolume } = get();
|
||||
set({ muted: false, volume: previousVolume ?? 1 });
|
||||
},
|
||||
|
||||
toggleMute: () => {
|
||||
const { muted } = get();
|
||||
if (muted) {
|
||||
get().unmute();
|
||||
} else {
|
||||
get().mute();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
@ -7,6 +7,7 @@ interface ProjectStore {
|
||||
// Actions
|
||||
createNewProject: (name: string) => void;
|
||||
closeProject: () => void;
|
||||
updateProjectName: (name: string) => void;
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectStore>((set) => ({
|
||||
@ -16,6 +17,7 @@ export const useProjectStore = create<ProjectStore>((set) => ({
|
||||
const newProject: TProject = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
thumbnail: "",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@ -25,4 +27,16 @@ export const useProjectStore = create<ProjectStore>((set) => ({
|
||||
closeProject: () => {
|
||||
set({ activeProject: null });
|
||||
},
|
||||
|
||||
updateProjectName: (name: string) => {
|
||||
set((state) => ({
|
||||
activeProject: state.activeProject
|
||||
? {
|
||||
...state.activeProject,
|
||||
name,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
@ -1,264 +1,567 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface TimelineClip {
|
||||
id: string;
|
||||
mediaId: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
startTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
}
|
||||
|
||||
export interface TimelineTrack {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "video" | "audio" | "effects";
|
||||
clips: TimelineClip[];
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
interface TimelineStore {
|
||||
tracks: TimelineTrack[];
|
||||
history: TimelineTrack[][];
|
||||
redoStack: TimelineTrack[][];
|
||||
|
||||
// Multi-selection
|
||||
selectedClips: { trackId: string; clipId: string }[];
|
||||
selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
|
||||
deselectClip: (trackId: string, clipId: string) => void;
|
||||
clearSelectedClips: () => void;
|
||||
setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
|
||||
|
||||
// Actions
|
||||
addTrack: (type: "video" | "audio" | "effects") => string;
|
||||
removeTrack: (trackId: string) => void;
|
||||
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
|
||||
removeClipFromTrack: (trackId: string, clipId: string) => void;
|
||||
moveClipToTrack: (
|
||||
fromTrackId: string,
|
||||
toTrackId: string,
|
||||
clipId: string
|
||||
) => void;
|
||||
updateClipTrim: (
|
||||
trackId: string,
|
||||
clipId: string,
|
||||
trimStart: number,
|
||||
trimEnd: number
|
||||
) => void;
|
||||
updateClipStartTime: (
|
||||
trackId: string,
|
||||
clipId: string,
|
||||
startTime: number
|
||||
) => void;
|
||||
toggleTrackMute: (trackId: string) => void;
|
||||
|
||||
// Computed values
|
||||
getTotalDuration: () => number;
|
||||
|
||||
// New actions
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
pushHistory: () => void;
|
||||
}
|
||||
|
||||
export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
||||
tracks: [],
|
||||
history: [],
|
||||
redoStack: [],
|
||||
selectedClips: [],
|
||||
|
||||
pushHistory: () => {
|
||||
const { tracks, history, redoStack } = get();
|
||||
// Deep copy tracks
|
||||
set({
|
||||
history: [...history, JSON.parse(JSON.stringify(tracks))],
|
||||
redoStack: [] // Clear redo stack when new action is performed
|
||||
});
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
const { history, redoStack, tracks } = get();
|
||||
if (history.length === 0) return;
|
||||
const prev = history[history.length - 1];
|
||||
set({
|
||||
tracks: prev,
|
||||
history: history.slice(0, -1),
|
||||
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack
|
||||
});
|
||||
},
|
||||
|
||||
selectClip: (trackId, clipId, multi = false) => {
|
||||
set((state) => {
|
||||
const exists = state.selectedClips.some(
|
||||
(c) => c.trackId === trackId && c.clipId === clipId
|
||||
);
|
||||
if (multi) {
|
||||
// Toggle selection
|
||||
return exists
|
||||
? { selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)) }
|
||||
: { selectedClips: [...state.selectedClips, { trackId, clipId }] };
|
||||
} else {
|
||||
return { selectedClips: [{ trackId, clipId }] };
|
||||
}
|
||||
});
|
||||
},
|
||||
deselectClip: (trackId, clipId) => {
|
||||
set((state) => ({
|
||||
selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)),
|
||||
}));
|
||||
},
|
||||
clearSelectedClips: () => {
|
||||
set({ selectedClips: [] });
|
||||
},
|
||||
|
||||
setSelectedClips: (clips) => set({ selectedClips: clips }),
|
||||
|
||||
addTrack: (type) => {
|
||||
get().pushHistory();
|
||||
const newTrack: TimelineTrack = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
|
||||
type,
|
||||
clips: [],
|
||||
muted: false,
|
||||
};
|
||||
set((state) => ({
|
||||
tracks: [...state.tracks, newTrack],
|
||||
}));
|
||||
return newTrack.id;
|
||||
},
|
||||
|
||||
removeTrack: (trackId) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks.filter((track) => track.id !== trackId),
|
||||
}));
|
||||
},
|
||||
|
||||
addClipToTrack: (trackId, clipData) => {
|
||||
get().pushHistory();
|
||||
const newClip: TimelineClip = {
|
||||
...clipData,
|
||||
id: crypto.randomUUID(),
|
||||
startTime: clipData.startTime || 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? { ...track, clips: [...track.clips, newClip] }
|
||||
: track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
removeClipFromTrack: (trackId, clipId) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks
|
||||
.map((track) =>
|
||||
track.id === trackId
|
||||
? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) }
|
||||
: track
|
||||
)
|
||||
// Remove track if it becomes empty
|
||||
.filter((track) => track.clips.length > 0),
|
||||
}));
|
||||
},
|
||||
|
||||
moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
|
||||
get().pushHistory();
|
||||
set((state) => {
|
||||
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
|
||||
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
|
||||
|
||||
if (!clipToMove) return state;
|
||||
|
||||
return {
|
||||
tracks: state.tracks
|
||||
.map((track) => {
|
||||
if (track.id === fromTrackId) {
|
||||
return {
|
||||
...track,
|
||||
clips: track.clips.filter((clip) => clip.id !== clipId),
|
||||
};
|
||||
} else if (track.id === toTrackId) {
|
||||
return {
|
||||
...track,
|
||||
clips: [...track.clips, clipToMove],
|
||||
};
|
||||
}
|
||||
return track;
|
||||
})
|
||||
// Remove track if it becomes empty
|
||||
.filter((track) => track.clips.length > 0),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? {
|
||||
...track,
|
||||
clips: track.clips.map((clip) =>
|
||||
clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip
|
||||
),
|
||||
}
|
||||
: track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
updateClipStartTime: (trackId, clipId, startTime) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? {
|
||||
...track,
|
||||
clips: track.clips.map((clip) =>
|
||||
clip.id === clipId ? { ...clip, startTime } : clip
|
||||
),
|
||||
}
|
||||
: track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
toggleTrackMute: (trackId) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId ? { ...track, muted: !track.muted } : track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
getTotalDuration: () => {
|
||||
const { tracks } = get();
|
||||
if (tracks.length === 0) return 0;
|
||||
|
||||
const trackEndTimes = tracks.map((track) =>
|
||||
track.clips.reduce((maxEnd, clip) => {
|
||||
const clipEnd =
|
||||
clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
|
||||
return Math.max(maxEnd, clipEnd);
|
||||
}, 0)
|
||||
);
|
||||
|
||||
return Math.max(...trackEndTimes, 0);
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
const { redoStack } = get();
|
||||
if (redoStack.length === 0) return;
|
||||
const next = redoStack[redoStack.length - 1];
|
||||
set({ tracks: next, redoStack: redoStack.slice(0, -1) });
|
||||
},
|
||||
}));
|
||||
import { create } from "zustand";
|
||||
import type { TrackType } from "@/types/timeline";
|
||||
|
||||
// Helper function to manage clip naming with suffixes
|
||||
const getClipNameWithSuffix = (
|
||||
originalName: string,
|
||||
suffix: string
|
||||
): string => {
|
||||
// Remove existing suffixes to prevent accumulation
|
||||
const baseName = originalName
|
||||
.replace(/ \(left\)$/, "")
|
||||
.replace(/ \(right\)$/, "")
|
||||
.replace(/ \(audio\)$/, "")
|
||||
.replace(/ \(split \d+\)$/, "");
|
||||
|
||||
return `${baseName} (${suffix})`;
|
||||
};
|
||||
|
||||
export interface TimelineClip {
|
||||
id: string;
|
||||
mediaId: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
startTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
}
|
||||
|
||||
export interface TimelineTrack {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TrackType;
|
||||
clips: TimelineClip[];
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
interface TimelineStore {
|
||||
tracks: TimelineTrack[];
|
||||
history: TimelineTrack[][];
|
||||
redoStack: TimelineTrack[][];
|
||||
|
||||
// Multi-selection
|
||||
selectedClips: { trackId: string; clipId: string }[];
|
||||
selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
|
||||
deselectClip: (trackId: string, clipId: string) => void;
|
||||
clearSelectedClips: () => void;
|
||||
setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
|
||||
|
||||
// Drag state
|
||||
dragState: {
|
||||
isDragging: boolean;
|
||||
clipId: string | null;
|
||||
trackId: string | null;
|
||||
startMouseX: number;
|
||||
startClipTime: number;
|
||||
clickOffsetTime: number;
|
||||
currentTime: number;
|
||||
};
|
||||
setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
|
||||
startDrag: (
|
||||
clipId: string,
|
||||
trackId: string,
|
||||
startMouseX: number,
|
||||
startClipTime: number,
|
||||
clickOffsetTime: number
|
||||
) => void;
|
||||
updateDragTime: (currentTime: number) => void;
|
||||
endDrag: () => void;
|
||||
|
||||
// Actions
|
||||
addTrack: (type: TrackType) => string;
|
||||
removeTrack: (trackId: string) => void;
|
||||
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
|
||||
removeClipFromTrack: (trackId: string, clipId: string) => void;
|
||||
moveClipToTrack: (
|
||||
fromTrackId: string,
|
||||
toTrackId: string,
|
||||
clipId: string
|
||||
) => void;
|
||||
updateClipTrim: (
|
||||
trackId: string,
|
||||
clipId: string,
|
||||
trimStart: number,
|
||||
trimEnd: number
|
||||
) => void;
|
||||
updateClipStartTime: (
|
||||
trackId: string,
|
||||
clipId: string,
|
||||
startTime: number
|
||||
) => void;
|
||||
toggleTrackMute: (trackId: string) => void;
|
||||
|
||||
// Split operations for clips
|
||||
splitClip: (
|
||||
trackId: string,
|
||||
clipId: string,
|
||||
splitTime: number
|
||||
) => string | null;
|
||||
splitAndKeepLeft: (
|
||||
trackId: string,
|
||||
clipId: string,
|
||||
splitTime: number
|
||||
) => void;
|
||||
splitAndKeepRight: (
|
||||
trackId: string,
|
||||
clipId: string,
|
||||
splitTime: number
|
||||
) => void;
|
||||
separateAudio: (trackId: string, clipId: string) => string | null;
|
||||
|
||||
// Computed values
|
||||
getTotalDuration: () => number;
|
||||
|
||||
// History actions
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
pushHistory: () => void;
|
||||
}
|
||||
|
||||
export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
||||
tracks: [],
|
||||
history: [],
|
||||
redoStack: [],
|
||||
selectedClips: [],
|
||||
|
||||
pushHistory: () => {
|
||||
const { tracks, history, redoStack } = get();
|
||||
set({
|
||||
history: [...history, JSON.parse(JSON.stringify(tracks))],
|
||||
redoStack: [],
|
||||
});
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
const { history, redoStack, tracks } = get();
|
||||
if (history.length === 0) return;
|
||||
const prev = history[history.length - 1];
|
||||
set({
|
||||
tracks: prev,
|
||||
history: history.slice(0, -1),
|
||||
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))],
|
||||
});
|
||||
},
|
||||
|
||||
selectClip: (trackId, clipId, multi = false) => {
|
||||
set((state) => {
|
||||
const exists = state.selectedClips.some(
|
||||
(c) => c.trackId === trackId && c.clipId === clipId
|
||||
);
|
||||
if (multi) {
|
||||
return exists
|
||||
? {
|
||||
selectedClips: state.selectedClips.filter(
|
||||
(c) => !(c.trackId === trackId && c.clipId === clipId)
|
||||
),
|
||||
}
|
||||
: { selectedClips: [...state.selectedClips, { trackId, clipId }] };
|
||||
} else {
|
||||
return { selectedClips: [{ trackId, clipId }] };
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
deselectClip: (trackId, clipId) => {
|
||||
set((state) => ({
|
||||
selectedClips: state.selectedClips.filter(
|
||||
(c) => !(c.trackId === trackId && c.clipId === clipId)
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
clearSelectedClips: () => {
|
||||
set({ selectedClips: [] });
|
||||
},
|
||||
|
||||
setSelectedClips: (clips) => set({ selectedClips: clips }),
|
||||
|
||||
addTrack: (type) => {
|
||||
get().pushHistory();
|
||||
const newTrack: TimelineTrack = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
|
||||
type,
|
||||
clips: [],
|
||||
muted: false,
|
||||
};
|
||||
set((state) => ({
|
||||
tracks: [...state.tracks, newTrack],
|
||||
}));
|
||||
return newTrack.id;
|
||||
},
|
||||
|
||||
removeTrack: (trackId) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks.filter((track) => track.id !== trackId),
|
||||
}));
|
||||
},
|
||||
|
||||
addClipToTrack: (trackId, clipData) => {
|
||||
get().pushHistory();
|
||||
const newClip: TimelineClip = {
|
||||
...clipData,
|
||||
id: crypto.randomUUID(),
|
||||
startTime: clipData.startTime || 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? { ...track, clips: [...track.clips, newClip] }
|
||||
: track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
removeClipFromTrack: (trackId, clipId) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks
|
||||
.map((track) =>
|
||||
track.id === trackId
|
||||
? {
|
||||
...track,
|
||||
clips: track.clips.filter((clip) => clip.id !== clipId),
|
||||
}
|
||||
: track
|
||||
)
|
||||
.filter((track) => track.clips.length > 0),
|
||||
}));
|
||||
},
|
||||
|
||||
moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
|
||||
get().pushHistory();
|
||||
set((state) => {
|
||||
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
|
||||
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
|
||||
|
||||
if (!clipToMove) return state;
|
||||
|
||||
return {
|
||||
tracks: state.tracks
|
||||
.map((track) => {
|
||||
if (track.id === fromTrackId) {
|
||||
return {
|
||||
...track,
|
||||
clips: track.clips.filter((clip) => clip.id !== clipId),
|
||||
};
|
||||
} else if (track.id === toTrackId) {
|
||||
return {
|
||||
...track,
|
||||
clips: [...track.clips, clipToMove],
|
||||
};
|
||||
}
|
||||
return track;
|
||||
})
|
||||
.filter((track) => track.clips.length > 0),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? {
|
||||
...track,
|
||||
clips: track.clips.map((clip) =>
|
||||
clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip
|
||||
),
|
||||
}
|
||||
: track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
updateClipStartTime: (trackId, clipId, startTime) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? {
|
||||
...track,
|
||||
clips: track.clips.map((clip) =>
|
||||
clip.id === clipId ? { ...clip, startTime } : clip
|
||||
),
|
||||
}
|
||||
: track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
toggleTrackMute: (trackId) => {
|
||||
get().pushHistory();
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId ? { ...track, muted: !track.muted } : track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
splitClip: (trackId, clipId, splitTime) => {
|
||||
const { tracks } = get();
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const clip = track?.clips.find((c) => c.id === clipId);
|
||||
|
||||
if (!clip) return null;
|
||||
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
|
||||
|
||||
get().pushHistory();
|
||||
|
||||
const relativeTime = splitTime - clip.startTime;
|
||||
const firstDuration = relativeTime;
|
||||
const secondDuration =
|
||||
clip.duration - clip.trimStart - clip.trimEnd - relativeTime;
|
||||
|
||||
const secondClipId = crypto.randomUUID();
|
||||
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? {
|
||||
...track,
|
||||
clips: track.clips.flatMap((c) =>
|
||||
c.id === clipId
|
||||
? [
|
||||
{
|
||||
...c,
|
||||
trimEnd: c.trimEnd + secondDuration,
|
||||
name: getClipNameWithSuffix(c.name, "left"),
|
||||
},
|
||||
{
|
||||
...c,
|
||||
id: secondClipId,
|
||||
startTime: splitTime,
|
||||
trimStart: c.trimStart + firstDuration,
|
||||
name: getClipNameWithSuffix(c.name, "right"),
|
||||
},
|
||||
]
|
||||
: [c]
|
||||
),
|
||||
}
|
||||
: track
|
||||
),
|
||||
}));
|
||||
|
||||
return secondClipId;
|
||||
},
|
||||
|
||||
// Split clip and keep only the left portion
|
||||
splitAndKeepLeft: (trackId, clipId, splitTime) => {
|
||||
const { tracks } = get();
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const clip = track?.clips.find((c) => c.id === clipId);
|
||||
|
||||
if (!clip) return;
|
||||
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
|
||||
|
||||
get().pushHistory();
|
||||
|
||||
const relativeTime = splitTime - clip.startTime;
|
||||
const durationToRemove =
|
||||
clip.duration - clip.trimStart - clip.trimEnd - relativeTime;
|
||||
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? {
|
||||
...track,
|
||||
clips: track.clips.map((c) =>
|
||||
c.id === clipId
|
||||
? {
|
||||
...c,
|
||||
trimEnd: c.trimEnd + durationToRemove,
|
||||
name: getClipNameWithSuffix(c.name, "left"),
|
||||
}
|
||||
: c
|
||||
),
|
||||
}
|
||||
: track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
// Split clip and keep only the right portion
|
||||
splitAndKeepRight: (trackId, clipId, splitTime) => {
|
||||
const { tracks } = get();
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const clip = track?.clips.find((c) => c.id === clipId);
|
||||
|
||||
if (!clip) return;
|
||||
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
|
||||
|
||||
get().pushHistory();
|
||||
|
||||
const relativeTime = splitTime - clip.startTime;
|
||||
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? {
|
||||
...track,
|
||||
clips: track.clips.map((c) =>
|
||||
c.id === clipId
|
||||
? {
|
||||
...c,
|
||||
startTime: splitTime,
|
||||
trimStart: c.trimStart + relativeTime,
|
||||
name: getClipNameWithSuffix(c.name, "right"),
|
||||
}
|
||||
: c
|
||||
),
|
||||
}
|
||||
: track
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
// Extract audio from video clip to an audio track
|
||||
separateAudio: (trackId, clipId) => {
|
||||
const { tracks } = get();
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const clip = track?.clips.find((c) => c.id === clipId);
|
||||
|
||||
if (!clip || track?.type !== "video") return null;
|
||||
|
||||
get().pushHistory();
|
||||
|
||||
// Find existing audio track or prepare to create one
|
||||
const existingAudioTrack = tracks.find((t) => t.type === "audio");
|
||||
const audioClipId = crypto.randomUUID();
|
||||
|
||||
if (existingAudioTrack) {
|
||||
// Add audio clip to existing audio track
|
||||
set((state) => ({
|
||||
tracks: state.tracks.map((track) =>
|
||||
track.id === existingAudioTrack.id
|
||||
? {
|
||||
...track,
|
||||
clips: [
|
||||
...track.clips,
|
||||
{
|
||||
...clip,
|
||||
id: audioClipId,
|
||||
name: getClipNameWithSuffix(clip.name, "audio"),
|
||||
},
|
||||
],
|
||||
}
|
||||
: track
|
||||
),
|
||||
}));
|
||||
} else {
|
||||
// Create new audio track with the audio clip in a single atomic update
|
||||
const newAudioTrack: TimelineTrack = {
|
||||
id: crypto.randomUUID(),
|
||||
name: "Audio Track",
|
||||
type: "audio",
|
||||
clips: [
|
||||
{
|
||||
...clip,
|
||||
id: audioClipId,
|
||||
name: getClipNameWithSuffix(clip.name, "audio"),
|
||||
},
|
||||
],
|
||||
muted: false,
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
tracks: [...state.tracks, newAudioTrack],
|
||||
}));
|
||||
}
|
||||
|
||||
return audioClipId;
|
||||
},
|
||||
|
||||
getTotalDuration: () => {
|
||||
const { tracks } = get();
|
||||
if (tracks.length === 0) return 0;
|
||||
|
||||
const trackEndTimes = tracks.map((track) =>
|
||||
track.clips.reduce((maxEnd, clip) => {
|
||||
const clipEnd =
|
||||
clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
|
||||
return Math.max(maxEnd, clipEnd);
|
||||
}, 0)
|
||||
);
|
||||
|
||||
return Math.max(...trackEndTimes, 0);
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
const { redoStack } = get();
|
||||
if (redoStack.length === 0) return;
|
||||
const next = redoStack[redoStack.length - 1];
|
||||
set({ tracks: next, redoStack: redoStack.slice(0, -1) });
|
||||
},
|
||||
|
||||
dragState: {
|
||||
isDragging: false,
|
||||
clipId: null,
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startClipTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
},
|
||||
|
||||
setDragState: (dragState) =>
|
||||
set((state) => ({
|
||||
dragState: { ...state.dragState, ...dragState },
|
||||
})),
|
||||
|
||||
startDrag: (clipId, trackId, startMouseX, startClipTime, clickOffsetTime) => {
|
||||
set({
|
||||
dragState: {
|
||||
isDragging: true,
|
||||
clipId,
|
||||
trackId,
|
||||
startMouseX,
|
||||
startClipTime,
|
||||
clickOffsetTime,
|
||||
currentTime: startClipTime,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
updateDragTime: (currentTime) => {
|
||||
set((state) => ({
|
||||
dragState: {
|
||||
...state.dragState,
|
||||
currentTime,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
endDrag: () => {
|
||||
set({
|
||||
dragState: {
|
||||
isDragging: false,
|
||||
clipId: null,
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startClipTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
1
apps/web/src/types/editor.ts
Normal file
@ -0,0 +1 @@
|
||||
export type BackgroundType = "blur" | "mirror" | "color";
|
@ -4,6 +4,8 @@ export interface PlaybackState {
|
||||
duration: number;
|
||||
volume: number;
|
||||
speed: number;
|
||||
muted: boolean;
|
||||
previousVolume?: number;
|
||||
}
|
||||
|
||||
export interface PlaybackControls {
|
||||
@ -13,4 +15,7 @@ export interface PlaybackControls {
|
||||
setVolume: (volume: number) => void;
|
||||
setSpeed: (speed: number) => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
mute: () => void;
|
||||
unmute: () => void;
|
||||
toggleMute: () => void;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
export interface TProject {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnail: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
20
apps/web/src/types/timeline.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { TimelineTrack, TimelineClip } from "@/stores/timeline-store";
|
||||
|
||||
export type TrackType = "video" | "audio" | "effects";
|
||||
|
||||
export interface TimelineClipProps {
|
||||
clip: TimelineClip;
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
isSelected: boolean;
|
||||
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;
|
||||
}
|
@ -8,6 +8,9 @@ export default {
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
screens: {
|
||||
xs: "480px",
|
||||
},
|
||||
fontSize: {
|
||||
base: "0.95rem",
|
||||
},
|
||||
@ -69,7 +72,7 @@ export default {
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
sm: "calc(var(--radius) - 6px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
|
5
bun.lock
@ -4,6 +4,7 @@
|
||||
"": {
|
||||
"dependencies": {
|
||||
"next": "^15.3.4",
|
||||
"wavesurfer.js": "^7.9.8",
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.5.4",
|
||||
@ -430,7 +431,7 @@
|
||||
|
||||
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||
|
||||
"@types/node": ["@types/node@22.15.32", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA=="],
|
||||
"@types/node": ["@types/node@22.15.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wzoocdnnpSxZ+6CjW4ADCK1jVmd1S/J3ArNWfn8FDDQtRm8dkDg7TA+mvek2wNrfCgwuZxqEOiB9B1XCJ6+dbw=="],
|
||||
|
||||
"@types/pg": ["@types/pg@8.15.4", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg=="],
|
||||
|
||||
@ -902,6 +903,8 @@
|
||||
|
||||
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
|
||||
|
||||
"wavesurfer.js": ["wavesurfer.js@7.9.8", "", {}, "sha512-Mxz6qRwkSmuWVxLzp0XQ6EzSv1FTvQgMEUJTirLN1Ox76sn0YeyQlI99WuE+B0IuxShPHXIhvEuoBSJdaQs7tA=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
@ -11,7 +11,7 @@ services:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready -U opencut"]
|
||||
test: ["CMD-SHELL", "pg_isready -U opencut"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
@ -48,7 +48,7 @@ services:
|
||||
start_period: 10s
|
||||
web:
|
||||
build:
|
||||
context: ./apps/web
|
||||
context: .
|
||||
dockerfile: ./apps/web/Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
@ -1,13 +1,10 @@
|
||||
[build]
|
||||
base = "../.."
|
||||
command = "bun install && bunx turbo build --filter=opencut"
|
||||
publish = "apps/web/.next"
|
||||
|
||||
# Next.js plugin
|
||||
[[plugins]]
|
||||
package = "@netlify/plugin-nextjs"
|
||||
|
||||
# Redirects for domain migration
|
||||
[[redirects]]
|
||||
from = "https://appcut.app/*"
|
||||
to = "https://opencut.app/:splat"
|
||||
status = 301
|
||||
force = true
|
||||
force = true
|
@ -1,4 +1,5 @@
|
||||
{
|
||||
"name": "opencut",
|
||||
"packageManager": "bun@1.2.17",
|
||||
"devDependencies": {
|
||||
"turbo": "^2.5.4"
|
||||
@ -15,6 +16,7 @@
|
||||
"format": "turbo run format"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.3.4"
|
||||
"next": "^15.3.4",
|
||||
"wavesurfer.js": "^7.9.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ export const auth = betterAuth({
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
socialProviders: {
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID as string,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
|
||||
@ -26,4 +26,4 @@ export const auth = betterAuth({
|
||||
trustedOrigins: ["http://localhost:3000"],
|
||||
});
|
||||
|
||||
export type Auth = typeof auth;
|
||||
export type Auth = typeof auth;
|
||||
|
@ -2,15 +2,43 @@ import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL is not set");
|
||||
// Create a lazy database instance that only initializes when accessed
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
function getDb() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL is not set");
|
||||
}
|
||||
|
||||
if (!_db) {
|
||||
const client = postgres(process.env.DATABASE_URL);
|
||||
_db = drizzle(client, { schema });
|
||||
}
|
||||
|
||||
return _db;
|
||||
}
|
||||
|
||||
// Create the postgres client
|
||||
const client = postgres(process.env.DATABASE_URL);
|
||||
|
||||
// Create the drizzle instance
|
||||
export const db = drizzle(client, { schema });
|
||||
// Export a proxy that forwards all calls to the actual db instance
|
||||
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
|
||||
get(target, prop) {
|
||||
return getDb()[prop as keyof typeof _db];
|
||||
},
|
||||
});
|
||||
|
||||
// Re-export schema for convenience
|
||||
export * from "./schema";
|
||||
export * from "./schema";
|
||||
|
||||
// Re-export drizzle-orm functions to ensure version consistency
|
||||
export {
|
||||
eq,
|
||||
and,
|
||||
or,
|
||||
not,
|
||||
isNull,
|
||||
isNotNull,
|
||||
inArray,
|
||||
notInArray,
|
||||
exists,
|
||||
notExists,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|