Compare commits
179 Commits
izadoesdev
...
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 |
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,31 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
title: '[BUG] '
|
|
||||||
labels: bug
|
|
||||||
assignees: ''
|
|
||||||
---
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
**To Reproduce**
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
**Expected behavior**
|
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
**Desktop (please complete the following information):**
|
|
||||||
- OS: [e.g. iOS]
|
|
||||||
- Browser [e.g. chrome, safari]
|
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context about the problem here.
|
|
70
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Create a report to help us improve
|
||||||
|
title: '[BUG] '
|
||||||
|
labels: bug
|
||||||
|
body:
|
||||||
|
- type: input
|
||||||
|
id: Platform
|
||||||
|
attributes:
|
||||||
|
label: Platform
|
||||||
|
description: Please enter the platform on which you encountered the bug.
|
||||||
|
placeholder: e.g. Windows 11, Ubuntu 14.04
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: Browser
|
||||||
|
attributes:
|
||||||
|
label: Browser
|
||||||
|
description: Please enter the browser on which you encountered the bug.
|
||||||
|
placeholder: e.g. Chrome 137, Firefox 137, Safari 17
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: current-behavior
|
||||||
|
attributes:
|
||||||
|
label: Current Behavior
|
||||||
|
description: A concise description of what you're experiencing.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: expected-behavior
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
description: A concise description of what you expected to happen.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: dropdown
|
||||||
|
id: recurrence-probability
|
||||||
|
attributes:
|
||||||
|
label: Recurrence Probability
|
||||||
|
description: How often does this bug occur?
|
||||||
|
options:
|
||||||
|
- Always
|
||||||
|
- Usually
|
||||||
|
- Sometimes
|
||||||
|
- Seldom
|
||||||
|
default: 0
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: steps-to-reproduce
|
||||||
|
attributes:
|
||||||
|
label: Steps To Reproduce
|
||||||
|
description: Steps to reproduce the behavior.
|
||||||
|
placeholder: |
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Anything else?
|
||||||
|
description: |
|
||||||
|
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||||
|
|
||||||
|
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||||
|
validations:
|
||||||
|
required: false
|
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -1,19 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
title: '[FEATURE] '
|
|
||||||
labels: enhancement
|
|
||||||
assignees: ''
|
|
||||||
---
|
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context or screenshots about the feature request here.
|
|
42
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest an idea for OpenCut
|
||||||
|
title: '[FEATURE] '
|
||||||
|
labels: enhancement
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: Please make sure that no duplicated issues has already been delivered.
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem
|
||||||
|
placeholder: Is your feature request related to a problem? Please describe.
|
||||||
|
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Solution
|
||||||
|
placeholder: Describe the solution you'd like.
|
||||||
|
description: A clear and concise description of what you want to happen.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternative
|
||||||
|
attributes:
|
||||||
|
label: Alternative
|
||||||
|
placeholder: Describe alternatives you've considered.
|
||||||
|
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Anything else?
|
||||||
|
description: |
|
||||||
|
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||||
|
|
||||||
|
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||||
|
validations:
|
||||||
|
required: false
|
4
.github/workflows/bun-ci.yml
vendored
@ -31,13 +31,13 @@ jobs:
|
|||||||
- name: Install Bun
|
- name: Install Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
|
||||||
with:
|
with:
|
||||||
bun-version: 1.2.2
|
bun-version: 1.2.17
|
||||||
|
|
||||||
- name: Cache Bun modules
|
- name: Cache Bun modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('apps/web/bun.lock') }}
|
key: ${{ runner.os }}-bun-1.2.17-${{ hashFiles('apps/web/bun.lock') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: apps/web
|
working-directory: apps/web
|
||||||
|
5
.gitignore
vendored
@ -28,3 +28,8 @@ node_modules
|
|||||||
.turbo
|
.turbo
|
||||||
|
|
||||||
*.env
|
*.env
|
||||||
|
|
||||||
|
# cursor
|
||||||
|
|
||||||
|
.cursor/
|
||||||
|
bun.lockb
|
33
README.md
@ -1,12 +1,18 @@
|
|||||||
<img src="apps/web/public/logo.png" align="left" width="130" height="130">
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
<div align="right">
|
<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?
|
## Why?
|
||||||
|
|
||||||
@ -59,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:
|
Navigate into the web app's directory and create a `.env` file from the example:
|
||||||
```bash
|
```bash
|
||||||
cd apps/web
|
cd apps/web
|
||||||
cp .env.example .env
|
|
||||||
|
|
||||||
|
# Unix/Linux/Mac
|
||||||
|
cp .env.example .env.local
|
||||||
|
|
||||||
|
# Windows Command Prompt
|
||||||
|
copy .env.example .env.local
|
||||||
|
|
||||||
|
# Windows PowerShell
|
||||||
|
Copy-Item .env.example .env.local
|
||||||
```
|
```
|
||||||
*The default values in the `.env` file should work for local development.*
|
*The default values in the `.env` file should work for local development.*
|
||||||
|
|
||||||
@ -94,13 +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).
|
The application will be available at [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
=======
|
---
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
||||||
=======
|
---
|
||||||
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
|
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
|
||||||
|
|
||||||
Quick start for contributors:
|
Quick start for contributors:
|
||||||
|
@ -1,30 +1,45 @@
|
|||||||
FROM oven/bun:latest AS base
|
FROM oven/bun:alpine AS base
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies and build the application
|
||||||
FROM base AS deps
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json bun.lock ./
|
|
||||||
RUN bun install --frozen-lockfile
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
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
|
RUN bun run build
|
||||||
|
|
||||||
# Production image
|
# Production image
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||||
|
|
||||||
|
RUN chown nextjs:nodejs apps
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
@ -33,4 +48,4 @@ EXPOSE 3000
|
|||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
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 {
|
export default {
|
||||||
schema: "./src/lib/db/schema.ts",
|
schema: "../../packages/db/src/schema.ts",
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL,
|
url: process.env.DATABASE_URL,
|
||||||
|
@ -6,6 +6,19 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
productionBrowserSourceMaps: true,
|
productionBrowserSourceMaps: true,
|
||||||
|
output: "standalone",
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "plus.unsplash.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "images.unsplash.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
@ -21,7 +21,6 @@
|
|||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@opencut/auth": "workspace:*",
|
"@opencut/auth": "workspace:*",
|
||||||
"@opencut/db": "workspace:*",
|
"@opencut/db": "workspace:*",
|
||||||
"@types/pg": "^8.15.4",
|
|
||||||
"@upstash/ratelimit": "^2.0.5",
|
"@upstash/ratelimit": "^2.0.5",
|
||||||
"@upstash/redis": "^1.35.0",
|
"@upstash/redis": "^1.35.0",
|
||||||
"@vercel/analytics": "^1.4.1",
|
"@vercel/analytics": "^1.4.1",
|
||||||
@ -57,6 +56,7 @@
|
|||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/pg": "^8.15.4",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { signIn } from "@opencut/auth/client";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@ -10,7 +9,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Suspense, useState } from "react";
|
import { memo, Suspense } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@ -18,51 +17,47 @@ import Link from "next/link";
|
|||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||||
import { GoogleIcon } from "@/components/icons";
|
import { GoogleIcon } from "@/components/icons";
|
||||||
|
import { useLogin } from "@/hooks/auth/useLogin";
|
||||||
|
|
||||||
function LoginForm() {
|
const LoginPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [email, setEmail] = useState("");
|
const {
|
||||||
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,
|
email,
|
||||||
|
setEmail,
|
||||||
password,
|
password,
|
||||||
});
|
setPassword,
|
||||||
|
error,
|
||||||
if (error) {
|
isAnyLoading,
|
||||||
setError(error.message || "An unexpected error occurred.");
|
isEmailLoading,
|
||||||
setIsEmailLoading(false);
|
isGoogleLoading,
|
||||||
return;
|
handleLogin,
|
||||||
}
|
handleGoogleLogin,
|
||||||
|
} = useLogin();
|
||||||
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 (
|
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">Welcome back</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
Sign in to your account to continue
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex flex-col space-y-6">
|
<div className="flex flex-col space-y-6">
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
@ -128,38 +123,6 @@ function LoginForm() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
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">Welcome back</CardTitle>
|
|
||||||
<CardDescription className="text-base">
|
|
||||||
Sign in to your account to continue
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="text-center">
|
|
||||||
<Loader2 className="animate-spin" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<LoginForm />
|
|
||||||
</Suspense>
|
|
||||||
<div className="mt-6 text-center text-sm">
|
<div className="mt-6 text-center text-sm">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<Link
|
<Link
|
||||||
@ -169,8 +132,11 @@ export default function LoginPage() {
|
|||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</Suspense>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(LoginPage);
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { signUp, signIn } from "@opencut/auth/client";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@ -10,62 +9,59 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Suspense, useState } from "react";
|
import { memo, Suspense } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
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 { GoogleIcon } from "@/components/icons";
|
||||||
|
import { useSignUp } from "@/hooks/auth/useSignUp";
|
||||||
|
|
||||||
function SignUpForm() {
|
const SignUpPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [name, setName] = useState("");
|
const {
|
||||||
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,
|
name,
|
||||||
|
setName,
|
||||||
email,
|
email,
|
||||||
|
setEmail,
|
||||||
password,
|
password,
|
||||||
});
|
setPassword,
|
||||||
|
error,
|
||||||
if (error) {
|
isAnyLoading,
|
||||||
setError(error.message || "An unexpected error occurred.");
|
isEmailLoading,
|
||||||
setIsEmailLoading(false);
|
isGoogleLoading,
|
||||||
return;
|
handleSignUp,
|
||||||
}
|
handleGoogleSignUp,
|
||||||
|
} = useSignUp();
|
||||||
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 (
|
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">
|
||||||
|
Create your account
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
Get started with your free account today
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex flex-col space-y-6">
|
<div className="flex flex-col space-y-6">
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
@ -73,7 +69,6 @@ function SignUpForm() {
|
|||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleGoogleSignUp}
|
onClick={handleGoogleSignUp}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -87,7 +82,6 @@ function SignUpForm() {
|
|||||||
)}{" "}
|
)}{" "}
|
||||||
Continue with Google
|
Continue with Google
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<Separator className="w-full" />
|
<Separator className="w-full" />
|
||||||
@ -98,7 +92,6 @@ function SignUpForm() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Full Name</Label>
|
<Label htmlFor="name">Full Name</Label>
|
||||||
@ -150,41 +143,6 @@ function SignUpForm() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SignUpPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
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">
|
|
||||||
Create your account
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-base">
|
|
||||||
Get started with your free account today
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="text-center">
|
|
||||||
<Loader2 className="animate-spin" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SignUpForm />
|
|
||||||
</Suspense>
|
|
||||||
<div className="mt-6 text-center text-sm">
|
<div className="mt-6 text-center text-sm">
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<Link
|
<Link
|
||||||
@ -194,8 +152,11 @@ export default function SignUpPage() {
|
|||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</Suspense>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { db } from "@opencut/db";
|
import { db, eq } from "@opencut/db";
|
||||||
import { waitlist } from "@opencut/db/schema";
|
import { waitlist } from "@opencut/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { waitlistRateLimit } from "@/lib/rate-limit";
|
import { waitlistRateLimit } from "@/lib/rate-limit";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { GithubIcon } from "@/components/icons";
|
import { GithubIcon } from "@/components/icons";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Contributors - OpenCut",
|
title: "Contributors - OpenCut",
|
||||||
@ -49,7 +50,7 @@ async function getContributors(): Promise<Contributor[]> {
|
|||||||
const contributors = await response.json();
|
const contributors = await response.json();
|
||||||
|
|
||||||
const filteredContributors = contributors.filter(
|
const filteredContributors = contributors.filter(
|
||||||
(contributor: any) => contributor.type === "User"
|
(contributor: Contributor) => contributor.type === "User"
|
||||||
);
|
);
|
||||||
|
|
||||||
return filteredContributors;
|
return filteredContributors;
|
||||||
@ -61,8 +62,8 @@ async function getContributors(): Promise<Contributor[]> {
|
|||||||
|
|
||||||
export default async function ContributorsPage() {
|
export default async function ContributorsPage() {
|
||||||
const contributors = await getContributors();
|
const contributors = await getContributors();
|
||||||
const topContributor = contributors[0];
|
const topContributors = contributors.slice(0, 2);
|
||||||
const otherContributors = contributors.slice(1);
|
const otherContributors = contributors.slice(2);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
@ -77,10 +78,10 @@ export default async function ContributorsPage() {
|
|||||||
<div className="relative container mx-auto px-4 py-16">
|
<div className="relative container mx-auto px-4 py-16">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="text-center mb-20">
|
<div className="text-center mb-20">
|
||||||
<div className="inline-flex items-center gap-2 bg-muted/50 text-muted-foreground px-3 py-1 rounded-full text-sm mb-6">
|
<Badge variant="secondary" className="gap-2 mb-6">
|
||||||
<GithubIcon className="h-3 w-3" />
|
<GithubIcon className="h-3 w-3" />
|
||||||
Open Source
|
Open Source
|
||||||
</div>
|
</Badge>
|
||||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
||||||
Contributors
|
Contributors
|
||||||
</h1>
|
</h1>
|
||||||
@ -105,22 +106,25 @@ export default async function ContributorsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{topContributor && (
|
{topContributors.length > 0 && (
|
||||||
<div className="mb-20">
|
<div className="mb-20">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-2xl font-semibold mb-2">
|
<h2 className="text-2xl font-semibold mb-2">
|
||||||
Top Contributor
|
Top Contributors
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Leading the way in contributions
|
Leading the way in contributions
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row gap-6 justify-center max-w-4xl mx-auto">
|
||||||
|
{topContributors.map((contributor, index) => (
|
||||||
<Link
|
<Link
|
||||||
href={topContributor.html_url}
|
key={contributor.id}
|
||||||
|
href={contributor.html_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="group block"
|
className="group block flex-1"
|
||||||
>
|
>
|
||||||
<div className="relative mx-auto max-w-md">
|
<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" />
|
<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" />
|
||||||
@ -129,23 +133,20 @@ export default async function ContributorsPage() {
|
|||||||
<div className="relative mb-6">
|
<div className="relative mb-6">
|
||||||
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
|
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={topContributor.avatar_url}
|
src={contributor.avatar_url}
|
||||||
alt={`${topContributor.login}'s avatar`}
|
alt={`${contributor.login}'s avatar`}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback className="text-lg font-semibold">
|
<AvatarFallback className="text-lg font-semibold">
|
||||||
{topContributor.login.charAt(0).toUpperCase()}
|
{contributor.login.charAt(0).toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</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>
|
</div>
|
||||||
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
|
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
|
||||||
{topContributor.login}
|
{contributor.login}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{topContributor.contributions}
|
{contributor.contributions}
|
||||||
</span>
|
</span>
|
||||||
<span>contributions</span>
|
<span>contributions</span>
|
||||||
</div>
|
</div>
|
||||||
@ -153,6 +154,8 @@ export default async function ContributorsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -167,7 +170,7 @@ export default async function ContributorsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||||
{otherContributors.map((contributor, index) => (
|
{otherContributors.map((contributor, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={contributor.id}
|
key={contributor.id}
|
||||||
@ -179,8 +182,8 @@ export default async function ContributorsPage() {
|
|||||||
animationDelay: `${index * 50}ms`,
|
animationDelay: `${index * 50}ms`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-center p-4 rounded-xl hover:bg-muted/50 transition-all duration-300 group-hover:scale-105">
|
<div className="text-center p-2 rounded-xl transition-all duration-300 hover:opacity-50">
|
||||||
<Avatar className="h-16 w-16 mx-auto mb-3 ring-2 ring-transparent group-hover:ring-muted-foreground/20 transition-all duration-300">
|
<Avatar className="h-16 w-16 mx-auto mb-3">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={contributor.avatar_url}
|
src={contributor.avatar_url}
|
||||||
alt={`${contributor.login}'s avatar`}
|
alt={`${contributor.login}'s avatar`}
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -43,7 +43,7 @@
|
|||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
--card: 0 0% 3.9%;
|
--card: 0 0% 3.9%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 0 0% 98%;
|
||||||
--popover: 0 0% 3.9%;
|
--popover: 0 0% 14.9%;
|
||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 0 0% 98%;
|
||||||
--primary: 0 0% 98%;
|
--primary: 0 0% 98%;
|
||||||
--primary-foreground: 0 0% 9%;
|
--primary-foreground: 0 0% 9%;
|
||||||
@ -55,7 +55,7 @@
|
|||||||
--accent-foreground: 0 0% 98%;
|
--accent-foreground: 0 0% 98%;
|
||||||
--destructive: 0 100% 60%;
|
--destructive: 0 100% 60%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 0 0% 14.9%;
|
--border: 0 0% 17%;
|
||||||
--input: 0 0% 14.9%;
|
--input: 0 0% 14.9%;
|
||||||
--ring: 0 0% 83.1%;
|
--ring: 0 0% 83.1%;
|
||||||
--chart-1: 220 70% 50%;
|
--chart-1: 220 70% 50%;
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import { Analytics } from "@vercel/analytics/react";
|
import { Analytics } from "@vercel/analytics/react";
|
||||||
@ -6,17 +5,15 @@ import Script from "next/script";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
import { TooltipProvider } from "../components/ui/tooltip";
|
import { TooltipProvider } from "../components/ui/tooltip";
|
||||||
|
import { DevelopmentDebug } from "../components/development-debug";
|
||||||
|
import { baseMetaData } from "./metadata";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata = baseMetaData;
|
||||||
title: "OpenCut",
|
|
||||||
description:
|
|
||||||
"A simple but powerful video editor that gets the job done. In your browser.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
@ -31,6 +28,7 @@ export default function RootLayout({
|
|||||||
{children}
|
{children}
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
<DevelopmentDebug />
|
||||||
<Script
|
<Script
|
||||||
src="https://app.databuddy.cc/databuddy.js"
|
src="https://app.databuddy.cc/databuddy.js"
|
||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
|
66
apps/web/src/app/metadata.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
const title = "OpenCut";
|
||||||
|
const description = "A simple but powerful video editor that gets the job done. In your browser.";
|
||||||
|
const openGraphImageUrl = "https://opencut.app/opengraph-image.jpg";
|
||||||
|
const twitterImageUrl = "/opengraph-image.jpg";
|
||||||
|
|
||||||
|
export const baseMetaData: Metadata = {
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
openGraph: {
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
url: "https://opencut.app",
|
||||||
|
siteName: "OpenCut",
|
||||||
|
locale: "en_US",
|
||||||
|
type: "website",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: openGraphImageUrl,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: "OpenCut",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
creator: "@opencutapp",
|
||||||
|
images: [twitterImageUrl],
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: "/favicon.ico" },
|
||||||
|
{ url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" },
|
||||||
|
{ url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
||||||
|
{ url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" },
|
||||||
|
],
|
||||||
|
apple: [
|
||||||
|
{ url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" },
|
||||||
|
{ url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" },
|
||||||
|
{ url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" },
|
||||||
|
{ url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" },
|
||||||
|
{ url: "/icons/apple-icon-114x114.png", sizes: "114x114", type: "image/png" },
|
||||||
|
{ url: "/icons/apple-icon-120x120.png", sizes: "120x120", type: "image/png" },
|
||||||
|
{ url: "/icons/apple-icon-144x144.png", sizes: "144x144", type: "image/png" },
|
||||||
|
{ url: "/icons/apple-icon-152x152.png", sizes: "152x152", type: "image/png" },
|
||||||
|
{ url: "/icons/apple-icon-180x180.png", sizes: "180x180", type: "image/png" },
|
||||||
|
],
|
||||||
|
shortcut: ["/favicon.ico"]
|
||||||
|
},
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
title: title,
|
||||||
|
},
|
||||||
|
manifest: "/manifest.json",
|
||||||
|
other: {
|
||||||
|
"msapplication-config": "/browserconfig.xml"
|
||||||
|
}
|
||||||
|
};
|
@ -1,6 +1,8 @@
|
|||||||
import { Hero } from "@/components/landing/hero";
|
import { Hero } from "@/components/landing/hero";
|
||||||
import { Header } from "@/components/header";
|
import { Header } from "@/components/header";
|
||||||
|
import { Footer } from "@/components/footer";
|
||||||
import { getWaitlistCount } from "@/lib/waitlist";
|
import { getWaitlistCount } from "@/lib/waitlist";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
// Force dynamic rendering so waitlist count updates in real-time
|
// Force dynamic rendering so waitlist count updates in real-time
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@ -12,6 +14,7 @@ export default async function Home() {
|
|||||||
<div>
|
<div>
|
||||||
<Header />
|
<Header />
|
||||||
<Hero signupCount={signupCount} />
|
<Hero signupCount={signupCount} />
|
||||||
|
<Footer />
|
||||||
</div>
|
</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 Link from "next/link";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ChevronLeft, Download } from "lucide-react";
|
import { ChevronLeft, Download } from "lucide-react";
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
import { HeaderBase } from "./header-base";
|
import { HeaderBase } from "./header-base";
|
||||||
|
import { ProjectNameEditor } from "./editor/project-name-editor";
|
||||||
|
|
||||||
export function EditorHeader() {
|
export function EditorHeader() {
|
||||||
const { activeProject } = useProjectStore();
|
|
||||||
const { getTotalDuration } = useTimelineStore();
|
const { getTotalDuration } = useTimelineStore();
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
@ -24,13 +23,15 @@ export function EditorHeader() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const leftContent = (
|
const leftContent = (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
|
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
<span className="text-sm">{activeProject?.name || "Loading..."}</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
<ProjectNameEditor />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const centerContent = (
|
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";
|
"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 { 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 { useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
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.
|
// 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.
|
// 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 { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [mediaFilter, setMediaFilter] = useState("all");
|
const [mediaFilter, setMediaFilter] = useState("all");
|
||||||
|
|
||||||
const processFiles = async (files: FileList | File[]) => {
|
const processFiles = async (files: FileList | File[]) => {
|
||||||
// If no files, do nothing
|
if (!files || files.length === 0) return;
|
||||||
if (!files?.length) return;
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
setProgress(0);
|
||||||
try {
|
try {
|
||||||
// Process files (extract metadata, generate thumbnails, etc.)
|
// Process files (extract metadata, generate thumbnails, etc.)
|
||||||
const items = await processMediaFiles(files);
|
const processedItems = await processMediaFiles(files, (p) =>
|
||||||
|
setProgress(p)
|
||||||
|
);
|
||||||
// Add each processed media item to the store
|
// Add each processed media item to the store
|
||||||
items.forEach((item) => {
|
processedItems.forEach((item) => addMediaItem(item));
|
||||||
addMediaItem(item);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Show error if processing fails
|
// Show error toast if processing fails
|
||||||
console.error("File processing failed:", error);
|
console.error("Error processing files:", error);
|
||||||
toast.error("Failed to process files");
|
toast.error("Failed to process files");
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
setProgress(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -57,6 +59,21 @@ export function MediaPanel() {
|
|||||||
const handleRemove = (e: React.MouseEvent, id: string) => {
|
const handleRemove = (e: React.MouseEvent, id: string) => {
|
||||||
// Remove a media item from the store
|
// Remove a media item from the store
|
||||||
e.stopPropagation();
|
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);
|
removeMediaItem(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -67,7 +84,7 @@ export function MediaPanel() {
|
|||||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const startDrag = (e: React.DragEvent, item: any) => {
|
const startDrag = (e: React.DragEvent, item: MediaItem) => {
|
||||||
// When dragging a media item, set drag data for timeline to read
|
// When dragging a media item, set drag data for timeline to read
|
||||||
e.dataTransfer.setData(
|
e.dataTransfer.setData(
|
||||||
"application/x-media-item",
|
"application/x-media-item",
|
||||||
@ -84,11 +101,14 @@ export function MediaPanel() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const filtered = mediaItems.filter((item) => {
|
const filtered = mediaItems.filter((item) => {
|
||||||
if (mediaFilter && mediaFilter !== 'all' && item.type !== mediaFilter) {
|
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchQuery && !item.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
if (
|
||||||
|
searchQuery &&
|
||||||
|
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,7 +118,7 @@ export function MediaPanel() {
|
|||||||
setFilteredMediaItems(filtered);
|
setFilteredMediaItems(filtered);
|
||||||
}, [mediaItems, mediaFilter, searchQuery]);
|
}, [mediaItems, mediaFilter, searchQuery]);
|
||||||
|
|
||||||
const renderPreview = (item: any) => {
|
const renderPreview = (item: MediaItem) => {
|
||||||
// Render a preview for each media type (image, video, audio, unknown)
|
// Render a preview for each media type (image, video, audio, unknown)
|
||||||
// Each preview is draggable to the timeline
|
// Each preview is draggable to the timeline
|
||||||
const baseDragProps = {
|
const baseDragProps = {
|
||||||
@ -233,17 +253,19 @@ export function MediaPanel() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleFileSelect}
|
onClick={handleFileSelect}
|
||||||
disabled={isProcessing}
|
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 ? (
|
{isProcessing ? (
|
||||||
<>
|
<>
|
||||||
<Upload className="h-4 w-4 mr-2 animate-spin" />
|
<Upload className="h-4 w-4 animate-spin" />
|
||||||
Processing...
|
<span className="hidden md:inline ml-2">{progress}%</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline ml-2" aria-label="Add file">
|
||||||
Add
|
Add
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@ -276,7 +298,15 @@ export function MediaPanel() {
|
|||||||
<AspectRatio ratio={item.aspectRatio}>
|
<AspectRatio ratio={item.aspectRatio}>
|
||||||
{renderPreview(item)}
|
{renderPreview(item)}
|
||||||
</AspectRatio>
|
</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>
|
</Button>
|
||||||
|
|
||||||
{/* Show remove button on hover */}
|
{/* Show remove button on hover */}
|
||||||
|
@ -1,46 +1,113 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import {
|
||||||
import { useMediaStore } from "@/stores/media-store";
|
useTimelineStore,
|
||||||
|
type TimelineClip,
|
||||||
|
type TimelineTrack,
|
||||||
|
} from "@/stores/timeline-store";
|
||||||
|
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
import { VideoPlayer } from "@/components/ui/video-player";
|
import { VideoPlayer } from "@/components/ui/video-player";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Play, Pause } from "lucide-react";
|
import { Play, Pause, Volume2, VolumeX, Plus } from "lucide-react";
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
// Debug flag - set to false to hide active clips info
|
interface ActiveClip {
|
||||||
const SHOW_DEBUG_INFO = process.env.NODE_ENV === 'development';
|
clip: TimelineClip;
|
||||||
|
track: TimelineTrack;
|
||||||
|
mediaItem: MediaItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function PreviewPanel() {
|
export function PreviewPanel() {
|
||||||
const { tracks } = useTimelineStore();
|
const { tracks } = useTimelineStore();
|
||||||
const { mediaItems } = useMediaStore();
|
const { mediaItems } = useMediaStore();
|
||||||
const { isPlaying, toggle, currentTime } = usePlaybackStore();
|
const { currentTime, muted, toggleMute, volume } = usePlaybackStore();
|
||||||
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
|
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
|
||||||
const [showDebug, setShowDebug] = useState(SHOW_DEBUG_INFO);
|
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [previewDimensions, setPreviewDimensions] = useState({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate optimal preview size that fits in container while maintaining aspect ratio
|
||||||
|
useEffect(() => {
|
||||||
|
const updatePreviewSize = () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const container = containerRef.current.getBoundingClientRect();
|
||||||
|
const computedStyle = getComputedStyle(containerRef.current);
|
||||||
|
|
||||||
|
// Get padding values
|
||||||
|
const paddingTop = parseFloat(computedStyle.paddingTop);
|
||||||
|
const paddingBottom = parseFloat(computedStyle.paddingBottom);
|
||||||
|
const paddingLeft = parseFloat(computedStyle.paddingLeft);
|
||||||
|
const paddingRight = parseFloat(computedStyle.paddingRight);
|
||||||
|
|
||||||
|
// Get gap value (gap-4 = 1rem = 16px)
|
||||||
|
const gap = parseFloat(computedStyle.gap) || 16;
|
||||||
|
|
||||||
|
// Get toolbar height if it exists
|
||||||
|
const toolbar = containerRef.current.querySelector("[data-toolbar]");
|
||||||
|
const toolbarHeight = toolbar
|
||||||
|
? toolbar.getBoundingClientRect().height
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Calculate available space after accounting for padding, gap, and toolbar
|
||||||
|
const availableWidth = container.width - paddingLeft - paddingRight;
|
||||||
|
const availableHeight =
|
||||||
|
container.height -
|
||||||
|
paddingTop -
|
||||||
|
paddingBottom -
|
||||||
|
toolbarHeight -
|
||||||
|
(toolbarHeight > 0 ? gap : 0);
|
||||||
|
|
||||||
|
const targetRatio = canvasSize.width / canvasSize.height;
|
||||||
|
const containerRatio = availableWidth / availableHeight;
|
||||||
|
|
||||||
|
let width, height;
|
||||||
|
|
||||||
|
if (containerRatio > targetRatio) {
|
||||||
|
// Container is wider - constrain by height
|
||||||
|
height = availableHeight;
|
||||||
|
width = height * targetRatio;
|
||||||
|
} else {
|
||||||
|
// Container is taller - constrain by width
|
||||||
|
width = availableWidth;
|
||||||
|
height = width / targetRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewDimensions({ width, height });
|
||||||
|
};
|
||||||
|
|
||||||
|
updatePreviewSize();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(updatePreviewSize);
|
||||||
|
if (containerRef.current) {
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, [canvasSize.width, canvasSize.height]);
|
||||||
|
|
||||||
// Get active clips at current time
|
// Get active clips at current time
|
||||||
const getActiveClips = () => {
|
const getActiveClips = (): ActiveClip[] => {
|
||||||
const activeClips: Array<{
|
const activeClips: ActiveClip[] = [];
|
||||||
clip: any;
|
|
||||||
track: any;
|
|
||||||
mediaItem: any;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
tracks.forEach((track) => {
|
tracks.forEach((track) => {
|
||||||
track.clips.forEach((clip) => {
|
track.clips.forEach((clip) => {
|
||||||
const clipStart = clip.startTime;
|
const clipStart = clip.startTime;
|
||||||
const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
const clipEnd =
|
||||||
|
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||||
|
|
||||||
if (currentTime >= clipStart && currentTime < clipEnd) {
|
if (currentTime >= clipStart && currentTime < clipEnd) {
|
||||||
const mediaItem = clip.mediaId === "test"
|
const mediaItem =
|
||||||
? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
|
clip.mediaId === "test"
|
||||||
: mediaItems.find((item) => item.id === clip.mediaId);
|
? null // Test clips don't have a real media item
|
||||||
|
: mediaItems.find((item) => item.id === clip.mediaId) || null;
|
||||||
|
|
||||||
if (mediaItem || clip.mediaId === "test") {
|
|
||||||
activeClips.push({ clip, track, mediaItem });
|
activeClips.push({ clip, track, mediaItem });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -48,10 +115,9 @@ export function PreviewPanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const activeClips = getActiveClips();
|
const activeClips = getActiveClips();
|
||||||
const aspectRatio = canvasSize.width / canvasSize.height;
|
|
||||||
|
|
||||||
// Render a clip
|
// Render a clip
|
||||||
const renderClip = (clipData: any, index: number) => {
|
const renderClip = (clipData: ActiveClip, index: number) => {
|
||||||
const { clip, mediaItem } = clipData;
|
const { clip, mediaItem } = clipData;
|
||||||
|
|
||||||
// Test clips
|
// Test clips
|
||||||
@ -134,80 +200,84 @@ export function PreviewPanel() {
|
|||||||
<select
|
<select
|
||||||
value={`${canvasSize.width}x${canvasSize.height}`}
|
value={`${canvasSize.width}x${canvasSize.height}`}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const preset = canvasPresets.find(p => `${p.width}x${p.height}` === e.target.value);
|
const preset = canvasPresets.find(
|
||||||
if (preset) setCanvasSize({ width: preset.width, height: preset.height });
|
(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"
|
className="bg-background border rounded px-2 py-1 text-xs"
|
||||||
>
|
>
|
||||||
{canvasPresets.map(preset => (
|
{canvasPresets.map((preset) => (
|
||||||
<option key={preset.name} value={`${preset.width}x${preset.height}`}>
|
<option
|
||||||
|
key={preset.name}
|
||||||
|
value={`${preset.width}x${preset.height}`}
|
||||||
|
>
|
||||||
{preset.name} ({preset.width}×{preset.height})
|
{preset.name} ({preset.width}×{preset.height})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{/* Debug Toggle - Only show in development */}
|
|
||||||
{SHOW_DEBUG_INFO && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="text"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowDebug(!showDebug)}
|
onClick={toggleMute}
|
||||||
className="text-xs"
|
className="ml-auto"
|
||||||
>
|
>
|
||||||
Debug {showDebug ? 'ON' : 'OFF'}
|
{muted || volume === 0 ? (
|
||||||
</Button>
|
<VolumeX className="h-3 w-3 mr-1" />
|
||||||
|
) : (
|
||||||
|
<Volume2 className="h-3 w-3 mr-1" />
|
||||||
)}
|
)}
|
||||||
|
{muted || volume === 0 ? "Unmute" : "Mute"}
|
||||||
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview Area */}
|
{/* Preview Area */}
|
||||||
<div className="flex-1 flex items-center justify-center p-2 sm:p-4 bg-gray-900 min-h-0 min-w-0">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref={previewRef}
|
ref={previewRef}
|
||||||
className="relative overflow-hidden rounded-sm max-w-full max-h-full bg-black border border-gray-600"
|
className="relative overflow-hidden rounded-sm bg-black border"
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: aspectRatio.toString(),
|
width: previewDimensions.width,
|
||||||
width: "100%",
|
height: previewDimensions.height,
|
||||||
height: "100%",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activeClips.length === 0 ? (
|
{activeClips.length === 0 ? (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-white/50">
|
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||||
{tracks.length === 0 ? "Drop media to start editing" : "No clips at current time"}
|
{tracks.length === 0
|
||||||
|
? "No media added to timeline"
|
||||||
|
: "No clips at current time"}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
activeClips.map((clipData, index) => renderClip(clipData, index))
|
activeClips.map((clipData, index) => renderClip(clipData, index))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Debug Info Panel - Conditionally rendered */}
|
<PreviewToolbar />
|
||||||
{showDebug && (
|
|
||||||
<div 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>
|
</div>
|
||||||
))}
|
</div>
|
||||||
{activeClips.length === 0 && (
|
);
|
||||||
<span className="text-muted-foreground">No active clips</span>
|
}
|
||||||
)}
|
|
||||||
</div>
|
function PreviewToolbar() {
|
||||||
</div>
|
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>
|
</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 { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SpeedControl } from "./speed-control";
|
import { SpeedControl } from "./speed-control";
|
||||||
|
import type { BackgroundType } from "@/types/editor";
|
||||||
|
|
||||||
export function PropertiesPanel() {
|
export function PropertiesPanel() {
|
||||||
const { tracks } = useTimelineStore();
|
const { tracks } = useTimelineStore();
|
||||||
const { mediaItems } = useMediaStore();
|
const { mediaItems } = useMediaStore();
|
||||||
const [backgroundType, setBackgroundType] = useState<
|
const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur");
|
||||||
"blur" | "mirror" | "color"
|
|
||||||
>("blur");
|
|
||||||
const [backgroundColor, setBackgroundColor] = useState("#000000");
|
const [backgroundColor, setBackgroundColor] = useState("#000000");
|
||||||
|
|
||||||
// Get the first video clip for preview (simplified)
|
// Get the first video clip for preview (simplified)
|
||||||
@ -78,7 +77,9 @@ export function PropertiesPanel() {
|
|||||||
<Label htmlFor="bg-type">Background Type</Label>
|
<Label htmlFor="bg-type">Background Type</Label>
|
||||||
<Select
|
<Select
|
||||||
value={backgroundType}
|
value={backgroundType}
|
||||||
onValueChange={(value: any) => setBackgroundType(value)}
|
onValueChange={(value: BackgroundType) =>
|
||||||
|
setBackgroundType(value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select background type" />
|
<SelectValue placeholder="Select background type" />
|
||||||
|
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 (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn("px-6 h-16 flex justify-between items-center", className)}
|
className={cn("px-6 h-14 flex justify-between items-center", className)}
|
||||||
>
|
>
|
||||||
{leftContent && <div className="flex items-center">{leftContent}</div>}
|
{leftContent && <div className="flex items-center">{leftContent}</div>}
|
||||||
{centerContent && (
|
{centerContent && (
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { HeaderBase } from "./header-base";
|
import { HeaderBase } from "./header-base";
|
||||||
import { useSession } from "@opencut/auth/client";
|
import { useSession } from "@opencut/auth/client";
|
||||||
import { getStars } from "@/lib/fetchGhStars";
|
import { getStars } from "@/lib/fetchGhStars";
|
||||||
import { Star } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
@ -29,26 +28,43 @@ export function Header() {
|
|||||||
|
|
||||||
const leftContent = (
|
const leftContent = (
|
||||||
<Link href="/" className="flex items-center gap-3">
|
<Link href="/" className="flex items-center gap-3">
|
||||||
<Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
|
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
|
||||||
<span className="font-medium tracking-tight">OpenCut</span>
|
<span className="text-xl font-medium hidden md:block">OpenCut</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
||||||
const rightContent = (
|
const rightContent = (
|
||||||
<nav className="flex items-center">
|
<nav className="flex items-center gap-3">
|
||||||
<Link href="/contributors">
|
<Link href="/contributors">
|
||||||
<Button variant="text" className="text-sm">
|
<Button variant="text" className="text-sm p-0">
|
||||||
Contributors
|
Contributors
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
|
{process.env.NODE_ENV === "development" ? (
|
||||||
|
<Link href="/editor">
|
||||||
<Button size="sm" className="text-sm ml-4">
|
<Button size="sm" className="text-sm ml-4">
|
||||||
GitHub
|
Editor
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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>
|
</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"
|
viewBox="0 -3.5 256 256"
|
||||||
preserveAspectRatio="xMinYMin meet"
|
preserveAspectRatio="xMinYMin meet"
|
||||||
>
|
>
|
||||||
<g fill="#161614">
|
<g fill="currentColor">
|
||||||
<path d="M127.505 0C57.095 0 0 57.085 0 127.505c0 56.336 36.534 104.13 87.196 120.99 6.372 1.18 8.712-2.766 8.712-6.134 0-3.04-.119-13.085-.173-23.739-35.473 7.713-42.958-15.044-42.958-15.044-5.8-14.738-14.157-18.656-14.157-18.656-11.568-7.914.872-7.752.872-7.752 12.804.9 19.546 13.14 19.546 13.14 11.372 19.493 29.828 13.857 37.104 10.6 1.144-8.242 4.449-13.866 8.095-17.05-28.32-3.225-58.092-14.158-58.092-63.014 0-13.92 4.981-25.295 13.138-34.224-1.324-3.212-5.688-16.18 1.235-33.743 0 0 10.707-3.427 35.073 13.07 10.17-2.826 21.078-4.242 31.914-4.29 10.836.048 21.752 1.464 31.942 4.29 24.337-16.497 35.029-13.07 35.029-13.07 6.94 17.563 2.574 30.531 1.25 33.743 8.175 8.929 13.122 20.303 13.122 34.224 0 48.972-29.828 59.756-58.22 62.912 4.573 3.957 8.648 11.717 8.648 23.612 0 17.06-.148 30.791-.148 34.991 0 3.393 2.295 7.369 8.759 6.117 50.634-16.879 87.122-64.656 87.122-120.973C255.009 57.085 197.922 0 127.505 0" />
|
<path d="M127.505 0C57.095 0 0 57.085 0 127.505c0 56.336 36.534 104.13 87.196 120.99 6.372 1.18 8.712-2.766 8.712-6.134 0-3.04-.119-13.085-.173-23.739-35.473 7.713-42.958-15.044-42.958-15.044-5.8-14.738-14.157-18.656-14.157-18.656-11.568-7.914.872-7.752.872-7.752 12.804.9 19.546 13.14 19.546 13.14 11.372 19.493 29.828 13.857 37.104 10.6 1.144-8.242 4.449-13.866 8.095-17.05-28.32-3.225-58.092-14.158-58.092-63.014 0-13.92 4.981-25.295 13.138-34.224-1.324-3.212-5.688-16.18 1.235-33.743 0 0 10.707-3.427 35.073 13.07 10.17-2.826 21.078-4.242 31.914-4.29 10.836.048 21.752 1.464 31.942 4.29 24.337-16.497 35.029-13.07 35.029-13.07 6.94 17.563 2.574 30.531 1.25 33.743 8.175 8.929 13.122 20.303 13.122 34.224 0 48.972-29.828 59.756-58.22 62.912 4.573 3.957 8.648 11.717 8.648 23.612 0 17.06-.148 30.791-.148 34.991 0 3.393 2.295 7.369 8.759 6.117 50.634-16.879 87.122-64.656 87.122-120.973C255.009 57.085 197.922 0 127.505 0" />
|
||||||
|
|
||||||
<path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398" />
|
<path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398" />
|
||||||
|
@ -5,33 +5,20 @@ import { Button } from "../ui/button";
|
|||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { getStars } from "@/lib/fetchGhStars";
|
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
interface HeroProps {
|
interface HeroProps {
|
||||||
signupCount: number;
|
signupCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Hero({ signupCount }: HeroProps) {
|
export function Hero({ signupCount }: HeroProps) {
|
||||||
const [star, setStar] = useState<string>();
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { toast } = useToast();
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@ -82,7 +69,14 @@ export function Hero({ signupCount }: HeroProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-4rem)] flex flex-col justify-between items-center text-center px-4">
|
<div className="min-h-[calc(100vh-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
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
@ -93,18 +87,21 @@ export function Hero({ signupCount }: HeroProps) {
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.2, duration: 0.8 }}
|
transition={{ delay: 0.2, duration: 0.8 }}
|
||||||
className="inline-block"
|
className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
|
||||||
>
|
>
|
||||||
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter">
|
<h1>The Open Source</h1>
|
||||||
The open source
|
<div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
|
||||||
</h1>
|
<div className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2">
|
||||||
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter mt-2">
|
<Image src="/frame.svg" height={79} width={459} alt="frame" />
|
||||||
video editor
|
<span className="absolute inset-0 flex items-center justify-center">
|
||||||
</h1>
|
Video Editor
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.p
|
<motion.p
|
||||||
className="mt-10 text-lg sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
|
className="mt-10 text-base sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: 0.4, duration: 0.8 }}
|
transition={{ delay: 0.4, duration: 0.8 }}
|
||||||
@ -119,7 +116,10 @@ export function Hero({ signupCount }: HeroProps) {
|
|||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: 0.6, duration: 0.8 }}
|
transition={{ delay: 0.6, duration: 0.8 }}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="flex gap-3 w-full max-w-lg">
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Enter your email"
|
placeholder="Enter your email"
|
||||||
@ -148,28 +148,13 @@ export function Hero({ signupCount }: HeroProps) {
|
|||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.8, duration: 0.6 }}
|
transition={{ delay: 0.8, duration: 0.6 }}
|
||||||
className="mt-8 inline-flex items-center gap-2 bg-muted/30 px-4 py-2 rounded-full text-sm text-muted-foreground justify-center"
|
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" />
|
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||||
<span>{signupCount.toLocaleString()} people already joined</span>
|
<span>{signupCount.toLocaleString()} people already joined</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="mb-8 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl border bg-card text-card-foreground shadow",
|
"rounded-xl border bg-card text-card-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
|
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
|
||||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
@ -18,23 +19,40 @@ const ContextMenuSub = ContextMenuPrimitive.Sub;
|
|||||||
|
|
||||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
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<
|
const ContextMenuSubTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
|
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||||
}
|
}
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
>(({ className, inset, children, variant = "default", ...props }, ref) => (
|
||||||
<ContextMenuPrimitive.SubTrigger
|
<ContextMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
inset && "pl-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
<ChevronRight className="ml-auto" />
|
||||||
</ContextMenuPrimitive.SubTrigger>
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
));
|
));
|
||||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||||
@ -62,7 +80,8 @@ const ContextMenuContent = React.forwardRef<
|
|||||||
<ContextMenuPrimitive.Content
|
<ContextMenuPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -75,12 +94,13 @@ const ContextMenuItem = React.forwardRef<
|
|||||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
|
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, variant = "default", ...props }, ref) => (
|
||||||
<ContextMenuPrimitive.Item
|
<ContextMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
inset && "pl-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@ -91,14 +111,13 @@ ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
|||||||
|
|
||||||
const ContextMenuCheckboxItem = React.forwardRef<
|
const ContextMenuCheckboxItem = React.forwardRef<
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
|
||||||
>(({ className, children, checked, ...props }, ref) => (
|
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||||
|
}
|
||||||
|
>(({ className, children, checked, variant = "default", ...props }, ref) => (
|
||||||
<ContextMenuPrimitive.CheckboxItem
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)}
|
||||||
"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
|
|
||||||
)}
|
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@ -115,19 +134,18 @@ ContextMenuCheckboxItem.displayName =
|
|||||||
|
|
||||||
const ContextMenuRadioItem = React.forwardRef<
|
const ContextMenuRadioItem = React.forwardRef<
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & {
|
||||||
>(({ className, children, ...props }, ref) => (
|
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||||
|
}
|
||||||
|
>(({ className, children, variant = "default", ...props }, ref) => (
|
||||||
<ContextMenuPrimitive.RadioItem
|
<ContextMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)}
|
||||||
"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
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<ContextMenuPrimitive.ItemIndicator>
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
<Circle className="h-4 w-4 fill-current" />
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
</ContextMenuPrimitive.ItemIndicator>
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
@ -144,7 +162,7 @@ const ContextMenuLabel = React.forwardRef<
|
|||||||
<ContextMenuPrimitive.Label
|
<ContextMenuPrimitive.Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
inset && "pl-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@ -159,7 +177,7 @@ const ContextMenuSeparator = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ContextMenuPrimitive.Separator
|
<ContextMenuPrimitive.Separator
|
||||||
ref={ref}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@ -171,10 +189,7 @@ const ContextMenuShortcut = ({
|
|||||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -19,16 +19,33 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
|||||||
|
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
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<
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
|
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||||
}
|
}
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
>(({ className, inset, children, variant = "default", ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
inset && "pl-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@ -66,7 +83,7 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
@ -76,22 +93,6 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
));
|
));
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
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<
|
const DropdownMenuItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
@ -113,12 +114,15 @@ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
|||||||
|
|
||||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||||
>(({ className, children, checked, ...props }, ref) => (
|
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||||
|
}
|
||||||
|
>(({ className, children, checked, variant = "default", ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@ -137,12 +141,15 @@ DropdownMenuCheckboxItem.displayName =
|
|||||||
|
|
||||||
const DropdownMenuRadioItem = React.forwardRef<
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||||
>(({ className, children, ...props }, ref) => (
|
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||||
|
}
|
||||||
|
>(({ className, children, variant = "default", ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -181,7 +188,7 @@ const DropdownMenuSeparator = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
ref={ref}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { BackgroundType } from "@/types/editor";
|
||||||
|
|
||||||
interface ImageTimelineTreatmentProps {
|
interface ImageTimelineTreatmentProps {
|
||||||
src: string;
|
src: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
targetAspectRatio?: number; // Default to 16:9 for video
|
targetAspectRatio?: number; // Default to 16:9 for video
|
||||||
className?: string;
|
className?: string;
|
||||||
backgroundType?: "blur" | "mirror" | "color";
|
backgroundType?: BackgroundType;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -20,14 +20,15 @@ export function VideoPlayer({
|
|||||||
clipStartTime,
|
clipStartTime,
|
||||||
trimStart,
|
trimStart,
|
||||||
trimEnd,
|
trimEnd,
|
||||||
clipDuration
|
clipDuration,
|
||||||
}: VideoPlayerProps) {
|
}: VideoPlayerProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const { isPlaying, currentTime, volume, speed } = usePlaybackStore();
|
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
|
||||||
|
|
||||||
// Calculate if we're within this clip's timeline range
|
// Calculate if we're within this clip's timeline range
|
||||||
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
|
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
|
||||||
const isInClipRange = currentTime >= clipStartTime && currentTime < clipEndTime;
|
const isInClipRange =
|
||||||
|
currentTime >= clipStartTime && currentTime < clipEndTime;
|
||||||
|
|
||||||
// Sync playback events
|
// Sync playback events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -37,20 +38,26 @@ export function VideoPlayer({
|
|||||||
const handleSeekEvent = (e: CustomEvent) => {
|
const handleSeekEvent = (e: CustomEvent) => {
|
||||||
// Always update video time, even if outside clip range
|
// Always update video time, even if outside clip range
|
||||||
const timelineTime = e.detail.time;
|
const timelineTime = e.detail.time;
|
||||||
const videoTime = Math.max(trimStart, Math.min(
|
const videoTime = Math.max(
|
||||||
|
trimStart,
|
||||||
|
Math.min(
|
||||||
clipDuration - trimEnd,
|
clipDuration - trimEnd,
|
||||||
timelineTime - clipStartTime + trimStart
|
timelineTime - clipStartTime + trimStart
|
||||||
));
|
)
|
||||||
|
);
|
||||||
video.currentTime = videoTime;
|
video.currentTime = videoTime;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateEvent = (e: CustomEvent) => {
|
const handleUpdateEvent = (e: CustomEvent) => {
|
||||||
// Always update video time, even if outside clip range
|
// Always update video time, even if outside clip range
|
||||||
const timelineTime = e.detail.time;
|
const timelineTime = e.detail.time;
|
||||||
const targetTime = Math.max(trimStart, Math.min(
|
const targetTime = Math.max(
|
||||||
|
trimStart,
|
||||||
|
Math.min(
|
||||||
clipDuration - trimEnd,
|
clipDuration - trimEnd,
|
||||||
timelineTime - clipStartTime + trimStart
|
timelineTime - clipStartTime + trimStart
|
||||||
));
|
)
|
||||||
|
);
|
||||||
|
|
||||||
if (Math.abs(video.currentTime - targetTime) > 0.5) {
|
if (Math.abs(video.currentTime - targetTime) > 0.5) {
|
||||||
video.currentTime = targetTime;
|
video.currentTime = targetTime;
|
||||||
@ -62,13 +69,25 @@ export function VideoPlayer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
|
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
|
||||||
window.addEventListener("playback-update", handleUpdateEvent as EventListener);
|
window.addEventListener(
|
||||||
|
"playback-update",
|
||||||
|
handleUpdateEvent as EventListener
|
||||||
|
);
|
||||||
window.addEventListener("playback-speed", handleSpeed as EventListener);
|
window.addEventListener("playback-speed", handleSpeed as EventListener);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("playback-seek", handleSeekEvent as EventListener);
|
window.removeEventListener(
|
||||||
window.removeEventListener("playback-update", handleUpdateEvent as EventListener);
|
"playback-seek",
|
||||||
window.removeEventListener("playback-speed", handleSpeed as EventListener);
|
handleSeekEvent as EventListener
|
||||||
|
);
|
||||||
|
window.removeEventListener(
|
||||||
|
"playback-update",
|
||||||
|
handleUpdateEvent as EventListener
|
||||||
|
);
|
||||||
|
window.removeEventListener(
|
||||||
|
"playback-speed",
|
||||||
|
handleSpeed as EventListener
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
|
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
|
||||||
|
|
||||||
@ -90,8 +109,9 @@ export function VideoPlayer({
|
|||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
video.volume = volume;
|
video.volume = volume;
|
||||||
|
video.muted = muted;
|
||||||
video.playbackRate = speed;
|
video.playbackRate = speed;
|
||||||
}, [volume, speed]);
|
}, [volume, speed, muted]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<video
|
<video
|
||||||
@ -104,7 +124,7 @@ export function VideoPlayer({
|
|||||||
controls={false}
|
controls={false}
|
||||||
disablePictureInPicture
|
disablePictureInPicture
|
||||||
disableRemotePlayback
|
disableRemotePlayback
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{ pointerEvents: "none" }}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
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 { usePlaybackStore } from "@/stores/playback-store";
|
||||||
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export function usePlaybackControls() {
|
export const usePlaybackControls = () => {
|
||||||
const { toggle } = usePlaybackStore();
|
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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
document.addEventListener("keydown", handleKeyPress);
|
||||||
if (e.code === "Space" && e.target === document.body) {
|
return () => document.removeEventListener("keydown", handleKeyPress);
|
||||||
e.preventDefault();
|
}, [handleKeyPress]);
|
||||||
toggle();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [toggle]);
|
|
||||||
}
|
|
@ -11,11 +11,15 @@ import {
|
|||||||
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
|
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
|
||||||
|
|
||||||
export async function processMediaFiles(
|
export async function processMediaFiles(
|
||||||
files: FileList | File[]
|
files: FileList | File[],
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
): Promise<ProcessedMediaItem[]> {
|
): Promise<ProcessedMediaItem[]> {
|
||||||
const fileArray = Array.from(files);
|
const fileArray = Array.from(files);
|
||||||
const processedItems: ProcessedMediaItem[] = [];
|
const processedItems: ProcessedMediaItem[] = [];
|
||||||
|
|
||||||
|
const total = fileArray.length;
|
||||||
|
let completed = 0;
|
||||||
|
|
||||||
for (const file of fileArray) {
|
for (const file of fileArray) {
|
||||||
const fileType = getFileType(file);
|
const fileType = getFileType(file);
|
||||||
|
|
||||||
@ -57,6 +61,15 @@ export async function processMediaFiles(
|
|||||||
duration,
|
duration,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Yield back to the event loop to keep the UI responsive
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
completed += 1;
|
||||||
|
if (onProgress) {
|
||||||
|
const percent = Math.round((completed / total) * 100);
|
||||||
|
onProgress(percent);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing file:", file.name, error);
|
console.error("Error processing file:", file.name, error);
|
||||||
toast.error(`Failed to process ${file.name}`);
|
toast.error(`Failed to process ${file.name}`);
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { db } from "@opencut/db";
|
import { db, sql } from "@opencut/db";
|
||||||
import { waitlist } from "@opencut/db/schema";
|
import { waitlist } from "@opencut/db/schema";
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
|
|
||||||
export async function getWaitlistCount() {
|
export async function getWaitlistCount() {
|
||||||
try {
|
try {
|
||||||
|
@ -8,7 +8,7 @@ interface PlaybackStore extends PlaybackState, PlaybackControls {
|
|||||||
|
|
||||||
let playbackTimer: number | null = null;
|
let playbackTimer: number | null = null;
|
||||||
|
|
||||||
const startTimer = (store: any) => {
|
const startTimer = (store: () => PlaybackStore) => {
|
||||||
if (playbackTimer) cancelAnimationFrame(playbackTimer);
|
if (playbackTimer) cancelAnimationFrame(playbackTimer);
|
||||||
|
|
||||||
// Use requestAnimationFrame for smoother updates
|
// Use requestAnimationFrame for smoother updates
|
||||||
@ -19,13 +19,21 @@ const startTimer = (store: any) => {
|
|||||||
const delta = (now - lastUpdate) / 1000; // Convert to seconds
|
const delta = (now - lastUpdate) / 1000; // Convert to seconds
|
||||||
lastUpdate = now;
|
lastUpdate = now;
|
||||||
|
|
||||||
const newTime = state.currentTime + (delta * state.speed);
|
const newTime = state.currentTime + delta * state.speed;
|
||||||
if (newTime >= state.duration) {
|
if (newTime >= state.duration) {
|
||||||
|
// When video completes, pause and reset playhead to start
|
||||||
state.pause();
|
state.pause();
|
||||||
|
state.setCurrentTime(0);
|
||||||
|
// Notify video elements to sync with reset
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("playback-seek", { detail: { time: 0 } })
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
state.setCurrentTime(newTime);
|
state.setCurrentTime(newTime);
|
||||||
// Notify video elements to sync
|
// 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);
|
playbackTimer = requestAnimationFrame(updateTime);
|
||||||
@ -47,6 +55,8 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
|
|||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
volume: 1,
|
volume: 1,
|
||||||
|
muted: false,
|
||||||
|
previousVolume: 1,
|
||||||
speed: 1.0,
|
speed: 1.0,
|
||||||
|
|
||||||
play: () => {
|
play: () => {
|
||||||
@ -73,21 +83,52 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
|
|||||||
const clampedTime = Math.max(0, Math.min(duration, time));
|
const clampedTime = Math.max(0, Math.min(duration, time));
|
||||||
set({ currentTime: clampedTime });
|
set({ currentTime: clampedTime });
|
||||||
|
|
||||||
// Notify video elements to seek
|
const event = new CustomEvent("playback-seek", {
|
||||||
const event = new CustomEvent('playback-seek', { detail: { time: clampedTime } });
|
detail: { time: clampedTime },
|
||||||
|
});
|
||||||
window.dispatchEvent(event);
|
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) => {
|
setSpeed: (speed: number) => {
|
||||||
const newSpeed = Math.max(0.1, Math.min(2.0, speed));
|
const newSpeed = Math.max(0.1, Math.min(2.0, speed));
|
||||||
set({ speed: newSpeed });
|
set({ speed: newSpeed });
|
||||||
|
|
||||||
const event = new CustomEvent('playback-speed', { detail: { speed: newSpeed } });
|
const event = new CustomEvent("playback-speed", {
|
||||||
|
detail: { speed: newSpeed },
|
||||||
|
});
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
},
|
},
|
||||||
|
|
||||||
setDuration: (duration: number) => set({ duration }),
|
setDuration: (duration: number) => set({ duration }),
|
||||||
setCurrentTime: (time: number) => set({ currentTime: time }),
|
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
|
// Actions
|
||||||
createNewProject: (name: string) => void;
|
createNewProject: (name: string) => void;
|
||||||
closeProject: () => void;
|
closeProject: () => void;
|
||||||
|
updateProjectName: (name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useProjectStore = create<ProjectStore>((set) => ({
|
export const useProjectStore = create<ProjectStore>((set) => ({
|
||||||
@ -16,6 +17,7 @@ export const useProjectStore = create<ProjectStore>((set) => ({
|
|||||||
const newProject: TProject = {
|
const newProject: TProject = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name,
|
name,
|
||||||
|
thumbnail: "",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@ -25,4 +27,16 @@ export const useProjectStore = create<ProjectStore>((set) => ({
|
|||||||
closeProject: () => {
|
closeProject: () => {
|
||||||
set({ activeProject: null });
|
set({ activeProject: null });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateProjectName: (name: string) => {
|
||||||
|
set((state) => ({
|
||||||
|
activeProject: state.activeProject
|
||||||
|
? {
|
||||||
|
...state.activeProject,
|
||||||
|
name,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
@ -1,4 +1,20 @@
|
|||||||
import { create } from "zustand";
|
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 {
|
export interface TimelineClip {
|
||||||
id: string;
|
id: string;
|
||||||
@ -13,7 +29,7 @@ export interface TimelineClip {
|
|||||||
export interface TimelineTrack {
|
export interface TimelineTrack {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: "video" | "audio" | "effects";
|
type: TrackType;
|
||||||
clips: TimelineClip[];
|
clips: TimelineClip[];
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
}
|
}
|
||||||
@ -30,8 +46,29 @@ interface TimelineStore {
|
|||||||
clearSelectedClips: () => void;
|
clearSelectedClips: () => void;
|
||||||
setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
|
setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
|
||||||
|
|
||||||
|
// Drag state
|
||||||
|
dragState: {
|
||||||
|
isDragging: boolean;
|
||||||
|
clipId: string | null;
|
||||||
|
trackId: string | null;
|
||||||
|
startMouseX: number;
|
||||||
|
startClipTime: number;
|
||||||
|
clickOffsetTime: number;
|
||||||
|
currentTime: number;
|
||||||
|
};
|
||||||
|
setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
|
||||||
|
startDrag: (
|
||||||
|
clipId: string,
|
||||||
|
trackId: string,
|
||||||
|
startMouseX: number,
|
||||||
|
startClipTime: number,
|
||||||
|
clickOffsetTime: number
|
||||||
|
) => void;
|
||||||
|
updateDragTime: (currentTime: number) => void;
|
||||||
|
endDrag: () => void;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
addTrack: (type: "video" | "audio" | "effects") => string;
|
addTrack: (type: TrackType) => string;
|
||||||
removeTrack: (trackId: string) => void;
|
removeTrack: (trackId: string) => void;
|
||||||
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
|
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
|
||||||
removeClipFromTrack: (trackId: string, clipId: string) => void;
|
removeClipFromTrack: (trackId: string, clipId: string) => void;
|
||||||
@ -53,10 +90,28 @@ interface TimelineStore {
|
|||||||
) => void;
|
) => void;
|
||||||
toggleTrackMute: (trackId: string) => 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
|
// Computed values
|
||||||
getTotalDuration: () => number;
|
getTotalDuration: () => number;
|
||||||
|
|
||||||
// New actions
|
// History actions
|
||||||
undo: () => void;
|
undo: () => void;
|
||||||
redo: () => void;
|
redo: () => void;
|
||||||
pushHistory: () => void;
|
pushHistory: () => void;
|
||||||
@ -70,10 +125,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
|
|
||||||
pushHistory: () => {
|
pushHistory: () => {
|
||||||
const { tracks, history, redoStack } = get();
|
const { tracks, history, redoStack } = get();
|
||||||
// Deep copy tracks
|
|
||||||
set({
|
set({
|
||||||
history: [...history, JSON.parse(JSON.stringify(tracks))],
|
history: [...history, JSON.parse(JSON.stringify(tracks))],
|
||||||
redoStack: [] // Clear redo stack when new action is performed
|
redoStack: [],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -84,7 +138,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
set({
|
set({
|
||||||
tracks: prev,
|
tracks: prev,
|
||||||
history: history.slice(0, -1),
|
history: history.slice(0, -1),
|
||||||
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack
|
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -94,20 +148,27 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
(c) => c.trackId === trackId && c.clipId === clipId
|
(c) => c.trackId === trackId && c.clipId === clipId
|
||||||
);
|
);
|
||||||
if (multi) {
|
if (multi) {
|
||||||
// Toggle selection
|
|
||||||
return exists
|
return exists
|
||||||
? { selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)) }
|
? {
|
||||||
|
selectedClips: state.selectedClips.filter(
|
||||||
|
(c) => !(c.trackId === trackId && c.clipId === clipId)
|
||||||
|
),
|
||||||
|
}
|
||||||
: { selectedClips: [...state.selectedClips, { trackId, clipId }] };
|
: { selectedClips: [...state.selectedClips, { trackId, clipId }] };
|
||||||
} else {
|
} else {
|
||||||
return { selectedClips: [{ trackId, clipId }] };
|
return { selectedClips: [{ trackId, clipId }] };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
deselectClip: (trackId, clipId) => {
|
deselectClip: (trackId, clipId) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)),
|
selectedClips: state.selectedClips.filter(
|
||||||
|
(c) => !(c.trackId === trackId && c.clipId === clipId)
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
clearSelectedClips: () => {
|
clearSelectedClips: () => {
|
||||||
set({ selectedClips: [] });
|
set({ selectedClips: [] });
|
||||||
},
|
},
|
||||||
@ -161,10 +222,12 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
tracks: state.tracks
|
tracks: state.tracks
|
||||||
.map((track) =>
|
.map((track) =>
|
||||||
track.id === trackId
|
track.id === trackId
|
||||||
? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) }
|
? {
|
||||||
|
...track,
|
||||||
|
clips: track.clips.filter((clip) => clip.id !== clipId),
|
||||||
|
}
|
||||||
: track
|
: track
|
||||||
)
|
)
|
||||||
// Remove track if it becomes empty
|
|
||||||
.filter((track) => track.clips.length > 0),
|
.filter((track) => track.clips.length > 0),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@ -193,7 +256,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
return track;
|
return track;
|
||||||
})
|
})
|
||||||
// Remove track if it becomes empty
|
|
||||||
.filter((track) => track.clips.length > 0),
|
.filter((track) => track.clips.length > 0),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -240,6 +302,195 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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: () => {
|
getTotalDuration: () => {
|
||||||
const { tracks } = get();
|
const { tracks } = get();
|
||||||
if (tracks.length === 0) return 0;
|
if (tracks.length === 0) return 0;
|
||||||
@ -261,4 +512,56 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
const next = redoStack[redoStack.length - 1];
|
const next = redoStack[redoStack.length - 1];
|
||||||
set({ tracks: next, redoStack: redoStack.slice(0, -1) });
|
set({ tracks: next, redoStack: redoStack.slice(0, -1) });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
dragState: {
|
||||||
|
isDragging: false,
|
||||||
|
clipId: null,
|
||||||
|
trackId: null,
|
||||||
|
startMouseX: 0,
|
||||||
|
startClipTime: 0,
|
||||||
|
clickOffsetTime: 0,
|
||||||
|
currentTime: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
setDragState: (dragState) =>
|
||||||
|
set((state) => ({
|
||||||
|
dragState: { ...state.dragState, ...dragState },
|
||||||
|
})),
|
||||||
|
|
||||||
|
startDrag: (clipId, trackId, startMouseX, startClipTime, clickOffsetTime) => {
|
||||||
|
set({
|
||||||
|
dragState: {
|
||||||
|
isDragging: true,
|
||||||
|
clipId,
|
||||||
|
trackId,
|
||||||
|
startMouseX,
|
||||||
|
startClipTime,
|
||||||
|
clickOffsetTime,
|
||||||
|
currentTime: startClipTime,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateDragTime: (currentTime) => {
|
||||||
|
set((state) => ({
|
||||||
|
dragState: {
|
||||||
|
...state.dragState,
|
||||||
|
currentTime,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
endDrag: () => {
|
||||||
|
set({
|
||||||
|
dragState: {
|
||||||
|
isDragging: false,
|
||||||
|
clipId: null,
|
||||||
|
trackId: null,
|
||||||
|
startMouseX: 0,
|
||||||
|
startClipTime: 0,
|
||||||
|
clickOffsetTime: 0,
|
||||||
|
currentTime: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
1
apps/web/src/types/editor.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export type BackgroundType = "blur" | "mirror" | "color";
|
@ -4,6 +4,8 @@ export interface PlaybackState {
|
|||||||
duration: number;
|
duration: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
speed: number;
|
speed: number;
|
||||||
|
muted: boolean;
|
||||||
|
previousVolume?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlaybackControls {
|
export interface PlaybackControls {
|
||||||
@ -13,4 +15,7 @@ export interface PlaybackControls {
|
|||||||
setVolume: (volume: number) => void;
|
setVolume: (volume: number) => void;
|
||||||
setSpeed: (speed: number) => void;
|
setSpeed: (speed: number) => void;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
|
mute: () => void;
|
||||||
|
unmute: () => void;
|
||||||
|
toggleMute: () => void;
|
||||||
}
|
}
|
@ -1,6 +1,7 @@
|
|||||||
export interface TProject {
|
export interface TProject {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
thumbnail: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: 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: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
screens: {
|
||||||
|
xs: "480px",
|
||||||
|
},
|
||||||
fontSize: {
|
fontSize: {
|
||||||
base: "0.95rem",
|
base: "0.95rem",
|
||||||
},
|
},
|
||||||
@ -69,7 +72,7 @@ export default {
|
|||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
md: "calc(var(--radius) - 2px)",
|
md: "calc(var(--radius) - 2px)",
|
||||||
sm: "calc(var(--radius) - 4px)",
|
sm: "calc(var(--radius) - 6px)",
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
"accordion-down": {
|
"accordion-down": {
|
||||||
|
3
bun.lock
@ -4,6 +4,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^15.3.4",
|
"next": "^15.3.4",
|
||||||
|
"wavesurfer.js": "^7.9.8",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"turbo": "^2.5.4",
|
"turbo": "^2.5.4",
|
||||||
@ -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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
@ -48,7 +48,7 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: ./apps/web
|
context: .
|
||||||
dockerfile: ./apps/web/Dockerfile
|
dockerfile: ./apps/web/Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
@ -1,11 +1,8 @@
|
|||||||
[build]
|
# Next.js plugin
|
||||||
base = "../.."
|
|
||||||
command = "bun install && bunx turbo build --filter=opencut"
|
|
||||||
publish = "apps/web/.next"
|
|
||||||
|
|
||||||
[[plugins]]
|
[[plugins]]
|
||||||
package = "@netlify/plugin-nextjs"
|
package = "@netlify/plugin-nextjs"
|
||||||
|
|
||||||
|
# Redirects for domain migration
|
||||||
[[redirects]]
|
[[redirects]]
|
||||||
from = "https://appcut.app/*"
|
from = "https://appcut.app/*"
|
||||||
to = "https://opencut.app/:splat"
|
to = "https://opencut.app/:splat"
|
@ -16,6 +16,7 @@
|
|||||||
"format": "turbo run format"
|
"format": "turbo run format"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^15.3.4"
|
"next": "^15.3.4",
|
||||||
|
"wavesurfer.js": "^7.9.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,15 +2,43 @@ import { drizzle } from "drizzle-orm/postgres-js";
|
|||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
|
|
||||||
|
// Create a lazy database instance that only initializes when accessed
|
||||||
|
let _db: ReturnType<typeof drizzle> | null = null;
|
||||||
|
|
||||||
|
function getDb() {
|
||||||
if (!process.env.DATABASE_URL) {
|
if (!process.env.DATABASE_URL) {
|
||||||
throw new Error("DATABASE_URL is not set");
|
throw new Error("DATABASE_URL is not set");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the postgres client
|
if (!_db) {
|
||||||
const client = postgres(process.env.DATABASE_URL);
|
const client = postgres(process.env.DATABASE_URL);
|
||||||
|
_db = drizzle(client, { schema });
|
||||||
|
}
|
||||||
|
|
||||||
// Create the drizzle instance
|
return _db;
|
||||||
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
|
// 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";
|
||||||
|