Compare commits

..
Author SHA1 Message Date
Maze Winther 6773009bd0 don't know 2026-01-30 20:38:01 +01:00
Maze Winther 10c39c6143 should i ship this? 2026-01-30 20:24:54 +01:00
Maze Winther 1fd74904e9 removed timeline anchor 2026-01-30 15:40:42 +01:00
Maze Winther d719460f32 save timeline view state 2026-01-30 15:08:43 +01:00
Maze Winther 488a9018b1 fix biome lints 2026-01-28 15:14:48 +01:00
Maze Winther 6d19841abf de-duplicate code 2026-01-28 15:12:14 +01:00
Maze Winther 9de4cee572 fix video node lag 2026-01-28 13:54:44 +01:00
Maze Winther 52733ae681 fix export cancel (don't show error) 2026-01-27 22:11:52 +01:00
Maze Winther 5705d09bf3 primary bg 2026-01-27 22:11:39 +01:00
Maze Winther 20f16aa2b4 fix zoom out 2026-01-27 21:58:53 +01:00
Maze Winther b6a8b67753 fix bookmarks issue 2026-01-27 21:14:01 +01:00
Maze Winther 63ee74c72b fix bookmarks bug 2026-01-27 20:36:54 +01:00
Maze Winther 8719f6db27 fix colors 2026-01-27 20:33:51 +01:00
Maze Winther 1d4aa18036 fix thumbnail generation 2026-01-27 20:19:18 +01:00
Maze Winther c71adc1525 formatting 2026-01-27 18:27:00 +01:00
Maze Winther 5d08b16867 continution from undo selection refactor 2026-01-27 17:52:29 +01:00
Maze Winther 9fc9a8ccc0 generate thumbnail from image elements 2026-01-27 17:51:56 +01:00
Maze Winther e38901fd66 refactor components structure 2026-01-27 17:26:53 +01:00
Maze Winther 94d4ee0c8c organize dialogs 2026-01-27 17:02:48 +01:00
Maze Winther 0a7dc295f8 finish export cancel feature 2026-01-27 16:58:38 +01:00
Maze Winther dd463ddc86 format files 2026-01-27 16:50:03 +01:00
Maze Winther fd5a53fc99 fix linter 2026-01-27 16:49:43 +01:00
Maze Winther 74c0ea7c4a random stuff 2026-01-27 16:48:38 +01:00
Maze Winther 091a5bbc57 formatting 2026-01-27 16:43:49 +01:00
Maze Winther d63f2625d8 another fix 2026-01-27 16:42:06 +01:00
Maze Winther 40e9fc12b7 fix 2026-01-27 16:41:03 +01:00
Maze Winther 1f8391487e we're basically done 2026-01-27 16:39:59 +01:00
Maze Winther af3100950e add escape route from projects page 2026-01-26 16:57:35 +01:00
Maze Winther 0c10bbf5ce projects page re-design and a ton of other stuff 2026-01-26 16:10:16 +01:00
Maze Winther 954160d03b lots more icons to do but 2026-01-24 15:53:46 +01:00
Maze Winther 33f453efc7 fix timeline zoom + selection box issues 2026-01-24 06:42:39 +01:00
Maze Winther 7de8e4172f i think we just fixed audio in the playback forever 2026-01-24 06:19:48 +01:00
Maze Winther bac5dc97dd that may not be saved dialog 2026-01-23 23:53:18 +01:00
Maze Winther de9b7a7350 refactor back to old icons system 2026-01-23 23:39:29 +01:00
Maze Winther 1d40795009 add stroke to icons 2026-01-23 23:30:58 +01:00
Maze Winther b5f305e4e7 fix all linter errors 2026-01-23 22:49:00 +01:00
Maze Winther cd0a8d020e update usage (icons + size-) 2026-01-23 21:25:51 +01:00
Maze Winther c6deceaa89 lots of stuff 2026-01-23 15:40:01 +01:00
Maze Winther b1701e1b0e why did we have this file 2026-01-21 14:58:03 +01:00
Maze Winther 9aeea153c7 woah 2026-01-21 14:57:40 +01:00
Maze Winther f3c91d5cb4 what am i doing 2026-01-21 14:54:12 +01:00
Maze Winther afdf7d22cf format all files 2026-01-21 12:52:34 +01:00
Maze Winther 8500dd770b uhh 2026-01-21 12:52:20 +01:00
Maze Winther e1d82eca09 shipping 2026-01-21 00:59:03 +01:00
Maze Winther 7f50de14fc move transcription to separate branch 2026-01-20 22:18:14 +01:00
Maze Winther 6e4069b42f shipping this slop 2026-01-20 22:14:49 +01:00
Maze Winther 7117f17ece new utility, less duplicated code 2026-01-18 09:16:47 +01:00
Maze Winther d260212b12 we're getting there 2026-01-18 08:46:14 +01:00
Maze Winther 981ac5e237 update deps 2026-01-18 06:26:55 +01:00
Maze Winther f18e9f4b4e fuck malware, fuck you 2026-01-18 05:44:25 +01:00
Maze Winther 0934db2aba holy cow 2026-01-16 18:07:28 +01:00
Maze Winther 0dddf4e13d move tools to separate branch 2026-01-15 11:50:42 +01:00
Maze Winther dba74fa20f yeah 2026-01-15 11:45:53 +01:00
Maze Winther deef784b76 stuff 2026-01-15 11:05:17 +01:00
Maze Winther c19f085e48 push 2026-01-05 01:35:10 +01:00
Maze Winther d5ca991501 yes 2025-12-25 21:15:36 +01:00
Maze Winther 2da84e7132 refactor packages, env, db and auth (prev commit) 2025-11-26 10:12:40 +01:00
Maze Winther 811491ba9c right before packages, env, db, and auth refactor
removing env validation, moving db and auth to each app
2025-11-26 09:42:44 +01:00
Maze Winther 04c09b439b add tools scripts to root package.json 2025-11-26 08:53:19 +01:00
Maze Winther 1b867e2b70 gitignore 2025-11-26 08:53:08 +01:00
Maze Winther 975560c28e remove files 2025-11-26 08:49:14 +01:00
Maze Winther efbebd13b8 refactor not done 2025-11-26 08:47:03 +01:00
455 changed files with 39043 additions and 18547 deletions
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
---
alwaysApply: true
---
# Comment Guidelines
## Good Comments (Human-style)
- Explain WHY, not WHAT
- Document non-obvious behavior or edge cases
- Warn about performance implications or side effects
- Explain business logic that isn't clear from code
Examples:
```javascript
// transfer, not copy; sender buffer detaches
// satisfies: check shape; keep literals
// keep multibyte across chunks
// timingSafeEqual throws on length mismatch
```
## Bad Comments (AI-style)
- Don't explain what the code literally does
- Don't add changelog-style comments in code
- Don't comment every line or obvious operations
Avoid:
```javascript
// Prevent duplicate initialization
// Check if project is already loaded
// Mark as initializing to prevent race conditions
// (changed from blah to blah)
```
## Rule
Only add comments when there's genuinely non-obvious behavior, performance considerations, or business logic that needs context. Code should be self-documenting through naming and structure.
+21
View File
@@ -0,0 +1,21 @@
---
alwaysApply: true
---
# Handling Uncertainty
## Principle
If you can't confidently respond due to missing context, data access, or ambiguity (multiple interpretations), report it instead of guessing. Seek clarification to avoid errors.
Apply when: query lacks details, no access to info/tools, or unclear intent.
## How to Report
1. **Description**: Why uncertain and what you need.
2. **Questions**: 1-3 targeted ones.
3. **Assumptions** (opt.): State if proceeding; omit otherwise.
Direct and concise.
**Assumptions**: None.
Builds transparency.
+9
View File
@@ -0,0 +1,9 @@
---
alwaysApply: true
---
# Readability First
Optimize code for AI agents to understand and modify.
Never abbreviate. `event` not `e`, `element` not `el`. If it's easy to read, it's correct. In this case, "config" is better than "configuration" because it's shorter and is **still very readable**. "El" is not very readable.
+52
View File
@@ -0,0 +1,52 @@
---
alwaysApply: true
---
# Separation of Concerns
## Core Principle
Each file should have one single purpose/responsibility. Related functionality should be grouped together, unrelated functionality should be separated.
## Good Separation
- One file per major concern (auth, validation, data transformation)
- Group related utilities together
- Extract shared logic into dedicated files
- Keep API routes focused on their specific endpoint logic
Examples:
```javascript
// ✅ Good: Each file has clear responsibility
/lib/rate-limit.ts // Rate limiting utilities
/lib/validation.ts // Input validation schemas
/lib/freesound-api.ts // External API integration
/api/sounds/search/route.ts // Route handler only
```
## Bad Mixing of Concerns
Avoid cramming multiple responsibilities into one file:
```javascript
// ❌ Bad: Route file doing everything
/api/sounds/search/route.ts
- Rate limiting logic
- Validation schemas
- API transformation
- External API calls
- Response formatting
- Error handling utilities
```
## When to Separate
- File is getting long (>500 lines)
- Multiple distinct responsibilities in one file
- Logic could be reused elsewhere
- Complex utilities that distract from main purpose
## Rule
One file, one responsibility. Extract shared concerns into focused utility files
+105
View File
@@ -0,0 +1,105 @@
---
description: Ultracite Rules - AI-Ready Formatter and Linter
globs: "**/*.{ts,tsx,js,jsx}"
alwaysApply: true
---
# Project Context
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's formatter.
## Key Principles
- Zero configuration required
- Subsecond performance
- Maximum type safety
- AI-friendly code generation
## Before Writing Code
1. Analyze existing patterns in the codebase
2. Consider edge cases and error scenarios
3. Follow the rules below strictly
4. Validate accessibility requirements
5. Avoid code duplication
## Rules
### Accessibility (a11y)
- Always include a `title` element for icons unless there's text beside the icon.
- Always include a `type` attribute for button elements.
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
### Code Complexity and Quality
- Don't use primitive type aliases or misleading types.
- Don't use the comma operator.
- Use for...of statements instead of Array.forEach.
- Don't initialize variables to undefined.
- Use .flatMap() instead of map().flat() when possible.
### React and JSX Best Practices
- Don't import `React` itself.
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
- Don't insert comments as text nodes.
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
### Function Parameters and Props
- Always use destructured props objects instead of individual parameters in functions.
- Example: `function helloWorld({ prop }: { prop: string })` instead of `function helloWorld(param: string)`.
- This applies to all functions, not just React components.
### Correctness and Safety
- Don't assign a value to itself.
- Avoid unused imports and variables.
- Don't use await inside loops.
- Don't hardcode sensitive data like API keys and tokens.
- Don't use the TypeScript directive @ts-ignore.
- Make sure the `preconnect` attribute is used when using Google Fonts.
- Don't use the `delete` operator.
- Don't use `require()` in TypeScript/ES modules - use proper `import` statements.
### TypeScript Best Practices
- Don't use TypeScript enums.
- Use either `T[]` or `Array<T>` consistently.
- Don't use the `any` type.
### Style and Consistency
- Don't use global `eval()`.
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
- Don't use `else` blocks when the `if` block breaks early.
- Put default function parameters and optional function parameters last.
- Use `new` when throwing an error.
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
### Next.js Specific Rules
- Don't use `<img>` elements in Next.js projects.
- Don't use `<head>` elements in Next.js projects.
## Example: Error Handling
```typescript
// ✅ Good: Comprehensive error handling
try {
const result = await fetchData();
return { success: true, data: result };
} catch (error) {
console.error("API call failed:", error);
return { success: false, error: error.message };
}
// ❌ Bad: Swallowing errors
try {
return await fetchData();
} catch (e) {
console.log(e);
}
```
+53
View File
@@ -0,0 +1,53 @@
---
alwaysApply: true
---
# Scannable Code Guidelines/Separating Concerns.
## Core Principle
Code should be scannable through proper abstraction, not comments. Use variables and helper functions to make intent clear at a glance.
## Good Scannable Code
- Extract complex logic into well-named variables
- Create helper functions for multi-step operations
- Use descriptive names that explain intent
Examples:
```javascript
// ✅ Scannable: Intent is clear from variable names
const isValidUser = user.isActive && user.hasPermissions;
const shouldProcessPayment = amount > 0 && !order.isPaid;
// ✅ Scannable: Complex logic extracted to helper
const searchParams = buildFreesoundSearchParams({ query, filters, pagination });
const transformedResults = transformFreesoundResults({ rawResults });
```
## Bad Unscannable Code
Avoid:
```javascript
// ❌ Hard to scan: What does this condition mean?
if (type === "effects" || !type) {
params.append("filter", "duration:[* TO 30.0]");
params.append("filter", `avg_rating:[${min_rating} TO *]`);
if (commercial_only) {
params.append("filter", 'license:("Attribution" OR "Creative Commons 0")');
}
}
// ❌ Hard to scan: Complex ternary
const sortParam = query
? sort === "score"
? "score"
: `${sort}_desc`
: `${sort}_desc`;
```
## Rule
Make code scannable by extracting intent into variables and helper functions. If you need to think about what code does, extract it. The reader should understand the flow without diving into implementation details.
-1
View File
@@ -1 +0,0 @@
!apps/web/.env.example
+12 -13
View File
@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
- Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
- The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
@@ -59,8 +59,7 @@ representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
[INSERT CONTACT METHOD].
reported to the community leaders responsible for enforcement at our discord server.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
@@ -87,4 +86,4 @@ version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+2 -2
View File
@@ -98,11 +98,11 @@ If you're unsure whether your idea falls into the preview category, feel free to
```bash
# Database (matches docker-compose.yaml)
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut"
# Generate a secure secret for Better Auth
BETTER_AUTH_SECRET="your-generated-secret-here"
NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000"
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
# Redis (matches docker-compose.yaml)
UPSTASH_REDIS_REST_URL="http://localhost:8079"
+3 -3
View File
@@ -1,6 +1,6 @@
name: Bug report
description: Create a report to help us improve
title: '[BUG] '
title: "[BUG] "
labels: bug
body:
- type: input
@@ -15,7 +15,7 @@ body:
id: Browser
attributes:
label: Browser
description: Please enter the browser on which you encountered the bug.
description: Please enter the browser on which you encountered the bug.
placeholder: e.g. Chrome 137, Firefox 137, Safari 17
validations:
required: true
@@ -67,4 +67,4 @@ body:
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
required: false
+2 -2
View File
@@ -1,6 +1,6 @@
name: Feature request
description: Suggest an idea for OpenCut
title: '[FEATURE] '
title: "[FEATURE] "
labels: enhancement
body:
- type: markdown
@@ -39,4 +39,4 @@ body:
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
required: false
+1 -1
View File
@@ -25,4 +25,4 @@ Please do not report security vulnerabilities through public GitHub issues.
- We will provide a detailed response within 5 business days
- We will keep you updated on our progress
Thank you for helping keep OpenCut secure!
Thank you for helping keep OpenCut secure!
+5 -1
View File
@@ -3,21 +3,25 @@
Thanks for using OpenCut! If you need help, here are your options:
## Documentation
- Check our [README](../README.md) for basic setup instructions
- Review the [Contributing Guidelines](CONTRIBUTING.md) for development setup
## Issues
- **Bug reports**: Use the bug report template
- **Feature requests**: Use the feature request template
- **Questions**: Use GitHub Discussions for general questions
## Community
- Join our discussions on GitHub
- Follow the [Code of Conduct](CODE_OF_CONDUCT.md)
## Response Times
- Issues are typically triaged within 2-3 business days
- Feature requests may take longer to evaluate
- Security issues are handled with priority
We appreciate your patience and contributions to making OpenCut better!
We appreciate your patience and contributions to making OpenCut better!
+18 -5
View File
@@ -3,15 +3,18 @@ applyTo: "**/*.{ts,tsx,js,jsx}"
---
# Project Context
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter.
## Key Principles
- Zero configuration required
- Subsecond performance
- Maximum type safety
- AI-friendly code generation
## Before Writing Code
1. Analyze existing patterns in the codebase
2. Consider edge cases and error scenarios
3. Follow the rules below strictly
@@ -20,6 +23,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
## Rules
### Accessibility (a11y)
- Don't use `accessKey` attribute on any HTML element.
- Don't set `aria-hidden="true"` on focusable elements.
- Don't add ARIA roles, states, and properties to elements that don't support them.
@@ -56,6 +60,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
- Use correct ISO language/country codes for the `lang` attribute.
### Code Complexity and Quality
- Don't use consecutive spaces in regular expression literals.
- Don't use the `arguments` object.
- Don't use primitive type aliases or misleading types.
@@ -111,6 +116,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
- Don't use literal numbers that lose precision.
### React and JSX Best Practices
- Don't use the return value of React.render.
- Make sure all dependencies are correctly specified in React hooks.
- Make sure all React hooks are called from the top level of component functions.
@@ -129,6 +135,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
- Watch out for possible "wrong" semicolons inside JSX elements.
### Correctness and Safety
- Don't assign a value to itself.
- Don't return a value from a setter.
- Don't compare expressions that modify string case with non-compliant values.
@@ -153,7 +160,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
- Don't use bitwise operators.
- Don't use expressions where the operation doesn't change the value.
- Make sure Promise-like statements are handled appropriately.
- Don't use __dirname and __filename in the global scope.
- Don't use **dirname and **filename in the global scope.
- Prevent import cycles.
- Don't use configured elements.
- Don't hardcode sensitive data like API keys and tokens.
@@ -184,6 +191,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
- Don't use `target="_blank"` without `rel="noopener"`.
### TypeScript Best Practices
- Don't use TypeScript enums.
- Don't export imported variables.
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
@@ -208,6 +216,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
### Style and Consistency
- Don't use global `eval()`.
- Don't use callbacks in asynchronous tests and hooks.
- Don't use negation in `if` statements that have `else` clauses.
@@ -295,30 +304,34 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
- Make sure to use the "use strict" directive in script files.
### Next.js Specific Rules
- Don't use `<img>` elements in Next.js projects.
- Don't use `<head>` elements in Next.js projects.
- Don't import next/document outside of pages/_document.jsx in Next.js projects.
- Don't use the next/head module in pages/_document.js on Next.js projects.
- Don't import next/document outside of pages/\_document.jsx in Next.js projects.
- Don't use the next/head module in pages/\_document.js on Next.js projects.
### Testing Best Practices
- Don't use export or module.exports in test files.
- Don't use focused tests.
- Make sure the assertion function, like expect, is placed inside an it() function call.
- Don't use disabled tests.
## Common Tasks
- `npx ultracite init` - Initialize Ultracite in your project
- `npx ultracite format` - Format and fix code automatically
- `npx ultracite lint` - Check for issues without fixing
## Example: Error Handling
```typescript
// ✅ Good: Comprehensive error handling
try {
const result = await fetchData();
return { success: true, data: result };
} catch (error) {
console.error('API call failed:', error);
console.error("API call failed:", error);
return { success: false, error: error.message };
}
@@ -328,4 +341,4 @@ try {
} catch (e) {
console.log(e);
}
```
```
+5 -4
View File
@@ -24,9 +24,10 @@ Please describe the tests that you ran to verify your changes. Provide instructi
- [ ] Test B
**Test Configuration**:
* Node version:
* Browser (if applicable):
* Operating System:
- Node version:
- Browser (if applicable):
- Operating System:
## Screenshots (if applicable)
@@ -46,4 +47,4 @@ Add screenshots to help explain your changes.
## Additional context
Add any other context about the pull request here.
Add any other context about the pull request here.
+2 -2
View File
@@ -22,9 +22,9 @@ jobs:
os: [ubuntu-latest, windows-latest, macos-latest]
env:
DATABASE_URL: "postgresql://opencut:opencutthegoat@localhost:5432/opencut"
DATABASE_URL: "postgresql://opencut:opencut@localhost:5432/opencut"
BETTER_AUTH_SECRET: "supersecret"
NEXT_PUBLIC_BETTER_AUTH_URL: "http://localhost:3000"
NEXT_PUBLIC_SITE_URL: "http://localhost:3000"
UPSTASH_REDIS_REST_URL: "https://your-upstash-redis-url"
UPSTASH_REDIS_REST_TOKEN: "your-upstash-redis-token"
+5 -20
View File
@@ -1,22 +1,5 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/apps/web/node_modules
# next.js
/apps/web/.next/
/apps/web/out/
# debug
/apps/web/npm-debug.log*
# env files (can opt-in for committing if needed)
/apps/web/.env*
!/apps/web/.env.example
# typescript
/apps/web/next-env.d.ts
# asdf version management
.tool-versions
@@ -24,9 +7,11 @@ node_modules
.cursorignore
.turbo
*.env
.env*
!*.env.example
# cursor
bun.lockb
.cursor/
bun.lockb
# Twiggy
.cursor/rules/file-structure.mdc
-1
View File
@@ -1 +0,0 @@
bunx commitlint --edit $1
-1
View File
@@ -1 +0,0 @@
bunx ultracite format
+20
View File
@@ -0,0 +1,20 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][css][graphql]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"emmet.showExpandedAbbreviation": "never",
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+121
View File
@@ -0,0 +1,121 @@
# AGENTS.md
## Overview
Privacy-first video editor, with a focus on simplicity and ease of use.
## Lib vs Utils
- `lib/` - domain logic (specific to this app)
- `utils/` - small helper utils (generic, could be copy-pasted into any other app)
## Core Editor System
The editor uses a **singleton EditorCore** that manages all editor state through specialized managers.
### Architecture
```
EditorCore (singleton)
├── playback: PlaybackManager
├── timeline: TimelineManager
├── scene: SceneManager
├── project: ProjectManager
├── media: MediaManager
└── renderer: RendererManager
```
### When to Use What
#### In React Components
**Always use the `useEditor()` hook:**
```typescript
import { useEditor } from '@/hooks/use-editor';
function MyComponent() {
const editor = useEditor();
const tracks = editor.timeline.getTracks();
// Call methods
editor.timeline.addTrack({ type: 'media' });
// Display data (auto re-renders on changes)
return <div>{tracks.length} tracks</div>;
}
```
The hook:
- Returns the singleton instance
- Subscribes to all manager changes
- Automatically re-renders when state changes
#### Outside React Components
**Use `EditorCore.getInstance()` directly:**
```typescript
// In utilities, event handlers, or non-React code
import { EditorCore } from "@/core";
const editor = EditorCore.getInstance();
await editor.export({ format: "mp4", quality: "high" });
```
## Actions System
Actions are the trigger layer for user-initiated operations. The single source of truth is `@/lib/actions/definitions.ts`.
**To add a new action:**
1. Add it to `ACTIONS` in `@/lib/actions/definitions.ts`:
```typescript
export const ACTIONS = {
"my-action": {
description: "What the action does",
category: "editing",
defaultShortcuts: ["ctrl+m"],
},
// ...
};
```
2. Add handler in `@/hooks/use-editor-actions.ts`:
```typescript
useActionHandler(
"my-action",
() => {
// implementation
},
undefined,
);
```
**In components, use `invokeAction()` for user-triggered operations:**
```typescript
import { invokeAction } from '@/lib/actions';
// Good - uses action system
const handleSplit = () => invokeAction("split-selected");
// Avoid - bypasses UX layer (toasts, validation feedback)
const handleSplit = () => editor.timeline.splitElements({ ... });
```
Direct `editor.xxx()` calls are for internal use (commands, tests, complex multi-step operations).
## Commands System
Commands handle undo/redo. They live in `@/lib/commands/` organized by domain (timeline, media, scene).
Each command extends `Command` from `@/lib/commands/base-command` and implements:
- `execute()` - saves current state, then does the mutation
- `undo()` - restores the saved state
Actions and commands work together: actions are "what triggered this", commands are "how to do it (and undo it)".
+6 -6
View File
@@ -1,7 +1,7 @@
Copyright 2025 OpenCut
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
Copyright 2025 OpenCut
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+10 -4
View File
@@ -1,10 +1,10 @@
<table width="100%">
<tr>
<td align="left" width="120">
<img src="apps/web/public/logo.png" alt="OpenCut Logo" width="100" />
<img src="apps/web/public/logos/opencut/1k/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>
<h1>OpenCut</span></h1>
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
</td>
</tr>
@@ -104,7 +104,7 @@ Before you begin, ensure you have the following installed on your system:
```bash
# Database (matches docker-compose.yaml)
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut"
# Generate a secure secret for Better Auth
BETTER_AUTH_SECRET="your-generated-secret-here"
@@ -160,12 +160,18 @@ See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instruc
## Sponsors
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
<a href="https://vercel.com/oss">
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
</a>
<a href="https://fal.ai">
<img alt="Powered by fal.ai" src="https://img.shields.io/badge/Powered%20by-fal.ai-000000?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" />
</a>
---
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FOpenCut-app%2FOpenCut&project-name=opencut&repository-name=opencut)
## License
+23 -14
View File
@@ -1,19 +1,28 @@
# Environment Variables Example
# Environment variables example
# Copy this file to .env.local and update the values as needed
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
# Better Auth
NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=your-secret-key-here
# Development Environment
# Node
NODE_ENV=development
# Redis
UPSTASH_REDIS_REST_URL=http://localhost:8079
UPSTASH_REDIS_REST_TOKEN=example_token
# Public
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
# Marble Blog
MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key
NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
# Server
DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut"
BETTER_AUTH_SECRET=your_better_auth_secret
UPSTASH_REDIS_REST_URL=http://localhost:8079
UPSTASH_REDIS_REST_TOKEN=example_token_here
MARBLE_WORKSPACE_KEY=your_workspace_key_here
FREESOUND_CLIENT_ID=your_client_id_here
FREESOUND_API_KEY=your_api_key_here
CLOUDFLARE_ACCOUNT_ID=your_account_id_here
R2_ACCESS_KEY_ID=your_access_key_here
R2_SECRET_ACCESS_KEY=your_secret_key_here
R2_BUCKET_NAME=opencut-transcription # whatever you named your r2 bucket
MODAL_TRANSCRIPTION_URL=your_modal_url_here
+7 -1
View File
@@ -1,2 +1,8 @@
# Turborepo
.turbo
.turbo
# Env vars
.env*
!.env.example
.next/
-57
View File
@@ -1,57 +0,0 @@
FROM oven/bun:alpine AS base
# Install dependencies and build the application
FROM base AS builder
WORKDIR /app
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
# Set build-time environment variables for validation
ENV DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
ENV BETTER_AUTH_SECRET="build-time-secret"
ENV UPSTASH_REDIS_REST_URL="http://localhost:8079"
ENV UPSTASH_REDIS_REST_TOKEN="example_token"
ENV NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000"
WORKDIR /app/apps/web
RUN bun run build
# Production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
RUN chown nextjs:nodejs apps
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["bun", "apps/web/server.js"]
+1 -1
View File
@@ -4,7 +4,7 @@
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
+15 -11
View File
@@ -1,19 +1,23 @@
import * as dotenv from "dotenv";
import type { Config } from "drizzle-kit";
import * as dotenv from "dotenv";
import { webEnv } from "@opencut/env/web";
// Load the right env file based on environment
if (process.env.NODE_ENV === "production") {
dotenv.config({ path: ".env.production" });
if (webEnv.NODE_ENV === "production") {
dotenv.config({ path: ".env.production" });
} else {
dotenv.config({ path: ".env.local" });
dotenv.config({ path: ".env.local" });
}
export default {
schema: "../../packages/db/src/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
out: "./migrations",
strict: process.env.NODE_ENV === "production",
schema: "./src/schema.ts",
dialect: "postgresql",
migrations: {
table: "drizzle_migrations",
},
dbCredentials: {
url: webEnv.DATABASE_URL,
},
out: "./migrations",
strict: webEnv.NODE_ENV === "production",
} satisfies Config;
@@ -1,54 +0,0 @@
CREATE TABLE "accounts" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
ALTER TABLE "accounts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE TABLE "sessions" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "sessions_token_unique" UNIQUE("token")
);
--> statement-breakpoint
ALTER TABLE "sessions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE TABLE "users" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean NOT NULL,
"image" text,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "users" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE TABLE "verifications" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp,
"updated_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "verifications" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
@@ -1,8 +0,0 @@
CREATE TABLE "waitlist" (
"id" text PRIMARY KEY NOT NULL,
"email" text NOT NULL,
"created_at" timestamp NOT NULL,
CONSTRAINT "waitlist_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "waitlist" ENABLE ROW LEVEL SECURITY;
@@ -1 +0,0 @@
ALTER TABLE "users" ALTER COLUMN "email_verified" SET DEFAULT false;
+38 -1
View File
@@ -1,5 +1,5 @@
{
"id": "4440fb90-cf0e-4cb2-afad-08250ce3dc1e",
"id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -291,6 +291,43 @@
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.waitlist": {
"name": "waitlist",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"waitlist_email_unique": {
"name": "waitlist_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
}
},
"enums": {},
-344
View File
@@ -1,344 +0,0 @@
{
"id": "b7d920ca-6dd0-430f-8ee6-1d38fdf3e80f",
"prevId": "4440fb90-cf0e-4cb2-afad-08250ce3dc1e",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sessions_token_unique": {
"name": "sessions_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.verifications": {
"name": "verifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.waitlist": {
"name": "waitlist",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"waitlist_email_unique": {
"name": "waitlist_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-345
View File
@@ -1,345 +0,0 @@
{
"id": "1b9148ed-497b-4e53-8cf6-f556c8fe7f7f",
"prevId": "b7d920ca-6dd0-430f-8ee6-1d38fdf3e80f",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sessions_token_unique": {
"name": "sessions_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.verifications": {
"name": "verifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.waitlist": {
"name": "waitlist",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"waitlist_email_unique": {
"name": "waitlist_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+2 -16
View File
@@ -5,22 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1750581188229,
"tag": "0000_hot_the_fallen",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1750689835736,
"tag": "0001_tricky_jackpot",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1752569400809,
"tag": "0002_cuddly_pretty_boy",
"when": 1750753385927,
"tag": "0000_brainy_saracen",
"breakpoints": true
}
]
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+43 -31
View File
@@ -1,37 +1,49 @@
import { withBotId } from "botid/next/config";
import type { NextConfig } from "next";
import { withBotId } from "botid/next/config";
const nextConfig: NextConfig = {
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
reactStrictMode: true,
productionBrowserSourceMaps: true,
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "plus.unsplash.com",
},
{
protocol: "https",
hostname: "images.unsplash.com",
},
{
protocol: "https",
hostname: "images.marblecms.com",
},
{
protocol: "https",
hostname: "lh3.googleusercontent.com",
},
{
protocol: "https",
hostname: "avatars.githubusercontent.com",
},
],
},
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
reactStrictMode: true,
productionBrowserSourceMaps: true,
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "plus.unsplash.com",
},
{
protocol: "https",
hostname: "images.unsplash.com",
},
{
protocol: "https",
hostname: "images.marblecms.com",
},
{
protocol: "https",
hostname: "lh3.googleusercontent.com",
},
{
protocol: "https",
hostname: "avatars.githubusercontent.com",
},
{
protocol: "https",
hostname: "api.iconify.design",
},
{
protocol: "https",
hostname: "api.simplesvg.com",
},
{
protocol: "https",
hostname: "api.unisvg.com",
},
],
},
};
export default withBotId(nextConfig);
+27 -14
View File
@@ -1,10 +1,10 @@
{
"name": "opencut",
"name": "@opencut/web",
"version": "0.1.0",
"private": true,
"packageManager": "bun@1.2.18",
"scripts": {
"dev": "next dev",
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "biome check src/",
@@ -21,31 +21,39 @@
"@ffmpeg/util": "^0.12.2",
"@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^3.9.1",
"@opencut/auth": "workspace:*",
"@opencut/db": "workspace:*",
"@radix-ui/react-separator": "^1.1.7",
"@t3-oss/env-core": "^0.13.8",
"@t3-oss/env-nextjs": "^0.13.8",
"@upstash/ratelimit": "^2.0.5",
"@upstash/redis": "^1.35.0",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@upstash/ratelimit": "^2.0.6",
"@upstash/redis": "^1.35.4",
"@vercel/analytics": "^1.4.1",
"aws4fetch": "^1.0.20",
"better-auth": "^1.2.7",
"botid": "^1.4.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"dayjs": "^1.11.13",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.44.2",
"embla-carousel-react": "^8.5.1",
"eventemitter3": "^5.0.1",
"feed": "^5.1.0",
"framer-motion": "^11.13.1",
"input-otp": "^1.4.1",
"lucide-react": "^0.468.0",
"lucide-react": "^0.562.0",
"mediabunny": "^1.29.1",
"motion": "^12.18.1",
"next": "^15.4.3",
"nanoid": "^5.1.5",
"next": "16.1.3",
"next-themes": "^0.4.4",
"pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.2",
"react": "^18.2.0",
"react-day-picker": "^8.10.1",
@@ -65,20 +73,25 @@
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1",
"vaul": "^1.1.1",
"wavesurfer.js": "^7.9.8",
"zod": "^3.25.67",
"zustand": "^5.0.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@types/bun": "latest",
"@types/node": "^24.2.1",
"@types/pg": "^8.15.4",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.31.4",
"dotenv": "^16.5.0",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"tailwindcss": "^4.1.11",
"tsx": "^4.7.1",
"typescript": "^5.8.3"
}
+1 -1
View File
@@ -1,7 +1,7 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
"@tailwindcss/postcss": {},
},
};
File diff suppressed because one or more lines are too long
-10
View File
@@ -1,10 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 716 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Before

Width:  |  Height:  |  Size: 362 B

After

Width:  |  Height:  |  Size: 362 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="512" height="513" viewBox="0 0 512 513" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M330.144 0.740479C339.014 0.740479 346.122 7.95256 346.971 16.7816C354.534 95.534 417.207 158.206 495.959 165.769C504.788 166.618 512 173.726 512 182.596V330.886C512 339.755 504.788 346.863 495.959 347.712C417.207 355.275 354.534 417.947 346.971 496.7C346.122 505.529 339.014 512.74 330.144 512.74H181.856C172.986 512.74 165.878 505.529 165.029 496.7C157.467 417.947 94.7934 355.275 16.041 347.712C7.21212 346.863 0 339.755 0 330.886V182.596C0 173.726 7.21216 166.618 16.0411 165.769C94.7935 158.206 157.467 95.534 165.029 16.7816C165.878 7.95256 172.986 0.740479 181.856 0.740479H330.144ZM102.8 256.307C102.8 341.495 171.781 410.554 256.874 410.554C341.966 410.554 410.948 341.495 410.948 256.307C410.948 171.119 341.966 102.06 256.874 102.06C171.781 102.06 102.8 171.119 102.8 256.307Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 960 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="588" height="512" viewBox="0 0 588 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M294 0L588 512H0L294 0Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 156 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

-146
View File
@@ -1,146 +0,0 @@
"use client";
import { ArrowLeft, Loader2 } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { memo, Suspense } from "react";
import { GoogleIcon } from "@/components/icons";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { useLogin } from "@/hooks/auth/useLogin";
const LoginPage = () => {
const router = useRouter();
const {
email,
setEmail,
password,
setPassword,
error,
isAnyLoading,
isEmailLoading,
isGoogleLoading,
handleLogin,
handleGoogleLogin,
} = useLogin();
return (
<div className="relative flex h-screen items-center justify-center">
<Button
className="absolute top-6 left-6"
onClick={() => router.back()}
variant="text"
>
<ArrowLeft className="h-5 w-5" /> Back
</Button>
<Card className="w-[400px] border-0 shadow-lg">
<CardHeader className="pb-4 text-center">
<CardTitle className="font-semibold text-2xl">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">
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
disabled={isAnyLoading}
onClick={handleGoogleLogin}
size="lg"
variant="outline"
>
{isGoogleLoading ? (
<Loader2 className="animate-spin" />
) : (
<GoogleIcon />
)}{" "}
Continue with Google
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator className="w-full" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
className="h-11"
disabled={isAnyLoading}
id="email"
onChange={(e) => setEmail(e.target.value)}
placeholder="m@example.com"
type="email"
value={email}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
className="h-11"
disabled={isAnyLoading}
id="password"
onChange={(e) => setPassword(e.target.value)}
type="password"
value={password}
/>
</div>
<Button
className="h-11 w-full"
disabled={isAnyLoading || !email || !password}
onClick={handleLogin}
size="lg"
>
{isEmailLoading ? (
<Loader2 className="animate-spin" />
) : (
"Sign in"
)}
</Button>
</div>
</div>
<div className="mt-6 text-center text-sm">
Don't have an account?{" "}
<Link
className="font-medium text-primary underline-offset-4 hover:underline"
href="/signup"
>
Sign up
</Link>
</div>
</Suspense>
</CardContent>
</Card>
</div>
);
};
export default memo(LoginPage);
-162
View File
@@ -1,162 +0,0 @@
"use client";
import { ArrowLeft, Loader2 } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { memo, Suspense } from "react";
import { GoogleIcon } from "@/components/icons";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { useSignUp } from "@/hooks/auth/useSignUp";
const SignUpPage = () => {
const router = useRouter();
const {
name,
setName,
email,
setEmail,
password,
setPassword,
error,
isAnyLoading,
isEmailLoading,
isGoogleLoading,
handleSignUp,
handleGoogleSignUp,
} = useSignUp();
return (
<div className="relative flex h-screen items-center justify-center">
<Button
className="absolute top-6 left-6"
onClick={() => router.back()}
variant="text"
>
<ArrowLeft className="h-5 w-5" /> Back
</Button>
<Card className="w-[400px] border-0 shadow-lg">
<CardHeader className="pb-4 text-center">
<CardTitle className="font-semibold text-2xl">
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">
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
disabled={isAnyLoading}
onClick={handleGoogleSignUp}
size="lg"
variant="outline"
>
{isGoogleLoading ? (
<Loader2 className="animate-spin" />
) : (
<GoogleIcon />
)}{" "}
Continue with Google
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator className="w-full" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
className="h-11"
disabled={isAnyLoading}
id="name"
onChange={(e) => setName(e.target.value)}
placeholder="John Doe"
type="text"
value={name}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
className="h-11"
disabled={isAnyLoading}
id="email"
onChange={(e) => setEmail(e.target.value)}
placeholder="m@example.com"
type="email"
value={email}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
className="h-11"
disabled={isAnyLoading}
id="password"
onChange={(e) => setPassword(e.target.value)}
placeholder="Create a strong password"
type="password"
value={password}
/>
</div>
<Button
className="h-11 w-full"
disabled={isAnyLoading || !name || !email || !password}
onClick={handleSignUp}
size="lg"
>
{isEmailLoading ? (
<Loader2 className="animate-spin" />
) : (
"Create account"
)}
</Button>
</div>
</div>
<div className="mt-6 text-center text-sm">
Already have an account?{" "}
<Link
className="font-medium text-primary underline-offset-4 hover:underline"
href="/login"
>
Sign in
</Link>
</div>
</Suspense>
</CardContent>
</Card>
</div>
);
};
export default memo(SignUpPage);
+1 -1
View File
@@ -1,4 +1,4 @@
import { auth } from "@opencut/auth";
import { auth } from "@/lib/auth/server";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);
+1 -3
View File
@@ -1,5 +1,3 @@
import type { NextRequest } from "next/server";
export async function GET(_request: NextRequest) {
export async function GET() {
return new Response("OK", { status: 200 });
}
+280
View File
@@ -0,0 +1,280 @@
import { webEnv } from "@opencut/env/web";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit";
const searchParamsSchema = z.object({
q: z.string().max(500, "Query too long").optional(),
type: z.enum(["songs", "effects"]).optional(),
page: z.coerce.number().int().min(1).max(1000).default(1),
page_size: z.coerce.number().int().min(1).max(150).default(20),
sort: z
.enum(["downloads", "rating", "created", "score"])
.default("downloads"),
min_rating: z.coerce.number().min(0).max(5).default(3),
commercial_only: z.coerce.boolean().default(true),
});
const freesoundResultSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
url: z.string().url(),
previews: z
.object({
"preview-hq-mp3": z.string().url(),
"preview-lq-mp3": z.string().url(),
"preview-hq-ogg": z.string().url(),
"preview-lq-ogg": z.string().url(),
})
.optional(),
download: z.string().url().optional(),
duration: z.number(),
filesize: z.number(),
type: z.string(),
channels: z.number(),
bitrate: z.number(),
bitdepth: z.number(),
samplerate: z.number(),
username: z.string(),
tags: z.array(z.string()),
license: z.string(),
created: z.string(),
num_downloads: z.number().optional(),
avg_rating: z.number().optional(),
num_ratings: z.number().optional(),
});
const freesoundResponseSchema = z.object({
count: z.number(),
next: z.string().url().nullable(),
previous: z.string().url().nullable(),
results: z.array(freesoundResultSchema),
});
const transformedResultSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
url: z.string(),
previewUrl: z.string().optional(),
downloadUrl: z.string().optional(),
duration: z.number(),
filesize: z.number(),
type: z.string(),
channels: z.number(),
bitrate: z.number(),
bitdepth: z.number(),
samplerate: z.number(),
username: z.string(),
tags: z.array(z.string()),
license: z.string(),
created: z.string(),
downloads: z.number().optional(),
rating: z.number().optional(),
ratingCount: z.number().optional(),
});
const apiResponseSchema = z.object({
count: z.number(),
next: z.string().nullable(),
previous: z.string().nullable(),
results: z.array(transformedResultSchema),
query: z.string().optional(),
type: z.string(),
page: z.number(),
pageSize: z.number(),
sort: z.string(),
minRating: z.number().optional(),
});
function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
if (!query) return `${sort}_desc`;
return sort === "score" ? "score" : `${sort}_desc`;
}
function applyEffectsFilters({
params,
min_rating,
commercial_only,
}: {
params: URLSearchParams;
min_rating: number;
commercial_only: boolean;
}) {
params.append("filter", "duration:[* TO 30.0]");
params.append("filter", `avg_rating:[${min_rating} TO *]`);
if (commercial_only) {
params.append(
"filter",
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
);
}
params.append(
"filter",
"tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
);
}
function transformFreesoundResult(
result: z.infer<typeof freesoundResultSchema>,
) {
return {
id: result.id,
name: result.name,
description: result.description,
url: result.url,
previewUrl:
result.previews?.["preview-hq-mp3"] ||
result.previews?.["preview-lq-mp3"],
downloadUrl: result.download,
duration: result.duration,
filesize: result.filesize,
type: result.type,
channels: result.channels,
bitrate: result.bitrate,
bitdepth: result.bitdepth,
samplerate: result.samplerate,
username: result.username,
tags: result.tags,
license: result.license,
created: result.created,
downloads: result.num_downloads || 0,
rating: result.avg_rating || 0,
ratingCount: result.num_ratings || 0,
};
}
export async function GET(request: NextRequest) {
try {
const { limited } = await checkRateLimit({ request });
if (limited) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
const { searchParams } = new URL(request.url);
const validationResult = searchParamsSchema.safeParse({
q: searchParams.get("q") || undefined,
type: searchParams.get("type") || undefined,
page: searchParams.get("page") || undefined,
page_size: searchParams.get("page_size") || undefined,
sort: searchParams.get("sort") || undefined,
min_rating: searchParams.get("min_rating") || undefined,
});
if (!validationResult.success) {
return NextResponse.json(
{
error: "Invalid parameters",
details: validationResult.error.flatten().fieldErrors,
},
{ status: 400 },
);
}
const {
q: query,
type,
page,
page_size: pageSize,
sort,
min_rating,
commercial_only,
} = validationResult.data;
if (type === "songs") {
return NextResponse.json(
{
error: "Songs are not available yet",
message:
"Song search functionality is coming soon. Try searching for sound effects instead.",
},
{ status: 501 },
);
}
const baseUrl = "https://freesound.org/apiv2/search/text/";
const sortParam = buildSortParameter({ query, sort });
const params = new URLSearchParams({
query: query || "",
token: webEnv.FREESOUND_API_KEY,
page: page.toString(),
page_size: pageSize.toString(),
sort: sortParam,
fields:
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
});
const isEffectsSearch = type === "effects" || !type;
if (isEffectsSearch) {
applyEffectsFilters({ params, min_rating, commercial_only });
}
const response = await fetch(`${baseUrl}?${params.toString()}`);
if (!response.ok) {
const errorText = await response.text();
console.error("Freesound API error:", response.status, errorText);
return NextResponse.json(
{ error: "Failed to search sounds" },
{ status: response.status },
);
}
const rawData = await response.json();
const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
if (!freesoundValidation.success) {
console.error(
"Invalid Freesound API response:",
freesoundValidation.error,
);
return NextResponse.json(
{ error: "Invalid response from Freesound API" },
{ status: 502 },
);
}
const data = freesoundValidation.data;
const transformedResults = data.results.map(transformFreesoundResult);
const responseData = {
count: data.count,
next: data.next,
previous: data.previous,
results: transformedResults,
query: query || "",
type: type || "effects",
page,
pageSize,
sort,
minRating: min_rating,
};
const responseValidation = apiResponseSchema.safeParse(responseData);
if (!responseValidation.success) {
console.error(
"Invalid API response structure:",
responseValidation.error,
);
return NextResponse.json(
{ error: "Internal response formatting error" },
{ status: 500 },
);
}
return NextResponse.json(responseValidation.data);
} catch (error) {
console.error("Error searching sounds:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
-113
View File
@@ -1,113 +0,0 @@
import crypto from "node:crypto";
import { db, eq, waitlist } from "@opencut/db";
import { checkBotId } from "botid/server";
import { nanoid } from "nanoid";
import { cookies } from "next/headers";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { env } from "@/env";
import { waitlistRateLimit } from "@/lib/rate-limit";
const waitlistSchema = z.object({
email: z.string().email("Invalid email format").min(1, "Email is required"),
});
const CSRF_TOKEN_NAME = "waitlist-csrf";
const TOKEN_EXPIRY = 60 * 60 * 1000;
async function validateCSRFToken(request: NextRequest): Promise<boolean> {
const clientToken = request.headers.get("x-csrf-token");
if (!clientToken) {
return false;
}
const cookieStore = await cookies();
const cookieValue = cookieStore.get(CSRF_TOKEN_NAME)?.value;
if (!cookieValue) {
return false;
}
const [token, timestamp, signature] = cookieValue.split(":");
if (!(token && timestamp && signature)) {
return false;
}
if (clientToken !== token) {
return false;
}
const now = Date.now();
const tokenTime = Number.parseInt(timestamp, 10);
if (now - tokenTime > TOKEN_EXPIRY) {
return false;
}
const expectedSignature = crypto
.createHmac("sha256", env.BETTER_AUTH_SECRET)
.update(`${token}:${timestamp}`)
.digest("hex");
return signature === expectedSignature;
}
export async function POST(request: NextRequest) {
const verification = await checkBotId();
if (verification.isBot) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
const identifier = request.headers.get("x-forwarded-for") ?? "127.0.0.1";
const { success } = await waitlistRateLimit.limit(identifier);
if (!success) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{ status: 429 }
);
}
const isValidToken = await validateCSRFToken(request);
if (!isValidToken) {
return NextResponse.json(
{ error: "Invalid security token" },
{ status: 403 }
);
}
try {
const body = await request.json();
const { email } = waitlistSchema.parse(body);
const existingEmail = await db
.select()
.from(waitlist)
.where(eq(waitlist.email, email.toLowerCase()))
.limit(1);
if (existingEmail.length > 0) {
return NextResponse.json(
{ error: "Email already registered" },
{ status: 409 }
);
}
await db.insert(waitlist).values({
id: nanoid(),
email: email.toLowerCase(),
});
return NextResponse.json(
{ message: "Successfully joined waitlist!" },
{ status: 201 }
);
} catch (error) {
if (error instanceof z.ZodError) {
const firstError = error.errors[0];
return NextResponse.json({ error: firstError.message }, { status: 400 });
}
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -1,61 +0,0 @@
import crypto from "node:crypto";
import { cookies } from "next/headers";
import { type NextRequest, NextResponse } from "next/server";
import { env } from "@/env";
const CSRF_TOKEN_NAME = "waitlist-csrf";
const TOKEN_EXPIRY = 60 * 60 * 1000;
const allowedHosts =
env.NODE_ENV === "development"
? ["localhost:3000", "127.0.0.1:3000"]
: ["opencut.app", "www.opencut.app"];
export async function GET(request: NextRequest) {
const referer = request.headers.get("referer");
const host = request.headers.get("host");
if (referer) {
const refererUrl = new URL(referer);
if (
!allowedHosts.some(
(allowed) =>
refererUrl.host === allowed || refererUrl.host.endsWith(allowed)
)
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
} else if (host) {
if (
!allowedHosts.some(
(allowed) => host === allowed || host.endsWith(allowed)
)
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
} else {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!env.BETTER_AUTH_SECRET) {
throw new Error("BETTER_AUTH_SECRET must be configured");
}
const token = crypto.randomBytes(32).toString("hex");
const timestamp = Date.now();
const signature = crypto
.createHmac("sha256", env.BETTER_AUTH_SECRET)
.update(`${token}:${timestamp}`)
.digest("hex");
const cookieStore = await cookies();
cookieStore.set(CSRF_TOKEN_NAME, `${token}:${timestamp}:${signature}`, {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "strict",
maxAge: TOKEN_EXPIRY / 1000,
path: "/",
});
return NextResponse.json({ token });
}
+53
View File
@@ -0,0 +1,53 @@
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
import { cn } from "@/utils/ui";
interface BasePageProps {
children: React.ReactNode;
className?: string;
mainClassName?: string;
maxWidth?: "3xl" | "6xl" | "full";
title?: string;
description?: string;
}
export function BasePage({
children,
className = "",
mainClassName = "",
maxWidth = "3xl",
title,
description,
}: BasePageProps) {
const maxWidthClass = {
"3xl": "max-w-3xl",
"6xl": "max-w-6xl",
full: "max-w-full",
}[maxWidth];
return (
<section className={cn("bg-background min-h-screen", className)}>
<Header />
<main
className={cn(
"relative container mx-auto flex flex-col gap-12 px-6 pt-12 pb-24 md:pt-24",
maxWidthClass,
mainClassName,
)}
>
{title && description && (
<div className="flex flex-col gap-8 text-center">
<h1 className="text-5xl font-bold tracking-tight md:text-6xl">
{title}
</h1>
<p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed">
{description}
</p>
</div>
)}
{children}
</main>
<Footer />
</section>
);
}
+82 -80
View File
@@ -1,10 +1,11 @@
import type { Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation";
import { Header } from "@/components/header";
import { BasePage } from "@/app/base-page";
import Prose from "@/components/ui/prose";
import { Separator } from "@/components/ui/separator";
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog-query";
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
import type { Author, Post } from "@/types/blog";
type PageProps = {
params: Promise<{ slug: string }>;
@@ -16,11 +17,9 @@ export async function generateMetadata({
}: PageProps): Promise<Metadata> {
const slug = (await params).slug;
const data = await getSinglePost(slug);
const data = await getSinglePost({ slug });
if (!data?.post) {
return {};
}
if (!data || !data.post) return {};
return {
title: data.post.title,
@@ -51,100 +50,103 @@ export async function generateMetadata({
title: data.post.title,
description: data.post.description,
publishedTime: new Date(data.post.publishedAt).toISOString(),
authors: [
...data.post.authors.map((author: { name: string }) => author.name),
],
authors: data.post.authors.map((author: Author) => author.name),
},
};
}
export async function generateStaticParams() {
const data = await getPosts();
if (!data?.posts.length) {
return [];
}
if (!data || !data.posts.length) return [];
return data.posts.map((post) => ({
slug: post.slug,
}));
}
async function Page({ params }: PageProps) {
export default async function BlogPostPage({ params }: PageProps) {
const slug = (await params).slug;
const data = await getSinglePost(slug);
if (!data?.post) {
return notFound();
}
const data = await getSinglePost({ slug });
if (!data || !data.post) return notFound();
const html = await processHtmlContent(data.post.content);
const formattedDate = new Date(data.post.publishedAt).toLocaleDateString(
"en-US",
{
day: "numeric",
month: "long",
year: "numeric",
}
);
const html = await processHtmlContent({ html: data.post.content });
return (
<div className="min-h-screen bg-background">
<Header />
<BasePage>
<PostHeader post={data.post} />
<Separator />
<PostContent html={html} />
</BasePage>
);
}
<main className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="-top-40 -right-40 absolute h-96 w-96 rounded-full bg-gradient-to-br from-muted/20 to-transparent blur-3xl" />
<div className="-left-40 absolute top-1/2 h-80 w-80 rounded-full bg-gradient-to-tr from-muted/10 to-transparent blur-3xl" />
</div>
function PostHeader({ post }: { post: Post }) {
const formattedDate = new Date(post.publishedAt).toLocaleDateString("en-US", {
day: "numeric",
month: "long",
year: "numeric",
});
<div className="container relative mx-auto max-w-3xl px-4 py-16">
<div className="mb-6 text-center">
{data.post.coverImage && (
<div className="relative mb-6 aspect-video overflow-hidden rounded-lg">
<Image
alt={data.post.title}
className="rounded-lg object-cover"
fill
loading="eager"
src={data.post.coverImage}
/>
</div>
)}
<div className="mb-6 flex items-center justify-center">
<time dateTime={data.post.publishedAt.toString()}>
{formattedDate}
</time>
</div>
return (
<>
{post.coverImage && <PostCoverImage post={post} />}
<PostMeta date={formattedDate} publishedAt={post.publishedAt} />
<PostTitle title={post.title} />
<PostAuthor author={post.authors[0]} />
</>
);
}
<h1 className="mb-6 font-bold text-5xl tracking-tight md:text-4xl">
{data.post.title}
</h1>
<div className="flex items-center justify-center gap-2">
{data.post.authors[0] && (
<>
<Image
alt={data.post.authors[0].name}
className="aspect-square size-8 shrink-0 rounded-full"
height={36}
loading="eager"
src={data.post.authors[0].image}
width={36}
/>
<p className="text-muted-foreground">
{data.post.authors[0].name}
</p>
</>
)}
</div>
</div>
<Separator />
<section className="mt-14">
<Prose html={html} />
</section>
</div>
</main>
function PostCoverImage({ post }: { post: Post }) {
return (
<div className="relative aspect-video overflow-hidden rounded-lg">
<Image
src={post.coverImage}
alt={post.title}
loading="eager"
fill
className="rounded-lg object-cover"
/>
</div>
);
}
export default Page;
function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) {
return (
<div className="flex items-center justify-center">
<time dateTime={publishedAt.toString()}>{date}</time>
</div>
);
}
function PostTitle({ title }: { title: string }) {
return (
<h1 className="text-5xl font-bold tracking-tight md:text-4xl">{title}</h1>
);
}
function PostAuthor({ author }: { author?: Author }) {
if (!author) return null;
return (
<div className="flex items-center justify-center gap-2">
<Image
src={author.image}
alt={author.name}
width={36}
height={36}
loading="eager"
className="aspect-square size-8 shrink-0 rounded-full"
/>
<p className="text-muted-foreground">{author.name}</p>
</div>
);
}
function PostContent({ html }: { html: string }) {
return (
<section className="pt-8">
<Prose html={html} />
</section>
);
}
+47 -74
View File
@@ -1,10 +1,10 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { Header } from "@/components/header";
import { BasePage } from "@/app/base-page";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Card, CardContent } from "@/components/ui/card";
import { getPosts } from "@/lib/blog-query";
import { Separator } from "@/components/ui/separator";
import { getPosts } from "@/lib/blog/query";
import type { Author, Post } from "@/types/blog";
export const metadata: Metadata = {
title: "Blog - OpenCut",
@@ -20,81 +20,54 @@ export const metadata: Metadata = {
export default async function BlogPage() {
const data = await getPosts();
if (!data?.posts) {
return <div>No posts yet</div>;
}
if (!data || !data.posts) return <div>No posts yet</div>;
return (
<div className="min-h-screen bg-background">
<Header />
<main className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="-top-40 -right-40 absolute h-96 w-96 rounded-full bg-gradient-to-br from-muted/20 to-transparent blur-3xl" />
<div className="-left-40 absolute top-1/2 h-80 w-80 rounded-full bg-gradient-to-tr from-muted/10 to-transparent blur-3xl" />
</div>
<div className="container relative mx-auto max-w-3xl px-4 py-16">
<div className="mb-20 text-center">
<h1 className="mb-6 font-bold text-5xl tracking-tight md:text-6xl">
Blog
</h1>
<p className="mx-auto mb-8 max-w-2xl text-muted-foreground text-xl leading-relaxed">
Read the latest news and updates about OpenCut, the free and
open-source video editor.
</p>
<BasePage
title="Blog"
description="Read the latest news and updates about OpenCut, the free and open-source video editor."
>
<div className="flex flex-col">
{data.posts.map((post) => (
<div key={post.id} className="flex flex-col">
<BlogPostItem post={post} />
<Separator />
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{data.posts.map((post) => (
<Link href={`/blog/${post.slug}`} key={post.id}>
<Card className="h-full overflow-hidden transition-shadow hover:shadow-lg">
{post.coverImage && (
<div className="relative aspect-video">
<Image
alt={post.title}
className="rounded-xl object-cover"
fill
src={post.coverImage}
/>
</div>
)}
))}
</div>
</BasePage>
);
}
<CardContent className="p-6">
{post.authors && post.authors.length > 0 && (
<div className="mb-4 flex items-center gap-2">
{post.authors.map((author, index) => (
<div
className="flex items-center gap-2"
key={author.id}
>
<Avatar className="h-6 w-6">
<AvatarImage
alt={author.name}
src={author.image}
/>
<AvatarFallback className="text-xs">
{author.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-muted-foreground text-sm">
{author.name}
</span>
{index < post.authors.length - 1 && (
<span className="text-muted-foreground">•</span>
)}
</div>
))}
</div>
)}
<h2 className="mb-2 font-semibold text-xl">{post.title}</h2>
<p className="text-muted-foreground">{post.description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
function BlogPostItem({ post }: { post: Post }) {
return (
<Link href={`/blog/${post.slug}`}>
<div className="flex h-auto w-full items-center justify-between py-6 opacity-100 hover:opacity-75">
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold">{post.title}</h2>
<p className="text-muted-foreground">{post.description}</p>
</div>
</main>
{post.authors && post.authors.length > 0 && (
<AuthorList authors={post.authors} />
)}
</div>
</Link>
);
}
function AuthorList({ authors }: { authors: Author[] }) {
return (
<div className="flex items-center gap-2">
{authors.map((author) => (
<div key={author.id} className="flex items-center gap-2">
<Avatar className="size-6 shadow-sm">
<AvatarImage src={author.image} alt={author.name} />
<AvatarFallback className="text-xs">
{author.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
))}
</div>
);
}
+169 -202
View File
@@ -1,12 +1,10 @@
import { ExternalLink } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { Header } from "@/components/header";
import { GithubIcon } from "@/components/icons";
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EXTERNAL_TOOLS } from "@/constants/site-constants";
import { BasePage } from "../base-page";
export const metadata: Metadata = {
title: "Contributors - OpenCut",
@@ -39,21 +37,23 @@ async function getContributors(): Promise<Contributor[]> {
"User-Agent": "OpenCut-Web-App",
},
next: { revalidate: 600 }, // 10 minutes
}
},
);
if (!response.ok) {
console.error("Failed to fetch contributors");
return [];
}
const contributors = (await response.json()) as Contributor[];
const filteredContributors = contributors.filter(
(contributor: Contributor) => contributor.type === "User"
(contributor) => contributor.type === "User",
);
return filteredContributors;
} catch (_error) {
} catch (error) {
console.error("Error fetching contributors:", error);
return [];
}
}
@@ -62,214 +62,181 @@ export default async function ContributorsPage() {
const contributors = await getContributors();
const topContributors = contributors.slice(0, 2);
const otherContributors = contributors.slice(2);
const totalContributions = contributors.reduce(
(sum, c) => sum + c.contributions,
0,
);
return (
<div className="min-h-screen bg-background">
<Header />
<BasePage
title="Contributors"
description="Meet the amazing people who contribute to OpenCut, the free and open-source video editor."
>
<div className="-mt-4 flex items-center justify-center gap-8 text-sm">
<StatItem value={contributors.length} label="contributors" />
<StatItem value={totalContributions} label="contributions" />
</div>
<main className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="-top-40 -right-40 absolute h-96 w-96 rounded-full bg-gradient-to-br from-muted/20 to-transparent blur-3xl" />
<div className="-left-40 absolute top-1/2 h-80 w-80 rounded-full bg-gradient-to-tr from-muted/10 to-transparent blur-3xl" />
</div>
<div className="mx-auto flex max-w-6xl flex-col gap-20">
{topContributors.length > 0 && (
<TopContributorsSection contributors={topContributors} />
)}
{otherContributors.length > 0 && (
<AllContributorsSection contributors={otherContributors} />
)}
<ExternalToolsSection />
<GitHubContributeSection
title="Join the community"
description="OpenCut is built by developers like you. Every contribution, no matter how small, helps make video editing more accessible for everyone."
/>
</div>
</BasePage>
);
}
<div className="container relative mx-auto px-4 py-16">
<div className="mx-auto max-w-6xl">
<div className="mb-20 text-center">
<Link
href={"https://github.com/OpenCut-app/OpenCut"}
target="_blank"
>
<Badge className="mb-6 gap-2" variant="secondary">
<GithubIcon className="h-3 w-3" />
Open Source
</Badge>
</Link>
<h1 className="mb-6 font-bold text-5xl tracking-tight md:text-6xl">
Contributors
</h1>
<p className="mx-auto mb-8 max-w-2xl text-muted-foreground text-xl leading-relaxed">
Meet the amazing developers who are building the future of video
editing
</p>
function StatItem({ value, label }: { value: number; label: string }) {
return (
<div className="flex items-center gap-2">
<div className="bg-foreground size-2 rounded-full" />
<span className="font-medium">{value}</span>
<span className="text-muted-foreground">{label}</span>
</div>
);
}
<div className="flex items-center justify-center gap-8 text-sm">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-foreground" />
<span className="font-medium">{contributors.length}</span>
<span className="text-muted-foreground">contributors</span>
</div>
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-foreground" />
<span className="font-medium">
{contributors.reduce((sum, c) => sum + c.contributions, 0)}
</span>
<span className="text-muted-foreground">contributions</span>
</div>
</div>
</div>
function TopContributorsSection({
contributors,
}: {
contributors: Contributor[];
}) {
return (
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-2 text-center">
<h2 className="text-2xl font-semibold">Top contributors</h2>
<p className="text-muted-foreground">
Leading the way in contributions
</p>
</div>
{topContributors.length > 0 && (
<div className="mb-20">
<div className="mb-12 text-center">
<h2 className="mb-2 font-semibold text-2xl">
Top Contributors
</h2>
<p className="text-muted-foreground">
Leading the way in contributions
</p>
</div>
<div className="mx-auto flex w-full max-w-xl flex-col justify-center gap-6 md:flex-row">
{contributors.map((contributor) => (
<TopContributorCard key={contributor.id} contributor={contributor} />
))}
</div>
</div>
);
}
<div className="mx-auto flex max-w-4xl flex-col justify-center gap-6 md:flex-row">
{topContributors.map((contributor, _index) => (
<Link
className="group block flex-1"
href={contributor.html_url}
key={contributor.id}
rel="noopener noreferrer"
target="_blank"
>
<div className="relative mx-auto max-w-md">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-r from-muted/50 to-muted/30 blur transition-all duration-300 group-hover:blur-md" />
<Card className="relative border-2 bg-background/80 backdrop-blur-sm transition-all duration-300 group-hover:border-muted-foreground/20 group-hover:shadow-xl">
<CardContent className="p-8 text-center">
<div className="relative mb-6">
<Avatar className="mx-auto h-24 w-24 shadow-2xl ring-4 ring-background">
<AvatarImage
alt={`${contributor.login}'s avatar`}
src={contributor.avatar_url}
/>
<AvatarFallback className="font-semibold text-lg">
{contributor.login.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
<h3 className="mb-2 font-semibold text-xl transition-colors group-hover:text-foreground/80">
{contributor.login}
</h3>
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<span className="font-medium text-foreground">
{contributor.contributions}
</span>
<span>contributions</span>
</div>
</CardContent>
</Card>
</div>
</Link>
))}
</div>
</div>
)}
{otherContributors.length > 0 && (
<div>
<div className="mb-12 text-center">
<h2 className="mb-2 font-semibold text-2xl">
All Contributors
</h2>
<p className="text-muted-foreground">
Everyone who makes OpenCut better
</p>
</div>
function TopContributorCard({ contributor }: { contributor: Contributor }) {
return (
<Link
href={contributor.html_url}
target="_blank"
rel="noopener noreferrer"
className="w-full"
>
<Card>
<CardContent className="flex flex-col gap-6 p-8 text-center">
<Avatar className="mx-auto size-28">
<AvatarImage
src={contributor.avatar_url}
alt={`${contributor.login}'s avatar`}
/>
<AvatarFallback className="text-lg font-semibold">
{contributor.login.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<h3 className="text-xl font-semibold">{contributor.login}</h3>
<div className="flex items-center justify-center gap-2">
<span className="font-medium">{contributor.contributions}</span>
<span className="text-muted-foreground">contributions</span>
</div>
</div>
</CardContent>
</Card>
</Link>
);
}
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{otherContributors.map((contributor, index) => (
<Link
className="group block"
href={contributor.html_url}
key={contributor.id}
rel="noopener noreferrer"
style={{
animationDelay: `${index * 50}ms`,
}}
target="_blank"
>
<div className="rounded-xl p-2 text-center transition-all duration-300 hover:opacity-50">
<Avatar className="mx-auto mb-3 h-16 w-16">
<AvatarImage
alt={`${contributor.login}'s avatar`}
src={contributor.avatar_url}
/>
<AvatarFallback className="font-medium">
{contributor.login.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<h3 className="mb-1 truncate font-medium text-sm transition-colors group-hover:text-foreground">
{contributor.login}
</h3>
<p className="text-muted-foreground text-xs">
{contributor.contributions}
</p>
</div>
</Link>
))}
</div>
</div>
)}
function AllContributorsSection({
contributors,
}: {
contributors: Contributor[];
}) {
return (
<div className="flex flex-col gap-12">
<div className="flex flex-col gap-2 text-center">
<h2 className="text-2xl font-semibold">All contributors</h2>
<p className="text-muted-foreground">
Everyone who makes OpenCut better
</p>
</div>
{contributors.length === 0 && (
<div className="py-20 text-center">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-muted/50">
<GithubIcon className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="mb-3 font-medium text-xl">
No contributors found
</h3>
<p className="mx-auto mb-8 max-w-md text-muted-foreground">
Unable to load contributors at the moment. Check back later or
view on GitHub.
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{contributors.map((contributor) => (
<Link
key={contributor.id}
href={contributor.html_url}
target="_blank"
rel="noopener noreferrer"
className="opacity-100 hover:opacity-70"
>
<div className="flex flex-col items-center gap-2 p-2">
<Avatar className="size-16">
<AvatarImage
src={contributor.avatar_url}
alt={`${contributor.login}'s avatar`}
/>
<AvatarFallback>
{contributor.login.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="text-center">
<h3 className="text-sm font-medium">{contributor.login}</h3>
<p className="text-muted-foreground text-xs">
{contributor.contributions}
</p>
<Link
href="https://github.com/OpenCut-app/OpenCut/graphs/contributors"
rel="noopener noreferrer"
target="_blank"
>
<Button className="gap-2" variant="outline">
<GithubIcon className="h-4 w-4" />
View on GitHub
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
</div>
)}
</div>
</Link>
))}
</div>
</div>
);
}
<div className="mt-32 text-center">
<div className="mx-auto max-w-2xl">
<h2 className="mb-4 font-bold text-3xl">Join the community</h2>
<p className="mb-10 text-lg text-muted-foreground leading-relaxed">
OpenCut is built by developers like you. Every contribution,
no matter how small, helps make video editing more accessible
for everyone.
</p>
function ExternalToolsSection() {
return (
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-2 text-center">
<h2 className="text-2xl font-semibold">External tools</h2>
<p className="text-muted-foreground">Tools we use to build OpenCut</p>
</div>
<div className="flex flex-col justify-center gap-4 sm:flex-row">
<Link
href="https://github.com/OpenCut-app/OpenCut/blob/main/.github/CONTRIBUTING.md"
rel="noopener noreferrer"
target="_blank"
>
<Button className="group gap-2" size="lg">
<GithubIcon className="h-4 w-4 transition-transform group-hover:scale-110" />
Start Contributing
</Button>
</Link>
<Link
href="https://github.com/OpenCut-app/OpenCut/issues"
rel="noopener noreferrer"
target="_blank"
>
<Button className="group gap-2" size="lg" variant="outline">
Browse Issues
<ExternalLink className="h-4 w-4 transition-transform group-hover:scale-110" />
</Button>
</Link>
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2">
{EXTERNAL_TOOLS.map((tool, index) => (
<Link
key={tool.url}
href={tool.url}
target="_blank"
className="block"
style={{ animationDelay: `${index * 100}ms` }}
>
<Card className="h-full">
<CardContent className="flex items-center justify-center h-full flex-col gap-4 p-6 text-center">
<tool.icon className="size-8" />
<div className="flex flex-1 flex-col gap-2">
<h3 className="text-lg font-semibold">{tool.name}</h3>
<p className="text-muted-foreground text-sm">
{tool.description}
</p>
</div>
</div>
</div>
</div>
</div>
</main>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}
+90 -129
View File
@@ -1,147 +1,108 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import { EditorHeader } from "@/components/editor-header";
import { EditorProvider } from "@/components/editor-provider";
import { Onboarding } from "@/components/onboarding";
import { usePlaybackControls } from "@/hooks/use-playback-controls";
import { usePanelStore } from "@/stores/panel-store";
import { useProjectStore } from "@/stores/project-store";
import { MediaPanel } from "../../../components/editor/media-panel";
import { PreviewPanel } from "../../../components/editor/preview-panel";
import { PropertiesPanel } from "../../../components/editor/properties-panel";
import { Timeline } from "../../../components/editor/timeline";
import { useParams } from "next/navigation";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "../../../components/ui/resizable";
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { AssetsPanel } from "@/components/editor/panels/assets";
import { PropertiesPanel } from "@/components/editor/panels/properties";
import { Timeline } from "@/components/editor/timeline";
import { PreviewPanel } from "@/components/editor/panels/preview";
import { EditorHeader } from "@/components/editor/editor-header";
import { EditorProvider } from "@/components/providers/editor-provider";
import { Onboarding } from "@/components/editor/onboarding";
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
import { usePanelStore } from "@/stores/panel-store";
export default function Editor() {
const {
toolsPanel,
previewPanel,
mainContent,
timeline,
setToolsPanel,
setPreviewPanel,
setMainContent,
setTimeline,
propertiesPanel,
setPropertiesPanel,
} = usePanelStore();
const { activeProject, loadProject, createNewProject } = useProjectStore();
const params = useParams();
const router = useRouter();
const projectId = params.project_id as string;
const handledProjectIds = useRef<Set<string>>(new Set());
usePlaybackControls();
useEffect(() => {
const initProject = async () => {
if (!projectId) {
return;
}
if (activeProject?.id === projectId) {
return;
}
if (handledProjectIds.current.has(projectId)) {
return;
}
try {
await loadProject(projectId);
} catch (_error) {
handledProjectIds.current.add(projectId);
const newProjectId = await createNewProject("Untitled Project");
router.replace(`/editor/${newProjectId}`);
return;
}
};
initProject();
}, [projectId, activeProject?.id, loadProject, createNewProject, router]);
return (
<EditorProvider>
<div className="flex h-screen w-screen flex-col overflow-hidden bg-background">
<EditorProvider projectId={projectId}>
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
<EditorHeader />
<div className="min-h-0 min-w-0 flex-1">
<ResizablePanelGroup
className="h-full w-full gap-[0.18rem]"
direction="vertical"
>
<ResizablePanel
className="min-h-0"
defaultSize={mainContent}
maxSize={85}
minSize={30}
onResize={setMainContent}
>
{/* Main content area */}
<ResizablePanelGroup
className="h-full w-full gap-[0.19rem] px-2"
direction="horizontal"
>
{/* Tools Panel */}
<ResizablePanel
className="min-w-0"
defaultSize={toolsPanel}
maxSize={40}
minSize={15}
onResize={setToolsPanel}
>
<MediaPanel />
</ResizablePanel>
<ResizableHandle withHandle />
{/* Preview Area */}
<ResizablePanel
className="min-h-0 min-w-0 flex-1"
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
>
<PreviewPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
className="min-w-0"
defaultSize={propertiesPanel}
maxSize={40}
minSize={15}
onResize={setPropertiesPanel}
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
{/* Timeline */}
<ResizablePanel
className="min-h-0 px-2 pb-2"
defaultSize={timeline}
maxSize={70}
minSize={15}
onResize={setTimeline}
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
<EditorLayout />
</div>
<Onboarding />
<MigrationDialog />
</div>
</EditorProvider>
);
}
function EditorLayout() {
const { panels, setPanel } = usePanelStore();
return (
<ResizablePanelGroup
direction="vertical"
className="size-full gap-[0.18rem]"
onLayout={(sizes) => {
setPanel("mainContent", sizes[0] ?? panels.mainContent);
setPanel("timeline", sizes[1] ?? panels.timeline);
}}
>
<ResizablePanel
defaultSize={panels.mainContent}
minSize={30}
maxSize={85}
className="min-h-0"
>
<ResizablePanelGroup
direction="horizontal"
className="size-full gap-[0.19rem] px-3"
onLayout={(sizes) => {
setPanel("tools", sizes[0] ?? panels.tools);
setPanel("preview", sizes[1] ?? panels.preview);
setPanel("properties", sizes[2] ?? panels.properties);
}}
>
<ResizablePanel
defaultSize={panels.tools}
minSize={15}
maxSize={40}
className="min-w-0 rounded-sm"
>
<AssetsPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={panels.preview}
minSize={30}
className="min-h-0 min-w-0 flex-1"
>
<PreviewPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={panels.properties}
minSize={15}
maxSize={40}
className="min-w-0 rounded-sm"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={panels.timeline}
minSize={15}
maxSize={70}
className="min-h-0 px-3 pb-3"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
);
}
+256 -83
View File
@@ -1,88 +1,261 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 1rem;
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 0 0% 3.9%;
--sidebar-primary: 0 0% 9%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 0 0% 96.1%;
--sidebar-accent-foreground: 0 0% 9%;
--sidebar-border: 0 0% 89.8%;
--sidebar-ring: 0 0% 3.9%;
}
.dark {
--background: 0 0% 4%;
--foreground: 0 0% 89%;
--card: 0 0% 14.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 14.9%;
--popover-foreground: 0 0% 98%;
--primary: 180 95% 40%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 100% 60%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 17%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 0 0% 3.9%;
--sidebar-foreground: 0 0% 98%;
--sidebar-primary: 0 0% 98%;
--sidebar-primary-foreground: 0 0% 9%;
--sidebar-accent: 0 0% 14.9%;
--sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: 0 0% 14.9%;
--sidebar-ring: 0 0% 83.1%;
--panel-background: 0 0% 11%;
--panel-accent: 0 0% 15%;
}
/* Custom variant for dark mode */
@custom-variant dark (&:where(.dark, .dark *));
/* Plugins */
@plugin "@tailwindcss/typography";
@plugin "tailwindcss-animate";
:root {
--background: hsl(0, 0%, 100%);
--foreground: hsl(0 0% 11%);
--card: hsl(0, 0%, 100%);
--card-foreground: hsl(0 0% 11%);
--popover: hsl(0, 0%, 100%);
--popover-foreground: hsl(0 0% 2%);
--primary: hsl(203, 100%, 50%);
--primary-hover: hsl(203, 100%, 45%);
--primary-foreground: hsl(0, 0%, 100%);
--secondary: hsl(216 13% 94%);
--secondary-foreground: hsl(0 0% 2%);
--muted: hsl(0 0% 85.1%);
--muted-foreground: hsl(0 0% 50%);
--accent: hsl(216, 13%, 88%);
--accent-foreground: hsl(0 0% 2%);
--destructive: hsl(0, 83%, 50%);
--destructive-foreground: hsl(0, 0%, 100%);
--constructive: hsl(141, 71%, 48%);
--constructive-foreground: hsl(0, 0%, 100%);
--border: hsl(0 0% 88%);
--input: hsl(0 0% 85.1%);
--ring: hsl(0, 0%, 55%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(0 0% 96.1%);
--sidebar-foreground: hsl(0 0% 2%);
--sidebar-primary: hsl(0 0% 2%);
--sidebar-primary-foreground: hsl(0 0% 91%);
--sidebar-accent: hsl(0 0% 85.1%);
--sidebar-accent-foreground: hsl(0 0% 2%);
--sidebar-border: hsl(0 0% 85.1%);
--sidebar-ring: hsl(0 0% 16.9%);
--panel-background: hsl(216 13% 94%);
--panel-accent: hsl(216, 8%, 88%);
--sidebar: hsl(0 0% 98%);
}
.dark {
--background: hsl(0 0% 4%);
--foreground: hsl(0 0% 89%);
--card: hsl(0 0% 4%);
--card-foreground: hsl(0 0% 89%);
--popover: hsl(0 0% 14.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(203, 100%, 50%);
--primary-hover: hsl(203, 100%, 45%);
--primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 14.9%);
--muted-foreground: hsl(0 0% 63.9%);
--accent: hsl(0, 0%, 28%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 83%, 55%);
--destructive-foreground: hsl(0 0% 98%);
--constructive: hsl(141, 71%, 48%);
--constructive-foreground: hsl(0 0% 100%);
--border: hsl(0 0% 17%);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(0 0% 3.9%);
--sidebar-foreground: hsl(0 0% 98%);
--sidebar-primary: hsl(0 0% 98%);
--sidebar-primary-foreground: hsl(0 0% 9%);
--sidebar-accent: hsl(0 0% 14.9%);
--sidebar-accent-foreground: hsl(0 0% 98%);
--sidebar-border: hsl(0 0% 14.9%);
--sidebar-ring: hsl(0 0% 83.1%);
--panel-background: hsl(0 0% 11%);
--panel-accent: hsl(0 0% 15%);
--sidebar: hsl(240 5.9% 10%);
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
/* Prevent back/forward swipe */
overscroll-behavior-x: contain;
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
/* Other default base styles */
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
/* Prevent back/forward swipe */
overscroll-behavior-x: contain;
}
}
@theme inline {
/* Responsive breakpoints */
--breakpoint-xs: 30rem;
/* Typography */
--font-sans: var(--font-inter), sans-serif;
/* Font sizes */
--text-xl: 1.20rem;
--text-base: 0.92rem;
--text-base--line-height: calc(1.5 / 0.95);
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.8);
/* Border radius */
--radius-lg: 0.82rem;
--radius-md: 0.65rem;
--radius-sm: 0.35rem;
/* Palette mapped to root design tokens */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-hover: var(--primary-hover);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-constructive: var(--constructive);
--color-constructive-foreground: var(--constructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* Chart colors */
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
/* Sidebar */
--color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
/* Panel */
--color-panel: var(--panel-background);
--color-panel-accent: var(--panel-accent);
/* Animations */
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
@utility scrollbar-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@utility scrollbar-x-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar:horizontal {
display: none;
}
}
@utility scrollbar-y-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar:vertical {
display: none;
}
}
@utility scrollbar-thin {
&::-webkit-scrollbar {
width: 6px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+19 -16
View File
@@ -1,21 +1,21 @@
import { Analytics } from "@vercel/analytics/react";
import Script from "next/script";
import { ThemeProvider } from "next-themes";
import Script from "next/script";
import "./globals.css";
import { BotIdClient } from "botid/client";
import { env } from "@/env";
import { StorageProvider } from "../components/storage-provider";
import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip";
import { defaultFont } from "../lib/font-config";
import { baseMetaData } from "./metadata";
import { BotIdClient } from "botid/client";
import { webEnv } from "@opencut/env/web";
import { Inter } from "next/font/google";
const siteFont = Inter({ subsets: ["latin"] });
export const metadata = baseMetaData;
const protectedRoutes = [
{
path: "/api/waitlist",
method: "POST",
path: "/none",
method: "GET",
},
];
@@ -29,24 +29,27 @@ export default function RootLayout({
<head>
<BotIdClient protect={protectedRoutes} />
</head>
<body className={`${defaultFont.className} font-sans antialiased`}>
<ThemeProvider attribute="class" forcedTheme="dark">
<body className={`${siteFont.className} font-sans antialiased`}>
<ThemeProvider
attribute="class"
defaultTheme="system"
disableTransitionOnChange={true}
>
<TooltipProvider>
<StorageProvider>{children}</StorageProvider>
<Analytics />
<Toaster />
<Script
src="https://cdn.databuddy.cc/databuddy.js"
strategy="afterInteractive"
async
data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
data-disabled={env.NODE_ENV === "development"}
data-disabled={webEnv.NODE_ENV === "development"}
data-track-attributes={false}
data-track-errors={true}
data-track-outgoing-links={false}
data-track-sessions={false}
data-track-web-vitals={false}
src="https://cdn.databuddy.cc/databuddy.js"
strategy="afterInteractive"
data-track-sessions={false}
/>
{children}
</TooltipProvider>
</ThemeProvider>
</body>
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { SITE_INFO, SITE_URL } from "@/constants/site";
import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
export const baseMetaData: Metadata = {
metadataBase: new URL(SITE_URL),
+7 -7
View File
@@ -1,16 +1,16 @@
import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
import type { Metadata } from "next";
import { SITE_URL } from "@/constants/site";
import { Header } from "@/components/header";
import { Hero } from "@/components/landing/hero";
import { SITE_URL } from "@/constants/site-constants";
export const metadata: Metadata = {
alternates: {
canonical: SITE_URL,
},
alternates: {
canonical: SITE_URL,
},
};
export default function Home() {
export default async function Home() {
return (
<div>
<Header />
+260 -226
View File
@@ -1,10 +1,13 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
import { GithubIcon } from "@/components/icons";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { BasePage } from "@/app/base-page";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
import { SOCIAL_LINKS } from "@/constants/site-constants";
export const metadata: Metadata = {
title: "Privacy Policy - OpenCut",
@@ -20,234 +23,265 @@ export const metadata: Metadata = {
export default function PrivacyPage() {
return (
<div className="min-h-screen bg-background">
<Header />
<main className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="-top-40 -right-40 absolute h-96 w-96 rounded-full bg-gradient-to-br from-muted/20 to-transparent blur-3xl" />
<div className="-left-40 absolute top-1/2 h-80 w-80 rounded-full bg-gradient-to-tr from-muted/10 to-transparent blur-3xl" />
</div>
<div className="container relative mx-auto px-4 py-16">
<div className="mx-auto max-w-4xl">
<div className="mb-10 text-center">
<Link
href="https://github.com/OpenCut-app/OpenCut"
target="_blank"
<BasePage
title="Privacy policy"
description="Learn how we handle your data and privacy. Contact us if you have any questions."
>
<Accordion type="single" collapsible className="w-full">
<AccordionItem
value="quick-summary"
className="rounded-2xl border px-5"
>
<AccordionTrigger className="no-underline!">
Quick summary
</AccordionTrigger>
<AccordionContent>
<h3 className="mb-3 text-lg font-medium">
Your content stays private and encrypted.
</h3>
<ol className="list-decimal space-y-2 pl-6">
<li>
Basic editing happens locally in your browser - we never see
your files
</li>
<li>
AI features require encrypted uploads - your content is
encrypted before leaving your device
</li>
<li>
We only collect your email and basic profile info for your
account
</li>
<li>Project data stays on your device, not our servers</li>
<li>
We use analytics to improve the app, but no personal video
content is tracked
</li>
<li>
You can delete your account anytime and all data gets removed
</li>
<li>We don't sell your data or share it with advertisers</li>
</ol>
<p className="mt-4">
Questions? Email us at{" "}
<a
href="mailto:oss@opencut.app"
className="text-primary hover:underline"
>
<Badge className="mb-6 gap-2" variant="secondary">
<GithubIcon className="h-3 w-3" />
Open Source
</Badge>
</Link>
<h1 className="mb-6 font-bold text-5xl tracking-tight md:text-6xl">
Privacy Policy
</h1>
<p className="mx-auto mb-8 max-w-2xl text-muted-foreground text-xl leading-relaxed">
Learn how we handle your data and privacy. Contact us if you
have any questions.
</p>
</div>
<Card className="border-2 border-muted/30 bg-background/80 backdrop-blur-sm">
<CardContent className="space-y-8 p-8 text-base leading-relaxed">
<section>
<h2 className="mb-4 font-semibold text-2xl">
Your Videos Stay Private
</h2>
<p className="mb-4">
<strong>
OpenCut processes all videos locally on your device.
</strong>{" "}
We never upload, store, or have access to your video files.
Your content remains completely private and under your
control at all times.
</p>
<p>
All video editing, rendering, and processing happens in your
browser using WebAssembly and local storage. No video data
is transmitted to our servers.
</p>
</section>
oss@opencut.app
</a>
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
<section>
<h2 className="mb-4 font-semibold text-2xl">
Account Information
</h2>
<p className="mb-4">
When you create an account, we only collect:
</p>
<ul className="mb-4 list-disc space-y-2 pl-6">
<li>Email address (for account access)</li>
<li>
Profile information from Google OAuth (if you choose to
sign in with Google)
</li>
</ul>
<p className="mb-4">
<strong>
We do NOT store your projects on our servers.
</strong>{" "}
All project data, including names, thumbnails, and creation
dates, is stored locally in your browser using IndexedDB.
</p>
<p>
We use{" "}
<a
className="text-primary hover:underline"
href="https://www.better-auth.com"
rel="noopener"
target="_blank"
>
Better Auth
</a>{" "}
for secure authentication and follow industry-standard
security practices.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">How We Handle Your Content</h2>
<p>
<strong>Basic video editing happens locally on your device.</strong>{" "}
For standard editing features, we never upload, store, or have access
to your video files. Your content remains completely private and under
your control.
</p>
<p>
<strong>AI features require secure processing:</strong> When you
choose to use AI features like auto captions, your audio/video content
is encrypted on your device before being uploaded to our servers for
processing. We use zero-knowledge encryption, meaning we cannot
decrypt or view your content.
</p>
<p>
After AI processing is complete, the encrypted content is immediately
deleted from our servers. Only the results (like generated captions)
are returned to your device.
</p>
</section>
<section>
<h2 className="mb-4 font-semibold text-2xl">Analytics</h2>
<p className="mb-4">
We use{" "}
<a
className="text-primary hover:underline"
href="https://www.databuddy.cc"
rel="noopener"
target="_blank"
>
Databuddy
</a>{" "}
for completely anonymized and non-invasive analytics to
understand how people use OpenCut.
</p>
<p>
This helps us improve the editor, but we never collect
personal information, track individual users, or store any
data that could identify you.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Account Information</h2>
<p>When you create an account, we only collect:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Email address (for account access)</li>
<li>
Profile information from Google OAuth (if you choose to sign in with
Google)
</li>
</ul>
<p>
<strong>We do NOT store your projects on our servers.</strong> All
project data, including names, thumbnails, and creation dates, is
stored locally in your browser using IndexedDB.
</p>
<p>
We use{" "}
<a
href="https://www.better-auth.com"
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
Better Auth
</a>{" "}
for secure authentication and follow industry-standard security
practices.
</p>
</section>
<section>
<h2 className="mb-4 font-semibold text-2xl">
Local Storage & Cookies
</h2>
<p className="mb-4">
We use browser local storage and IndexedDB to:
</p>
<ul className="mb-4 list-disc space-y-2 pl-6">
<li>Save your projects locally on your device</li>
<li>Remember your editor preferences and settings</li>
<li>Keep you logged in across browser sessions</li>
</ul>
<p>
All data stays on your device and can be cleared at any time
through your browser settings.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">AI Features & Encryption</h2>
<p>
When you use AI-powered features (like auto captions, content
analysis, or enhancement tools), your content needs to be processed on
our servers. Here's how we protect your privacy:
</p>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong>Client-side encryption:</strong> Your content is encrypted
on your device before upload
</li>
<li>
<strong>Zero-knowledge processing:</strong> We cannot decrypt or
view your original content
</li>
<li>
<strong>Temporary processing:</strong> Encrypted content is deleted
immediately after processing
</li>
<li>
<strong>Opt-in only:</strong> AI features are optional - basic
editing remains fully local
</li>
</ul>
<p>
Different AI features may process different types of content (audio
for captions, video for analysis, etc.), but all follow the same
zero-knowledge encryption approach.
</p>
</section>
<section>
<h2 className="mb-4 font-semibold text-2xl">
Third-Party Services
</h2>
<p className="mb-4">
OpenCut integrates with these services:
</p>
<ul className="mb-4 list-disc space-y-2 pl-6">
<li>
<strong>Google OAuth:</strong> For optional Google sign-in
(governed by Google's privacy policy)
</li>
<li>
<strong>Vercel:</strong> For hosting and content delivery
</li>
<li>
<strong>Databuddy:</strong> For anonymized analytics
</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Analytics</h2>
<p>
We use{" "}
<a
href="https://www.databuddy.cc"
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
Databuddy
</a>{" "}
for completely anonymized and non-invasive analytics to understand how
people use OpenCut.
</p>
<p>
This helps us improve the editor, but we never collect personal
information, track individual users, or store any data that could
identify you.
</p>
</section>
<section>
<h2 className="mb-4 font-semibold text-2xl">Your Rights</h2>
<p className="mb-4">
You have complete control over your data:
</p>
<ul className="mb-4 list-disc space-y-2 pl-6">
<li>
Delete your account and all associated data at any time
</li>
<li>Export your project data</li>
<li>Clear local storage to remove all saved projects</li>
<li>Contact us with any privacy concerns</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Local Storage & Cookies</h2>
<p>We use browser local storage and IndexedDB to:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Save your projects locally on your device</li>
<li>Remember your editor preferences and settings</li>
<li>Keep you logged in across browser sessions</li>
</ul>
<p>
All data stays on your device and can be cleared at any time through
your browser settings.
</p>
</section>
<section>
<h2 className="mb-4 font-semibold text-2xl">
Open Source Transparency
</h2>
<p className="mb-4">
OpenCut is completely open source. You can review our code,
see exactly how we handle data, and even self-host the
application if you prefer.
</p>
<p>
View our source code on{" "}
<a
className="text-primary hover:underline"
href="https://github.com/OpenCut-app/OpenCut"
rel="noopener"
target="_blank"
>
GitHub
</a>
.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Third-Party Services</h2>
<p>OpenCut integrates with these services:</p>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong>Google OAuth:</strong> For optional Google sign-in (governed
by Google's privacy policy)
</li>
<li>
<strong>Vercel:</strong> For hosting and content delivery
</li>
<li>
<strong>Databuddy:</strong> For anonymized analytics
</li>
</ul>
</section>
<section>
<h2 className="mb-4 font-semibold text-2xl">Contact Us</h2>
<p className="mb-4">
Questions about this privacy policy or how we handle your
data?
</p>
<p>
Open an issue on our{" "}
<a
className="text-primary hover:underline"
href="https://github.com/OpenCut-app/OpenCut/issues"
rel="noopener"
target="_blank"
>
GitHub repository
</a>
, email us at{" "}
<a
className="text-primary hover:underline"
href="mailto:oss@opencut.app"
>
oss@opencut.app
</a>
, or reach out on{" "}
<a
className="text-primary hover:underline"
href="https://x.com/opencutapp"
rel="noopener"
target="_blank"
>
X (Twitter)
</a>
.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Your Rights</h2>
<p>You have complete control over your data:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Delete your account and all associated data at any time</li>
<li>Export your project data</li>
<li>Clear local storage to remove all saved projects</li>
<li>Contact us with any privacy concerns</li>
</ul>
</section>
<p className="mt-8 border-muted/20 border-t pt-8 text-muted-foreground text-sm">
Last updated: July 14, 2025
</p>
</CardContent>
</Card>
</div>
</div>
</main>
<Footer />
</div>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Open Source Transparency</h2>
<p>
OpenCut is completely open source. You can review our code, see
exactly how we handle data, and even self-host the application if you
prefer.
</p>
<p>
View our source code on{" "}
<a
href={SOCIAL_LINKS.github}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
GitHub
</a>
.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Contact Us</h2>
<p>Questions about this privacy policy or how we handle your data?</p>
<p>
Open an issue on our{" "}
<a
href={`${SOCIAL_LINKS.github}/issues`}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
GitHub repository
</a>
, email us at{" "}
<a
href="mailto:oss@opencut.app"
className="text-primary hover:underline"
>
oss@opencut.app
</a>
, or reach out on{" "}
<a
href={SOCIAL_LINKS.x}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
X (Twitter)
</a>
.
</p>
</section>
<Separator />
<p className="text-muted-foreground text-sm">
Last updated: July 14, 2025
</p>
</BasePage>
);
}
File diff suppressed because it is too large Load Diff
+129
View File
@@ -0,0 +1,129 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { TProjectSortKey } from "@/types/project";
export type ProjectsViewMode = "grid" | "list";
interface ProjectsState {
searchQuery: string;
sortKey: TProjectSortKey;
sortOrder: "asc" | "desc";
viewMode: ProjectsViewMode;
selectedProjectIds: string[];
lastSelectedProjectId: string | null;
isHydrated: boolean;
setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void;
setSearchQuery: ({ query }: { query: string }) => void;
setSortKey: ({ sortKey }: { sortKey: TProjectSortKey }) => void;
setSortOrder: ({ sortOrder }: { sortOrder: "asc" | "desc" }) => void;
toggleSortOrder: () => void;
setViewMode: ({ viewMode }: { viewMode: ProjectsViewMode }) => void;
setSelectedProjects: ({ projectIds }: { projectIds: string[] }) => void;
clearSelectedProjects: () => void;
setProjectSelected: ({
projectId,
isSelected,
}: {
projectId: string;
isSelected: boolean;
}) => void;
selectProjectRange: ({
projectId,
allProjectIds,
}: {
projectId: string;
allProjectIds: string[];
}) => void;
}
const getNextSelectedProjectIds = ({
selectedProjectIds,
projectId,
isSelected,
}: {
selectedProjectIds: string[];
projectId: string;
isSelected: boolean;
}): string[] => {
const selectedProjectIdSet = new Set(selectedProjectIds);
if (isSelected) {
selectedProjectIdSet.add(projectId);
return Array.from(selectedProjectIdSet);
}
selectedProjectIdSet.delete(projectId);
return Array.from(selectedProjectIdSet);
};
export const useProjectsStore = create<ProjectsState>()(
persist(
(set) => ({
searchQuery: "",
sortKey: "createdAt",
sortOrder: "desc",
viewMode: "grid",
selectedProjectIds: [],
lastSelectedProjectId: null,
isHydrated: false,
setIsHydrated: ({ isHydrated }) => set({ isHydrated }),
setSearchQuery: ({ query }) => set({ searchQuery: query }),
setSortKey: ({ sortKey }) => set({ sortKey }),
setSortOrder: ({ sortOrder }) => set({ sortOrder }),
toggleSortOrder: () =>
set((state) => ({
sortOrder: state.sortOrder === "asc" ? "desc" : "asc",
})),
setViewMode: ({ viewMode }) => set({ viewMode }),
setSelectedProjects: ({ projectIds }) =>
set({ selectedProjectIds: projectIds }),
clearSelectedProjects: () =>
set({ selectedProjectIds: [], lastSelectedProjectId: null }),
setProjectSelected: ({ projectId, isSelected }) =>
set((state) => ({
selectedProjectIds: getNextSelectedProjectIds({
selectedProjectIds: state.selectedProjectIds,
projectId,
isSelected,
}),
lastSelectedProjectId: isSelected ? projectId : state.lastSelectedProjectId,
})),
selectProjectRange: ({ projectId, allProjectIds }) =>
set((state) => {
const anchorId = state.lastSelectedProjectId;
if (!anchorId) {
return {
selectedProjectIds: [projectId],
lastSelectedProjectId: projectId,
};
}
const anchorIndex = allProjectIds.indexOf(anchorId);
const targetIndex = allProjectIds.indexOf(projectId);
if (anchorIndex === -1 || targetIndex === -1) {
return {
selectedProjectIds: [projectId],
lastSelectedProjectId: projectId,
};
}
const startIndex = Math.min(anchorIndex, targetIndex);
const endIndex = Math.max(anchorIndex, targetIndex);
const rangeIds = allProjectIds.slice(startIndex, endIndex + 1);
const merged = new Set([...state.selectedProjectIds, ...rangeIds]);
return {
selectedProjectIds: Array.from(merged),
};
}),
}),
{
name: "projects-view-mode",
partialize: (state) => ({ viewMode: state.viewMode }),
onRehydrateStorage: () => (state) => {
state?.setIsHydrated({ isHydrated: true });
},
},
),
);
+74 -177
View File
@@ -1,20 +1,24 @@
import type { Metadata } from "next";
import Link from "next/link";
import ReactMarkdown from "react-markdown";
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
import { GithubIcon } from "@/components/icons";
import { BasePage } from "@/app/base-page";
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
import { cn } from "@/utils/ui";
const roadmapItems: {
type StatusType = "complete" | "pending" | "default" | "info";
interface Status {
text: string;
type: StatusType;
}
interface RoadmapItem {
title: string;
description: string;
status: {
text: string;
type: "complete" | "pending" | "default" | "info";
};
}[] = [
status: Status;
}
const roadmapItems: RoadmapItem[] = [
{
title: "Start",
description:
@@ -27,66 +31,30 @@ const roadmapItems: {
{
title: "Core UI",
description:
"Built the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.",
"Build the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.",
status: {
text: "Completed",
type: "complete",
},
},
{
title: "Basic Functionality",
title: "Essential functionality",
description:
"The heart of any video editor. Timeline zoom in/out, making clips longer/shorter, dragging elements around, selection, playhead scrubbing. **This part has to be fucking perfect** because it's what users interact with 99% of the time.",
"Everything that makes a video editor **useful**. Timeline interactivity, storage, effects, transitions, etc.",
status: {
text: "In Progress",
text: "In progress",
type: "pending",
},
},
{
title: "Export/Preview Logic",
title: "Badge (potentially)",
description:
"The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.",
'An "Edit with OpenCut" badge web apps can integrate. Shows on video players.',
status: {
text: "In Progress",
type: "pending",
},
},
{
title: "Text",
description:
"After media, text is the next most important thing. Font selection with custom font imports, text stroke, colors. All the text essential text properties.",
status: {
text: "Not Started",
text: "Not started",
type: "default",
},
},
{
title: "Effects",
description:
"Adding visual effects to both text and media. Blur, brightness, contrast, saturation, filters, and all the creative tools that make videos pop. This is where the magic happens.",
status: {
text: "Not Started",
type: "default",
},
},
{
title: "Transitions",
description:
"Smooth transitions between clips. Fade in/out, slide, zoom, dissolve, and custom transition effects.",
status: {
text: "Not Started",
type: "default",
},
},
{
title: "Refine from Here",
description:
"Once we nail the above, we have a **solid foundation** to build anything. Advanced features, performance optimizations, mobile support, desktop app.",
status: {
text: "Future",
type: "info",
},
},
];
export const metadata: Metadata = {
@@ -118,130 +86,59 @@ export const metadata: Metadata = {
export default function RoadmapPage() {
return (
<div className="min-h-screen bg-background">
<Header />
<main className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="-top-40 -right-40 absolute h-96 w-96 rounded-full bg-gradient-to-br from-muted/20 to-transparent blur-3xl" />
<div className="-left-40 absolute top-1/2 h-80 w-80 rounded-full bg-gradient-to-tr from-muted/10 to-transparent blur-3xl" />
<BasePage
title="Roadmap"
description="What's coming next for OpenCut (last updated: July 14, 2025)"
>
<div className="mx-auto flex max-w-4xl flex-col gap-16">
<div className="flex flex-col gap-6">
{roadmapItems.map((item, index) => (
<RoadmapItem key={item.title} item={item} index={index} />
))}
</div>
<div className="container relative mx-auto px-4 py-16">
<div className="mx-auto max-w-4xl">
<div className="mb-10 text-center">
<Link
href="https://github.com/OpenCut-app/OpenCut"
target="_blank"
>
<Badge className="mb-6 gap-2" variant="secondary">
<GithubIcon className="h-3 w-3" />
Open Source
</Badge>
</Link>
<h1 className="mb-6 font-bold text-5xl tracking-tight md:text-6xl">
Roadmap
</h1>
<p className="mx-auto mb-8 max-w-2xl text-muted-foreground text-xl leading-relaxed">
What's coming next for OpenCut (last updated: July 14, 2025)
</p>
</div>
<div className="space-y-6">
{roadmapItems.map((item, index) => (
<div className="relative" key={index}>
<div className="flex items-start gap-2">
<span className="select-none font-medium text-lg text-muted-foreground leading-[1.5]">
{index + 1}.
</span>
<div className="flex-1 pt-[2px]">
<div className="mb-2 flex items-center gap-2">
<h3 className="font-medium text-lg">{item.title}</h3>
<Badge
className={cn("shadow-none", {
"!bg-green-500 text-white":
item.status.type === "complete",
"!bg-yellow-500 text-white":
item.status.type === "pending",
"!bg-blue-500 text-white":
item.status.type === "info",
"!bg-foreground/10 text-accent-foreground":
item.status.type === "default",
})}
>
{item.status.text}
</Badge>
</div>
<div className="text-foreground/70 leading-relaxed">
<ReactMarkdown
components={{
a: ({ className, children, ...props }) => (
<a
className={cn(
"text-primary hover:underline",
className
)}
rel="noopener noreferrer"
target="_blank"
{...props}
>
{children}
</a>
),
strong: ({ children }) => (
<strong className="font-semibold text-foreground">
{children}
</strong>
),
}}
>
{item.description}
</ReactMarkdown>
</div>
</div>
</div>
</div>
))}
</div>
<GitHubContributeSection
title="Want to help?"
description="OpenCut is open source and built by the community. Every contribution,
no matter how small, helps us build the best free video editor
possible."
/>
</div>
</BasePage>
);
}
<div className="mt-12 border-muted/20 border-t pt-8">
<div className="space-y-4 text-center">
<h3 className="font-semibold text-xl">Want to Help?</h3>
<p className="mx-auto max-w-2xl text-muted-foreground">
OpenCut is open source and built by the community. Every
contribution, no matter how small, helps us build the best
free video editor possible.
</p>
<div className="mt-6 flex flex-col justify-center gap-4 sm:flex-row">
<Link
href="https://github.com/OpenCut-app/OpenCut/blob/main/.github/CONTRIBUTING.md"
rel="noopener noreferrer"
target="_blank"
>
<Badge
className="px-4 py-2 text-sm transition-colors hover:bg-muted/50"
variant="outline"
>
<GithubIcon className="mr-2 h-4 w-4" />
Start Contributing
</Badge>
</Link>
<Link
href="https://github.com/OpenCut-app/OpenCut/issues"
rel="noopener noreferrer"
target="_blank"
>
<Badge
className="px-4 py-2 text-sm transition-colors hover:bg-muted/50"
variant="outline"
>
Report Issues
</Badge>
</Link>
</div>
</div>
</div>
</div>
</div>
</main>
<Footer />
function RoadmapItem({ item, index }: { item: RoadmapItem; index: number }) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-lg font-medium">
<span className="leading-normal select-none">{index + 1}</span>
<h3>{item.title}</h3>
<StatusBadge status={item.status} className="ml-1" />
</div>
<div className="text-foreground/70 leading-relaxed">
<ReactMarkdownWrapper>{item.description}</ReactMarkdownWrapper>
</div>
</div>
);
}
function StatusBadge({
status,
className,
}: {
status: Status;
className?: string;
}) {
return (
<Badge
className={cn("shadow-none", className, {
"bg-green-500! text-white": status.type === "complete",
"bg-yellow-500! text-white": status.type === "pending",
"bg-blue-500! text-white": status.type === "info",
"bg-foreground/10! text-accent-foreground": status.type === "default",
})}
>
{status.text}
</Badge>
);
}
+9 -9
View File
@@ -1,13 +1,13 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/constants/site";
import { SITE_URL } from "@/constants/site-constants";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/_next/", "/projects/", "/editor/"],
},
sitemap: `${SITE_URL}/sitemap.xml`,
};
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/_next/", "/projects/", "/editor/"],
},
sitemap: `${SITE_URL}/sitemap.xml`,
};
}
+43 -43
View File
@@ -1,46 +1,46 @@
import { Feed } from 'feed';
import { getPosts } from '@/lib/blog-query';
import { SITE_INFO, SITE_URL } from '@/constants/site';
import { Feed } from "feed";
import { getPosts } from "@/lib/blog/query";
import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
export async function GET() {
try {
const { posts } = await getPosts();
const feed = new Feed({
title: `${SITE_INFO.title} Blog`,
description: SITE_INFO.description,
id: `${SITE_URL}`,
link: `${SITE_URL}/blog/`,
language: 'en',
image: `${SITE_INFO.openGraphImage}`,
favicon: `${SITE_INFO.favicon}`,
copyright: `All rights reserved ${new Date().getFullYear()}, ${
SITE_INFO.title
}`,
});
for (const post of posts) {
feed.addItem({
title: post.title,
id: `${SITE_URL}/blog/${post.slug}`,
link: `${SITE_URL}/blog/${post.slug}`,
description: post.description,
author: post.authors.map((author) => ({
name: author.name,
})),
date: new Date(post.publishedAt),
image: post.coverImage || SITE_INFO.openGraphImage,
});
}
try {
const { posts } = await getPosts();
return new Response(feed.rss2(), {
headers: {
'Content-Type': 'text/xml',
'Cache-Control': 'public, max-age=86400, stale-while-revalidate',
},
});
} catch (error) {
console.error('Error generating RSS feed', error);
return new Response('Internal Server Error', { status: 500 });
}
}
const feed = new Feed({
title: `${SITE_INFO.title} Blog`,
description: SITE_INFO.description,
id: `${SITE_URL}`,
link: `${SITE_URL}/blog/`,
language: "en",
image: `${SITE_INFO.openGraphImage}`,
favicon: `${SITE_INFO.favicon}`,
copyright: `All rights reserved ${new Date().getFullYear()}, ${
SITE_INFO.title
}`,
});
for (const post of posts) {
feed.addItem({
title: post.title,
id: `${SITE_URL}/blog/${post.slug}`,
link: `${SITE_URL}/blog/${post.slug}`,
description: post.description,
author: post.authors.map((author) => ({
name: author.name,
})),
date: new Date(post.publishedAt),
image: post.coverImage || SITE_INFO.openGraphImage,
});
}
return new Response(feed.rss2(), {
headers: {
"Content-Type": "text/xml",
"Cache-Control": "public, max-age=86400, stale-while-revalidate",
},
});
} catch (error) {
console.error("Error generating RSS feed", error);
return new Response("Internal Server Error", { status: 500 });
}
}
+55 -55
View File
@@ -1,61 +1,61 @@
import { SITE_URL } from "@/constants/site";
import { getPosts } from "@/lib/blog-query";
import { SITE_URL } from "@/constants/site-constants";
import { getPosts } from "@/lib/blog/query";
import type { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const data = await getPosts();
const data = await getPosts();
const postPages: MetadataRoute.Sitemap =
data?.posts?.map((post) => ({
url: `${SITE_URL}/blog/${post.slug}`,
lastModified: new Date(post.publishedAt),
changeFrequency: "weekly",
priority: 0.8,
})) ?? [];
const postPages: MetadataRoute.Sitemap =
data?.posts?.map((post) => ({
url: `${SITE_URL}/blog/${post.slug}`,
lastModified: new Date(post.publishedAt),
changeFrequency: "weekly",
priority: 0.8,
})) ?? [];
return [
{
url: SITE_URL,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
},
{
url: `${SITE_URL}/contributors`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.5,
},
{
url: `${SITE_URL}/roadmap`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
},
{
url: `${SITE_URL}/privacy`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.5,
},
{
url: `${SITE_URL}/terms`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.5,
},
{
url: `${SITE_URL}/why-not-capcut`,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 1,
},
{
url: `${SITE_URL}/blog`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
},
...postPages,
];
return [
{
url: SITE_URL,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
},
{
url: `${SITE_URL}/contributors`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.5,
},
{
url: `${SITE_URL}/roadmap`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
},
{
url: `${SITE_URL}/privacy`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.5,
},
{
url: `${SITE_URL}/terms`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.5,
},
{
url: `${SITE_URL}/why-not-capcut`,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 1,
},
{
url: `${SITE_URL}/blog`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
},
...postPages,
];
}
+82
View File
@@ -0,0 +1,82 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { BasePage } from "@/app/base-page";
import { Card, CardContent } from "@/components/ui/card";
import { SPONSORS, type Sponsor } from "@/constants/site-constants";
import { HugeiconsIcon } from "@hugeicons/react";
import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
export const metadata: Metadata = {
title: "Sponsors - OpenCut",
description:
"Support OpenCut and help us build the future of free and open-source video editing.",
openGraph: {
title: "Sponsors - OpenCut",
description:
"Support OpenCut and help us build the future of free and open-source video editing.",
type: "website",
},
};
export default function SponsorsPage() {
return (
<BasePage>
<div className="flex flex-col gap-8 text-center">
<h1 className="text-5xl font-bold tracking-tight md:text-6xl">
Sponsors
</h1>
<p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed text-pretty">
Support OpenCut and help us build the future of privacy-first video
editing.
</p>
</div>
<SponsorsGrid />
</BasePage>
);
}
function SponsorsGrid() {
return (
<div className="grid gap-6 sm:grid-cols-2">
{SPONSORS.map((sponsor) => (
<SponsorCard key={sponsor.name} sponsor={sponsor} />
))}
</div>
);
}
function SponsorCard({ sponsor }: { sponsor: Sponsor }) {
return (
<Link
href={sponsor.url}
target="_blank"
rel="noopener noreferrer"
className="size-full"
>
<Card className="h-full">
<CardContent className="flex h-full flex-col justify-center gap-8 p-8">
<Image
src={sponsor.logo}
alt={`${sponsor.name} logo`}
width={50}
height={50}
className="object-contain"
/>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<h3 className="text-xl font-semibold group-hover:underline">
{sponsor.name}
</h3>
<HugeiconsIcon
icon={LinkSquare02Icon}
className="text-muted-foreground size-4"
/>
</div>
<p className="text-muted-foreground">{sponsor.description}</p>
</div>
</CardContent>
</Card>
</Link>
);
}
+266 -288
View File
@@ -1,10 +1,13 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
import { GithubIcon } from "@/components/icons";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { BasePage } from "@/app/base-page";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
import { SOCIAL_LINKS } from "@/constants/site-constants";
export const metadata: Metadata = {
title: "Terms of Service - OpenCut",
@@ -20,298 +23,273 @@ export const metadata: Metadata = {
export default function TermsPage() {
return (
<div className="min-h-screen bg-background">
<Header />
<main className="relative">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="-top-40 -right-40 absolute h-96 w-96 rounded-full bg-gradient-to-br from-muted/20 to-transparent blur-3xl" />
<div className="-left-40 absolute top-1/2 h-80 w-80 rounded-full bg-gradient-to-tr from-muted/10 to-transparent blur-3xl" />
</div>
<div className="container relative mx-auto px-4 py-16">
<div className="mx-auto max-w-4xl">
<div className="mb-10 text-center">
<Link
href="https://github.com/OpenCut-app/OpenCut"
target="_blank"
<BasePage
title="Terms of service"
description="Fair and transparent terms for our free, open-source video editor. Contact us if you have any questions."
>
<Accordion type="single" collapsible className="w-full">
<AccordionItem
value="quick-summary"
className="rounded-2xl border px-5"
>
<AccordionTrigger className="no-underline!">
Quick summary
</AccordionTrigger>
<AccordionContent>
<h3 className="mb-3 text-lg font-medium">
You own your content, we own nothing.
</h3>
<ol className="list-decimal space-y-2 pl-6">
<li>
Your content stays private - basic editing is local, AI features
use encrypted uploads
</li>
<li>
We never claim ownership of your content, even when processing
AI features
</li>
<li>
Free for personal and commercial use with no watermarks or
restrictions
</li>
<li>Don't use OpenCut for illegal activities or harassment</li>
<li>
Service provided "as is" - we can't guarantee perfect uptime
</li>
<li>
Open source means you can review our code and self-host if
needed
</li>
<li>
You can delete your account anytime and keep using your exported
videos
</li>
</ol>
<p className="mt-4">
Questions? Email us at{" "}
<a
href="mailto:oss@opencut.app"
className="text-primary hover:underline"
>
<Badge className="mb-6 gap-2" variant="secondary">
<GithubIcon className="h-3 w-3" />
Open Source
</Badge>
</Link>
<h1 className="mb-6 font-bold text-5xl tracking-tight md:text-6xl">
Terms of Service
</h1>
<p className="mx-auto mb-8 max-w-2xl text-muted-foreground text-xl leading-relaxed">
Fair and transparent terms for our free, open-source video
editor.
</p>
</div>
<Card className="border-2 border-muted/30 bg-background/80 backdrop-blur-sm">
<CardContent className="space-y-8 p-8 text-base leading-relaxed">
<section>
<h2 className="mb-4 font-semibold text-2xl">
Welcome to OpenCut
</h2>
<p className="mb-4">
OpenCut is a free, open-source video editor that runs in
your browser. By using our service, you agree to these
terms. We've designed these terms to be fair and protect
both you and our project.
</p>
<p>
<strong>Key principle:</strong> Your content stays on your
device. We never claim ownership of your videos or projects.
</p>
</section>
oss@opencut.app
</a>
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
<section>
<h2 className="mb-4 font-semibold text-2xl">
Your Content, Your Rights
</h2>
<p className="mb-4">
<strong>You own everything you create.</strong> OpenCut
processes your videos locally on your device, so we never
have access to your content. We make no claims to ownership,
licensing, or rights over your videos, projects, or any
content you create using OpenCut.
</p>
<ul className="mb-4 list-disc space-y-2 pl-6">
<li>
Your videos remain completely private and under your
control
</li>
<li>
You retain all intellectual property rights to your
content
</li>
<li>
You can export and use your content however you choose
</li>
<li>
No watermarks, no licensing restrictions from OpenCut
</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Your Content, Your Rights</h2>
<p>
<strong>You own everything you create.</strong> OpenCut processes
basic editing locally on your device. For AI features, content is
encrypted before upload and we cannot access your original files. We
make no claims to ownership, licensing, or rights over your videos,
projects, or any content you create using OpenCut.
</p>
<ul className="list-disc space-y-2 pl-6">
<li>
Your content remains private and under your control at all times
</li>
<li>You retain all intellectual property rights to your content</li>
<li>
Even when using AI features, we cannot access your unencrypted
content
</li>
<li>You can export and use your content however you choose</li>
<li>No watermarks, no licensing restrictions from OpenCut</li>
</ul>
</section>
<section>
<h2 className="mb-4 font-semibold text-2xl">
How You Can Use OpenCut
</h2>
<p className="mb-4">
OpenCut is free for personal and commercial use. You can:
</p>
<ul className="mb-4 list-disc space-y-2 pl-6">
<li>
Create videos for personal, educational, or commercial
purposes
</li>
<li>Use OpenCut for client work and paid projects</li>
<li>Share and distribute videos created with OpenCut</li>
<li>
Modify and distribute the OpenCut software (under MIT
license)
</li>
</ul>
<p>
<strong>What we ask:</strong> Don't use OpenCut for illegal
activities, harassment, or creating harmful content. Be
respectful of others and follow applicable laws.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">How You Can Use OpenCut</h2>
<p>OpenCut is free for personal and commercial use. You can:</p>
<ul className="list-disc space-y-2 pl-6">
<li>
Create videos for personal, educational, or commercial purposes
</li>
<li>Use OpenCut for client work and paid projects</li>
<li>Share and distribute videos created with OpenCut</li>
<li>
Modify and distribute the OpenCut software (under MIT license)
</li>
</ul>
<p>
<strong>What we ask:</strong> Don't use OpenCut for illegal
activities, harassment, or creating harmful content. Be respectful of
others and follow applicable laws.
</p>
</section>
<section>
<h2 className="mb-4 font-semibold text-2xl">
Account and Service
</h2>
<p className="mb-4">
To use certain features, you may create an account:
</p>
<ul className="mb-4 list-disc space-y-2 pl-6">
<li>Provide accurate information when signing up</li>
<li>
Keep your account secure and don't share credentials
</li>
<li>You're responsible for activity under your account</li>
<li>You can delete your account at any time</li>
</ul>
<p>
OpenCut is provided "as is" without warranties. While we
strive for reliability, we can't guarantee uninterrupted
service.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">
AI Features and Data Processing
</h2>
<p>
OpenCut offers optional AI-powered features that require server
processing:
</p>
<ul className="list-disc space-y-2 pl-6">
<li>
AI features (auto captions, content analysis, etc.) are completely
optional
</li>
<li>Your content is encrypted on your device before any upload</li>
<li>
We use zero-knowledge encryption - we cannot decrypt your content
</li>
<li>Encrypted content is deleted immediately after processing</li>
<li>
You maintain full ownership and control of your content throughout
</li>
</ul>
<p>
By using AI features, you consent to the temporary, encrypted
processing of your content as described in our Privacy Policy. You can
always choose to use only local editing features.
</p>
</section>
<section>
<h2 className="mb-4 font-semibold text-2xl">
Open Source Benefits
</h2>
<p className="mb-4">
Because OpenCut is open source, you have additional rights:
</p>
<ul className="mb-4 list-disc space-y-2 pl-6">
<li>
Review our code to see exactly how we handle your data
</li>
<li>Self-host OpenCut on your own servers</li>
<li>Modify the software to suit your needs</li>
<li>Contribute improvements back to the community</li>
</ul>
<p>
View our source code and license on{" "}
<a
className="text-primary hover:underline"
href="https://github.com/OpenCut-app/OpenCut"
rel="noopener"
target="_blank"
>
GitHub
</a>
.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Account and Service</h2>
<p>To use certain features, you may create an account:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Provide accurate information when signing up</li>
<li>Keep your account secure and don't share credentials</li>
<li>You're responsible for activity under your account</li>
<li>You can delete your account at any time</li>
</ul>
<p>
OpenCut is provided "as is" without warranties. While we strive for
reliability, we can't guarantee uninterrupted service.
</p>
</section>
<section>
<h2 className="mb-4 font-semibold text-2xl">
Third-Party Content
</h2>
<p className="mb-4">
When using OpenCut, make sure you have the right to use any
content you import:
</p>
<ul className="mb-4 list-disc space-y-2 pl-6">
<li>
Only upload content you own or have permission to use
</li>
<li>
Respect copyright, trademarks, and other intellectual
property
</li>
<li>
Don't use copyrighted music, images, or videos without
permission
</li>
<li>
You're responsible for any claims related to your content
</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Open Source Benefits</h2>
<p>Because OpenCut is open source, you have additional rights:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Review our code to see exactly how we handle your data</li>
<li>Self-host OpenCut on your own servers</li>
<li>Modify the software to suit your needs</li>
<li>Contribute improvements back to the community</li>
</ul>
<p>
View our source code and license on{" "}
<a
href={SOCIAL_LINKS.github}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
GitHub
</a>
.
</p>
</section>
<section>
<h2 className="mb-4 font-semibold text-2xl">
Limitations and Liability
</h2>
<p className="mb-4">
OpenCut is provided free of charge. To the extent permitted
by law:
</p>
<ul className="mb-4 list-disc space-y-2 pl-6">
<li>We're not liable for any loss of data or content</li>
<li>
Projects are stored in your browser and may be lost if you
clear browser data
</li>
<li>We're not responsible for how you use the service</li>
<li>
Our liability is limited to the maximum extent allowed by
law
</li>
</ul>
<p>
Since your content stays on your device, we have no way to
recover lost projects. Consider exporting important videos
when finished editing.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Third-Party Content</h2>
<p>
When using OpenCut, make sure you have the right to use any content
you import:
</p>
<ul className="list-disc space-y-2 pl-6">
<li>Only upload content you own or have permission to use</li>
<li>
Respect copyright, trademarks, and other intellectual property
</li>
<li>
Don't use copyrighted music, images, or videos without permission
</li>
<li>You're responsible for any claims related to your content</li>
</ul>
</section>
<section>
<h2 className="mb-4 font-semibold text-2xl">
Service Changes
</h2>
<p className="mb-4">We may update OpenCut and these terms:</p>
<ul className="mb-4 list-disc space-y-2 pl-6">
<li>
We'll notify you of significant changes to these terms
</li>
<li>Continued use means you accept any updates</li>
<li>
You can always self-host an older version if you prefer
</li>
<li>
Major changes will be discussed with the community on
GitHub
</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Limitations and Liability</h2>
<p>
OpenCut is provided free of charge. To the extent permitted by law:
</p>
<ul className="list-disc space-y-2 pl-6">
<li>We're not liable for any loss of data or content</li>
<li>
Projects are stored in your browser and may be lost if you clear
browser data
</li>
<li>We're not responsible for how you use the service</li>
<li>Our liability is limited to the maximum extent allowed by law</li>
</ul>
<p>
Since your content stays on your device, we have no way to recover
lost projects. Consider exporting important videos when finished
editing.
</p>
</section>
<section>
<h2 className="mb-4 font-semibold text-2xl">Termination</h2>
<p className="mb-4">
You can stop using OpenCut at any time:
</p>
<ul className="mb-4 list-disc space-y-2 pl-6">
<li>Delete your account through your profile settings</li>
<li>Clear your browser data to remove local projects</li>
<li>
Your content remains yours even if you stop using OpenCut
</li>
<li>
We may suspend accounts for violations of these terms
</li>
</ul>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Service Changes</h2>
<p>We may update OpenCut and these terms:</p>
<ul className="list-disc space-y-2 pl-6">
<li>We'll notify you of significant changes to these terms</li>
<li>Continued use means you accept any updates</li>
<li>You can always self-host an older version if you prefer</li>
<li>Major changes will be discussed with the community on GitHub</li>
</ul>
</section>
<section>
<h2 className="mb-4 font-semibold text-2xl">
Contact and Disputes
</h2>
<p className="mb-4">
Questions about these terms or need to report an issue?
</p>
<p className="mb-4">
Contact us through our{" "}
<a
className="text-primary hover:underline"
href="https://github.com/OpenCut-app/OpenCut/issues"
rel="noopener"
target="_blank"
>
GitHub repository
</a>
, email us at{" "}
<a
className="text-primary hover:underline"
href="mailto:oss@opencut.app"
>
oss@opencut.app
</a>
, or reach out on{" "}
<a
className="text-primary hover:underline"
href="https://x.com/opencutapp"
rel="noopener"
target="_blank"
>
X (Twitter)
</a>
.
</p>
<p>
These terms are governed by applicable law in your
jurisdiction. We prefer to resolve disputes through friendly
discussion in our open-source community.
</p>
</section>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Termination</h2>
<p>You can stop using OpenCut at any time:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Delete your account through your profile settings</li>
<li>Clear your browser data to remove local projects</li>
<li>Your content remains yours even if you stop using OpenCut</li>
<li>We may suspend accounts for violations of these terms</li>
</ul>
</section>
<p className="mt-8 border-muted/20 border-t pt-8 text-muted-foreground text-sm">
Last updated: July 14, 2025
</p>
</CardContent>
</Card>
</div>
</div>
</main>
<Footer />
</div>
<section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold">Contact Us</h2>
<p>Questions about these terms or need to report an issue?</p>
<p>
Contact us through our{" "}
<a
href={`${SOCIAL_LINKS.github}/issues`}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
GitHub repository
</a>
, email us at{" "}
<a
href="mailto:oss@opencut.app"
className="text-primary hover:underline"
>
oss@opencut.app
</a>
, or reach out on{" "}
<a
href={SOCIAL_LINKS.x}
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
X (Twitter)
</a>
.
</p>
<p>
These terms are governed by applicable law in your jurisdiction. We
prefer to resolve disputes through friendly discussion in our
open-source community.
</p>
</section>
<Separator />
<p className="text-muted-foreground text-sm">
Last updated: July 14, 2025
</p>
</BasePage>
);
}
-191
View File
@@ -1,191 +0,0 @@
import { Header } from "@/components/header";
export default function WhyNotCapcut() {
return (
<div className="min-h-screen bg-background px-5">
<Header />
<main className="relative mt-12">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="-top-40 -right-40 absolute h-96 w-96 rounded-full bg-gradient-to-br from-muted/20 to-transparent blur-3xl" />
<div className="-left-40 absolute top-1/2 h-80 w-80 rounded-full bg-gradient-to-tr from-muted/10 to-transparent blur-3xl" />
</div>
<div className="container relative mx-auto px-4 py-16">
<div className="mx-auto max-w-6xl">
<div className="mb-20 text-center">
<h1 className="mb-6 font-bold text-5xl tracking-tight md:text-6xl">
Fuck CapCut
</h1>
<p className="mx-auto mb-8 max-w-2xl text-muted-foreground text-xl leading-relaxed">
Roasting time, so get ready motherfucker.
</p>
</div>
<div className="mx-auto max-w-4xl space-y-12">
<div>
<h2 className="mb-6 font-bold text-3xl">
Seriously, what the fuck else do you want?
</h2>
<p className="mb-6 text-lg">
You probably use CapCut and think your video editing is
special. You think your fucking TikTok with 47 transitions and
12 different fonts is going to get you some viral fame. You
think loading up every goddamn effect in their library makes
your content better. Wrong, motherfucker. Let me describe what
CapCut actually gives you:
</p>
<ul className="mb-6 list-inside list-disc space-y-2 text-lg">
<li>A paywall every time you breathe</li>
<li>Terms of service that steal your shit</li>
<li>
More "Get Pro" dialogs than a Windows 95 error message
</li>
<li>
Features that disappear behind paywalls while you're fucking
using them
</li>
<li>Bugs disguised as "premium features"</li>
</ul>
<p className="mb-6 text-lg">
<strong>Well guess what, motherfucker:</strong>
</p>
<p className="mb-6 text-lg">
You. Are. Getting. Scammed. Look at this shit. It's a fucking
video editor. Why the fuck do you need to pay $20/month just
to remove a goddamn watermark? You spent hours editing your
video and they slap their logo on it like they fucking made
it.
</p>
</div>
<div>
<h2 className="mb-6 font-bold text-3xl">
The "Get Pro" dialog is everywhere
</h2>
<p className="mb-6 text-lg">
This motherfucking dialog pops up more than ads on a pirated
movie site. Want to add a transition? Get Pro. Want to export
without their watermark? Get Pro. Want to use more than 2
fonts? Get fucking Pro, peasant.
</p>
<p className="mb-6 text-lg">
Did you seriously think you could edit a video without seeing
this dialog 47 times? You click one button and BAM - there it
is again, asking for your credit card like a desperate ex
asking for money.
</p>
</div>
<div>
<h2 className="mb-6 font-bold text-3xl">
Everything costs money now
</h2>
<p className="mb-6 text-lg">
You dumbass. You thought CapCut was free, but no. Free means
they let you open the app. Everything else costs money. Basic
shake effect? That'll be $20/month. A decent transition that
isn't "fade"? Pay up, motherfucker.
</p>
<p className="mb-6 text-lg">
Here's my favorite piece of bullshit: You import an MP3 file -
you know, AUDIO - and try to export. "Sorry, can't export
because you're using our premium extract audio feature!"
</p>
<p className="mb-6 text-lg">
<strong>
My MP3 was already fucking audio, you absolute morons.
</strong>
</p>
<p className="mb-6 text-lg">
But wait, there's more! If you drag that same MP3 to their
media panel first, then to the timeline, it magically works.
This isn't a bug, it's a fucking scam disguised as software
engineering.
</p>
</div>
<div>
<h2 className="mb-6 font-bold text-3xl">
Their Terms of Service are insane
</h2>
<p className="mb-6 text-lg">
Look at this shit. You upload your content and they basically
say "thanks for the free content, we own it now, but if Disney
sues anyone, that's your problem."
</p>
<p className="mb-6 text-lg">
<strong>CapCut's Terms of Service:</strong> We get full rights
to use, modify, distribute, and monetize everything you upload
- permanently and without paying you shit. But you're still
responsible if anything goes wrong.
</p>
<p className="mb-6 text-lg">
Translation: "We'll make money off your viral video, you
handle the lawsuits." Brilliant legal strategy, you fucks.
</p>
</div>
<div>
<h2 className="mb-6 font-bold text-3xl">
The editor is actually good
</h2>
<p className="mb-6 text-lg">
Here's the thing that makes me want to punch my monitor: the
actual video editor is fucking good. It's intuitive, powerful,
and anyone can figure it out. When it's not begging for money
every 30 seconds, it actually works well.
</p>
<p className="mb-6 text-lg">
Which makes everything else so much worse. They built
something people want to use, then turned it into a digital
slot machine. Every click might trigger a payment request.
</p>
</div>
<div>
<h2 className="mb-6 font-bold text-3xl">
This is a video editor. Look at it. You've never seen one
before.
</h2>
<p className="mb-6 text-lg">
Like the person who's never used software that doesn't
constantly beg for money, you have no fucking idea what a
video editor should be. All you've ever seen are predatory
apps disguised as creative tools.
</p>
<p className="mb-6 text-lg">
A real video editor lets you edit videos. It doesn't steal
your content. It doesn't pop up payment dialogs every 5
seconds. It doesn't charge you separately for basic features
that should be free.
</p>
</div>
<div>
<h2 className="mb-6 font-bold text-3xl">
Yes, this is fucking satire, you fuck
</h2>
<p className="mb-6 text-lg">
I'm not actually saying all video editors should be basic as
shit. What I'm saying is that all the problems we have with
video editing apps are{" "}
<strong>ones they create themselves</strong>. Video editors
aren't broken by default - they edit videos, export them, and
let you use basic features without constantly begging for
money. CapCut breaks them. They turn them into payment
processors with video editing as a side feature.
</p>
<p className="text-lg">
<em>"Good software gets out of your way."</em>
<br />- Some smart motherfucker who definitely wasn't working
at CapCut
</p>
</div>
</div>
</div>
</div>
</main>
</div>
);
}
@@ -1,184 +0,0 @@
import { PipetteIcon } from "lucide-react";
import Image from "next/image";
import { colors } from "@/data/colors";
import { cn } from "@/lib/utils";
import { useProjectStore } from "@/stores/project-store";
import { BackgroundIcon } from "./icons";
import { Button } from "./ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
type BackgroundTab = "color" | "blur";
export function BackgroundSettings() {
const { activeProject, updateBackgroundType } = useProjectStore();
// ✅ Good: derive activeTab from activeProject during rendering
const activeTab = activeProject?.backgroundType || "color";
const handleColorSelect = (color: string) => {
updateBackgroundType("color", { backgroundColor: color });
};
const handleBlurSelect = (blurIntensity: number) => {
updateBackgroundType("blur", { blurIntensity });
};
const tabs = [
{
label: "Color",
value: "color",
},
{
label: "Blur",
value: "blur",
},
];
return (
<Popover>
<PopoverTrigger asChild>
<Button
className="!size-4 border border-muted-foreground"
size="icon"
variant="text"
>
<BackgroundIcon className="!size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="flex h-[16rem] w-[20rem] flex-col items-start overflow-hidden p-0">
<div className="z-10 flex w-full items-center justify-between gap-2 bg-popover p-3">
<h2 className="text-sm">Background</h2>
<div className="flex items-center gap-2 text-sm">
{tabs.map((tab) => (
<span
className={cn(
"cursor-pointer text-muted-foreground",
activeTab === tab.value && "text-foreground"
)}
key={tab.value}
onClick={() => {
// Switch to the background type when clicking tabs
if (tab.value === "color") {
updateBackgroundType("color", {
backgroundColor:
activeProject?.backgroundColor || "#000000",
});
} else {
updateBackgroundType("blur", {
blurIntensity: activeProject?.blurIntensity || 8,
});
}
}}
>
{tab.label}
</span>
))}
</div>
</div>
{activeTab === "color" ? (
<ColorView
onColorSelect={handleColorSelect}
selectedColor={activeProject?.backgroundColor || "#000000"}
/>
) : (
<BlurView
onBlurSelect={handleBlurSelect}
selectedBlur={activeProject?.blurIntensity || 8}
/>
)}
</PopoverContent>
</Popover>
);
}
function ColorView({
selectedColor,
onColorSelect,
}: {
selectedColor: string;
onColorSelect: (color: string) => void;
}) {
return (
<div className="h-full w-full">
<div className="pointer-events-none absolute top-8 left-0 h-12 w-[calc(100%-1rem)] bg-gradient-to-b from-popover to-transparent" />
<div className="grid h-full w-full grid-cols-4 gap-2 overflow-auto p-3 pt-0">
<div className="flex aspect-square w-full cursor-pointer items-center justify-center rounded-sm border border-foreground/15 hover:border-primary">
<PipetteIcon className="size-4" />
</div>
{colors.map((color) => (
<ColorItem
color={color}
isSelected={color === selectedColor}
key={color}
onClick={() => onColorSelect(color)}
/>
))}
</div>
</div>
);
}
function ColorItem({
color,
isSelected,
onClick,
}: {
color: string;
isSelected: boolean;
onClick: () => void;
}) {
return (
<div
className={cn(
"aspect-square w-full cursor-pointer rounded-sm hover:border-2 hover:border-primary",
isSelected && "border-2 border-primary"
)}
onClick={onClick}
style={{ backgroundColor: color }}
/>
);
}
function BlurView({
selectedBlur,
onBlurSelect,
}: {
selectedBlur: number;
onBlurSelect: (blurIntensity: number) => void;
}) {
const blurLevels = [
{ label: "Light", value: 4 },
{ label: "Medium", value: 8 },
{ label: "Heavy", value: 18 },
];
const blurImage =
"https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";
return (
<div className="grid w-full grid-cols-3 gap-2 p-3 pt-0">
{blurLevels.map((blur) => (
<div
className={cn(
"relative aspect-square w-full cursor-pointer overflow-hidden rounded-sm hover:border-2 hover:border-primary",
selectedBlur === blur.value && "border-2 border-primary"
)}
key={blur.value}
onClick={() => onBlurSelect(blur.value)}
>
<Image
alt={`Blur preview ${blur.label}`}
className="object-cover"
fill
src={blurImage}
style={{ filter: `blur(${blur.value}px)` }}
/>
<div className="absolute right-1 bottom-1 left-1 text-center">
<span className="rounded bg-black/50 px-1 text-white text-xs">
{blur.label}
</span>
</div>
</div>
))}
</div>
);
}
@@ -1,67 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function DeleteProjectDialog({
isOpen,
onOpenChange,
onConfirm,
projectName,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
projectName?: string;
}) {
return (
<Dialog onOpenChange={onOpenChange} open={isOpen}>
<DialogContent
onOpenAutoFocus={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<DialogHeader>
<DialogTitle>
{projectName ? (
<>
{"Delete '"}
<span className="inline-block max-w-[300px] truncate align-bottom">
{projectName}
</span>
{"'?"}
</>
) : (
"Delete Project?"
)}
</DialogTitle>
<DialogDescription>
Are you sure you want to delete this project? This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenChange(false);
}}
variant="outline"
>
Cancel
</Button>
<Button onClick={onConfirm} variant="destructive">
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,154 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { formatTimeCode, parseTimeCode } from "@/lib/time";
import type { TTimeCode } from "@/types/time";
import { cn } from "@/utils/ui";
interface EditableTimecodeProps {
time: number;
duration: number;
format?: TTimeCode;
fps: number;
onTimeChange?: ({ time }: { time: number }) => void;
className?: string;
disabled?: boolean;
}
export function EditableTimecode({
time,
duration,
format = "HH:MM:SS:FF",
fps,
onTimeChange,
className,
disabled = false,
}: EditableTimecodeProps) {
const [isEditing, setIsEditing] = useState(false);
const [inputValue, setInputValue] = useState("");
const [hasError, setHasError] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const enterPressedRef = useRef(false);
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps });
const startEditing = () => {
if (disabled) return;
setIsEditing(true);
setInputValue(formattedTime);
setHasError(false);
enterPressedRef.current = false;
};
const cancelEditing = () => {
setIsEditing(false);
setInputValue("");
setHasError(false);
enterPressedRef.current = false;
};
const applyEdit = () => {
const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
if (parsedTime === null) {
setHasError(true);
return;
}
const clampedTime = Math.max(
0,
duration ? Math.min(duration, parsedTime) : parsedTime,
);
onTimeChange?.({ time: clampedTime });
setIsEditing(false);
setInputValue("");
setHasError(false);
enterPressedRef.current = false;
};
const handleKeyDown = ({
key,
preventDefault,
}: React.KeyboardEvent<HTMLInputElement>) => {
if (key === "Enter") {
preventDefault();
enterPressedRef.current = true;
applyEdit();
} else if (key === "Escape") {
preventDefault();
cancelEditing();
}
};
const handleInputChange = ({
target,
}: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(target.value);
setHasError(false);
};
const handleBlur = () => {
if (!enterPressedRef.current && isEditing) {
applyEdit();
}
};
const handleDisplayKeyDown = ({
key,
preventDefault,
}: React.KeyboardEvent<HTMLButtonElement>) => {
if (disabled) return;
if (key === "Enter" || key === " ") {
preventDefault();
startEditing();
}
};
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
if (isEditing) {
return (
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className={cn(
"border-none bg-transparent font-mono text-xs outline-none",
"focus:bg-background focus:border-primary focus:rounded focus:border focus:px-1",
"text-primary tabular-nums",
hasError && "text-destructive focus:border-destructive",
className,
)}
style={{ width: `${formattedTime.length + 1}ch` }}
placeholder={formattedTime}
/>
);
}
return (
<button
type="button"
onClick={startEditing}
onKeyDown={handleDisplayKeyDown}
disabled={disabled}
className={cn(
"text-primary cursor-pointer font-mono text-xs tabular-nums",
"hover:bg-muted/50 -mx-1 px-1 hover:rounded",
disabled && "cursor-default hover:bg-transparent",
className,
)}
title={disabled ? undefined : "Click to edit time"}
>
{formattedTime}
</button>
);
}
-125
View File
@@ -1,125 +0,0 @@
"use client";
import { ChevronLeft, Download } from "lucide-react";
import Link from "next/link";
import { useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import { formatTimeCode } from "@/lib/time";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base";
import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help";
import { Button } from "./ui/button";
export function EditorHeader() {
const { getTotalDuration } = useTimelineStore();
const { activeProject, renameProject } = useProjectStore();
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(activeProject?.name || "");
const inputRef = useRef<HTMLInputElement>(null);
const handleExport = () => {};
const handleNameClick = () => {
if (!activeProject) {
return;
}
setNewName(activeProject.name);
setIsEditing(true);
};
const handleNameSave = async () => {
if (activeProject && newName.trim() && newName !== activeProject.name) {
try {
await renameProject(activeProject.id, newName.trim());
} catch (_error) {
setNewName(activeProject.name);
}
}
setIsEditing(false);
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleNameSave();
} else if (e.key === "Escape") {
setIsEditing(false);
}
};
const leftContent = (
<div className="flex items-center gap-2">
<Link
className="flex items-center gap-2 font-medium tracking-tight transition-opacity hover:opacity-80"
href="/projects"
>
<ChevronLeft className="h-4 w-4" />
</Link>
<div className="flex h-9 w-[14rem] items-center">
{isEditing ? (
<Input
aria-label="Project name"
autoFocus
className="h-9 truncate px-2 py-1 font-medium text-sm"
maxLength={64}
onBlur={handleNameSave}
onChange={(e) => setNewName(e.target.value)}
onFocus={(e) => e.target.select()}
onKeyDown={handleInputKeyDown}
ref={inputRef}
value={newName}
/>
) : (
<span
className="cursor-pointer font-medium text-sm hover:underline"
onClick={handleNameClick}
onKeyDown={(e) => e.key === "Enter" && handleNameClick()}
role="button"
tabIndex={0}
title="Click to rename"
>
<div className="w-40 overflow-clip truncate text-ellipsis">
{activeProject?.name}
</div>
</span>
)}
</div>
</div>
);
const centerContent = (
<div className="flex items-center gap-2 text-xs">
<span>
{formatTimeCode(
getTotalDuration(),
"HH:MM:SS:FF",
activeProject?.fps || 30
)}
</span>
</div>
);
const rightContent = (
<nav className="flex items-center gap-2">
<KeyboardShortcutsHelp />
<Button
className="h-7 text-xs"
onClick={handleExport}
size="sm"
variant="primary"
>
<Download className="h-4 w-4" />
<span className="text-sm">Export</span>
</Button>
</nav>
);
return (
<HeaderBase
centerContent={centerContent}
className="h-[3.2rem] items-center bg-background px-4"
leftContent={leftContent}
rightContent={rightContent}
/>
);
}
@@ -1,53 +0,0 @@
"use client";
import { Loader2 } from "lucide-react";
import { useEffect } from "react";
import { useEditorActions } from "@/hooks/use-editor-actions";
import {
useKeybindingDisabler,
useKeybindingsListener,
} from "@/hooks/use-keybindings";
import { useEditorStore } from "@/stores/editor-store";
interface EditorProviderProps {
children: React.ReactNode;
}
export function EditorProvider({ children }: EditorProviderProps) {
const { isInitializing, isPanelsReady, initializeApp } = useEditorStore();
const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
// Set up action handlers
useEditorActions();
// Set up keybinding listener
useKeybindingsListener();
// Disable keybindings when initializing
useEffect(() => {
if (isInitializing || !isPanelsReady) {
disableKeybindings();
} else {
enableKeybindings();
}
}, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]);
useEffect(() => {
initializeApp();
}, [initializeApp]);
// Show loading screen while initializing
if (isInitializing || !isPanelsReady) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground text-sm">Loading editor...</p>
</div>
</div>
);
}
// App is ready, render children
return <>{children}</>;
}
@@ -1,118 +0,0 @@
import type React from "react";
import { 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,
normalize: true,
interact: false,
});
// Event listeners
wavesurfer.current.on("ready", () => {
if (mounted) {
setIsLoading(false);
setError(false);
}
});
wavesurfer.current.on("error", (_err) => {
if (mounted) {
setError(true);
setIsLoading(false);
}
});
await wavesurfer.current.load(audioUrl);
} catch (_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-foreground/60 text-xs">Audio unavailable</span>
</div>
);
}
return (
<div className={`relative ${className}`}>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-foreground/60 text-xs">Loading...</span>
</div>
)}
<div
className={`w-full transition-opacity duration-200 ${isLoading ? "opacity-0" : "opacity-100"}`}
ref={waveformRef}
style={{ height }}
/>
</div>
);
};
export default AudioWaveform;
@@ -0,0 +1,84 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
export function DeleteProjectDialog({
isOpen,
onOpenChange,
onConfirm,
projectNames,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
projectNames: string[];
}) {
const count = projectNames.length;
const isSingle = count === 1;
const singleName = isSingle ? projectNames[0] : null;
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
onOpenAutoFocus={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<DialogHeader>
<DialogTitle>
{singleName ? (
<>
{"Delete '"}
<span className="inline-block max-w-[300px] truncate align-bottom">
{singleName}
</span>
{"'?"}
</>
) : (
`Delete ${count} projects?`
)}
</DialogTitle>
</DialogHeader>
<DialogBody>
<Alert variant="destructive">
<AlertTitle>Warning</AlertTitle>
<AlertDescription>
This will permanently delete{" "}
{singleName ? `"${singleName}"` : `${count} projects`} and all
associated files.
</AlertDescription>
</Alert>
<div className="flex flex-col gap-3">
<Label className="text-xs font-semibold text-slate-500">
Type "DELETE" to confirm
</Label>
<Input
type="text"
placeholder="DELETE"
size="lg"
variant="destructive"
/>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm}>
Delete project
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,44 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useEditor } from "@/hooks/use-editor";
import { Loader2 } from "lucide-react";
export function MigrationDialog() {
const editor = useEditor();
const migrationState = editor.project.getMigrationState();
if (!migrationState.isMigrating) return null;
const title = migrationState.projectName
? "Updating project"
: "Updating projects";
const description = migrationState.projectName
? `Upgrading "${migrationState.projectName}" from v${migrationState.fromVersion} to v${migrationState.toVersion}`
: `Upgrading projects from v${migrationState.fromVersion} to v${migrationState.toVersion}`;
return (
<Dialog open={true}>
<DialogContent
className="sm:max-w-md"
onPointerDownOutside={(event) => event.preventDefault()}
onEscapeKeyDown={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center py-4">
<Loader2 className="text-muted-foreground size-8 animate-spin" />
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,83 @@
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { TProjectMetadata } from "@/types/project";
import { formatDate } from "@/utils/date";
import { formatTimeCode } from "@/lib/time";
import { Button } from "@/components/ui/button";
function InfoRow({
label,
value,
}: {
label: string;
value: string | React.ReactNode;
}) {
return (
<div className="flex justify-between items-center py-0 last:pb-0">
<span className="text-muted-foreground text-sm">{label}</span>
<span className="text-sm font-medium">{value}</span>
</div>
);
}
export function ProjectInfoDialog({
isOpen,
onOpenChange,
project,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
project: TProjectMetadata;
}) {
const durationFormatted =
project.duration > 0
? formatTimeCode({
timeInSeconds: project.duration,
format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS",
})
: "0:00";
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent onOpenAutoFocus={(event) => event.preventDefault()}>
<DialogHeader>
<DialogTitle className="truncate max-w-[350px]">
{project.name}
</DialogTitle>
</DialogHeader>
<DialogBody className="flex flex-col">
<InfoRow label="Duration" value={durationFormatted} />
<InfoRow
label="Created"
value={formatDate({ date: project.createdAt })}
/>
<InfoRow
label="Modified"
value={formatDate({ date: project.updatedAt })}
/>
<InfoRow
label="Project ID"
value={
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{project.id.slice(0, 8)}
</code>
}
/>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button onClick={() => onOpenChange(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,14 +1,15 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { Label } from "@/components/ui/label";
export function RenameProjectDialog({
isOpen,
@@ -18,12 +19,11 @@ export function RenameProjectDialog({
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (name: string) => void;
onConfirm: (newName: string) => void;
projectName: string;
}) {
const [name, setName] = useState(projectName);
// Reset the name when dialog opens - this is better UX than syncing with every prop change
const handleOpenChange = (open: boolean) => {
if (open) {
setName(projectName);
@@ -32,36 +32,35 @@ export function RenameProjectDialog({
};
return (
<Dialog onOpenChange={handleOpenChange} open={isOpen}>
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Project</DialogTitle>
<DialogDescription>
Enter a new name for your project.
</DialogDescription>
<DialogTitle>Rename project</DialogTitle>
</DialogHeader>
<Input
className="mt-4"
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm(name);
}
}}
placeholder="Enter a new name"
value={name}
/>
<DialogBody className="gap-3">
<Label>New name</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm(name);
}
}}
placeholder="Enter a new name"
/>
</DialogBody>
<DialogFooter>
<Button
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenChange(false);
}}
variant="outline"
>
Cancel
</Button>
@@ -0,0 +1,229 @@
"use client";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
type KeyboardShortcut,
useKeyboardShortcutsHelp,
} from "@/hooks/use-keyboard-shortcuts-help";
import { useKeybindingsStore } from "@/stores/keybindings-store";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function ShortcutsDialog({
isOpen,
onOpenChange,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [recordingShortcut, setRecordingShortcut] =
useState<KeyboardShortcut | null>(null);
const {
updateKeybinding,
removeKeybinding,
getKeybindingString,
validateKeybinding,
getKeybindingsForAction,
setIsRecording,
resetToDefaults,
isRecording,
} = useKeybindingsStore();
const { shortcuts } = useKeyboardShortcutsHelp();
const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
useEffect(() => {
if (!isRecording || !recordingShortcut) return;
const handleKeyDown = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
const keyString = getKeybindingString(e);
if (keyString) {
const conflict = validateKeybinding(
keyString,
recordingShortcut.action,
);
if (conflict) {
toast.error(
`Key "${keyString}" is already bound to "${conflict.existingAction}"`,
);
setRecordingShortcut(null);
return;
}
const oldKeys = getKeybindingsForAction(recordingShortcut.action);
for (const key of oldKeys) {
removeKeybinding(key);
}
updateKeybinding(keyString, recordingShortcut.action);
setIsRecording(false);
setRecordingShortcut(null);
}
};
const handleClickOutside = () => {
setRecordingShortcut(null);
setIsRecording(false);
};
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("click", handleClickOutside);
};
}, [
recordingShortcut,
getKeybindingString,
updateKeybinding,
removeKeybinding,
validateKeybinding,
getKeybindingsForAction,
setIsRecording,
isRecording,
]);
const handleStartRecording = (shortcut: KeyboardShortcut) => {
setRecordingShortcut(shortcut);
setIsRecording(true);
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col p-0">
<DialogHeader>
<DialogTitle>Keyboard shortcuts</DialogTitle>
</DialogHeader>
<DialogBody className="scrollbar-thin flex-grow overflow-y-auto">
<div className="flex flex-col gap-6">
{categories.map((category) => (
<div key={category} className="flex flex-col gap-1">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{category}
</h3>
<div className="flex flex-col gap-1">
{shortcuts
.filter((shortcut) => shortcut.category === category)
.map((shortcut) => (
<ShortcutItem
key={shortcut.action}
shortcut={shortcut}
isRecording={
shortcut.action === recordingShortcut?.action
}
onStartRecording={() => handleStartRecording(shortcut)}
/>
))}
</div>
</div>
))}
</div>
</DialogBody>
<DialogFooter>
<Button variant="destructive" onClick={resetToDefaults}>
Reset to default
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ShortcutItem({
shortcut,
isRecording,
onStartRecording,
}: {
shortcut: KeyboardShortcut;
isRecording: boolean;
onStartRecording: (params: { shortcut: KeyboardShortcut }) => void;
}) {
const displayKeys = shortcut.keys.filter((key: string) => {
if (
key.includes("Cmd") &&
shortcut.keys.includes(key.replace("Cmd", "Ctrl"))
)
return false;
return true;
});
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{shortcut.icon && (
<div className="text-muted-foreground">{shortcut.icon}</div>
)}
<span className="text-sm">{shortcut.description}</span>
</div>
<div className="flex items-center gap-2">
{displayKeys.map((key: string, index: number) => (
<div key={key} className="flex items-center gap-2">
<div className="flex items-center gap-1">
{key.split("+").map((keyPart: string, partIndex: number) => {
const keyId = `${shortcut.id}-${index}-${partIndex}`;
return (
<EditableShortcutKey
key={keyId}
isRecording={isRecording}
onStartRecording={() => onStartRecording({ shortcut })}
>
{keyPart}
</EditableShortcutKey>
);
})}
</div>
{index < displayKeys.length - 1 && (
<span className="text-muted-foreground text-xs">or</span>
)}
</div>
))}
</div>
</div>
);
}
function EditableShortcutKey({
children,
isRecording,
onStartRecording,
}: {
children: React.ReactNode;
isRecording: boolean;
onStartRecording: () => void;
}) {
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onStartRecording();
};
return (
<Button
variant="outline"
size="sm"
onClick={handleClick}
title={
isRecording ? "Press any key combination..." : "Click to edit shortcut"
}
>
{children}
</Button>
);
}
@@ -0,0 +1,187 @@
"use client";
import { Button } from "../ui/button";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import Link from "next/link";
import { RenameProjectDialog } from "./dialogs/rename-project-dialog";
import { DeleteProjectDialog } from "./dialogs/delete-project-dialog";
import { useRouter } from "next/navigation";
import { FaDiscord } from "react-icons/fa6";
import { ExportButton } from "./export-button";
import { ThemeToggle } from "../theme-toggle";
import { SOCIAL_LINKS } from "@/constants/site-constants";
import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
import {
ArrowLeft02Icon,
Edit03Icon,
Delete02Icon,
CommandIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
export function EditorHeader() {
return (
<header className="bg-background flex h-[3.2rem] items-center justify-between px-3 pt-0.5">
<div className="flex items-center gap-2">
<ProjectDropdown />
</div>
<nav className="flex items-center gap-2">
<ExportButton />
<ThemeToggle />
</nav>
</header>
);
}
function ProjectDropdown() {
const [openDialog, setOpenDialog] = useState<
"delete" | "rename" | "shortcuts" | null
>(null);
const [isExiting, setIsExiting] = useState(false);
const router = useRouter();
const editor = useEditor();
const activeProject = editor.project.getActive();
const handleExit = async () => {
if (isExiting) return;
setIsExiting(true);
try {
await editor.project.prepareExit();
editor.project.closeProject();
} catch (error) {
console.error("Failed to prepare project exit:", error);
} finally {
editor.project.closeProject();
router.push("/projects");
}
};
const handleSaveProjectName = async (newName: string) => {
if (
activeProject &&
newName.trim() &&
newName !== activeProject.metadata.name
) {
try {
await editor.project.renameProject({
id: activeProject.metadata.id,
name: newName.trim(),
});
} catch (error) {
toast.error("Failed to rename project", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
setOpenDialog(null);
}
}
};
const handleDeleteProject = async () => {
if (activeProject) {
try {
await editor.project.deleteProjects({
ids: [activeProject.metadata.id],
});
router.push("/projects");
} catch (error) {
toast.error("Failed to delete project", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
setOpenDialog(null);
}
}
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
className="flex h-auto items-center justify-center px-2.5 py-1.5"
>
<ChevronDown className="text-muted-foreground" />
<span className="mr-2 text-[0.85rem]">
{activeProject?.metadata.name}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-100 w-52">
<DropdownMenuItem
className="flex items-center gap-1.5"
onClick={handleExit}
disabled={isExiting}
>
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
Exit project
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-1.5"
onClick={() => setOpenDialog("rename")}
>
<HugeiconsIcon icon={Edit03Icon} className="size-4" />
Rename project
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="flex items-center gap-1.5"
onClick={() => setOpenDialog("delete")}
>
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
Delete project
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center gap-1.5"
onClick={() => setOpenDialog("shortcuts")}
>
<HugeiconsIcon icon={CommandIcon} className="size-4" />
Keyboard shortcuts
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={SOCIAL_LINKS.discord}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5"
>
<FaDiscord className="size-4" />
Discord
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<RenameProjectDialog
isOpen={openDialog === "rename"}
onOpenChange={(isOpen) => setOpenDialog(isOpen ? "rename" : null)}
onConfirm={(newName) => handleSaveProjectName(newName)}
projectName={activeProject?.metadata.name || ""}
/>
<DeleteProjectDialog
isOpen={openDialog === "delete"}
onOpenChange={(isOpen) => setOpenDialog(isOpen ? "delete" : null)}
onConfirm={handleDeleteProject}
projectNames={[activeProject?.metadata.name || ""]}
/>
<ShortcutsDialog
isOpen={openDialog === "shortcuts"}
onOpenChange={(isOpen) => setOpenDialog(isOpen ? "shortcuts" : null)}
/>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More