codebase overhaul (#697)

This commit is contained in:
Maze 2026-01-31 00:20:04 +01:00 committed by GitHub
parent 0173db9944
commit 7bf0984698
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
469 changed files with 36234 additions and 32981 deletions

File diff suppressed because it is too large Load Diff

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.

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.

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.

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

View File

@ -6,7 +6,7 @@ alwaysApply: true
# Project Context # 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. Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's formatter.
## Key Principles ## Key Principles
@ -43,7 +43,6 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
### React and JSX Best Practices ### React and JSX Best Practices
- Don't import `React` itself. - Don't import `React` itself.
- Don't define React components inside other components.
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element. - Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
- Don't insert comments as text nodes. - Don't insert comments as text nodes.
- Use `<>...</>` instead of `<Fragment>...</Fragment>`. - Use `<>...</>` instead of `<Fragment>...</Fragment>`.

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.

View File

@ -1 +0,0 @@
!apps/web/.env.example

View File

@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our Examples of behavior that contributes to a positive environment for our
community include: community include:
* Demonstrating empathy and kindness toward other people - Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences - Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback - Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, - Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience 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 community
Examples of unacceptable behavior include: 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 any kind
* Trolling, insulting or derogatory comments, and personal or political attacks - Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment - Public or private harassment
* Publishing others' private information, such as a physical or email address, - Publishing others' private information, such as a physical or email address,
without their explicit permission 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 professional setting
## Enforcement Responsibilities ## Enforcement Responsibilities

View File

@ -98,11 +98,11 @@ If you're unsure whether your idea falls into the preview category, feel free to
```bash ```bash
# Database (matches docker-compose.yaml) # 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 # Generate a secure secret for Better Auth
BETTER_AUTH_SECRET="your-generated-secret-here" 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) # Redis (matches docker-compose.yaml)
UPSTASH_REDIS_REST_URL="http://localhost:8079" UPSTASH_REDIS_REST_URL="http://localhost:8079"

View File

@ -1,6 +1,6 @@
name: Bug report name: Bug report
description: Create a report to help us improve description: Create a report to help us improve
title: '[BUG] ' title: "[BUG] "
labels: bug labels: bug
body: body:
- type: input - type: input
@ -15,7 +15,7 @@ body:
id: Browser id: Browser
attributes: attributes:
label: Browser 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 placeholder: e.g. Chrome 137, Firefox 137, Safari 17
validations: validations:
required: true 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. Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations: validations:
required: false required: false

View File

@ -1,6 +1,6 @@
name: Feature request name: Feature request
description: Suggest an idea for OpenCut description: Suggest an idea for OpenCut
title: '[FEATURE] ' title: "[FEATURE] "
labels: enhancement labels: enhancement
body: body:
- type: markdown - 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. Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations: validations:
required: false required: false

2
.github/SECURITY.md vendored
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 provide a detailed response within 5 business days
- We will keep you updated on our progress - We will keep you updated on our progress
Thank you for helping keep OpenCut secure! Thank you for helping keep OpenCut secure!

6
.github/SUPPORT.md vendored
View File

@ -3,21 +3,25 @@
Thanks for using OpenCut! If you need help, here are your options: Thanks for using OpenCut! If you need help, here are your options:
## Documentation ## Documentation
- Check our [README](../README.md) for basic setup instructions - Check our [README](../README.md) for basic setup instructions
- Review the [Contributing Guidelines](CONTRIBUTING.md) for development setup - Review the [Contributing Guidelines](CONTRIBUTING.md) for development setup
## Issues ## Issues
- **Bug reports**: Use the bug report template - **Bug reports**: Use the bug report template
- **Feature requests**: Use the feature request template - **Feature requests**: Use the feature request template
- **Questions**: Use GitHub Discussions for general questions - **Questions**: Use GitHub Discussions for general questions
## Community ## Community
- Join our discussions on GitHub - Join our discussions on GitHub
- Follow the [Code of Conduct](CODE_OF_CONDUCT.md) - Follow the [Code of Conduct](CODE_OF_CONDUCT.md)
## Response Times ## Response Times
- Issues are typically triaged within 2-3 business days - Issues are typically triaged within 2-3 business days
- Feature requests may take longer to evaluate - Feature requests may take longer to evaluate
- Security issues are handled with priority - 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!

View File

@ -3,15 +3,18 @@ applyTo: "**/*.{ts,tsx,js,jsx}"
--- ---
# Project Context # 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. 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 ## Key Principles
- Zero configuration required - Zero configuration required
- Subsecond performance - Subsecond performance
- Maximum type safety - Maximum type safety
- AI-friendly code generation - AI-friendly code generation
## Before Writing Code ## Before Writing Code
1. Analyze existing patterns in the codebase 1. Analyze existing patterns in the codebase
2. Consider edge cases and error scenarios 2. Consider edge cases and error scenarios
3. Follow the rules below strictly 3. Follow the rules below strictly
@ -20,6 +23,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
## Rules ## Rules
### Accessibility (a11y) ### Accessibility (a11y)
- Don't use `accessKey` attribute on any HTML element. - Don't use `accessKey` attribute on any HTML element.
- Don't set `aria-hidden="true"` on focusable elements. - Don't set `aria-hidden="true"` on focusable elements.
- Don't add ARIA roles, states, and properties to elements that don't support them. - 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. - Use correct ISO language/country codes for the `lang` attribute.
### Code Complexity and Quality ### Code Complexity and Quality
- Don't use consecutive spaces in regular expression literals. - Don't use consecutive spaces in regular expression literals.
- Don't use the `arguments` object. - Don't use the `arguments` object.
- Don't use primitive type aliases or misleading types. - 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. - Don't use literal numbers that lose precision.
### React and JSX Best Practices ### React and JSX Best Practices
- Don't use the return value of React.render. - Don't use the return value of React.render.
- Make sure all dependencies are correctly specified in React hooks. - Make sure all dependencies are correctly specified in React hooks.
- Make sure all React hooks are called from the top level of component functions. - 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. - Watch out for possible "wrong" semicolons inside JSX elements.
### Correctness and Safety ### Correctness and Safety
- Don't assign a value to itself. - Don't assign a value to itself.
- Don't return a value from a setter. - Don't return a value from a setter.
- Don't compare expressions that modify string case with non-compliant values. - 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 bitwise operators.
- Don't use expressions where the operation doesn't change the value. - Don't use expressions where the operation doesn't change the value.
- Make sure Promise-like statements are handled appropriately. - 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. - Prevent import cycles.
- Don't use configured elements. - Don't use configured elements.
- Don't hardcode sensitive data like API keys and tokens. - 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"`. - Don't use `target="_blank"` without `rel="noopener"`.
### TypeScript Best Practices ### TypeScript Best Practices
- Don't use TypeScript enums. - Don't use TypeScript enums.
- Don't export imported variables. - Don't export imported variables.
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions. - 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. - Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
### Style and Consistency ### Style and Consistency
- Don't use global `eval()`. - Don't use global `eval()`.
- Don't use callbacks in asynchronous tests and hooks. - Don't use callbacks in asynchronous tests and hooks.
- Don't use negation in `if` statements that have `else` clauses. - 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. - Make sure to use the "use strict" directive in script files.
### Next.js Specific Rules ### Next.js Specific Rules
- Don't use `<img>` elements in Next.js projects. - Don't use `<img>` elements in Next.js projects.
- Don't use `<head>` 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 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 use the next/head module in pages/\_document.js on Next.js projects.
### Testing Best Practices ### Testing Best Practices
- Don't use export or module.exports in test files. - Don't use export or module.exports in test files.
- Don't use focused tests. - Don't use focused tests.
- Make sure the assertion function, like expect, is placed inside an it() function call. - Make sure the assertion function, like expect, is placed inside an it() function call.
- Don't use disabled tests. - Don't use disabled tests.
## Common Tasks ## Common Tasks
- `npx ultracite init` - Initialize Ultracite in your project - `npx ultracite init` - Initialize Ultracite in your project
- `npx ultracite format` - Format and fix code automatically - `npx ultracite format` - Format and fix code automatically
- `npx ultracite lint` - Check for issues without fixing - `npx ultracite lint` - Check for issues without fixing
## Example: Error Handling ## Example: Error Handling
```typescript ```typescript
// ✅ Good: Comprehensive error handling // ✅ Good: Comprehensive error handling
try { try {
const result = await fetchData(); const result = await fetchData();
return { success: true, data: result }; return { success: true, data: result };
} catch (error) { } catch (error) {
console.error('API call failed:', error); console.error("API call failed:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
@ -328,4 +341,4 @@ try {
} catch (e) { } catch (e) {
console.log(e); console.log(e);
} }
``` ```

View File

@ -24,9 +24,10 @@ Please describe the tests that you ran to verify your changes. Provide instructi
- [ ] Test B - [ ] Test B
**Test Configuration**: **Test Configuration**:
* Node version:
* Browser (if applicable): - Node version:
* Operating System: - Browser (if applicable):
- Operating System:
## Screenshots (if applicable) ## Screenshots (if applicable)
@ -46,4 +47,4 @@ Add screenshots to help explain your changes.
## Additional context ## Additional context
Add any other context about the pull request here. Add any other context about the pull request here.

View File

@ -22,9 +22,9 @@ jobs:
os: [ubuntu-latest, windows-latest, macos-latest] os: [ubuntu-latest, windows-latest, macos-latest]
env: env:
DATABASE_URL: "postgresql://opencut:opencutthegoat@localhost:5432/opencut" DATABASE_URL: "postgresql://opencut:opencut@localhost:5432/opencut"
BETTER_AUTH_SECRET: "supersecret" 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_URL: "https://your-upstash-redis-url"
UPSTASH_REDIS_REST_TOKEN: "your-upstash-redis-token" UPSTASH_REDIS_REST_TOKEN: "your-upstash-redis-token"

25
.gitignore vendored
View File

@ -1,22 +1,5 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # 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 # asdf version management
.tool-versions .tool-versions
@ -24,11 +7,11 @@ node_modules
.cursorignore .cursorignore
.turbo .turbo
*.env .env*
!*.env.example
# cursor # cursor
bun.lockb bun.lockb
apps/transcription/__pycache__
apps/transcription/env
apps/bg-remover/env # Twiggy
.cursor/rules/file-structure.mdc

View File

@ -1 +0,0 @@
npx ultracite format

10
.vscode/settings.json vendored
View File

@ -1,14 +1,20 @@
{ {
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][css][graphql]": { "[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][css][graphql]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.formatOnPaste": true, "editor.formatOnPaste": true,
"emmet.showExpandedAbbreviation": "never",
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit", "source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit" "source.organizeImports.biome": "explicit"
},
"emmet.showExpandedAbbreviation": "never",
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
} }
} }

121
AGENTS.md Normal file
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)".

168
CLAUDE.md
View File

@ -1,168 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
OpenCut is a free, open-source video editor built with Next.js, focusing on privacy (no server processing), multi-track timeline editing, and real-time preview. The project is a monorepo using Turborepo with multiple apps including a web application, desktop app (Tauri), background remover tools, and transcription services.
## Essential Commands
**Development:**
```bash
# Root level development
bun dev # Start all apps in development mode
bun build # Build all apps
bun lint # Lint all code using Ultracite
bun format # Format all code using Ultracite
# Web app specific (from apps/web/)
cd apps/web
bun run dev # Start Next.js development server with Turbopack
bun run build # Build for production
bun run lint # Run Biome linting
bun run lint:fix # Fix linting issues automatically
bun run format # Format code with Biome
# Database operations (from apps/web/)
bun run db:generate # Generate Drizzle migrations
bun run db:migrate # Run migrations
bun run db:push:local # Push schema to local development database
bun run db:push:prod # Push schema to production database
```
**Testing:**
- No unified test commands are currently configured
- Individual apps may have their own test setups
## Architecture & Key Components
### State Management
The application uses **Zustand** for state management with separate stores for different concerns:
- **editor-store.ts**: Canvas presets, layout guides, app initialization
- **timeline-store.ts**: Timeline tracks, elements, playback state
- **media-store.ts**: Media files and asset management
- **playback-store.ts**: Video playback controls and timing
- **project-store.ts**: Project-level data and persistence
- **panel-store.ts**: UI panel visibility and layout
- **keybindings-store.ts**: Keyboard shortcut management
- **sounds-store.ts**: Audio effects and sound management
- **stickers-store.ts**: Sticker/graphics management
### Storage System
**Multi-layer storage approach:**
- **IndexedDB**: Projects, saved sounds, and structured data
- **OPFS (Origin Private File System)**: Large media files for better performance
- **Storage Service** (`lib/storage/`): Abstraction layer managing both storage types
### Editor Architecture
**Core editor components:**
- **Timeline Canvas**: Custom canvas-based timeline with tracks and elements
- **Preview Panel**: Real-time video preview (currently DOM-based, planned binary refactor)
- **Media Panel**: Asset management with drag-and-drop support
- **Properties Panel**: Context-sensitive element properties
### Media Processing
- **FFmpeg Integration**: Client-side video processing using @ffmpeg/ffmpeg
- **Background Removal**: Python-based tools with multiple AI models (U2Net, SAM, Gemini)
- **Transcription**: Separate service for audio-to-text conversion
## Development Focus Areas
**✅ Recommended contribution areas:**
- Timeline functionality and UI improvements
- Project management features
- Performance optimizations
- Bug fixes in existing functionality
- UI/UX improvements outside preview panel
- Documentation and testing
**⚠️ Areas to avoid (pending refactor):**
- Preview panel enhancements (fonts, stickers, effects)
- Export functionality improvements
- Preview rendering optimizations
**Reason:** The preview system is planned for a major refactor from DOM-based rendering to binary rendering for consistency with export and better performance.
## Code Quality Standards
**Linting & Formatting:**
- Uses **Biome** for JavaScript/TypeScript linting and formatting
- Extends **Ultracite** configuration for strict type safety and AI-friendly code
- Comprehensive accessibility (a11y) rules enforced
- Zero configuration approach with subsecond performance
**Key coding standards from Ultracite:**
- Strict TypeScript with no `any` types
- No React imports (uses automatic JSX runtime)
- Comprehensive accessibility requirements
- Use `for...of` instead of `Array.forEach`
- No TypeScript enums, use const objects
- Always include error handling with try-catch
## Environment Setup
**Required environment variables (apps/web/.env.local):**
```bash
# Database
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
# Authentication
BETTER_AUTH_SECRET="your-generated-secret-here"
BETTER_AUTH_URL="http://localhost:3000"
# Redis
UPSTASH_REDIS_REST_URL="http://localhost:8079"
UPSTASH_REDIS_REST_TOKEN="example_token"
# Content Management
MARBLE_WORKSPACE_KEY="workspace-key"
NEXT_PUBLIC_MARBLE_API_URL="https://api.marblecms.com"
```
**Docker services:**
```bash
# Start local database and Redis
docker-compose up -d
```
## Project Structure
**Monorepo layout:**
- `apps/web/` - Main Next.js application
- `apps/desktop/` - Tauri desktop application
- `apps/bg-remover/` - Python background removal tools
- `apps/transcription/` - Audio transcription service
- `packages/` - Shared packages (auth, database)
**Web app structure:**
- `src/components/` - React components organized by feature
- `src/stores/` - Zustand state management
- `src/hooks/` - Custom React hooks
- `src/lib/` - Utility functions and services
- `src/types/` - TypeScript type definitions
- `src/app/` - Next.js app router pages and API routes
## Common Patterns
**Error handling:**
```typescript
try {
const result = await processData();
return { success: true, data: result };
} catch (error) {
console.error('Operation failed:', error);
return { success: false, error: error.message };
}
```
**Store usage:**
```typescript
const { tracks, addTrack, updateTrack } = useTimelineStore();
```
**Media processing:**
```typescript
import { processVideo } from '@/lib/ffmpeg-utils';
const processedVideo = await processVideo(inputFile, options);
```

View File

@ -1,7 +1,7 @@
<table width="100%"> <table width="100%">
<tr> <tr>
<td align="left" width="120"> <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>
<td align="right"> <td align="right">
<h1>OpenCut</span></h1> <h1>OpenCut</span></h1>
@ -104,7 +104,7 @@ Before you begin, ensure you have the following installed on your system:
```bash ```bash
# Database (matches docker-compose.yaml) # 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 # Generate a secure secret for Better Auth
BETTER_AUTH_SECRET="your-generated-secret-here" BETTER_AUTH_SECRET="your-generated-secret-here"
@ -160,6 +160,12 @@ See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instruc
## Sponsors ## Sponsors
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"> <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=" /> <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> </a>

View File

@ -1,86 +0,0 @@
Before you follow anything in this guide, please make sure you've followed the steps in the [README](../../README.md) (under "Optional: Auto-captions (Transcription) Setup").
Open your terminal and make sure you're in the `apps/transcription` directory.
1. Create virtual environment
```bash
python -m venv env
```
2. Activate it
**Windows:**
```bash
env\Scripts\activate
```
**macOS/Linux:**
```bash
source env/bin/activate
```
> Note: if you're using VS Code/Cursor and you're seeing errors with the imports about the modules not being found,
> You might have to press CTRL + Shift + P -> Python: Select Interpreter -> Enter interpreter path -> Find -> env -> scripts -> python.exe
3. Install libraries/packages/whatever you wanna call them
```bash
pip install -r requirements.txt
```
4. Make sure you have a Modal account. If you don't: [create one](https://modal.com/)
> If you don't know what Modal is: it allows us to process the actual audio and transcribe with Whisper by providing the infra to run Python code with a lot of RAM, generally affordable.
5. Once you've got a Modal accoumt, run this:
```bash
python -m modal setup
```
It's gonna open a browser so you can authenticate.
6. Test it if you want to make sure it actually works:
```bash
modal run transcription.py
```
6. Deploy the function!
```bash
modal deploy transcription.py
```
7. Set the required secrets in Modal
So the script we just deployed interacts with Cloudflare to do two things:
- Download the audio (so it can be transcribed with Whisper)
- Delete the file after processing (privacy)
To do those things, the script needs access to these environment variables:
```bash
CLOUDFLARE_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key-id
R2_SECRET_ACCESS_KEY=your-secret-access-key
R2_BUCKET_NAME=opencut-transcription
```
Remember, we set these earlier in `.env.local`.
So let's do it:
- Go to [Modal Secrets](https://modal.com/secrets/mazewinther/main)
- Click "Custom" and enter "opencut-r2-secrets" for the name.
- Now you can just click "Import .env" and copy/paste the 4 variables from your `.env.local` file. Copy and paste these only:
```bash
CLOUDFLARE_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key-id
R2_SECRET_ACCESS_KEY=your-secret-access-key
R2_BUCKET_NAME=opencut-transcription
```
- Click "Done" and you should see some cool particles!

View File

@ -1,5 +0,0 @@
modal
openai-whisper
boto3
pydantic
cryptography

View File

@ -1,143 +0,0 @@
import modal
from pydantic import BaseModel
app = modal.App("opencut-transcription")
class TranscribeRequest(BaseModel):
filename: str
language: str = "auto"
decryptionKey: str = None
iv: str = None
@app.function(
image=modal.Image.debian_slim()
.apt_install(["ffmpeg"])
.pip_install(["openai-whisper", "boto3", "fastapi[standard]", "pydantic", "cryptography"]),
gpu="A10G",
timeout=300, # 5m
secrets=[modal.Secret.from_name("opencut-r2-secrets")]
)
@modal.fastapi_endpoint(method="POST")
def transcribe_audio(request: TranscribeRequest):
import whisper
import boto3
import tempfile
import os
import json
try:
filename = request.filename
language = request.language
decryption_key = request.decryptionKey
iv = request.iv
if not filename:
return {
"error": "Missing filename parameter"
}
# Initialize R2 client
s3_client = boto3.client(
's3',
endpoint_url=f'https://{os.environ["CLOUDFLARE_ACCOUNT_ID"]}.r2.cloudflarestorage.com',
aws_access_key_id=os.environ["R2_ACCESS_KEY_ID"],
aws_secret_access_key=os.environ["R2_SECRET_ACCESS_KEY"],
region_name='auto'
)
# Create temporary file for audio
with tempfile.NamedTemporaryFile(delete=False, suffix='.wav') as temp_file:
temp_path = temp_file.name
try:
# Download audio from R2
s3_client.download_file(
os.environ["R2_BUCKET_NAME"],
filename,
temp_path
)
# If decryption key provided, decrypt the file directly (zero-knowledge)
if decryption_key and iv:
import base64
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
# Read the encrypted file
with open(temp_path, 'rb') as f:
encrypted_data = f.read()
# Decode the key and IV from base64
key_bytes = base64.b64decode(decryption_key)
iv_bytes = base64.b64decode(iv)
# Decrypt the data using AES-GCM
# Extract the tag (last 16 bytes) and ciphertext
tag = encrypted_data[-16:]
ciphertext = encrypted_data[:-16]
cipher = Cipher(
algorithms.AES(key_bytes),
modes.GCM(iv_bytes, tag),
backend=default_backend()
)
decryptor = cipher.decryptor()
decrypted_data = decryptor.update(ciphertext) + decryptor.finalize()
# Write decrypted audio back to temp file
with open(temp_path, 'wb') as f:
f.write(decrypted_data)
# Load Whisper model
model = whisper.load_model("base")
# Transcribe audio
if language == "auto":
result = model.transcribe(temp_path)
else:
result = model.transcribe(temp_path, language=language.lower())
# Delete audio file from R2 (cleanup)
s3_client.delete_object(
Bucket=os.environ["R2_BUCKET_NAME"],
Key=filename
)
# Adjust segment timing - Whisper is consistently late by ~500ms
adjusted_segments = []
for segment in result["segments"]:
adjusted_segment = segment.copy()
# Shift start/end times earlier by 500ms, don't go below 0
adjusted_segment["start"] = max(0, segment["start"] - 0.5)
adjusted_segment["end"] = max(0.5, segment["end"] - 0.5) # Ensure duration is at least 0.5s
adjusted_segments.append(adjusted_segment)
return {
"text": result["text"],
"segments": adjusted_segments,
"language": result["language"]
}
finally:
# Clean up temporary file
if os.path.exists(temp_path):
os.unlink(temp_path)
except Exception as e:
import traceback
print(f"Transcription error: {str(e)}")
print(f"Traceback: {traceback.format_exc()}")
# Return error response that matches expected format
return {
"error": str(e),
"text": "",
"segments": [],
"language": "unknown"
}
@app.local_entrypoint()
def main():
# Test function - you can call this with modal run transcription.py
print("Transcription service is ready to deploy!")
print("Deploy with: modal deploy transcription.py")

View File

@ -1,33 +1,28 @@
# Environment Variables Example # Environment variables example
# Copy this file to .env.local and update the values as needed # Copy this file to .env.local and update the values as needed
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut" # Node
# Better Auth
NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=your-secret-key-here
# Development Environment
NODE_ENV=development NODE_ENV=development
# Redis # Public
UPSTASH_REDIS_REST_URL=http://localhost:8079 NEXT_PUBLIC_SITE_URL=http://localhost:3000
UPSTASH_REDIS_REST_TOKEN=example_token
# Marble Blog
MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key
NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
# Freesound (generate at https://freesound.org/apiv2/apply/) # Server
FREESOUND_CLIENT_ID=... DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut"
FREESOUND_API_KEY=... BETTER_AUTH_SECRET=your_better_auth_secret
# Cloudflare R2 (for auto-captions/transcription) UPSTASH_REDIS_REST_URL=http://localhost:8079
# Get these from Cloudflare Dashboard > R2 > Manage R2 API tokens UPSTASH_REDIS_REST_TOKEN=example_token_here
CLOUDFLARE_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key-id
R2_SECRET_ACCESS_KEY=your-secret-access-key
R2_BUCKET_NAME=opencut-transcription
# Modal transcription endpoint (from modal deploy transcription.py) MARBLE_WORKSPACE_KEY=your_workspace_key_here
MODAL_TRANSCRIPTION_URL=https://your-username--opencut-transcription-transcribe-audio.modal.run
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

8
apps/web/.gitignore vendored
View File

@ -1,2 +1,8 @@
# Turborepo # Turborepo
.turbo .turbo
# Env vars
.env*
!.env.example
.next/

View File

@ -1,65 +0,0 @@
FROM oven/bun:alpine AS base
# Install dependencies and build the application
FROM base AS builder
WORKDIR /app
ARG FREESOUND_CLIENT_ID
ARG FREESOUND_API_KEY
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"
ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID
ENV FREESOUND_API_KEY=$FREESOUND_API_KEY
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"]

View File

@ -1,19 +1,23 @@
import type { Config } from "drizzle-kit"; import type { Config } from "drizzle-kit";
import * as dotenv from "dotenv"; import * as dotenv from "dotenv";
import { webEnv } from "@opencut/env/web";
// Load the right env file based on environment // Load the right env file based on environment
if (process.env.NODE_ENV === "production") { if (webEnv.NODE_ENV === "production") {
dotenv.config({ path: ".env.production" }); dotenv.config({ path: ".env.production" });
} else { } else {
dotenv.config({ path: ".env.local" }); dotenv.config({ path: ".env.local" });
} }
export default { export default {
schema: "../../packages/db/src/schema.ts", schema: "./src/schema.ts",
dialect: "postgresql", dialect: "postgresql",
migrations: {
table: "drizzle_migrations",
},
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_URL!, url: webEnv.DATABASE_URL,
}, },
out: "./migrations", out: "./migrations",
strict: process.env.NODE_ENV === "production", strict: webEnv.NODE_ENV === "production",
} satisfies Config; } satisfies Config;

View File

@ -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;

View File

@ -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;

View File

@ -1 +0,0 @@
ALTER TABLE "users" ALTER COLUMN "email_verified" SET DEFAULT false;

View File

@ -1,10 +0,0 @@
CREATE TABLE "export_waitlist" (
"id" text PRIMARY KEY NOT NULL,
"email" text NOT NULL,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
CONSTRAINT "export_waitlist_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "export_waitlist" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
DROP TABLE "waitlist" CASCADE;

View File

@ -1,5 +1,5 @@
{ {
"id": "4440fb90-cf0e-4cb2-afad-08250ce3dc1e", "id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@ -291,6 +291,43 @@
"policies": {}, "policies": {},
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": true "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": {}, "enums": {},

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": {}
}
}

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": {}
}
}

View File

@ -1,365 +0,0 @@
{
"id": "71b84015-78f8-4d07-9115-8d56ea550459",
"prevId": "1b9148ed-497b-4e53-8cf6-f556c8fe7f7f",
"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.export_waitlist": {
"name": "export_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
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"export_waitlist_email_unique": {
"name": "export_waitlist_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"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
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -5,30 +5,9 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1750581188229, "when": 1750753385927,
"tag": "0000_hot_the_fallen", "tag": "0000_brainy_saracen",
"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",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1755377469662,
"tag": "0003_long_polaris",
"breakpoints": true "breakpoints": true
} }
] ]
} }

6
apps/web/next-env.d.ts vendored Normal file
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.

View File

@ -1,5 +1,5 @@
{ {
"name": "opencut", "name": "@opencut/web",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "bun@1.2.18", "packageManager": "bun@1.2.18",
@ -21,13 +21,18 @@
"@ffmpeg/util": "^0.12.2", "@ffmpeg/util": "^0.12.2",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@opencut/auth": "workspace:*", "@hugeicons/core-free-icons": "^3.1.1",
"@opencut/db": "workspace:*", "@hugeicons/react": "^1.1.4",
"@radix-ui/react-separator": "^1.1.7", "@huggingface/transformers": "^3.8.1",
"@t3-oss/env-core": "^0.13.8", "@opencut/env": "workspace:*",
"@t3-oss/env-nextjs": "^0.13.8", "@opencut/ui": "workspace:*",
"@upstash/ratelimit": "^2.0.5", "@radix-ui/react-dialog": "^1.1.15",
"@upstash/redis": "^1.35.0", "@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", "@vercel/analytics": "^1.4.1",
"aws4fetch": "^1.0.20", "aws4fetch": "^1.0.20",
"better-auth": "^1.2.7", "better-auth": "^1.2.7",
@ -36,18 +41,19 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.44.2", "drizzle-orm": "^0.44.2",
"embla-carousel-react": "^8.5.1", "embla-carousel-react": "^8.5.1",
"eventemitter3": "^5.0.1",
"feed": "^5.1.0", "feed": "^5.1.0",
"framer-motion": "^11.13.1",
"input-otp": "^1.4.1", "input-otp": "^1.4.1",
"lucide-react": "^0.468.0", "lucide-react": "^0.562.0",
"mediabunny": "^1.29.1",
"motion": "^12.18.1", "motion": "^12.18.1",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next": "^15.5.7", "next": "16.1.3",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"pg": "^8.16.2", "pg": "^8.16.2",
"postgres": "^3.4.5",
"radix-ui": "^1.4.2", "radix-ui": "^1.4.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
@ -67,7 +73,9 @@
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5", "unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1",
"vaul": "^1.1.1", "vaul": "^1.1.1",
"wavesurfer.js": "^7.9.8",
"zod": "^3.25.67", "zod": "^3.25.67",
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
@ -81,6 +89,7 @@
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",
"dotenv": "^16.5.0",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"tsx": "^4.7.1", "tsx": "^4.7.1",

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: 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

View File

Before

Width:  |  Height:  |  Size: 362 B

After

Width:  |  Height:  |  Size: 362 B

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

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

View File

@ -1,146 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { memo, Suspense } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ArrowLeft, Loader2 } from "lucide-react";
import { GoogleIcon } from "@/components/icons";
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="flex h-screen items-center justify-center relative">
<Button
variant="text"
onClick={() => router.back()}
className="absolute top-6 left-6"
>
<ArrowLeft className="h-5 w-5" /> Back
</Button>
<Card className="w-[400px] shadow-lg border-0">
<CardHeader className="text-center pb-4">
<CardTitle className="text-2xl font-semibold">Welcome back</CardTitle>
<CardDescription className="text-base">
Sign in to your account to continue
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Suspense
fallback={
<div className="text-center">
<Loader2 className="animate-spin" />
</div>
}
>
<div className="flex flex-col space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
onClick={handleGoogleLogin}
variant="outline"
size="lg"
disabled={isAnyLoading}
>
{isGoogleLoading ? (
<Loader2 className="animate-spin" />
) : (
<GoogleIcon />
)}{" "}
Continue with Google
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator className="w-full" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isAnyLoading}
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isAnyLoading}
className="h-11"
/>
</div>
<Button
onClick={handleLogin}
disabled={isAnyLoading || !email || !password}
className="w-full h-11"
size="lg"
>
{isEmailLoading ? (
<Loader2 className="animate-spin" />
) : (
"Sign in"
)}
</Button>
</div>
</div>
<div className="mt-6 text-center text-sm">
Don't have an account?{" "}
<Link
href="/signup"
className="font-medium text-primary underline-offset-4 hover:underline"
>
Sign up
</Link>
</div>
</Suspense>
</CardContent>
</Card>
</div>
);
};
export default memo(LoginPage);

View File

@ -1,162 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { memo, Suspense } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ArrowLeft, Loader2 } from "lucide-react";
import { GoogleIcon } from "@/components/icons";
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="flex h-screen items-center justify-center relative">
<Button
variant="text"
onClick={() => router.back()}
className="absolute top-6 left-6"
>
<ArrowLeft className="h-5 w-5" /> Back
</Button>
<Card className="w-[400px] shadow-lg border-0">
<CardHeader className="text-center pb-4">
<CardTitle className="text-2xl font-semibold">
Create your account
</CardTitle>
<CardDescription className="text-base">
Get started with your free account today
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Suspense
fallback={
<div className="text-center">
<Loader2 className="animate-spin" />
</div>
}
>
<div className="flex flex-col space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
onClick={handleGoogleSignUp}
variant="outline"
size="lg"
disabled={isAnyLoading}
>
{isGoogleLoading ? (
<Loader2 className="animate-spin" />
) : (
<GoogleIcon />
)}{" "}
Continue with Google
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator className="w-full" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
type="text"
placeholder="John Doe"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isAnyLoading}
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isAnyLoading}
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Create a strong password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isAnyLoading}
className="h-11"
/>
</div>
<Button
onClick={handleSignUp}
disabled={isAnyLoading || !name || !email || !password}
className="w-full h-11"
size="lg"
>
{isEmailLoading ? (
<Loader2 className="animate-spin" />
) : (
"Create account"
)}
</Button>
</div>
</div>
<div className="mt-6 text-center text-sm">
Already have an account?{" "}
<Link
href="/login"
className="font-medium text-primary underline-offset-4 hover:underline"
>
Sign in
</Link>
</div>
</Suspense>
</CardContent>
</Card>
</div>
);
};
export default memo(SignUpPage);

View File

@ -1,24 +0,0 @@
"use client";
import { InputWithBack } from "@/components/ui/input-with-back";
import { useState } from "react";
export default function AnimationPage() {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="h-dvh flex">
<div className="bg-panel p-6 w-[20rem]">
<InputWithBack isExpanded={isExpanded} setIsExpanded={setIsExpanded} />
</div>
<div className="p-6 flex items-end justify-end">
<p
onClick={() => setIsExpanded(!isExpanded)}
className="cursor-pointer hover:opacity-75 transition-opacity"
>
{isExpanded ? "Collapse" : "Expand"}
</p>
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { auth } from "@opencut/auth"; import { auth } from "@/lib/auth/server";
import { toNextJsHandler } from "better-auth/next-js"; import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth); export const { POST, GET } = toNextJsHandler(auth);

View File

@ -1,128 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { AwsClient } from "aws4fetch";
import { nanoid } from "nanoid";
import { env } from "@/env";
import { baseRateLimit } from "@/lib/rate-limit";
import { isTranscriptionConfigured } from "@/lib/transcription-utils";
const uploadRequestSchema = z.object({
fileExtension: z.enum(["wav", "mp3", "m4a", "flac"], {
errorMap: () => ({
message: "File extension must be wav, mp3, m4a, or flac",
}),
}),
});
const apiResponseSchema = z.object({
uploadUrl: z.string().url(),
fileName: z.string().min(1),
});
export async function POST(request: NextRequest) {
try {
// Rate limiting
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await baseRateLimit.limit(ip);
if (!success) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
// Check transcription configuration
const transcriptionCheck = isTranscriptionConfigured();
if (!transcriptionCheck.configured) {
console.error(
"Missing environment variables:",
JSON.stringify(transcriptionCheck.missingVars)
);
return NextResponse.json(
{
error: "Transcription not configured",
message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`,
},
{ status: 503 }
);
}
// Parse and validate request body
const rawBody = await request.json().catch(() => null);
if (!rawBody) {
return NextResponse.json(
{ error: "Invalid JSON in request body" },
{ status: 400 }
);
}
const validationResult = uploadRequestSchema.safeParse(rawBody);
if (!validationResult.success) {
return NextResponse.json(
{
error: "Invalid request parameters",
details: validationResult.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
const { fileExtension } = validationResult.data;
// Initialize R2 client
const client = new AwsClient({
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
});
// Generate unique filename with timestamp
const timestamp = Date.now();
const fileName = `audio/${timestamp}-${nanoid()}.${fileExtension}`;
// Create presigned URL
const url = new URL(
`https://${env.R2_BUCKET_NAME}.${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}`
);
url.searchParams.set("X-Amz-Expires", "3600"); // 1 hour expiry
const signed = await client.sign(new Request(url, { method: "PUT" }), {
aws: { signQuery: true },
});
if (!signed.url) {
throw new Error("Failed to generate presigned URL");
}
// Prepare and validate response
const responseData = {
uploadUrl: signed.url,
fileName,
};
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 generating upload URL:", error);
return NextResponse.json(
{
error: "Failed to generate upload URL",
message:
error instanceof Error
? error.message
: "An unexpected error occurred",
},
{ status: 500 }
);
}
}

View File

@ -1,5 +1,3 @@
import { NextRequest } from "next/server"; export async function GET() {
return new Response("OK", { status: 200 });
export async function GET(request: NextRequest) {
return new Response("OK", { status: 200 });
} }

View File

@ -1,265 +1,280 @@
import { NextRequest, NextResponse } from "next/server"; import { webEnv } from "@opencut/env/web";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { env } from "@/env"; import { checkRateLimit } from "@/lib/rate-limit";
import { baseRateLimit } from "@/lib/rate-limit";
const searchParamsSchema = z.object({ const searchParamsSchema = z.object({
q: z.string().max(500, "Query too long").optional(), q: z.string().max(500, "Query too long").optional(),
type: z.enum(["songs", "effects"]).optional(), type: z.enum(["songs", "effects"]).optional(),
page: z.coerce.number().int().min(1).max(1000).default(1), page: z.coerce.number().int().min(1).max(1000).default(1),
page_size: z.coerce.number().int().min(1).max(150).default(20), page_size: z.coerce.number().int().min(1).max(150).default(20),
sort: z sort: z
.enum(["downloads", "rating", "created", "score"]) .enum(["downloads", "rating", "created", "score"])
.default("downloads"), .default("downloads"),
min_rating: z.coerce.number().min(0).max(5).default(3), min_rating: z.coerce.number().min(0).max(5).default(3),
commercial_only: z.coerce.boolean().default(true), commercial_only: z.coerce.boolean().default(true),
}); });
const freesoundResultSchema = z.object({ const freesoundResultSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
url: z.string().url(), url: z.string().url(),
previews: z previews: z
.object({ .object({
"preview-hq-mp3": z.string().url(), "preview-hq-mp3": z.string().url(),
"preview-lq-mp3": z.string().url(), "preview-lq-mp3": z.string().url(),
"preview-hq-ogg": z.string().url(), "preview-hq-ogg": z.string().url(),
"preview-lq-ogg": z.string().url(), "preview-lq-ogg": z.string().url(),
}) })
.optional(), .optional(),
download: z.string().url().optional(), download: z.string().url().optional(),
duration: z.number(), duration: z.number(),
filesize: z.number(), filesize: z.number(),
type: z.string(), type: z.string(),
channels: z.number(), channels: z.number(),
bitrate: z.number(), bitrate: z.number(),
bitdepth: z.number(), bitdepth: z.number(),
samplerate: z.number(), samplerate: z.number(),
username: z.string(), username: z.string(),
tags: z.array(z.string()), tags: z.array(z.string()),
license: z.string(), license: z.string(),
created: z.string(), created: z.string(),
num_downloads: z.number().optional(), num_downloads: z.number().optional(),
avg_rating: z.number().optional(), avg_rating: z.number().optional(),
num_ratings: z.number().optional(), num_ratings: z.number().optional(),
}); });
const freesoundResponseSchema = z.object({ const freesoundResponseSchema = z.object({
count: z.number(), count: z.number(),
next: z.string().url().nullable(), next: z.string().url().nullable(),
previous: z.string().url().nullable(), previous: z.string().url().nullable(),
results: z.array(freesoundResultSchema), results: z.array(freesoundResultSchema),
}); });
const transformedResultSchema = z.object({ const transformedResultSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
url: z.string(), url: z.string(),
previewUrl: z.string().optional(), previewUrl: z.string().optional(),
downloadUrl: z.string().optional(), downloadUrl: z.string().optional(),
duration: z.number(), duration: z.number(),
filesize: z.number(), filesize: z.number(),
type: z.string(), type: z.string(),
channels: z.number(), channels: z.number(),
bitrate: z.number(), bitrate: z.number(),
bitdepth: z.number(), bitdepth: z.number(),
samplerate: z.number(), samplerate: z.number(),
username: z.string(), username: z.string(),
tags: z.array(z.string()), tags: z.array(z.string()),
license: z.string(), license: z.string(),
created: z.string(), created: z.string(),
downloads: z.number().optional(), downloads: z.number().optional(),
rating: z.number().optional(), rating: z.number().optional(),
ratingCount: z.number().optional(), ratingCount: z.number().optional(),
}); });
const apiResponseSchema = z.object({ const apiResponseSchema = z.object({
count: z.number(), count: z.number(),
next: z.string().nullable(), next: z.string().nullable(),
previous: z.string().nullable(), previous: z.string().nullable(),
results: z.array(transformedResultSchema), results: z.array(transformedResultSchema),
query: z.string().optional(), query: z.string().optional(),
type: z.string(), type: z.string(),
page: z.number(), page: z.number(),
pageSize: z.number(), pageSize: z.number(),
sort: z.string(), sort: z.string(),
minRating: z.number().optional(), minRating: z.number().optional(),
}); });
export async function GET(request: NextRequest) { function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
try { if (!query) return `${sort}_desc`;
const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; return sort === "score" ? "score" : `${sort}_desc`;
const { success } = await baseRateLimit.limit(ip); }
if (!success) { function applyEffectsFilters({
return NextResponse.json({ error: "Too many requests" }, { status: 429 }); params,
} min_rating,
commercial_only,
const { searchParams } = new URL(request.url); }: {
params: URLSearchParams;
const validationResult = searchParamsSchema.safeParse({ min_rating: number;
q: searchParams.get("q") || undefined, commercial_only: boolean;
type: searchParams.get("type") || undefined, }) {
page: searchParams.get("page") || undefined, params.append("filter", "duration:[* TO 30.0]");
page_size: searchParams.get("page_size") || undefined, params.append("filter", `avg_rating:[${min_rating} TO *]`);
sort: searchParams.get("sort") || undefined,
min_rating: searchParams.get("min_rating") || undefined, if (commercial_only) {
}); params.append(
"filter",
if (!validationResult.success) { 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
return NextResponse.json( );
{ }
error: "Invalid parameters",
details: validationResult.error.flatten().fieldErrors, params.append(
}, "filter",
{ status: 400 } "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",
); );
} }
const { function transformFreesoundResult(
q: query, result: z.infer<typeof freesoundResultSchema>,
type, ) {
page, return {
page_size: pageSize, id: result.id,
sort, name: result.name,
min_rating, description: result.description,
commercial_only, url: result.url,
} = validationResult.data; previewUrl:
result.previews?.["preview-hq-mp3"] ||
if (type === "songs") { result.previews?.["preview-lq-mp3"],
return NextResponse.json( downloadUrl: result.download,
{ duration: result.duration,
error: "Songs are not available yet", filesize: result.filesize,
message: type: result.type,
"Song search functionality is coming soon. Try searching for sound effects instead.", channels: result.channels,
}, bitrate: result.bitrate,
{ status: 501 } bitdepth: result.bitdepth,
); samplerate: result.samplerate,
} username: result.username,
tags: result.tags,
const baseUrl = "https://freesound.org/apiv2/search/text/"; license: result.license,
created: result.created,
// Use score sorting for search queries, downloads for top sounds downloads: result.num_downloads || 0,
const sortParam = query rating: result.avg_rating || 0,
? sort === "score" ratingCount: result.num_ratings || 0,
? "score" };
: `${sort}_desc` }
: `${sort}_desc`;
export async function GET(request: NextRequest) {
const params = new URLSearchParams({ try {
query: query || "", const { limited } = await checkRateLimit({ request });
token: env.FREESOUND_API_KEY, if (limited) {
page: page.toString(), return NextResponse.json({ error: "Too many requests" }, { status: 429 });
page_size: pageSize.toString(), }
sort: sortParam,
fields: const { searchParams } = new URL(request.url);
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
}); const validationResult = searchParamsSchema.safeParse({
q: searchParams.get("q") || undefined,
// Always apply sound effect filters (since we're primarily a sound effects search) type: searchParams.get("type") || undefined,
if (type === "effects" || !type) { page: searchParams.get("page") || undefined,
params.append("filter", "duration:[* TO 30.0]"); page_size: searchParams.get("page_size") || undefined,
params.append("filter", `avg_rating:[${min_rating} TO *]`); sort: searchParams.get("sort") || undefined,
min_rating: searchParams.get("min_rating") || undefined,
// Filter by license if commercial_only is true });
if (commercial_only) {
params.append( if (!validationResult.success) {
"filter", return NextResponse.json(
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")' {
); error: "Invalid parameters",
} details: validationResult.error.flatten().fieldErrors,
},
params.append( { status: 400 },
"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" }
);
} const {
q: query,
const response = await fetch(`${baseUrl}?${params.toString()}`); type,
page,
if (!response.ok) { page_size: pageSize,
const errorText = await response.text(); sort,
console.error("Freesound API error:", response.status, errorText); min_rating,
return NextResponse.json( commercial_only,
{ error: "Failed to search sounds" }, } = validationResult.data;
{ status: response.status }
); if (type === "songs") {
} return NextResponse.json(
{
const rawData = await response.json(); error: "Songs are not available yet",
message:
const freesoundValidation = freesoundResponseSchema.safeParse(rawData); "Song search functionality is coming soon. Try searching for sound effects instead.",
if (!freesoundValidation.success) { },
console.error( { status: 501 },
"Invalid Freesound API response:", );
freesoundValidation.error }
);
return NextResponse.json( const baseUrl = "https://freesound.org/apiv2/search/text/";
{ error: "Invalid response from Freesound API" },
{ status: 502 } const sortParam = buildSortParameter({ query, sort });
);
} const params = new URLSearchParams({
query: query || "",
const data = freesoundValidation.data; token: webEnv.FREESOUND_API_KEY,
page: page.toString(),
const transformedResults = data.results.map((result) => ({ page_size: pageSize.toString(),
id: result.id, sort: sortParam,
name: result.name, fields:
description: result.description, "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
url: result.url, });
previewUrl:
result.previews?.["preview-hq-mp3"] || const isEffectsSearch = type === "effects" || !type;
result.previews?.["preview-lq-mp3"], if (isEffectsSearch) {
downloadUrl: result.download, applyEffectsFilters({ params, min_rating, commercial_only });
duration: result.duration, }
filesize: result.filesize,
type: result.type, const response = await fetch(`${baseUrl}?${params.toString()}`);
channels: result.channels,
bitrate: result.bitrate, if (!response.ok) {
bitdepth: result.bitdepth, const errorText = await response.text();
samplerate: result.samplerate, console.error("Freesound API error:", response.status, errorText);
username: result.username, return NextResponse.json(
tags: result.tags, { error: "Failed to search sounds" },
license: result.license, { status: response.status },
created: result.created, );
downloads: result.num_downloads || 0, }
rating: result.avg_rating || 0,
ratingCount: result.num_ratings || 0, const rawData = await response.json();
}));
const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
const responseData = { if (!freesoundValidation.success) {
count: data.count, console.error(
next: data.next, "Invalid Freesound API response:",
previous: data.previous, freesoundValidation.error,
results: transformedResults, );
query: query || "", return NextResponse.json(
type: type || "effects", { error: "Invalid response from Freesound API" },
page, { status: 502 },
pageSize, );
sort, }
minRating: min_rating,
}; const data = freesoundValidation.data;
const responseValidation = apiResponseSchema.safeParse(responseData); const transformedResults = data.results.map(transformFreesoundResult);
if (!responseValidation.success) {
console.error( const responseData = {
"Invalid API response structure:", count: data.count,
responseValidation.error next: data.next,
); previous: data.previous,
return NextResponse.json( results: transformedResults,
{ error: "Internal response formatting error" }, query: query || "",
{ status: 500 } type: type || "effects",
); page,
} pageSize,
sort,
return NextResponse.json(responseValidation.data); minRating: min_rating,
} catch (error) { };
console.error("Error searching sounds:", error);
return NextResponse.json( const responseValidation = apiResponseSchema.safeParse(responseData);
{ error: "Internal server error" }, if (!responseValidation.success) {
{ status: 500 } 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 },
);
}
} }

View File

@ -1,189 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { env } from "@/env";
import { baseRateLimit } from "@/lib/rate-limit";
import { isTranscriptionConfigured } from "@/lib/transcription-utils";
const transcribeRequestSchema = z.object({
filename: z.string().min(1, "Filename is required"),
language: z.string().optional().default("auto"),
decryptionKey: z.string().min(1, "Decryption key is required").optional(),
iv: z.string().min(1, "IV is required").optional(),
});
const modalResponseSchema = z.object({
text: z.string(),
segments: z.array(
z.object({
id: z.number(),
seek: z.number(),
start: z.number(),
end: z.number(),
text: z.string(),
tokens: z.array(z.number()),
temperature: z.number(),
avg_logprob: z.number(),
compression_ratio: z.number(),
no_speech_prob: z.number(),
})
),
language: z.string(),
});
const apiResponseSchema = z.object({
text: z.string(),
segments: z.array(
z.object({
id: z.number(),
seek: z.number(),
start: z.number(),
end: z.number(),
text: z.string(),
tokens: z.array(z.number()),
temperature: z.number(),
avg_logprob: z.number(),
compression_ratio: z.number(),
no_speech_prob: z.number(),
})
),
language: z.string(),
});
export async function POST(request: NextRequest) {
try {
// Rate limiting
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await baseRateLimit.limit(ip);
const origin = request.headers.get("origin");
if (!success) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
// Check transcription configuration
const transcriptionCheck = isTranscriptionConfigured();
if (!transcriptionCheck.configured) {
console.error(
"Missing environment variables:",
JSON.stringify(transcriptionCheck.missingVars)
);
return NextResponse.json(
{
error: "Transcription not configured",
message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`,
},
{ status: 503 }
);
}
// Parse and validate request body
const rawBody = await request.json().catch(() => null);
if (!rawBody) {
return NextResponse.json(
{ error: "Invalid JSON in request body" },
{ status: 400 }
);
}
const validationResult = transcribeRequestSchema.safeParse(rawBody);
if (!validationResult.success) {
return NextResponse.json(
{
error: "Invalid request parameters",
details: validationResult.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
const { filename, language, decryptionKey, iv } = validationResult.data;
// Prepare request body for Modal
const modalRequestBody: any = {
filename,
language,
};
// Add encryption parameters if provided (zero-knowledge)
if (decryptionKey && iv) {
modalRequestBody.decryptionKey = decryptionKey;
modalRequestBody.iv = iv;
}
// Call Modal transcription service
const response = await fetch(env.MODAL_TRANSCRIPTION_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(modalRequestBody),
});
if (!response.ok) {
const errorText = await response.text();
console.error("Modal API error:", response.status, errorText);
let errorMessage = "Transcription service unavailable";
try {
const errorData = JSON.parse(errorText);
errorMessage = errorData.error || errorMessage;
} catch {
// Use default message if parsing fails
}
return NextResponse.json(
{
error: errorMessage,
message: "Failed to process transcription request",
},
{ status: response.status >= 500 ? 502 : response.status }
);
}
const rawResult = await response.json();
console.log("Raw Modal response:", JSON.stringify(rawResult, null, 2));
// Validate Modal response
const modalValidation = modalResponseSchema.safeParse(rawResult);
if (!modalValidation.success) {
console.error("Invalid Modal API response:", modalValidation.error);
return NextResponse.json(
{ error: "Invalid response from transcription service" },
{ status: 502 }
);
}
const result = modalValidation.data;
// Prepare and validate API response
const responseData = {
text: result.text,
segments: result.segments,
language: result.language,
};
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("Transcription API error:", error);
return NextResponse.json(
{
error: "Internal server error",
message: "An unexpected error occurred during transcription",
},
{ status: 500 }
);
}
}

View File

@ -1,83 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { baseRateLimit } from "@/lib/rate-limit";
import { db, exportWaitlist, eq } from "@opencut/db";
import { randomUUID } from "crypto";
import {
exportWaitlistSchema,
exportWaitlistResponseSchema,
} from "@/lib/schemas/waitlist";
const requestSchema = exportWaitlistSchema;
const responseSchema = exportWaitlistResponseSchema;
export async function POST(request: NextRequest) {
try {
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await baseRateLimit.limit(ip);
if (!success) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
const body = await request.json().catch(() => null);
if (!body) {
return NextResponse.json(
{ error: "Invalid JSON in request body" },
{ status: 400 }
);
}
const parsed = requestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
error: "Invalid request parameters",
details: parsed.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
const { email } = parsed.data;
const existing = await db
.select({ id: exportWaitlist.id })
.from(exportWaitlist)
.where(eq(exportWaitlist.email, email))
.limit(1);
if (existing.length > 0) {
const responseData = { success: true, alreadySubscribed: true } as const;
const validated = responseSchema.safeParse(responseData);
if (!validated.success) {
return NextResponse.json(
{ error: "Internal response formatting error" },
{ status: 500 }
);
}
return NextResponse.json(validated.data);
}
await db.insert(exportWaitlist).values({
id: randomUUID(),
email,
createdAt: new Date(),
updatedAt: new Date(),
});
const responseData = { success: true } as const;
const validated = responseSchema.safeParse(responseData);
if (!validated.success) {
return NextResponse.json(
{ error: "Internal response formatting error" },
{ status: 500 }
);
}
return NextResponse.json(validated.data);
} catch (error) {
console.error("Waitlist API error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

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>
);
}

View File

@ -1,144 +1,152 @@
import { Header } from "@/components/header"; import type { Metadata } from "next";
import Prose from "@/components/ui/prose";
import { Separator } from "@/components/ui/separator";
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog-query";
import { Metadata } from "next";
import Image from "next/image"; import Image from "next/image";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
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 type { Author, Post } from "@/types/blog";
type PageProps = { type PageProps = {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}; };
export async function generateMetadata({ export async function generateMetadata({
params, params,
}: PageProps): Promise<Metadata> { }: PageProps): Promise<Metadata> {
const slug = (await params).slug; const slug = (await params).slug;
const data = await getSinglePost(slug); const data = await getSinglePost({ slug });
if (!data || !data.post) return {}; if (!data || !data.post) return {};
return { return {
title: data.post.title, title: data.post.title,
description: data.post.description, description: data.post.description,
twitter: { twitter: {
title: `${data.post.title}`, title: `${data.post.title}`,
description: `${data.post.description}`, description: `${data.post.description}`,
card: "summary_large_image", card: "summary_large_image",
images: [ images: [
{ {
url: data.post.coverImage, url: data.post.coverImage,
width: "1200", width: "1200",
height: "630", height: "630",
alt: data.post.title, alt: data.post.title,
}, },
], ],
}, },
openGraph: { openGraph: {
type: "article", type: "article",
images: [ images: [
{ {
url: data.post.coverImage, url: data.post.coverImage,
width: "1200", width: "1200",
height: "630", height: "630",
alt: data.post.title, alt: data.post.title,
}, },
], ],
title: data.post.title, title: data.post.title,
description: data.post.description, description: data.post.description,
publishedTime: new Date(data.post.publishedAt).toISOString(), publishedTime: new Date(data.post.publishedAt).toISOString(),
authors: [ authors: data.post.authors.map((author: Author) => author.name),
...data.post.authors.map((author: { name: string }) => author.name), },
], };
},
};
} }
export async function generateStaticParams() { export async function generateStaticParams() {
const data = await getPosts(); const data = await getPosts();
if (!data || !data.posts.length) return []; if (!data || !data.posts.length) return [];
return data.posts.map((post) => ({ return data.posts.map((post) => ({
slug: post.slug, slug: post.slug,
})); }));
} }
async function Page({ params }: PageProps) { export default async function BlogPostPage({ params }: PageProps) {
const slug = (await params).slug; const slug = (await params).slug;
const data = await getSinglePost(slug); const data = await getSinglePost({ slug });
if (!data || !data.post) return notFound(); if (!data || !data.post) return notFound();
const html = await processHtmlContent(data.post.content); const html = await processHtmlContent({ html: data.post.content });
const formattedDate = new Date(data.post.publishedAt).toLocaleDateString( return (
"en-US", <BasePage>
{ <PostHeader post={data.post} />
day: "numeric", <Separator />
month: "long", <PostContent html={html} />
year: "numeric", </BasePage>
} );
);
return (
<div className="min-h-screen bg-background">
<Header />
<main className="relative">
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
</div>
<div className="relative container max-w-3xl mx-auto px-4 py-16">
<div className="text-center mb-6">
{data.post.coverImage && (
<div className="relative aspect-video rounded-lg overflow-hidden mb-6">
<Image
src={data.post.coverImage}
alt={data.post.title}
loading="eager"
fill
className="object-cover rounded-lg"
/>
</div>
)}
<div className="flex items-center justify-center mb-6">
<time dateTime={data.post.publishedAt.toString()}>
{formattedDate}
</time>
</div>
<h1 className="text-5xl md:text-4xl font-bold tracking-tight mb-6">
{data.post.title}
</h1>
<div className="flex items-center justify-center gap-2">
{data.post.authors[0] && (
<>
<Image
src={data.post.authors[0].image}
alt={data.post.authors[0].name}
width={36}
height={36}
loading="eager"
className="aspect-square shrink-0 size-8 rounded-full"
/>
<p className="text-muted-foreground">
{data.post.authors[0].name}
</p>
</>
)}
</div>
</div>
<Separator />
<section className="mt-14">
<Prose html={html} />
</section>
</div>
</main>
</div>
);
} }
export default Page; function PostHeader({ post }: { post: Post }) {
const formattedDate = new Date(post.publishedAt).toLocaleDateString("en-US", {
day: "numeric",
month: "long",
year: "numeric",
});
return (
<>
{post.coverImage && <PostCoverImage post={post} />}
<PostMeta date={formattedDate} publishedAt={post.publishedAt} />
<PostTitle title={post.title} />
<PostAuthor author={post.authors[0]} />
</>
);
}
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>
);
}
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>
);
}

View File

@ -1,98 +1,73 @@
import { Metadata } from "next"; import type { Metadata } from "next";
import { Header } from "@/components/header";
import { Card, CardContent } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import Link from "next/link"; import Link from "next/link";
import { getPosts } from "@/lib/blog-query"; import { BasePage } from "@/app/base-page";
import Image from "next/image"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import { getPosts } from "@/lib/blog/query";
import type { Author, Post } from "@/types/blog";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Blog - OpenCut", title: "Blog - OpenCut",
description: description:
"Read the latest news and updates about OpenCut, the free and open-source video editor.", "Read the latest news and updates about OpenCut, the free and open-source video editor.",
openGraph: { openGraph: {
title: "Blog - OpenCut", title: "Blog - OpenCut",
description: description:
"Read the latest news and updates about OpenCut, the free and open-source video editor.", "Read the latest news and updates about OpenCut, the free and open-source video editor.",
type: "website", type: "website",
}, },
}; };
export default async function BlogPage() { export default async function BlogPage() {
const data = await getPosts(); const data = await getPosts();
if (!data || !data.posts) return <div>No posts yet</div>; if (!data || !data.posts) return <div>No posts yet</div>;
return ( return (
<div className="min-h-screen bg-background"> <BasePage
<Header /> title="Blog"
description="Read the latest news and updates about OpenCut, the free and open-source video editor."
<main className="relative"> >
<div className="absolute inset-0 overflow-hidden pointer-events-none"> <div className="flex flex-col">
<div className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" /> {data.posts.map((post) => (
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" /> <div key={post.id} className="flex flex-col">
</div> <BlogPostItem post={post} />
<Separator />
<div className="relative container max-w-3xl mx-auto px-4 py-16"> </div>
<div className="text-center mb-20"> ))}
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6"> </div>
Blog </BasePage>
</h1> );
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed"> }
Read the latest news and updates about OpenCut, the free and
open-source video editor. function BlogPostItem({ post }: { post: Post }) {
</p> return (
</div> <Link href={`/blog/${post.slug}`}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="flex h-auto w-full items-center justify-between py-6 opacity-100 hover:opacity-75">
{data.posts.map((post) => ( <div className="flex flex-col gap-2">
<Link key={post.id} href={`/blog/${post.slug}`}> <h2 className="text-xl font-semibold">{post.title}</h2>
<Card className="h-full hover:shadow-lg transition-shadow overflow-hidden"> <p className="text-muted-foreground">{post.description}</p>
{post.coverImage && ( </div>
<div className="relative aspect-video"> {post.authors && post.authors.length > 0 && (
<Image <AuthorList authors={post.authors} />
src={post.coverImage} )}
alt={post.title} </div>
fill </Link>
className="object-cover rounded-xl" );
/> }
</div>
)} function AuthorList({ authors }: { authors: Author[] }) {
return (
<CardContent className="p-6"> <div className="flex items-center gap-2">
{post.authors && post.authors.length > 0 && ( {authors.map((author) => (
<div className="flex items-center gap-2 mb-4"> <div key={author.id} className="flex items-center gap-2">
{post.authors.map((author, index) => ( <Avatar className="size-6 shadow-sm">
<div <AvatarImage src={author.image} alt={author.name} />
key={author.id} <AvatarFallback className="text-xs">
className="flex items-center gap-2" {author.name.charAt(0).toUpperCase()}
> </AvatarFallback>
<Avatar className="w-6 h-6"> </Avatar>
<AvatarImage </div>
src={author.image} ))}
alt={author.name} </div>
/> );
<AvatarFallback className="text-xs">
{author.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-sm text-muted-foreground">
{author.name}
</span>
{index < post.authors.length - 1 && (
<span className="text-muted-foreground"></span>
)}
</div>
))}
</div>
)}
<h2 className="text-xl font-semibold mb-2">{post.title}</h2>
<p className="text-muted-foreground">{post.description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
</main>
</div>
);
} }

View File

@ -1,330 +1,242 @@
import { Metadata } from "next"; import type { Metadata } from "next";
import { Header } from "@/components/header";
import { Card, CardContent } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { ExternalLink } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
GithubIcon, import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
MarbleIcon, import { Card, CardContent } from "@/components/ui/card";
VercelIcon, import { EXTERNAL_TOOLS } from "@/constants/site-constants";
DataBuddyIcon, import { BasePage } from "../base-page";
} from "@/components/icons";
import { Badge } from "@/components/ui/badge";
import { EXTERNAL_TOOLS } from "@/constants/site";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Contributors - OpenCut", title: "Contributors - OpenCut",
description: description:
"Meet the amazing people who contribute to OpenCut, the free and open-source video editor.", "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
openGraph: { openGraph: {
title: "Contributors - OpenCut", title: "Contributors - OpenCut",
description: description:
"Meet the amazing people who contribute to OpenCut, the free and open-source video editor.", "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
type: "website", type: "website",
}, },
}; };
interface Contributor { interface Contributor {
id: number; id: number;
login: string; login: string;
avatar_url: string; avatar_url: string;
html_url: string; html_url: string;
contributions: number; contributions: number;
type: string; type: string;
} }
async function getContributors(): Promise<Contributor[]> { async function getContributors(): Promise<Contributor[]> {
try { try {
const response = await fetch( const response = await fetch(
"https://api.github.com/repos/OpenCut-app/OpenCut/contributors?per_page=100", "https://api.github.com/repos/OpenCut-app/OpenCut/contributors?per_page=100",
{ {
headers: { headers: {
Accept: "application/vnd.github.v3+json", Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenCut-Web-App", "User-Agent": "OpenCut-Web-App",
}, },
next: { revalidate: 600 }, // 10 minutes next: { revalidate: 600 }, // 10 minutes
} as RequestInit },
); );
if (!response.ok) { if (!response.ok) {
console.error("Failed to fetch contributors"); console.error("Failed to fetch contributors");
return []; return [];
} }
const contributors = (await response.json()) as Contributor[]; const contributors = (await response.json()) as Contributor[];
const filteredContributors = contributors.filter( const filteredContributors = contributors.filter(
(contributor: Contributor) => contributor.type === "User" (contributor) => contributor.type === "User",
); );
return filteredContributors; return filteredContributors;
} catch (error) { } catch (error) {
console.error("Error fetching contributors:", error); console.error("Error fetching contributors:", error);
return []; return [];
} }
} }
export default async function ContributorsPage() { export default async function ContributorsPage() {
const contributors = await getContributors(); const contributors = await getContributors();
const topContributors = contributors.slice(0, 2); const topContributors = contributors.slice(0, 2);
const otherContributors = contributors.slice(2); const otherContributors = contributors.slice(2);
const totalContributions = contributors.reduce(
(sum, c) => sum + c.contributions,
0,
);
return ( return (
<div className="min-h-screen bg-background"> <BasePage
<Header /> 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="mx-auto flex max-w-6xl flex-col gap-20">
<div className="absolute inset-0 overflow-hidden pointer-events-none"> {topContributors.length > 0 && (
<div className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" /> <TopContributorsSection contributors={topContributors} />
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" /> )}
</div> {otherContributors.length > 0 && (
<AllContributorsSection contributors={otherContributors} />
<div className="relative container mx-auto px-4 py-16"> )}
<div className="max-w-6xl mx-auto"> <ExternalToolsSection />
<div className="text-center mb-20"> <GitHubContributeSection
<Link title="Join the community"
href={"https://github.com/OpenCut-app/OpenCut"} description="OpenCut is built by developers like you. Every contribution, no matter how small, helps make video editing more accessible for everyone."
target="_blank" />
> </div>
<Badge variant="secondary" className="gap-2 mb-6"> </BasePage>
<GithubIcon className="h-3 w-3" /> );
Open Source }
</Badge>
</Link> function StatItem({ value, label }: { value: number; label: string }) {
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6"> return (
Contributors <div className="flex items-center gap-2">
</h1> <div className="bg-foreground size-2 rounded-full" />
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed"> <span className="font-medium">{value}</span>
Meet the amazing developers who are building the future of video <span className="text-muted-foreground">{label}</span>
editing </div>
</p> );
}
<div className="flex items-center justify-center gap-8 text-sm">
<div className="flex items-center gap-2"> function TopContributorsSection({
<div className="w-2 h-2 bg-foreground rounded-full" /> contributors,
<span className="font-medium">{contributors.length}</span> }: {
<span className="text-muted-foreground">contributors</span> contributors: Contributor[];
</div> }) {
<div className="flex items-center gap-2"> return (
<div className="w-2 h-2 bg-foreground rounded-full" /> <div className="flex flex-col gap-10">
<span className="font-medium"> <div className="flex flex-col gap-2 text-center">
{contributors.reduce((sum, c) => sum + c.contributions, 0)} <h2 className="text-2xl font-semibold">Top contributors</h2>
</span> <p className="text-muted-foreground">
<span className="text-muted-foreground">contributions</span> Leading the way in contributions
</div> </p>
</div> </div>
</div>
<div className="mx-auto flex w-full max-w-xl flex-col justify-center gap-6 md:flex-row">
{topContributors.length > 0 && ( {contributors.map((contributor) => (
<div className="mb-20"> <TopContributorCard key={contributor.id} contributor={contributor} />
<div className="text-center mb-12"> ))}
<h2 className="text-2xl font-semibold mb-2"> </div>
Top Contributors </div>
</h2> );
<p className="text-muted-foreground"> }
Leading the way in contributions
</p> function TopContributorCard({ contributor }: { contributor: Contributor }) {
</div> return (
<Link
<div className="flex flex-col md:flex-row gap-6 justify-center max-w-4xl mx-auto"> href={contributor.html_url}
{topContributors.map((contributor, index) => ( target="_blank"
<Link rel="noopener noreferrer"
key={contributor.id} className="w-full"
href={contributor.html_url} >
target="_blank" <Card>
rel="noopener noreferrer" <CardContent className="flex flex-col gap-6 p-8 text-center">
className="group block flex-1" <Avatar className="mx-auto size-28">
> <AvatarImage
<div className="relative mx-auto max-w-md"> src={contributor.avatar_url}
<div className="absolute inset-0 bg-linear-to-r from-muted/50 to-muted/30 rounded-2xl blur-sm group-hover:blur-md transition-all duration-300" /> alt={`${contributor.login}'s avatar`}
<Card className="relative bg-background/80 backdrop-blur-xs border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl"> />
<CardContent className="p-8 text-center"> <AvatarFallback className="text-lg font-semibold">
<div className="relative mb-6"> {contributor.login.charAt(0).toUpperCase()}
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl"> </AvatarFallback>
<AvatarImage </Avatar>
src={contributor.avatar_url} <div className="flex flex-col gap-2">
alt={`${contributor.login}'s avatar`} <h3 className="text-xl font-semibold">{contributor.login}</h3>
/> <div className="flex items-center justify-center gap-2">
<AvatarFallback className="text-lg font-semibold"> <span className="font-medium">{contributor.contributions}</span>
{contributor.login.charAt(0).toUpperCase()} <span className="text-muted-foreground">contributions</span>
</AvatarFallback> </div>
</Avatar> </div>
</div> </CardContent>
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors"> </Card>
{contributor.login} </Link>
</h3> );
<div className="flex items-center justify-center gap-2 text-muted-foreground"> }
<span className="font-medium text-foreground">
{contributor.contributions} function AllContributorsSection({
</span> contributors,
<span>contributions</span> }: {
</div> contributors: Contributor[];
</CardContent> }) {
</Card> return (
</div> <div className="flex flex-col gap-12">
</Link> <div className="flex flex-col gap-2 text-center">
))} <h2 className="text-2xl font-semibold">All contributors</h2>
</div> <p className="text-muted-foreground">
</div> Everyone who makes OpenCut better
)} </p>
</div>
{otherContributors.length > 0 && (
<div> <div className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
<div className="text-center mb-12"> {contributors.map((contributor) => (
<h2 className="text-2xl font-semibold mb-2"> <Link
All Contributors key={contributor.id}
</h2> href={contributor.html_url}
<p className="text-muted-foreground"> target="_blank"
Everyone who makes OpenCut better rel="noopener noreferrer"
</p> className="opacity-100 hover:opacity-70"
</div> >
<div className="flex flex-col items-center gap-2 p-2">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6"> <Avatar className="size-16">
{otherContributors.map((contributor, index) => ( <AvatarImage
<Link src={contributor.avatar_url}
key={contributor.id} alt={`${contributor.login}'s avatar`}
href={contributor.html_url} />
target="_blank" <AvatarFallback>
rel="noopener noreferrer" {contributor.login.charAt(0).toUpperCase()}
className="group block" </AvatarFallback>
style={{ </Avatar>
animationDelay: `${index * 50}ms`, <div className="text-center">
}} <h3 className="text-sm font-medium">{contributor.login}</h3>
> <p className="text-muted-foreground text-xs">
<div className="text-center p-2 rounded-xl transition-all duration-300 hover:opacity-50"> {contributor.contributions}
<Avatar className="h-16 w-16 mx-auto mb-3"> </p>
<AvatarImage </div>
src={contributor.avatar_url} </div>
alt={`${contributor.login}'s avatar`} </Link>
/> ))}
<AvatarFallback className="font-medium"> </div>
{contributor.login.charAt(0).toUpperCase()} </div>
</AvatarFallback> );
</Avatar> }
<h3 className="font-medium text-sm truncate group-hover:text-foreground transition-colors mb-1">
{contributor.login} function ExternalToolsSection() {
</h3> return (
<p className="text-xs text-muted-foreground"> <div className="flex flex-col gap-10">
{contributor.contributions} <div className="flex flex-col gap-2 text-center">
</p> <h2 className="text-2xl font-semibold">External tools</h2>
</div> <p className="text-muted-foreground">Tools we use to build OpenCut</p>
</Link> </div>
))}
</div> <div className="mx-auto grid max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2">
</div> {EXTERNAL_TOOLS.map((tool, index) => (
)} <Link
key={tool.url}
{contributors.length === 0 && ( href={tool.url}
<div className="text-center py-20"> target="_blank"
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted/50 flex items-center justify-center"> className="block"
<GithubIcon className="h-8 w-8 text-muted-foreground" /> style={{ animationDelay: `${index * 100}ms` }}
</div> >
<h3 className="text-xl font-medium mb-3"> <Card className="h-full">
No contributors found <CardContent className="flex items-center justify-center h-full flex-col gap-4 p-6 text-center">
</h3> <tool.icon className="size-8" />
<p className="text-muted-foreground mb-8 max-w-md mx-auto"> <div className="flex flex-1 flex-col gap-2">
Unable to load contributors at the moment. Check back later or <h3 className="text-lg font-semibold">{tool.name}</h3>
view on GitHub. <p className="text-muted-foreground text-sm">
</p> {tool.description}
<Link </p>
href="https://github.com/OpenCut-app/OpenCut/graphs/contributors" </div>
target="_blank" </CardContent>
rel="noopener noreferrer" </Card>
> </Link>
<Button variant="outline" className="gap-2"> ))}
<GithubIcon className="h-4 w-4" /> </div>
View on GitHub </div>
<ExternalLink className="h-4 w-4" /> );
</Button>
</Link>
</div>
)}
<div className="mt-6 mb-20">
<div className="text-center mb-12">
<h2 className="text-2xl font-semibold mb-2">External Tools</h2>
<p className="text-muted-foreground">
Tools we use to build OpenCut
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
{EXTERNAL_TOOLS.map((tool, index) => {
const IconComponent = {
MarbleIcon,
VercelIcon,
DataBuddyIcon,
}[tool.icon];
return (
<Link
key={tool.name}
href={tool.url}
target="_blank"
className="group block"
style={{
animationDelay: `${index * 100}ms`,
}}
>
<Card className="h-full bg-background/80 backdrop-blur-xs border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
<CardContent className="p-6 text-center h-full flex flex-col">
<div className="mb-4">
<div className="w-12 h-12 mx-auto rounded-full bg-muted/50 flex items-center justify-center group-hover:bg-muted/70 transition-colors">
<IconComponent className="h-6 w-6" size={24} />
</div>
</div>
<h3 className="font-semibold text-lg mb-2 group-hover:text-foreground/80 transition-colors">
{tool.name}
</h3>
<p className="text-sm text-muted-foreground flex-1">
{tool.description}
</p>
</CardContent>
</Card>
</Link>
);
})}
</div>
</div>
<div className="mt-32 text-center">
<div className="max-w-2xl mx-auto">
<h2 className="text-3xl font-bold mb-4">Join the community</h2>
<p className="text-lg text-muted-foreground mb-10 leading-relaxed">
OpenCut is built by developers like you. Every contribution,
no matter how small, helps make video editing more accessible
for everyone.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="https://github.com/OpenCut-app/OpenCut/blob/main/.github/CONTRIBUTING.md"
target="_blank"
rel="noopener noreferrer"
>
<Button size="lg" className="gap-2 group">
<GithubIcon className="h-4 w-4 group-hover:scale-110 transition-transform" />
Start Contributing
</Button>
</Link>
<Link
href="https://github.com/OpenCut-app/OpenCut/issues"
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="lg" className="gap-2 group">
Browse Issues
<ExternalLink className="h-4 w-4 group-hover:scale-110 transition-transform" />
</Button>
</Link>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
);
} }

View File

@ -1,9 +0,0 @@
"use client";
export default function EditorLayout({
children,
}: {
children: React.ReactNode;
}) {
return <div>{children}</div>;
}

View File

@ -1,459 +1,108 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { import {
ResizablePanelGroup, ResizablePanelGroup,
ResizablePanel, ResizablePanel,
ResizableHandle, ResizableHandle,
} from "@/components/ui/resizable"; } from "@/components/ui/resizable";
import { MediaPanel } from "@/components/editor/media-panel"; import { AssetsPanel } from "@/components/editor/panels/assets";
import { PropertiesPanel } from "@/components/editor/properties-panel"; import { PropertiesPanel } from "@/components/editor/panels/properties";
import { Timeline } from "@/components/editor/timeline"; import { Timeline } from "@/components/editor/timeline";
import { PreviewPanel } from "@/components/editor/preview-panel"; import { PreviewPanel } from "@/components/editor/panels/preview";
import { EditorHeader } from "@/components/editor/editor-header"; import { EditorHeader } from "@/components/editor/editor-header";
import { usePanelStore } from "@/stores/panel-store";
import { useProjectStore } from "@/stores/project-store";
import { EditorProvider } from "@/components/providers/editor-provider"; import { EditorProvider } from "@/components/providers/editor-provider";
import { usePlaybackControls } from "@/hooks/use-playback-controls";
import { Onboarding } from "@/components/editor/onboarding"; import { Onboarding } from "@/components/editor/onboarding";
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
import { usePanelStore } from "@/stores/panel-store";
export default function Editor() { export default function Editor() {
const { const params = useParams();
toolsPanel, const projectId = params.project_id as string;
previewPanel,
mainContent,
timeline,
setToolsPanel,
setPreviewPanel,
setMainContent,
setTimeline,
propertiesPanel,
setPropertiesPanel,
activePreset,
resetCounter,
} = usePanelStore();
const { return (
activeProject, <EditorProvider projectId={projectId}>
loadProject, <div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
createNewProject, <EditorHeader />
isInvalidProjectId, <div className="min-h-0 min-w-0 flex-1">
markProjectIdAsInvalid, <EditorLayout />
} = useProjectStore(); </div>
const params = useParams(); <Onboarding />
const router = useRouter(); <MigrationDialog />
const projectId = params.project_id as string; </div>
const handledProjectIds = useRef<Set<string>>(new Set()); </EditorProvider>
const isInitializingRef = useRef<boolean>(false); );
}
usePlaybackControls();
function EditorLayout() {
useEffect(() => { const { panels, setPanel } = usePanelStore();
let isCancelled = false;
return (
const initProject = async () => { <ResizablePanelGroup
if (!projectId) { direction="vertical"
return; className="size-full gap-[0.18rem]"
} onLayout={(sizes) => {
setPanel("mainContent", sizes[0] ?? panels.mainContent);
// Prevent duplicate initialization setPanel("timeline", sizes[1] ?? panels.timeline);
if (isInitializingRef.current) { }}
return; >
} <ResizablePanel
defaultSize={panels.mainContent}
// Check if project is already loaded minSize={30}
if (activeProject?.id === projectId) { maxSize={85}
return; className="min-h-0"
} >
<ResizablePanelGroup
// Check global invalid tracking first (most important for preventing duplicates) direction="horizontal"
if (isInvalidProjectId(projectId)) { className="size-full gap-[0.19rem] px-3"
return; onLayout={(sizes) => {
} setPanel("tools", sizes[0] ?? panels.tools);
setPanel("preview", sizes[1] ?? panels.preview);
// Check if we've already handled this project ID locally setPanel("properties", sizes[2] ?? panels.properties);
if (handledProjectIds.current.has(projectId)) { }}
return; >
} <ResizablePanel
defaultSize={panels.tools}
// Mark as initializing to prevent race conditions minSize={15}
isInitializingRef.current = true; maxSize={40}
handledProjectIds.current.add(projectId); className="min-w-0 rounded-sm"
>
try { <AssetsPanel />
await loadProject(projectId); </ResizablePanel>
// Check if component was unmounted during async operation <ResizableHandle withHandle />
if (isCancelled) {
return; <ResizablePanel
} defaultSize={panels.preview}
minSize={30}
// Project loaded successfully className="min-h-0 min-w-0 flex-1"
isInitializingRef.current = false; >
} catch (error) { <PreviewPanel />
// Check if component was unmounted during async operation </ResizablePanel>
if (isCancelled) {
return; <ResizableHandle withHandle />
}
<ResizablePanel
// More specific error handling - only create new project for actual "not found" errors defaultSize={panels.properties}
const isProjectNotFound = minSize={15}
error instanceof Error && maxSize={40}
(error.message.includes("not found") || className="min-w-0 rounded-sm"
error.message.includes("does not exist") || >
error.message.includes("Project not found")); <PropertiesPanel />
</ResizablePanel>
if (isProjectNotFound) { </ResizablePanelGroup>
// Mark this project ID as invalid globally BEFORE creating project </ResizablePanel>
markProjectIdAsInvalid(projectId);
<ResizableHandle withHandle />
try {
const newProjectId = await createNewProject("Untitled Project"); <ResizablePanel
defaultSize={panels.timeline}
// Check again if component was unmounted minSize={15}
if (isCancelled) { maxSize={70}
return; className="min-h-0 px-3 pb-3"
} >
<Timeline />
router.replace(`/editor/${newProjectId}`); </ResizablePanel>
} catch (createError) { </ResizablePanelGroup>
console.error("Failed to create new project:", createError); );
}
} else {
// For other errors (storage issues, corruption, etc.), don't create new project
console.error(
"Project loading failed with recoverable error:",
error
);
// Remove from handled set so user can retry
handledProjectIds.current.delete(projectId);
}
isInitializingRef.current = false;
}
};
initProject();
// Cleanup function to cancel async operations
return () => {
isCancelled = true;
isInitializingRef.current = false;
};
}, [
projectId,
loadProject,
createNewProject,
router,
isInvalidProjectId,
markProjectIdAsInvalid,
]);
return (
<EditorProvider>
<div className="h-screen w-screen flex flex-col bg-background overflow-hidden">
<EditorHeader />
<div className="flex-1 min-h-0 min-w-0">
{activePreset === "media" ? (
<ResizablePanelGroup
key={`media-${activePreset}-${resetCounter}`}
direction="horizontal"
className="h-full w-full gap-[0.18rem] px-3 pb-3"
>
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<MediaPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={100 - toolsPanel}
minSize={60}
className="min-w-0 min-h-0"
>
<ResizablePanelGroup
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem]"
>
<ResizablePanel
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-w-0 min-h-0 flex-1"
>
<PreviewPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-w-0"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
) : activePreset === "inspector" ? (
<ResizablePanelGroup
key={`inspector-${activePreset}-${resetCounter}`}
direction="horizontal"
className="h-full w-full gap-[0.18rem] px-3 pb-3"
>
<ResizablePanel
defaultSize={100 - propertiesPanel}
minSize={30}
onResize={(size) => setPropertiesPanel(100 - size)}
className="min-w-0 min-h-0"
>
<ResizablePanelGroup
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem]"
>
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<MediaPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-w-0 min-h-0 flex-1"
>
<PreviewPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-w-0 min-h-0"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
) : activePreset === "vertical-preview" ? (
<ResizablePanelGroup
key={`vertical-preview-${activePreset}-${resetCounter}`}
direction="horizontal"
className="h-full w-full gap-[0.18rem] px-3 pb-3"
>
<ResizablePanel
defaultSize={100 - previewPanel}
minSize={30}
onResize={(size) => setPreviewPanel(100 - size)}
className="min-w-0 min-h-0"
>
<ResizablePanelGroup
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem]"
>
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<MediaPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-w-0"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-w-0 min-h-0"
>
<PreviewPanel />
</ResizablePanel>
</ResizablePanelGroup>
) : (
<ResizablePanelGroup
key={`default-${activePreset}-${resetCounter}`}
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel
defaultSize={mainContent}
minSize={30}
maxSize={85}
onResize={setMainContent}
className="min-h-0"
>
{/* Main content area */}
<ResizablePanelGroup
direction="horizontal"
className="h-full w-full gap-[0.19rem] px-3"
>
{/* Tools Panel */}
<ResizablePanel
defaultSize={toolsPanel}
minSize={15}
maxSize={40}
onResize={setToolsPanel}
className="min-w-0 rounded-sm"
>
<MediaPanel />
</ResizablePanel>
<ResizableHandle withHandle />
{/* Preview Area */}
<ResizablePanel
defaultSize={previewPanel}
minSize={30}
onResize={setPreviewPanel}
className="min-w-0 min-h-0 flex-1"
>
<PreviewPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
maxSize={40}
onResize={setPropertiesPanel}
className="min-w-0 rounded-sm"
>
<PropertiesPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle withHandle />
{/* Timeline */}
<ResizablePanel
defaultSize={timeline}
minSize={15}
maxSize={70}
onResize={setTimeline}
className="min-h-0 px-3 pb-3"
>
<Timeline />
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
<Onboarding />
</div>
</EditorProvider>
);
} }

View File

@ -8,85 +8,88 @@
@plugin "tailwindcss-animate"; @plugin "tailwindcss-animate";
:root { :root {
/* Custom colors - light mode (default) */ --background: hsl(0, 0%, 100%);
--background: hsl(0, 0%, 100%); --foreground: hsl(0 0% 11%);
--foreground: hsl(0 0% 11%); --card: hsl(0, 0%, 100%);
--card: hsl(216, 8%, 86%); --card-foreground: hsl(0 0% 11%);
--card-foreground: hsl(0 0% 2%); --popover: hsl(0, 0%, 100%);
--popover: hsl(0, 0%, 100%); --popover-foreground: hsl(0 0% 2%);
--popover-foreground: hsl(0 0% 2%); --primary: hsl(203, 100%, 50%);
--primary: hsl(205, 84%, 47%); --primary-hover: hsl(203, 100%, 45%);
--primary-foreground: hsl(0 0% 91%); --primary-foreground: hsl(0, 0%, 100%);
--secondary: hsl(216, 13%, 92%); --secondary: hsl(216 13% 94%);
--secondary-foreground: hsl(0 0% 2%); --secondary-foreground: hsl(0 0% 2%);
--muted: hsl(0 0% 85.1%); --muted: hsl(0 0% 85.1%);
--muted-foreground: hsl(0 0% 50%); --muted-foreground: hsl(0 0% 50%);
--accent: hsl(216, 13%, 92%); --accent: hsl(216, 13%, 88%);
--accent-foreground: hsl(0 0% 2%); --accent-foreground: hsl(0 0% 2%);
--destructive: hsl(0, 83%, 50%); --destructive: hsl(0, 83%, 50%);
--destructive-foreground: hsl(0, 0%, 100%); --destructive-foreground: hsl(0, 0%, 100%);
--border: hsl(0 0% 83%); --constructive: hsl(141, 71%, 48%);
--input: hsl(0 0% 85.1%); --constructive-foreground: hsl(0, 0%, 100%);
--ring: hsl(0, 0%, 55%); --border: hsl(0 0% 88%);
--chart-1: hsl(220 70% 50%); --input: hsl(0 0% 85.1%);
--chart-2: hsl(160 60% 45%); --ring: hsl(0, 0%, 55%);
--chart-3: hsl(30 80% 55%); --chart-1: hsl(220 70% 50%);
--chart-4: hsl(280 65% 60%); --chart-2: hsl(160 60% 45%);
--chart-5: hsl(340 75% 55%); --chart-3: hsl(30 80% 55%);
--sidebar-background: hsl(0 0% 96.1%); --chart-4: hsl(280 65% 60%);
--sidebar-foreground: hsl(0 0% 2%); --chart-5: hsl(340 75% 55%);
--sidebar-primary: hsl(0 0% 2%); --sidebar-background: hsl(0 0% 96.1%);
--sidebar-primary-foreground: hsl(0 0% 91%); --sidebar-foreground: hsl(0 0% 2%);
--sidebar-accent: hsl(0 0% 85.1%); --sidebar-primary: hsl(0 0% 2%);
--sidebar-accent-foreground: hsl(0 0% 2%); --sidebar-primary-foreground: hsl(0 0% 91%);
--sidebar-border: hsl(0 0% 85.1%); --sidebar-accent: hsl(0 0% 85.1%);
--sidebar-ring: hsl(0 0% 16.9%); --sidebar-accent-foreground: hsl(0 0% 2%);
--panel-background: hsl(216 13% 92%); --sidebar-border: hsl(0 0% 85.1%);
--panel-accent: hsl(216, 8%, 86%); --sidebar-ring: hsl(0 0% 16.9%);
--panel-background: hsl(216 13% 94%);
/* Radius base */ --panel-accent: hsl(216, 8%, 88%);
--radius: 1rem; --sidebar: hsl(0 0% 98%);
} }
.dark { .dark {
/* Custom colors - dark mode */ --background: hsl(0 0% 4%);
--background: hsl(0 0% 4%); --foreground: hsl(0 0% 89%);
--foreground: hsl(0 0% 89%); --card: hsl(0 0% 4%);
--card: hsl(0 0% 14.9%); --card-foreground: hsl(0 0% 89%);
--card-foreground: hsl(0 0% 98%); --popover: hsl(0 0% 14.9%);
--popover: hsl(0 0% 14.9%); --popover-foreground: hsl(0 0% 98%);
--popover-foreground: hsl(0 0% 98%); --primary: hsl(203, 100%, 50%);
--primary: hsl(205, 84%, 53%); --primary-hover: hsl(203, 100%, 45%);
--primary-foreground: hsl(0 0% 9%); --primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%); --secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%); --secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 14.9%); --muted: hsl(0 0% 14.9%);
--muted-foreground: hsl(0 0% 63.9%); --muted-foreground: hsl(0 0% 63.9%);
--accent: hsl(0 0% 14.9%); --accent: hsl(0, 0%, 28%);
--accent-foreground: hsl(0 0% 98%); --accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 100% 60%); --destructive: hsl(0 83%, 55%);
--destructive-foreground: hsl(0 0% 98%); --destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 17%); --constructive: hsl(141, 71%, 48%);
--input: hsl(0 0% 14.9%); --constructive-foreground: hsl(0 0% 100%);
--ring: hsl(0 0% 83.1%); --border: hsl(0 0% 17%);
--chart-1: hsl(220 70% 50%); --input: hsl(0 0% 14.9%);
--chart-2: hsl(160 60% 45%); --ring: hsl(0 0% 83.1%);
--chart-3: hsl(30 80% 55%); --chart-1: hsl(220 70% 50%);
--chart-4: hsl(280 65% 60%); --chart-2: hsl(160 60% 45%);
--chart-5: hsl(340 75% 55%); --chart-3: hsl(30 80% 55%);
--sidebar-background: hsl(0 0% 3.9%); --chart-4: hsl(280 65% 60%);
--sidebar-foreground: hsl(0 0% 98%); --chart-5: hsl(340 75% 55%);
--sidebar-primary: hsl(0 0% 98%); --sidebar-background: hsl(0 0% 3.9%);
--sidebar-primary-foreground: hsl(0 0% 9%); --sidebar-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(0 0% 14.9%); --sidebar-primary: hsl(0 0% 98%);
--sidebar-accent-foreground: hsl(0 0% 98%); --sidebar-primary-foreground: hsl(0 0% 9%);
--sidebar-border: hsl(0 0% 14.9%); --sidebar-accent: hsl(0 0% 14.9%);
--sidebar-ring: hsl(0 0% 83.1%); --sidebar-accent-foreground: hsl(0 0% 98%);
--panel-background: hsl(0 0% 11%); --sidebar-border: hsl(0 0% 14.9%);
--panel-accent: hsl(0 0% 15%); --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 { @layer base {
/* /*
The default border color has changed to `currentcolor` in Tailwind CSS v4, The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3. looks the same as it did with Tailwind CSS v3.
@ -94,152 +97,165 @@
If we ever want to remove these styles, we need to add an explicit border 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. color utility to any element that depends on these defaults.
*/ */
*, *,
::after, ::after,
::before, ::before,
::backdrop, ::backdrop,
::file-selector-button { ::file-selector-button {
border-color: var(--color-gray-200, currentcolor); border-color: var(--color-gray-200, currentcolor);
} }
/* Other default base styles */ /* Other default base styles */
* { * {
@apply border-border; @apply border-border;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
/* Prevent back/forward swipe */ /* Prevent back/forward swipe */
overscroll-behavior-x: contain; overscroll-behavior-x: contain;
} }
} }
@theme inline { @theme inline {
/* Responsive breakpoints */ /* Responsive breakpoints */
--breakpoint-xs: 30rem; --breakpoint-xs: 30rem;
/* Typography */ /* Typography */
--font-sans: var(--font-inter), sans-serif; --font-sans: var(--font-inter), sans-serif;
/* Font sizes */ /* Font sizes */
--text-base: 0.95rem; --text-xl: 1.20rem;
--text-base--line-height: calc(1.5 / 0.95); --text-base: 0.92rem;
--text-xs: 0.8rem; --text-base--line-height: calc(1.5 / 0.95);
--text-xs--line-height: calc(1 / 0.8); --text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.8);
/* Border radius */ /* Border radius */
--radius-lg: var(--radius); --radius-lg: 0.82rem;
--radius-md: calc(var(--radius) - 2px); --radius-md: 0.65rem;
--radius-sm: calc(var(--radius) - 8px); --radius-sm: 0.35rem;
/* Palette mapped to root design tokens */ /* Palette mapped to root design tokens */
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-primary-hover: var(--primary-hover);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary); --color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground); --color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted); --color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground); --color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent); --color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-destructive: var(--destructive); --color-constructive: var(--constructive);
--color-destructive-foreground: var(--destructive-foreground); --color-constructive-foreground: var(--constructive-foreground);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
/* Chart colors */ /* Chart colors */
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
/* Sidebar */ /* Sidebar */
--color-sidebar: var(--sidebar-background); --color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
/* Panel */ /* Panel */
--color-panel: var(--panel-background); --color-panel: var(--panel-background);
--color-panel-accent: var(--panel-accent); --color-panel-accent: var(--panel-accent);
/* Animations */ /* Animations */
--animate-accordion-down: accordion-down 0.2s ease-out; --animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out; --animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down { @keyframes accordion-down {
from { from {
height: 0; height: 0;
} }
to { to {
height: var(--radix-accordion-content-height); height: var(--radix-accordion-content-height);
} }
} }
@keyframes accordion-up { @keyframes accordion-up {
from { from {
height: var(--radix-accordion-content-height); height: var(--radix-accordion-content-height);
} }
to { to {
height: 0; height: 0;
} }
} }
} }
@utility scrollbar-hidden { @utility scrollbar-hidden {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
&::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; display: none;
} }
} }
@utility scrollbar-x-hidden { @utility scrollbar-x-hidden {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
&::-webkit-scrollbar:horizontal { &::-webkit-scrollbar:horizontal {
display: none; display: none;
} }
} }
@utility scrollbar-y-hidden { @utility scrollbar-y-hidden {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
&::-webkit-scrollbar:vertical { &::-webkit-scrollbar:vertical {
display: none; display: none;
} }
} }
@utility scrollbar-thin { @utility scrollbar-thin {
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 6px; width: 6px;
height: 8px; height: 8px;
} }
&::-webkit-scrollbar-track { &::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background: var(--border); background: var(--border);
border-radius: 4px; border-radius: 4px;
} }
&::-webkit-scrollbar-thumb:hover { &::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground); background: var(--muted-foreground);
} }
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
} }

View File

@ -1,62 +1,58 @@
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import { Analytics } from "@vercel/analytics/react";
import Script from "next/script"; import Script from "next/script";
import "./globals.css"; import "./globals.css";
import { Toaster } from "../components/ui/sonner"; import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip"; import { TooltipProvider } from "../components/ui/tooltip";
import { StorageProvider } from "../components/storage-provider";
import { ScenesMigrator } from "../components/providers/migrators/scenes-migrator";
import { baseMetaData } from "./metadata"; import { baseMetaData } from "./metadata";
import { defaultFont } from "../lib/font-config";
import { BotIdClient } from "botid/client"; import { BotIdClient } from "botid/client";
import { env } from "@/env"; import { webEnv } from "@opencut/env/web";
import { Inter } from "next/font/google";
const siteFont = Inter({ subsets: ["latin"] });
export const metadata = baseMetaData; export const metadata = baseMetaData;
const protectedRoutes = [ const protectedRoutes = [
{ {
path: "/none", path: "/none",
method: "GET", method: "GET",
}, },
{
path: "/api/waitlist/export",
method: "POST",
},
]; ];
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<head> <head>
<BotIdClient protect={protectedRoutes} /> <BotIdClient protect={protectedRoutes} />
</head> </head>
<body className={`${defaultFont.className} font-sans antialiased`}> <body className={`${siteFont.className} font-sans antialiased`}>
<ThemeProvider attribute="class" defaultTheme="dark"> <ThemeProvider
<TooltipProvider> attribute="class"
<StorageProvider> defaultTheme="system"
<ScenesMigrator>{children}</ScenesMigrator> disableTransitionOnChange={true}
</StorageProvider> >
<Analytics /> <TooltipProvider>
<Toaster /> <Toaster />
<Script <Script
src="https://cdn.databuddy.cc/databuddy.js" src="https://cdn.databuddy.cc/databuddy.js"
strategy="afterInteractive" strategy="afterInteractive"
async async
data-client-id="UP-Wcoy5arxFeK7oyjMMZ" data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
data-disabled={env.NODE_ENV === "development"} data-disabled={webEnv.NODE_ENV === "development"}
data-track-attributes={false} data-track-attributes={false}
data-track-errors={true} data-track-errors={true}
data-track-outgoing-links={false} data-track-outgoing-links={false}
data-track-web-vitals={false} data-track-web-vitals={false}
data-track-sessions={false} data-track-sessions={false}
/> />
</TooltipProvider> {children}
</ThemeProvider> </TooltipProvider>
</body> </ThemeProvider>
</html> </body>
); </html>
);
} }

View File

@ -1,86 +1,86 @@
import type { Metadata } from "next"; 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 = { export const baseMetaData: Metadata = {
metadataBase: new URL(SITE_URL), metadataBase: new URL(SITE_URL),
title: SITE_INFO.title, title: SITE_INFO.title,
description: SITE_INFO.description, description: SITE_INFO.description,
openGraph: { openGraph: {
title: SITE_INFO.title, title: SITE_INFO.title,
description: SITE_INFO.description, description: SITE_INFO.description,
url: SITE_URL, url: SITE_URL,
siteName: SITE_INFO.title, siteName: SITE_INFO.title,
locale: "en_US", locale: "en_US",
type: "website", type: "website",
images: [ images: [
{ {
url: SITE_INFO.openGraphImage, url: SITE_INFO.openGraphImage,
width: 1200, width: 1200,
height: 630, height: 630,
alt: "OpenCut Wordmark", alt: "OpenCut Wordmark",
}, },
], ],
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: SITE_INFO.title, title: SITE_INFO.title,
description: SITE_INFO.description, description: SITE_INFO.description,
creator: "@opencutapp", creator: "@opencutapp",
images: [SITE_INFO.twitterImage], images: [SITE_INFO.twitterImage],
}, },
pinterest: { pinterest: {
richPin: false, richPin: false,
}, },
robots: { robots: {
index: true, index: true,
follow: true, follow: true,
}, },
icons: { icons: {
icon: [ icon: [
{ url: "/favicon.ico" }, { url: "/favicon.ico" },
{ url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" }, { url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" },
{ url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" }, { url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" },
{ url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" }, { url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" },
], ],
apple: [ apple: [
{ url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" }, { url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" },
{ url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" }, { url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" },
{ url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" }, { url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" },
{ url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" }, { url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" },
{ {
url: "/icons/apple-icon-114x114.png", url: "/icons/apple-icon-114x114.png",
sizes: "114x114", sizes: "114x114",
type: "image/png", type: "image/png",
}, },
{ {
url: "/icons/apple-icon-120x120.png", url: "/icons/apple-icon-120x120.png",
sizes: "120x120", sizes: "120x120",
type: "image/png", type: "image/png",
}, },
{ {
url: "/icons/apple-icon-144x144.png", url: "/icons/apple-icon-144x144.png",
sizes: "144x144", sizes: "144x144",
type: "image/png", type: "image/png",
}, },
{ {
url: "/icons/apple-icon-152x152.png", url: "/icons/apple-icon-152x152.png",
sizes: "152x152", sizes: "152x152",
type: "image/png", type: "image/png",
}, },
{ {
url: "/icons/apple-icon-180x180.png", url: "/icons/apple-icon-180x180.png",
sizes: "180x180", sizes: "180x180",
type: "image/png", type: "image/png",
}, },
], ],
shortcut: ["/favicon.ico"], shortcut: ["/favicon.ico"],
}, },
appleWebApp: { appleWebApp: {
capable: true, capable: true,
title: SITE_INFO.title, title: SITE_INFO.title,
}, },
manifest: "/manifest.json", manifest: "/manifest.json",
other: { other: {
"msapplication-config": "/browserconfig.xml", "msapplication-config": "/browserconfig.xml",
}, },
}; };

View File

@ -2,20 +2,20 @@ import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header"; import { Header } from "@/components/header";
import { Footer } from "@/components/footer"; import { Footer } from "@/components/footer";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { SITE_URL } from "@/constants/site"; import { SITE_URL } from "@/constants/site-constants";
export const metadata: Metadata = { export const metadata: Metadata = {
alternates: { alternates: {
canonical: SITE_URL, canonical: SITE_URL,
}, },
}; };
export default async function Home() { export default async function Home() {
return ( return (
<div> <div>
<Header /> <Header />
<Hero /> <Hero />
<Footer /> <Footer />
</div> </div>
); );
} }

View File

@ -1,253 +1,287 @@
import { Metadata } from "next"; import type { Metadata } from "next";
import { Card, CardContent } from "@/components/ui/card"; import { BasePage } from "@/app/base-page";
import { Badge } from "@/components/ui/badge"; import {
import { GithubIcon } from "@/components/icons"; Accordion,
import Link from "next/link"; AccordionContent,
import { Footer } from "@/components/footer"; AccordionItem,
import { Header } from "@/components/header"; AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
import { SOCIAL_LINKS } from "@/constants/site-constants";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Privacy Policy - OpenCut", title: "Privacy Policy - OpenCut",
description: description:
"Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.", "Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.",
openGraph: { openGraph: {
title: "Privacy Policy - OpenCut", title: "Privacy Policy - OpenCut",
description: description:
"Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.", "Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.",
type: "website", type: "website",
}, },
}; };
export default function PrivacyPage() { export default function PrivacyPage() {
return ( return (
<div className="min-h-screen bg-background"> <BasePage
<Header /> title="Privacy policy"
<main className="relative"> description="Learn how we handle your data and privacy. Contact us if you have any questions."
<div className="absolute inset-0 overflow-hidden pointer-events-none"> >
<div className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" /> <Accordion type="single" collapsible className="w-full">
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" /> <AccordionItem
</div> value="quick-summary"
<div className="relative container mx-auto px-4 py-16"> className="rounded-2xl border px-5"
<div className="max-w-4xl mx-auto"> >
<div className="text-center mb-10"> <AccordionTrigger className="no-underline!">
<Link Quick summary
href="https://github.com/OpenCut-app/OpenCut" </AccordionTrigger>
target="_blank" <AccordionContent>
> <h3 className="mb-3 text-lg font-medium">
<Badge variant="secondary" className="gap-2 mb-6"> Your content stays private and encrypted.
<GithubIcon className="h-3 w-3" /> </h3>
Open Source <ol className="list-decimal space-y-2 pl-6">
</Badge> <li>
</Link> Basic editing happens locally in your browser - we never see
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6"> your files
Privacy Policy </li>
</h1> <li>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed"> AI features require encrypted uploads - your content is
Learn how we handle your data and privacy. Contact us if you encrypted before leaving your device
have any questions. </li>
</p> <li>
</div> We only collect your email and basic profile info for your
<Card className="bg-background/80 backdrop-blur-xs border-2 border-muted/30"> account
<CardContent className="p-8 text-base leading-relaxed space-y-8"> </li>
<section> <li>Project data stays on your device, not our servers</li>
<h2 className="text-2xl font-semibold mb-4"> <li>
Your Videos Stay Private We use analytics to improve the app, but no personal video
</h2> content is tracked
<p className="mb-4"> </li>
<strong> <li>
OpenCut processes all videos locally on your device. You can delete your account anytime and all data gets removed
</strong>{" "} </li>
We never upload, store, or have access to your video files. <li>We don't sell your data or share it with advertisers</li>
Your content remains completely private and under your </ol>
control at all times. <p className="mt-4">
</p> Questions? Email us at{" "}
<p> <a
All video editing, rendering, and processing happens in your href="mailto:oss@opencut.app"
browser using WebAssembly and local storage. No video data className="text-primary hover:underline"
is transmitted to our servers. >
</p> oss@opencut.app
</section> </a>
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
<section> <section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold mb-4"> <h2 className="text-2xl font-semibold">How We Handle Your Content</h2>
Account Information <p>
</h2> <strong>Basic video editing happens locally on your device.</strong>{" "}
<p className="mb-4"> For standard editing features, we never upload, store, or have access
When you create an account, we only collect: to your video files. Your content remains completely private and under
</p> your control.
<ul className="list-disc pl-6 mb-4 space-y-2"> </p>
<li>Email address (for account access)</li> <p>
<li> <strong>AI features require secure processing:</strong> When you
Profile information from Google OAuth (if you choose to choose to use AI features like auto captions, your audio/video content
sign in with Google) is encrypted on your device before being uploaded to our servers for
</li> processing. We use zero-knowledge encryption, meaning we cannot
</ul> decrypt or view your content.
<p className="mb-4"> </p>
<strong> <p>
We do NOT store your projects on our servers. After AI processing is complete, the encrypted content is immediately
</strong>{" "} deleted from our servers. Only the results (like generated captions)
All project data, including names, thumbnails, and creation are returned to your device.
dates, is stored locally in your browser using IndexedDB. </p>
</p> </section>
<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> <section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold mb-4">Analytics</h2> <h2 className="text-2xl font-semibold">Account Information</h2>
<p className="mb-4"> <p>When you create an account, we only collect:</p>
We use{" "} <ul className="list-disc space-y-2 pl-6">
<a <li>Email address (for account access)</li>
href="https://www.databuddy.cc" <li>
target="_blank" Profile information from Google OAuth (if you choose to sign in with
rel="noopener" Google)
className="text-primary hover:underline" </li>
> </ul>
Databuddy <p>
</a>{" "} <strong>We do NOT store your projects on our servers.</strong> All
for completely anonymized and non-invasive analytics to project data, including names, thumbnails, and creation dates, is
understand how people use OpenCut. stored locally in your browser using IndexedDB.
</p> </p>
<p> <p>
This helps us improve the editor, but we never collect We use{" "}
personal information, track individual users, or store any <a
data that could identify you. href="https://www.better-auth.com"
</p> target="_blank"
</section> rel="noopener"
className="text-primary hover:underline"
>
Better Auth
</a>{" "}
for secure authentication and follow industry-standard security
practices.
</p>
</section>
<section> <section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold mb-4"> <h2 className="text-2xl font-semibold">AI Features & Encryption</h2>
Local Storage & Cookies <p>
</h2> When you use AI-powered features (like auto captions, content
<p className="mb-4"> analysis, or enhancement tools), your content needs to be processed on
We use browser local storage and IndexedDB to: our servers. Here's how we protect your privacy:
</p> </p>
<ul className="list-disc pl-6 mb-4 space-y-2"> <ul className="list-disc space-y-2 pl-6">
<li>Save your projects locally on your device</li> <li>
<li>Remember your editor preferences and settings</li> <strong>Client-side encryption:</strong> Your content is encrypted
<li>Keep you logged in across browser sessions</li> on your device before upload
</ul> </li>
<p> <li>
All data stays on your device and can be cleared at any time <strong>Zero-knowledge processing:</strong> We cannot decrypt or
through your browser settings. view your original content
</p> </li>
</section> <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> <section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold mb-4"> <h2 className="text-2xl font-semibold">Analytics</h2>
Third-Party Services <p>
</h2> We use{" "}
<p className="mb-4"> <a
OpenCut integrates with these services: href="https://www.databuddy.cc"
</p> target="_blank"
<ul className="list-disc pl-6 mb-4 space-y-2"> rel="noopener"
<li> className="text-primary hover:underline"
<strong>Google OAuth:</strong> For optional Google sign-in >
(governed by Google's privacy policy) Databuddy
</li> </a>{" "}
<li> for completely anonymized and non-invasive analytics to understand how
<strong>Vercel:</strong> For hosting and content delivery people use OpenCut.
</li> </p>
<li> <p>
<strong>Databuddy:</strong> For anonymized analytics This helps us improve the editor, but we never collect personal
</li> information, track individual users, or store any data that could
</ul> identify you.
</section> </p>
</section>
<section> <section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold mb-4">Your Rights</h2> <h2 className="text-2xl font-semibold">Local Storage & Cookies</h2>
<p className="mb-4"> <p>We use browser local storage and IndexedDB to:</p>
You have complete control over your data: <ul className="list-disc space-y-2 pl-6">
</p> <li>Save your projects locally on your device</li>
<ul className="list-disc pl-6 mb-4 space-y-2"> <li>Remember your editor preferences and settings</li>
<li> <li>Keep you logged in across browser sessions</li>
Delete your account and all associated data at any time </ul>
</li> <p>
<li>Export your project data</li> All data stays on your device and can be cleared at any time through
<li>Clear local storage to remove all saved projects</li> your browser settings.
<li>Contact us with any privacy concerns</li> </p>
</ul> </section>
</section>
<section> <section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold mb-4"> <h2 className="text-2xl font-semibold">Third-Party Services</h2>
Open Source Transparency <p>OpenCut integrates with these services:</p>
</h2> <ul className="list-disc space-y-2 pl-6">
<p className="mb-4"> <li>
OpenCut is completely open source. You can review our code, <strong>Google OAuth:</strong> For optional Google sign-in (governed
see exactly how we handle data, and even self-host the by Google's privacy policy)
application if you prefer. </li>
</p> <li>
<p> <strong>Vercel:</strong> For hosting and content delivery
View our source code on{" "} </li>
<a <li>
href="https://github.com/OpenCut-app/OpenCut" <strong>Databuddy:</strong> For anonymized analytics
target="_blank" </li>
rel="noopener" </ul>
className="text-primary hover:underline" </section>
>
GitHub
</a>
.
</p>
</section>
<section> <section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold mb-4">Contact Us</h2> <h2 className="text-2xl font-semibold">Your Rights</h2>
<p className="mb-4"> <p>You have complete control over your data:</p>
Questions about this privacy policy or how we handle your <ul className="list-disc space-y-2 pl-6">
data? <li>Delete your account and all associated data at any time</li>
</p> <li>Export your project data</li>
<p> <li>Clear local storage to remove all saved projects</li>
Open an issue on our{" "} <li>Contact us with any privacy concerns</li>
<a </ul>
href="https://github.com/OpenCut-app/OpenCut/issues" </section>
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="https://x.com/opencutapp"
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
X (Twitter)
</a>
.
</p>
</section>
<p className="text-sm text-muted-foreground mt-8 pt-8 border-t border-muted/20"> <section className="flex flex-col gap-3">
Last updated: July 14, 2025 <h2 className="text-2xl font-semibold">Open Source Transparency</h2>
</p> <p>
</CardContent> OpenCut is completely open source. You can review our code, see
</Card> exactly how we handle data, and even self-host the application if you
</div> prefer.
</div> </p>
</main> <p>
<Footer /> View our source code on{" "}
</div> <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

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 });
},
},
),
);

View File

@ -1,247 +1,144 @@
import { Metadata } from "next"; import type { Metadata } from "next";
import { BasePage } from "@/app/base-page";
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { GithubIcon } from "@/components/icons"; import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
import Link from "next/link"; import { cn } from "@/utils/ui";
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
import ReactMarkdown from "react-markdown";
import { cn } from "@/lib/utils";
const roadmapItems: { type StatusType = "complete" | "pending" | "default" | "info";
title: string;
description: string; interface Status {
status: { text: string;
text: string; type: StatusType;
type: "complete" | "pending" | "default" | "info"; }
};
}[] = [ interface RoadmapItem {
{ title: string;
title: "Start", description: string;
description: status: Status;
"This is where it all started. Repository created, initial project structure, and the vision for a free, open-source video editor. [Check out the first tweet](https://x.com/mazeincoding/status/1936706642512388188) to see where it started.", }
status: {
text: "Completed", const roadmapItems: RoadmapItem[] = [
type: "complete", {
}, title: "Start",
}, description:
{ "This is where it all started. Repository created, initial project structure, and the vision for a free, open-source video editor. [Check out the first tweet](https://x.com/mazeincoding/status/1936706642512388188) to see where it started.",
title: "Core UI", status: {
description: text: "Completed",
"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.", type: "complete",
status: { },
text: "Completed", },
type: "complete", {
}, title: "Core UI",
}, description:
{ "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.",
title: "Basic Functionality", status: {
description: text: "Completed",
"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.", type: "complete",
status: { },
text: "In Progress", },
type: "pending", {
}, title: "Essential functionality",
}, description:
{ "Everything that makes a video editor **useful**. Timeline interactivity, storage, effects, transitions, etc.",
title: "Export/Preview Logic", status: {
description: text: "In progress",
"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.", type: "pending",
status: { },
text: "Completed", },
type: "complete", {
}, title: "Badge (potentially)",
}, description:
{ 'An "Edit with OpenCut" badge web apps can integrate. Shows on video players.',
title: "Text", status: {
description: text: "Not started",
"After media, text is the next most important thing. Font selection with custom font imports, text stroke, colors. All the text essential text properties.", type: "default",
status: { },
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 = { export const metadata: Metadata = {
title: "Roadmap - OpenCut", title: "Roadmap - OpenCut",
description: description:
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.", "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
openGraph: { openGraph: {
title: "OpenCut Roadmap - What's Coming Next", title: "OpenCut Roadmap - What's Coming Next",
description: description:
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.", "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
type: "website", type: "website",
images: [ images: [
{ {
url: "/open-graph/roadmap.jpg", url: "/open-graph/roadmap.jpg",
width: 1200, width: 1200,
height: 630, height: 630,
alt: "OpenCut Roadmap", alt: "OpenCut Roadmap",
}, },
], ],
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "OpenCut Roadmap - What's Coming Next", title: "OpenCut Roadmap - What's Coming Next",
description: description:
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.", "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
images: ["/open-graph/roadmap.jpg"], images: ["/open-graph/roadmap.jpg"],
}, },
}; };
export default function RoadmapPage() { export default function RoadmapPage() {
return ( return (
<div className="min-h-screen bg-background"> <BasePage
<Header /> title="Roadmap"
<main className="relative"> description="What's coming next for OpenCut (last updated: July 14, 2025)"
<div className="absolute inset-0 overflow-hidden pointer-events-none"> >
<div className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" /> <div className="mx-auto flex max-w-4xl flex-col gap-16">
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" /> <div className="flex flex-col gap-6">
</div> {roadmapItems.map((item, index) => (
<div className="relative container mx-auto px-4 py-16"> <RoadmapItem key={item.title} item={item} index={index} />
<div className="max-w-4xl mx-auto"> ))}
<div className="text-center mb-10"> </div>
<Link <GitHubContributeSection
href="https://github.com/OpenCut-app/OpenCut" title="Want to help?"
target="_blank" description="OpenCut is open source and built by the community. Every contribution,
> no matter how small, helps us build the best free video editor
<Badge variant="secondary" className="gap-2 mb-6"> possible."
<GithubIcon className="h-3 w-3" /> />
Open Source </div>
</Badge> </BasePage>
</Link> );
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6"> }
Roadmap
</h1> function RoadmapItem({ item, index }: { item: RoadmapItem; index: number }) {
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed"> return (
What's coming next for OpenCut (last updated: July 14, 2025) <div className="flex flex-col gap-2">
</p> <div className="flex items-center gap-2 text-lg font-medium">
</div> <span className="leading-normal select-none">{index + 1}</span>
<div className="space-y-6"> <h3>{item.title}</h3>
{roadmapItems.map((item, index) => ( <StatusBadge status={item.status} className="ml-1" />
<div key={index} className="relative"> </div>
<div className="flex items-start gap-2"> <div className="text-foreground/70 leading-relaxed">
<span className="text-lg font-medium text-muted-foreground select-none leading-normal"> <ReactMarkdownWrapper>{item.description}</ReactMarkdownWrapper>
{index + 1}. </div>
</span> </div>
<div className="flex-1 pt-[2px]"> );
<div className="flex items-center gap-2 mb-2"> }
<h3 className="font-medium text-lg">{item.title}</h3>
<Badge function StatusBadge({
className={cn("shadow-none", { status,
"bg-green-500! text-white": className,
item.status.type === "complete", }: {
"bg-yellow-500! text-white": status: Status;
item.status.type === "pending", className?: string;
"bg-blue-500! text-white": }) {
item.status.type === "info", return (
"bg-foreground/10! text-accent-foreground": <Badge
item.status.type === "default", className={cn("shadow-none", className, {
})} "bg-green-500! text-white": status.type === "complete",
> "bg-yellow-500! text-white": status.type === "pending",
{item.status.text} "bg-blue-500! text-white": status.type === "info",
</Badge> "bg-foreground/10! text-accent-foreground": status.type === "default",
</div> })}
<div className="text-foreground/70 leading-relaxed"> >
<ReactMarkdown {status.text}
components={{ </Badge>
a: ({ className, children, ...props }) => ( );
<a
className={cn(
"text-primary hover:underline",
className
)}
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
),
strong: ({ children }) => (
<strong className="font-semibold text-foreground">
{children}
</strong>
),
}}
>
{item.description}
</ReactMarkdown>
</div>
</div>
</div>
</div>
))}
</div>
<div className="mt-12 pt-8 border-t border-muted/20">
<div className="text-center space-y-4">
<h3 className="text-xl font-semibold">Want to Help?</h3>
<p className="text-muted-foreground max-w-2xl mx-auto">
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="flex flex-col sm:flex-row gap-4 justify-center mt-6">
<Link
href="https://github.com/OpenCut-app/OpenCut/blob/main/.github/CONTRIBUTING.md"
target="_blank"
rel="noopener noreferrer"
>
<Badge
variant="outline"
className="text-sm px-4 py-2 hover:bg-muted/50 transition-colors"
>
<GithubIcon className="h-4 w-4 mr-2" />
Start Contributing
</Badge>
</Link>
<Link
href="https://github.com/OpenCut-app/OpenCut/issues"
target="_blank"
rel="noopener noreferrer"
>
<Badge
variant="outline"
className="text-sm px-4 py-2 hover:bg-muted/50 transition-colors"
>
Report Issues
</Badge>
</Link>
</div>
</div>
</div>
</div>
</div>
</main>
<Footer />
</div>
);
} }

View File

@ -1,13 +1,13 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { SITE_URL } from "@/constants/site"; import { SITE_URL } from "@/constants/site-constants";
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
return { return {
rules: { rules: {
userAgent: "*", userAgent: "*",
allow: "/", allow: "/",
disallow: ["/_next/", "/projects/", "/editor/"], disallow: ["/_next/", "/projects/", "/editor/"],
}, },
sitemap: `${SITE_URL}/sitemap.xml`, sitemap: `${SITE_URL}/sitemap.xml`,
}; };
} }

View File

@ -1,46 +1,46 @@
import { Feed } from "feed"; import { Feed } from "feed";
import { getPosts } from "@/lib/blog-query"; import { getPosts } from "@/lib/blog/query";
import { SITE_INFO, SITE_URL } from "@/constants/site"; import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
export async function GET() { export async function GET() {
try { try {
const { posts } = await getPosts(); const { posts } = await getPosts();
const feed = new Feed({ const feed = new Feed({
title: `${SITE_INFO.title} Blog`, title: `${SITE_INFO.title} Blog`,
description: SITE_INFO.description, description: SITE_INFO.description,
id: `${SITE_URL}`, id: `${SITE_URL}`,
link: `${SITE_URL}/blog/`, link: `${SITE_URL}/blog/`,
language: "en", language: "en",
image: `${SITE_INFO.openGraphImage}`, image: `${SITE_INFO.openGraphImage}`,
favicon: `${SITE_INFO.favicon}`, favicon: `${SITE_INFO.favicon}`,
copyright: `All rights reserved ${new Date().getFullYear()}, ${ copyright: `All rights reserved ${new Date().getFullYear()}, ${
SITE_INFO.title SITE_INFO.title
}`, }`,
}); });
for (const post of posts) { for (const post of posts) {
feed.addItem({ feed.addItem({
title: post.title, title: post.title,
id: `${SITE_URL}/blog/${post.slug}`, id: `${SITE_URL}/blog/${post.slug}`,
link: `${SITE_URL}/blog/${post.slug}`, link: `${SITE_URL}/blog/${post.slug}`,
description: post.description, description: post.description,
author: post.authors.map((author) => ({ author: post.authors.map((author) => ({
name: author.name, name: author.name,
})), })),
date: new Date(post.publishedAt), date: new Date(post.publishedAt),
image: post.coverImage || SITE_INFO.openGraphImage, image: post.coverImage || SITE_INFO.openGraphImage,
}); });
} }
return new Response(feed.rss2(), { return new Response(feed.rss2(), {
headers: { headers: {
"Content-Type": "text/xml", "Content-Type": "text/xml",
"Cache-Control": "public, max-age=86400, stale-while-revalidate", "Cache-Control": "public, max-age=86400, stale-while-revalidate",
}, },
}); });
} catch (error) { } catch (error) {
console.error("Error generating RSS feed", error); console.error("Error generating RSS feed", error);
return new Response("Internal Server Error", { status: 500 }); return new Response("Internal Server Error", { status: 500 });
} }
} }

View File

@ -1,61 +1,61 @@
import { SITE_URL } from "@/constants/site"; import { SITE_URL } from "@/constants/site-constants";
import { getPosts } from "@/lib/blog-query"; import { getPosts } from "@/lib/blog/query";
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const data = await getPosts(); const data = await getPosts();
const postPages: MetadataRoute.Sitemap = const postPages: MetadataRoute.Sitemap =
data?.posts?.map((post) => ({ data?.posts?.map((post) => ({
url: `${SITE_URL}/blog/${post.slug}`, url: `${SITE_URL}/blog/${post.slug}`,
lastModified: new Date(post.publishedAt), lastModified: new Date(post.publishedAt),
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 0.8, priority: 0.8,
})) ?? []; })) ?? [];
return [ return [
{ {
url: SITE_URL, url: SITE_URL,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 1, priority: 1,
}, },
{ {
url: `${SITE_URL}/contributors`, url: `${SITE_URL}/contributors`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.5, priority: 0.5,
}, },
{ {
url: `${SITE_URL}/roadmap`, url: `${SITE_URL}/roadmap`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 1, priority: 1,
}, },
{ {
url: `${SITE_URL}/privacy`, url: `${SITE_URL}/privacy`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.5, priority: 0.5,
}, },
{ {
url: `${SITE_URL}/terms`, url: `${SITE_URL}/terms`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.5, priority: 0.5,
}, },
{ {
url: `${SITE_URL}/why-not-capcut`, url: `${SITE_URL}/why-not-capcut`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "yearly", changeFrequency: "yearly",
priority: 1, priority: 1,
}, },
{ {
url: `${SITE_URL}/blog`, url: `${SITE_URL}/blog`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 1, priority: 1,
}, },
...postPages, ...postPages,
]; ];
} }

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>
);
}

View File

@ -1,317 +1,295 @@
import { Metadata } from "next"; import type { Metadata } from "next";
import { Card, CardContent } from "@/components/ui/card"; import { BasePage } from "@/app/base-page";
import { Badge } from "@/components/ui/badge"; import {
import { GithubIcon } from "@/components/icons"; Accordion,
import Link from "next/link"; AccordionContent,
import { Footer } from "@/components/footer"; AccordionItem,
import { Header } from "@/components/header"; AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
import { SOCIAL_LINKS } from "@/constants/site-constants";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Terms of Service - OpenCut", title: "Terms of Service - OpenCut",
description: description:
"OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.", "OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.",
openGraph: { openGraph: {
title: "Terms of Service - OpenCut", title: "Terms of Service - OpenCut",
description: description:
"OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.", "OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.",
type: "website", type: "website",
}, },
}; };
export default function TermsPage() { export default function TermsPage() {
return ( return (
<div className="min-h-screen bg-background"> <BasePage
<Header /> title="Terms of service"
<main className="relative"> description="Fair and transparent terms for our free, open-source video editor. Contact us if you have any questions."
<div className="absolute inset-0 overflow-hidden pointer-events-none"> >
<div className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" /> <Accordion type="single" collapsible className="w-full">
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" /> <AccordionItem
</div> value="quick-summary"
<div className="relative container mx-auto px-4 py-16"> className="rounded-2xl border px-5"
<div className="max-w-4xl mx-auto"> >
<div className="text-center mb-10"> <AccordionTrigger className="no-underline!">
<Link Quick summary
href="https://github.com/OpenCut-app/OpenCut" </AccordionTrigger>
target="_blank" <AccordionContent>
> <h3 className="mb-3 text-lg font-medium">
<Badge variant="secondary" className="gap-2 mb-6"> You own your content, we own nothing.
<GithubIcon className="h-3 w-3" /> </h3>
Open Source <ol className="list-decimal space-y-2 pl-6">
</Badge> <li>
</Link> Your content stays private - basic editing is local, AI features
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6"> use encrypted uploads
Terms of Service </li>
</h1> <li>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed"> We never claim ownership of your content, even when processing
Fair and transparent terms for our free, open-source video AI features
editor. </li>
</p> <li>
</div> Free for personal and commercial use with no watermarks or
<Card className="bg-background/80 backdrop-blur-xs border-2 border-muted/30"> restrictions
<CardContent className="p-8 text-base leading-relaxed space-y-8"> </li>
<section> <li>Don't use OpenCut for illegal activities or harassment</li>
<h2 className="text-2xl font-semibold mb-4"> <li>
Welcome to OpenCut Service provided "as is" - we can't guarantee perfect uptime
</h2> </li>
<p className="mb-4"> <li>
OpenCut is a free, open-source video editor that runs in Open source means you can review our code and self-host if
your browser. By using our service, you agree to these needed
terms. We've designed these terms to be fair and protect </li>
both you and our project. <li>
</p> You can delete your account anytime and keep using your exported
<p> videos
<strong>Key principle:</strong> Your content stays on your </li>
device. We never claim ownership of your videos or projects. </ol>
</p> <p className="mt-4">
</section> Questions? Email us at{" "}
<a
href="mailto:oss@opencut.app"
className="text-primary hover:underline"
>
oss@opencut.app
</a>
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
<section> <section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold mb-4"> <h2 className="text-2xl font-semibold">Your Content, Your Rights</h2>
Your Content, Your Rights <p>
</h2> <strong>You own everything you create.</strong> OpenCut processes
<p className="mb-4"> basic editing locally on your device. For AI features, content is
<strong>You own everything you create.</strong> OpenCut encrypted before upload and we cannot access your original files. We
processes your videos locally on your device, so we never make no claims to ownership, licensing, or rights over your videos,
have access to your content. We make no claims to ownership, projects, or any content you create using OpenCut.
licensing, or rights over your videos, projects, or any </p>
content you create using OpenCut. <ul className="list-disc space-y-2 pl-6">
</p> <li>
<ul className="list-disc pl-6 mb-4 space-y-2"> Your content remains private and under your control at all times
<li> </li>
Your videos remain completely private and under your <li>You retain all intellectual property rights to your content</li>
control <li>
</li> Even when using AI features, we cannot access your unencrypted
<li> content
You retain all intellectual property rights to your </li>
content <li>You can export and use your content however you choose</li>
</li> <li>No watermarks, no licensing restrictions from OpenCut</li>
<li> </ul>
You can export and use your content however you choose </section>
</li>
<li>
No watermarks, no licensing restrictions from OpenCut
</li>
</ul>
</section>
<section> <section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold mb-4"> <h2 className="text-2xl font-semibold">How You Can Use OpenCut</h2>
How You Can Use OpenCut <p>OpenCut is free for personal and commercial use. You can:</p>
</h2> <ul className="list-disc space-y-2 pl-6">
<p className="mb-4"> <li>
OpenCut is free for personal and commercial use. You can: Create videos for personal, educational, or commercial purposes
</p> </li>
<ul className="list-disc pl-6 mb-4 space-y-2"> <li>Use OpenCut for client work and paid projects</li>
<li> <li>Share and distribute videos created with OpenCut</li>
Create videos for personal, educational, or commercial <li>
purposes Modify and distribute the OpenCut software (under MIT license)
</li> </li>
<li>Use OpenCut for client work and paid projects</li> </ul>
<li>Share and distribute videos created with OpenCut</li> <p>
<li> <strong>What we ask:</strong> Don't use OpenCut for illegal
Modify and distribute the OpenCut software (under MIT activities, harassment, or creating harmful content. Be respectful of
license) others and follow applicable laws.
</li> </p>
</ul> </section>
<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> <section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold mb-4"> <h2 className="text-2xl font-semibold">
Account and Service AI Features and Data Processing
</h2> </h2>
<p className="mb-4"> <p>
To use certain features, you may create an account: OpenCut offers optional AI-powered features that require server
</p> processing:
<ul className="list-disc pl-6 mb-4 space-y-2"> </p>
<li>Provide accurate information when signing up</li> <ul className="list-disc space-y-2 pl-6">
<li> <li>
Keep your account secure and don't share credentials AI features (auto captions, content analysis, etc.) are completely
</li> optional
<li>You're responsible for activity under your account</li> </li>
<li>You can delete your account at any time</li> <li>Your content is encrypted on your device before any upload</li>
</ul> <li>
<p> We use zero-knowledge encryption - we cannot decrypt your content
OpenCut is provided "as is" without warranties. While we </li>
strive for reliability, we can't guarantee uninterrupted <li>Encrypted content is deleted immediately after processing</li>
service. <li>
</p> You maintain full ownership and control of your content throughout
</section> </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> <section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold mb-4"> <h2 className="text-2xl font-semibold">Account and Service</h2>
Open Source Benefits <p>To use certain features, you may create an account:</p>
</h2> <ul className="list-disc space-y-2 pl-6">
<p className="mb-4"> <li>Provide accurate information when signing up</li>
Because OpenCut is open source, you have additional rights: <li>Keep your account secure and don't share credentials</li>
</p> <li>You're responsible for activity under your account</li>
<ul className="list-disc pl-6 mb-4 space-y-2"> <li>You can delete your account at any time</li>
<li> </ul>
Review our code to see exactly how we handle your data <p>
</li> OpenCut is provided "as is" without warranties. While we strive for
<li>Self-host OpenCut on your own servers</li> reliability, we can't guarantee uninterrupted service.
<li>Modify the software to suit your needs</li> </p>
<li>Contribute improvements back to the community</li> </section>
</ul>
<p>
View our source code and license on{" "}
<a
href="https://github.com/OpenCut-app/OpenCut"
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
GitHub
</a>
.
</p>
</section>
<section> <section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold mb-4"> <h2 className="text-2xl font-semibold">Open Source Benefits</h2>
Third-Party Content <p>Because OpenCut is open source, you have additional rights:</p>
</h2> <ul className="list-disc space-y-2 pl-6">
<p className="mb-4"> <li>Review our code to see exactly how we handle your data</li>
When using OpenCut, make sure you have the right to use any <li>Self-host OpenCut on your own servers</li>
content you import: <li>Modify the software to suit your needs</li>
</p> <li>Contribute improvements back to the community</li>
<ul className="list-disc pl-6 mb-4 space-y-2"> </ul>
<li> <p>
Only upload content you own or have permission to use View our source code and license on{" "}
</li> <a
<li> href={SOCIAL_LINKS.github}
Respect copyright, trademarks, and other intellectual target="_blank"
property rel="noopener"
</li> className="text-primary hover:underline"
<li> >
Don't use copyrighted music, images, or videos without GitHub
permission </a>
</li> .
<li> </p>
You're responsible for any claims related to your content </section>
</li>
</ul>
</section>
<section> <section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold mb-4"> <h2 className="text-2xl font-semibold">Third-Party Content</h2>
Limitations and Liability <p>
</h2> When using OpenCut, make sure you have the right to use any content
<p className="mb-4"> you import:
OpenCut is provided free of charge. To the extent permitted </p>
by law: <ul className="list-disc space-y-2 pl-6">
</p> <li>Only upload content you own or have permission to use</li>
<ul className="list-disc pl-6 mb-4 space-y-2"> <li>
<li>We're not liable for any loss of data or content</li> Respect copyright, trademarks, and other intellectual property
<li> </li>
Projects are stored in your browser and may be lost if you <li>
clear browser data Don't use copyrighted music, images, or videos without permission
</li> </li>
<li>We're not responsible for how you use the service</li> <li>You're responsible for any claims related to your content</li>
<li> </ul>
Our liability is limited to the maximum extent allowed by </section>
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> <section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold mb-4"> <h2 className="text-2xl font-semibold">Limitations and Liability</h2>
Service Changes <p>
</h2> OpenCut is provided free of charge. To the extent permitted by law:
<p className="mb-4">We may update OpenCut and these terms:</p> </p>
<ul className="list-disc pl-6 mb-4 space-y-2"> <ul className="list-disc space-y-2 pl-6">
<li> <li>We're not liable for any loss of data or content</li>
We'll notify you of significant changes to these terms <li>
</li> Projects are stored in your browser and may be lost if you clear
<li>Continued use means you accept any updates</li> browser data
<li> </li>
You can always self-host an older version if you prefer <li>We're not responsible for how you use the service</li>
</li> <li>Our liability is limited to the maximum extent allowed by law</li>
<li> </ul>
Major changes will be discussed with the community on <p>
GitHub Since your content stays on your device, we have no way to recover
</li> lost projects. Consider exporting important videos when finished
</ul> editing.
</section> </p>
</section>
<section> <section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold mb-4">Termination</h2> <h2 className="text-2xl font-semibold">Service Changes</h2>
<p className="mb-4"> <p>We may update OpenCut and these terms:</p>
You can stop using OpenCut at any time: <ul className="list-disc space-y-2 pl-6">
</p> <li>We'll notify you of significant changes to these terms</li>
<ul className="list-disc pl-6 mb-4 space-y-2"> <li>Continued use means you accept any updates</li>
<li>Delete your account through your profile settings</li> <li>You can always self-host an older version if you prefer</li>
<li>Clear your browser data to remove local projects</li> <li>Major changes will be discussed with the community on GitHub</li>
<li> </ul>
Your content remains yours even if you stop using OpenCut </section>
</li>
<li>
We may suspend accounts for violations of these terms
</li>
</ul>
</section>
<section> <section className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold mb-4"> <h2 className="text-2xl font-semibold">Termination</h2>
Contact and Disputes <p>You can stop using OpenCut at any time:</p>
</h2> <ul className="list-disc space-y-2 pl-6">
<p className="mb-4"> <li>Delete your account through your profile settings</li>
Questions about these terms or need to report an issue? <li>Clear your browser data to remove local projects</li>
</p> <li>Your content remains yours even if you stop using OpenCut</li>
<p className="mb-4"> <li>We may suspend accounts for violations of these terms</li>
Contact us through our{" "} </ul>
<a </section>
href="https://github.com/OpenCut-app/OpenCut/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="https://x.com/opencutapp"
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>
<p className="text-sm text-muted-foreground mt-8 pt-8 border-t border-muted/20"> <section className="flex flex-col gap-3">
Last updated: July 14, 2025 <h2 className="text-2xl font-semibold">Contact Us</h2>
</p> <p>Questions about these terms or need to report an issue?</p>
</CardContent> <p>
</Card> Contact us through our{" "}
</div> <a
</div> href={`${SOCIAL_LINKS.github}/issues`}
</main> target="_blank"
<Footer /> rel="noopener"
</div> 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>
);
} }

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="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
</div>
<div className="relative container mx-auto px-4 py-16">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-20">
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
Fuck CapCut
</h1>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
Roasting time, so get ready motherfucker.
</p>
</div>
<div className="max-w-4xl mx-auto space-y-12">
<div>
<h2 className="text-3xl font-bold mb-6">
Seriously, what the fuck else do you want?
</h2>
<p className="text-lg mb-6">
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="text-lg space-y-2 mb-6 list-disc list-inside">
<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="text-lg mb-6">
<strong>Well guess what, motherfucker:</strong>
</p>
<p className="text-lg mb-6">
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="text-3xl font-bold mb-6">
The "Get Pro" dialog is everywhere
</h2>
<p className="text-lg mb-6">
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="text-lg mb-6">
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="text-3xl font-bold mb-6">
Everything costs money now
</h2>
<p className="text-lg mb-6">
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="text-lg mb-6">
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="text-lg mb-6">
<strong>
My MP3 was already fucking audio, you absolute morons.
</strong>
</p>
<p className="text-lg mb-6">
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="text-3xl font-bold mb-6">
Their Terms of Service are insane
</h2>
<p className="text-lg mb-6">
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="text-lg mb-6">
<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="text-lg mb-6">
Translation: "We'll make money off your viral video, you
handle the lawsuits." Brilliant legal strategy, you fucks.
</p>
</div>
<div>
<h2 className="text-3xl font-bold mb-6">
The editor is actually good
</h2>
<p className="text-lg mb-6">
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="text-lg mb-6">
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="text-3xl font-bold mb-6">
This is a video editor. Look at it. You've never seen one
before.
</h2>
<p className="text-lg mb-6">
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="text-lg mb-6">
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="text-3xl font-bold mb-6">
Yes, this is fucking satire, you fuck
</h2>
<p className="text-lg mb-6">
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>
);
}

View File

@ -1,68 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
export function DeleteProjectDialog({
isOpen,
onOpenChange,
onConfirm,
projectName,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
projectName?: string;
}) {
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<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
variant="text"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenChange(false);
}}
>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -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>
);
}

View File

@ -1,162 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import WaveSurfer from "wavesurfer.js";
interface AudioWaveformProps {
audioUrl: string;
height?: number;
className?: string;
}
const AudioWaveform: React.FC<AudioWaveformProps> = ({
audioUrl,
height = 32,
className = "",
}) => {
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurfer = useRef<WaveSurfer | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let mounted = true;
let ws = wavesurfer.current;
const initWaveSurfer = async () => {
if (!waveformRef.current || !audioUrl) return;
try {
// Clear any existing instance safely
if (ws) {
// Instead of immediately destroying, just set to null
// We'll destroy it outside this function
wavesurfer.current = null;
}
// Create a fresh instance
const newWaveSurfer = 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,
});
// Assign to ref only if component is still mounted
if (mounted) {
wavesurfer.current = newWaveSurfer;
} else {
// Component unmounted during initialization, clean up
try {
newWaveSurfer.destroy();
} catch (e) {
// Ignore destroy errors
}
return;
}
// Event listeners
newWaveSurfer.on("ready", () => {
if (mounted) {
setIsLoading(false);
setError(false);
}
});
newWaveSurfer.on("error", (err) => {
if (mounted) {
console.error("WaveSurfer error:", err);
setError(true);
setIsLoading(false);
}
});
await newWaveSurfer.load(audioUrl);
} catch (err) {
if (mounted) {
console.error("Failed to initialize WaveSurfer:", err);
setError(true);
setIsLoading(false);
}
}
};
// First safely destroy previous instance if it exists
if (ws) {
// Use this pattern to safely destroy the previous instance
const wsToDestroy = ws;
// Detach from ref immediately
wavesurfer.current = null;
// Wait a tick to destroy so any pending operations can complete
requestAnimationFrame(() => {
try {
wsToDestroy.destroy();
} catch (e) {
// Ignore errors during destroy
}
// Only initialize new instance after destroying the old one
if (mounted) {
initWaveSurfer();
}
});
} else {
// No previous instance to clean up, initialize directly
initWaveSurfer();
}
return () => {
// Mark component as unmounted
mounted = false;
// Store reference to current wavesurfer instance
const wsToDestroy = wavesurfer.current;
// Immediately clear the ref to prevent accessing it after unmount
wavesurfer.current = null;
// If we have an instance to clean up, do it safely
if (wsToDestroy) {
// Delay destruction to avoid race conditions
requestAnimationFrame(() => {
try {
wsToDestroy.destroy();
} catch (e) {
// Ignore destroy errors - they're expected
}
});
}
};
}, [audioUrl, height]);
if (error) {
return (
<div
className={`flex items-center justify-center ${className}`}
style={{ height }}
>
<span className="text-xs text-foreground/60">Audio unavailable</span>
</div>
);
}
return (
<div className={`relative ${className}`}>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs text-foreground/60">Loading...</span>
</div>
)}
<div
ref={waveformRef}
className={`w-full transition-opacity duration-200 ${isLoading ? "opacity-0" : "opacity-100"}`}
style={{ height }}
/>
</div>
);
};
export default AudioWaveform;

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -0,0 +1,72 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogContent,
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,
onOpenChange,
onConfirm,
projectName,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (newName: string) => void;
projectName: string;
}) {
const [name, setName] = useState(projectName);
const handleOpenChange = (open: boolean) => {
if (open) {
setName(projectName);
}
onOpenChange(open);
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename project</DialogTitle>
</DialogHeader>
<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);
}}
>
Cancel
</Button>
<Button onClick={() => onConfirm(name)}>Rename</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -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>
);
}

View File

@ -1,130 +1,187 @@
"use client"; "use client";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { ChevronDown, ArrowLeft, SquarePen, Trash } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { HeaderBase } from "../header-base";
import { useProjectStore } from "@/stores/project-store";
import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help";
import { useState } from "react"; import { useState } from "react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "../ui/dropdown-menu"; } from "../ui/dropdown-menu";
import Link from "next/link"; import Link from "next/link";
import { RenameProjectDialog } from "../rename-project-dialog"; import { RenameProjectDialog } from "./dialogs/rename-project-dialog";
import { DeleteProjectDialog } from "../delete-project-dialog"; import { DeleteProjectDialog } from "./dialogs/delete-project-dialog";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { FaDiscord } from "react-icons/fa6"; import { FaDiscord } from "react-icons/fa6";
import { PanelPresetSelector } from "./panel-preset-selector";
import { ExportButton } from "./export-button"; import { ExportButton } from "./export-button";
import { ThemeToggle } from "../theme-toggle"; 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() { export function EditorHeader() {
const { activeProject, renameProject, deleteProject } = useProjectStore(); return (
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); <header className="bg-background flex h-[3.2rem] items-center justify-between px-3 pt-0.5">
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); <div className="flex items-center gap-2">
const router = useRouter(); <ProjectDropdown />
</div>
const handleNameSave = async (newName: string) => { <nav className="flex items-center gap-2">
console.log("handleNameSave", newName); <ExportButton />
if (activeProject && newName.trim() && newName !== activeProject.name) { <ThemeToggle />
try { </nav>
await renameProject(activeProject.id, newName.trim()); </header>
setIsRenameDialogOpen(false); );
} catch (error) { }
console.error("Failed to rename project:", error);
} function ProjectDropdown() {
} const [openDialog, setOpenDialog] = useState<
}; "delete" | "rename" | "shortcuts" | null
>(null);
const handleDelete = () => { const [isExiting, setIsExiting] = useState(false);
if (activeProject) { const router = useRouter();
deleteProject(activeProject.id); const editor = useEditor();
setIsDeleteDialogOpen(false); const activeProject = editor.project.getActive();
router.push("/projects");
} const handleExit = async () => {
}; if (isExiting) return;
setIsExiting(true);
const leftContent = (
<div className="flex items-center gap-2"> try {
<DropdownMenu> await editor.project.prepareExit();
<DropdownMenuTrigger asChild> editor.project.closeProject();
<Button } catch (error) {
variant="secondary" console.error("Failed to prepare project exit:", error);
className="h-auto py-1.5 px-2.5 flex items-center justify-center" } finally {
> editor.project.closeProject();
<ChevronDown className="text-muted-foreground" /> router.push("/projects");
<span className="text-[0.85rem] mr-2">{activeProject?.name}</span> }
</Button> };
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40 z-100"> const handleSaveProjectName = async (newName: string) => {
<Link href="/projects"> if (
<DropdownMenuItem className="flex items-center gap-1.5"> activeProject &&
<ArrowLeft className="h-4 w-4" /> newName.trim() &&
Projects newName !== activeProject.metadata.name
</DropdownMenuItem> ) {
</Link> try {
<DropdownMenuItem await editor.project.renameProject({
className="flex items-center gap-1.5" id: activeProject.metadata.id,
onClick={() => setIsRenameDialogOpen(true)} name: newName.trim(),
> });
<SquarePen className="h-4 w-4" /> } catch (error) {
Rename project toast.error("Failed to rename project", {
</DropdownMenuItem> description:
<DropdownMenuItem error instanceof Error ? error.message : "Please try again",
variant="destructive" });
className="flex items-center gap-1.5" } finally {
onClick={() => setIsDeleteDialogOpen(true)} setOpenDialog(null);
> }
<Trash className="h-4 w-4" /> }
Delete Project };
</DropdownMenuItem>
<DropdownMenuSeparator /> const handleDeleteProject = async () => {
<DropdownMenuItem asChild> if (activeProject) {
<Link try {
href="https://discord.gg/zmR9N35cjK" await editor.project.deleteProjects({
target="_blank" ids: [activeProject.metadata.id],
rel="noopener noreferrer" });
className="flex items-center gap-1.5" router.push("/projects");
> } catch (error) {
<FaDiscord className="h-4 w-4" /> toast.error("Failed to delete project", {
Discord description:
</Link> error instanceof Error ? error.message : "Please try again",
</DropdownMenuItem> });
</DropdownMenuContent> } finally {
</DropdownMenu> setOpenDialog(null);
<RenameProjectDialog }
isOpen={isRenameDialogOpen} }
onOpenChange={setIsRenameDialogOpen} };
onConfirm={handleNameSave}
projectName={activeProject?.name || ""} return (
/> <>
<DeleteProjectDialog <DropdownMenu>
isOpen={isDeleteDialogOpen} <DropdownMenuTrigger asChild>
onOpenChange={setIsDeleteDialogOpen} <Button
onConfirm={handleDelete} variant="secondary"
projectName={activeProject?.name || ""} className="flex h-auto items-center justify-center px-2.5 py-1.5"
/> >
</div> <ChevronDown className="text-muted-foreground" />
); <span className="mr-2 text-[0.85rem]">
{activeProject?.metadata.name}
const rightContent = ( </span>
<nav className="flex items-center gap-2"> </Button>
<PanelPresetSelector /> </DropdownMenuTrigger>
<KeyboardShortcutsHelp /> <DropdownMenuContent align="start" className="z-100 w-52">
<ExportButton /> <DropdownMenuItem
<ThemeToggle /> className="flex items-center gap-1.5"
</nav> onClick={handleExit}
); disabled={isExiting}
>
return ( <HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
<HeaderBase Exit project
leftContent={leftContent} </DropdownMenuItem>
rightContent={rightContent} <DropdownMenuItem
className="bg-background h-[3.2rem] px-3 items-center mt-0.5" 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)}
/>
</>
);
} }

View File

@ -1,317 +1,347 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useRef } from "react";
import { TransitionUpIcon } from "../icons"; import { TransitionTopIcon } from "@hugeicons/core-free-icons";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "../ui/button";
import { Label } from "../ui/label";
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import { Progress } from "../ui/progress";
import { Checkbox } from "../ui/checkbox";
import { cn } from "@/lib/utils";
import { import {
exportProject, Popover,
getExportMimeType, PopoverContent,
getExportFileExtension, PopoverTrigger,
DEFAULT_EXPORT_OPTIONS, } from "@/components/ui/popover";
} from "@/lib/export"; import { Button } from "@/components/ui/button";
import { useProjectStore } from "@/stores/project-store"; import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Progress } from "@/components/ui/progress";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/utils/ui";
import { getExportMimeType, getExportFileExtension } from "@/lib/export";
import { Check, Copy, Download, RotateCcw, X } from "lucide-react"; import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
import { ExportFormat, ExportQuality, ExportResult } from "@/types/export"; import {
import { PropertyGroup } from "./properties-panel/property-item"; EXPORT_FORMAT_VALUES,
EXPORT_QUALITY_VALUES,
type ExportFormat,
type ExportQuality,
type ExportResult,
} from "@/types/export";
import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
export function ExportButton() { export function ExportButton() {
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
const { activeProject } = useProjectStore(); const editor = useEditor();
const handleExport = () => { const handleExport = () => {
setIsExportPopoverOpen(true); setIsExportPopoverOpen(true);
}; };
const hasProject = !!activeProject; const hasProject = !!editor.project.getActive();
return ( return (
<Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}> <Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
className={cn( className={cn(
"flex items-center gap-1.5 bg-[#38BDF8] text-white rounded-md px-[0.12rem] py-[0.12rem] transition-all duration-200", "flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
hasProject hasProject
? "cursor-pointer hover:brightness-95" ? "cursor-pointer hover:brightness-105"
: "cursor-not-allowed opacity-50" : "cursor-not-allowed opacity-50",
)} )}
onClick={hasProject ? handleExport : undefined} onClick={hasProject ? handleExport : undefined}
disabled={!hasProject} disabled={!hasProject}
onKeyDown={(event) => { onKeyDown={(event) => {
if (hasProject && (event.key === "Enter" || event.key === " ")) { if (hasProject && (event.key === "Enter" || event.key === " ")) {
event.preventDefault(); event.preventDefault();
handleExport(); handleExport();
} }
}} }}
> >
<div className="flex items-center gap-1.5 bg-linear-270 from-[#2567EC] to-[#37B6F7] rounded-[0.8rem] px-4 py-1 relative shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]"> <div className="relative flex items-center gap-1.5 rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] px-4 py-1 shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
<TransitionUpIcon className="z-50" /> <HugeiconsIcon icon={TransitionTopIcon} className="z-50 size-4" />
<span className="text-[0.875rem] z-50">Export</span> <span className="z-50 text-[0.875rem]">Export</span>
<div className="absolute w-full h-full left-0 top-0 bg-linear-to-t from-white/0 to-white/50 z-10 rounded-[0.8rem] flex items-center justify-center"> <div className="absolute top-0 left-0 z-10 flex size-full items-center justify-center rounded-[0.6rem] bg-linear-to-t from-white/0 to-white/50">
<div className="absolute w-[calc(100%-2px)] h-[calc(100%-2px)] top-[0.08rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] z-50 rounded-[0.8rem]"></div> <div className="absolute top-[0.08rem] z-50 h-[calc(100%-2px)] w-[calc(100%-2px)] rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7]"></div>
</div> </div>
</div> </div>
</button> </button>
</PopoverTrigger> </PopoverTrigger>
{hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />} {hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />}
</Popover> </Popover>
); );
} }
function ExportPopover({ function ExportPopover({
onOpenChange, onOpenChange,
}: { }: {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
const { activeProject } = useProjectStore(); const editor = useEditor();
const [format, setFormat] = useState<ExportFormat>( const activeProject = editor.project.getActive();
DEFAULT_EXPORT_OPTIONS.format const [format, setFormat] = useState<ExportFormat>(
); DEFAULT_EXPORT_OPTIONS.format,
const [quality, setQuality] = useState<ExportQuality>( );
DEFAULT_EXPORT_OPTIONS.quality const [quality, setQuality] = useState<ExportQuality>(
); DEFAULT_EXPORT_OPTIONS.quality,
const [includeAudio, setIncludeAudio] = useState<boolean>( );
DEFAULT_EXPORT_OPTIONS.includeAudio || true const [includeAudio, setIncludeAudio] = useState<boolean>(
); DEFAULT_EXPORT_OPTIONS.includeAudio || true,
const [isExporting, setIsExporting] = useState(false); );
const [progress, setProgress] = useState(0); const [isExporting, setIsExporting] = useState(false);
const [exportResult, setExportResult] = useState<ExportResult | null>(null); const [progress, setProgress] = useState(0);
const [exportResult, setExportResult] = useState<ExportResult | null>(null);
const cancelRequestedRef = useRef(false);
const handleExport = async () => { const handleExport = async () => {
if (!activeProject) return; if (!activeProject) return;
setIsExporting(true); cancelRequestedRef.current = false;
setProgress(0); setIsExporting(true);
setExportResult(null); setProgress(0);
setExportResult(null);
const result = await exportProject({ const result = await editor.project.export({
format, options: {
quality, format,
fps: activeProject.fps, quality,
includeAudio, fps: activeProject.settings.fps,
onProgress: setProgress, includeAudio,
onCancel: () => false, // TODO: Add cancel functionality onProgress: ({ progress }) => setProgress(progress),
}); onCancel: () => cancelRequestedRef.current,
},
});
setIsExporting(false); setIsExporting(false);
setExportResult(result);
if (result.success && result.buffer) { if (result.cancelled) {
// Download the file setExportResult(null);
const mimeType = getExportMimeType(format); setProgress(0);
const extension = getExportFileExtension(format); return;
const blob = new Blob([result.buffer], { type: mimeType }); }
const url = URL.createObjectURL(blob);
const a = document.createElement("a"); setExportResult(result);
a.href = url;
a.download = `${activeProject.name}${extension}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
onOpenChange(false); if (result.success && result.buffer) {
setExportResult(null); const mimeType = getExportMimeType({ format });
setProgress(0); const extension = getExportFileExtension({ format });
} const blob = new Blob([result.buffer], { type: mimeType });
}; const url = URL.createObjectURL(blob);
const handleClose = () => { const a = document.createElement("a");
if (!isExporting) { a.href = url;
onOpenChange(false); a.download = `${activeProject.metadata.name}${extension}`;
setExportResult(null); document.body.appendChild(a);
setProgress(0); a.click();
} document.body.removeChild(a);
}; URL.revokeObjectURL(url);
return ( onOpenChange(false);
<PopoverContent className="w-80 mr-4 flex flex-col gap-3 bg-background"> setExportResult(null);
<> setProgress(0);
{exportResult && !exportResult.success ? ( }
<ExportError };
error={exportResult.error || "Unknown error occurred"}
onRetry={handleExport}
/>
) : (
<>
<div className="flex items-center justify-between">
<h3 className=" font-medium">
{isExporting ? "Exporting project" : "Export project"}
</h3>
<Button variant="text" size="icon" onClick={handleClose}>
<X className="!size-5 text-foreground/85" />
</Button>
</div>
<div className="flex flex-col gap-4"> const handleClose = () => {
{!isExporting && ( if (!isExporting) {
<> onOpenChange(false);
<div className="flex flex-col gap-3"> setExportResult(null);
<PropertyGroup setProgress(0);
title="Format" }
titleClassName="text-sm" };
defaultExpanded={false}
>
<RadioGroup
value={format}
onValueChange={(value) =>
setFormat(value as ExportFormat)
}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="mp4" id="mp4" />
<Label htmlFor="mp4">
MP4 (H.264) - Better compatibility
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="webm" id="webm" />
<Label htmlFor="webm">
WebM (VP9) - Smaller file size
</Label>
</div>
</RadioGroup>
</PropertyGroup>
<PropertyGroup const handleCancel = () => {
title="Quality" cancelRequestedRef.current = true;
titleClassName="text-sm" };
defaultExpanded={false}
>
<RadioGroup
value={quality}
onValueChange={(value) =>
setQuality(value as ExportQuality)
}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="low" id="low" />
<Label htmlFor="low">Low - Smallest file size</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="medium" id="medium" />
<Label htmlFor="medium">Medium - Balanced</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="high" id="high" />
<Label htmlFor="high">High - Recommended</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="very_high" id="very_high" />
<Label htmlFor="very_high">
Very High - Largest file size
</Label>
</div>
</RadioGroup>
</PropertyGroup>
<PropertyGroup return (
title="Audio" <PopoverContent className="bg-background mr-4 flex w-80 flex-col gap-3">
titleClassName="text-sm" {exportResult && !exportResult.success ? (
defaultExpanded={false} <ExportError
> error={exportResult.error || "Unknown error occurred"}
<div className="flex items-center space-x-2"> onRetry={handleExport}
<Checkbox />
id="include-audio" ) : (
checked={includeAudio} <>
onCheckedChange={(checked) => <div className="flex items-center justify-between">
setIncludeAudio(!!checked) <h3 className="font-medium">
} {isExporting ? "Exporting project" : "Export project"}
/> </h3>
<Label htmlFor="include-audio"> <Button variant="text" size="icon" onClick={handleClose}>
Include audio in export <X className="text-foreground/85 !size-5" />
</Label> </Button>
</div> </div>
</PropertyGroup>
</div>
<Button onClick={handleExport} className="w-full gap-2"> <div className="flex flex-col gap-4">
<Download className="w-4 h-4" /> {!isExporting && (
Export <>
</Button> <div className="flex flex-col gap-3">
</> <PropertyGroup
)} title="Format"
titleClassName="text-sm"
defaultExpanded={false}
>
<RadioGroup
value={format}
onValueChange={(value) => {
if (isExportFormat(value)) {
setFormat(value);
}
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="mp4" id="mp4" />
<Label htmlFor="mp4">
MP4 (H.264) - Better compatibility
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="webm" id="webm" />
<Label htmlFor="webm">
WebM (VP9) - Smaller file size
</Label>
</div>
</RadioGroup>
</PropertyGroup>
{isExporting && ( <PropertyGroup
<div className="space-y-4"> title="Quality"
<div className="flex flex-col"> titleClassName="text-sm"
<div className="text-center flex items-center justify-between"> defaultExpanded={false}
<p className="text-sm text-muted-foreground mb-2"> >
{Math.round(progress * 100)}% <RadioGroup
</p> value={quality}
<p className="text-sm text-muted-foreground mb-2">100%</p> onValueChange={(value) => {
</div> if (isExportQuality(value)) {
<Progress value={progress * 100} className="w-full" /> setQuality(value);
</div> }
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="low" id="low" />
<Label htmlFor="low">Low - Smallest file size</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="medium" id="medium" />
<Label htmlFor="medium">Medium - Balanced</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="high" id="high" />
<Label htmlFor="high">High - Recommended</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="very_high" id="very_high" />
<Label htmlFor="very_high">
Very High - Largest file size
</Label>
</div>
</RadioGroup>
</PropertyGroup>
<Button <PropertyGroup
variant="outline" title="Audio"
className="rounded-md w-full" titleClassName="text-sm"
onClick={() => {}} defaultExpanded={false}
> >
Cancel <div className="flex items-center space-x-2">
</Button> <Checkbox
</div> id="include-audio"
)} checked={includeAudio}
</div> onCheckedChange={(checked) =>
</> setIncludeAudio(!!checked)
)} }
</> />
</PopoverContent> <Label htmlFor="include-audio">
); Include audio in export
</Label>
</div>
</PropertyGroup>
</div>
<Button onClick={handleExport} className="w-full gap-2">
<Download className="size-4" />
Export
</Button>
</>
)}
{isExporting && (
<div className="space-y-4">
<div className="flex flex-col">
<div className="flex items-center justify-between text-center">
<p className="text-muted-foreground mb-2 text-sm">
{Math.round(progress * 100)}%
</p>
<p className="text-muted-foreground mb-2 text-sm">100%</p>
</div>
<Progress value={progress * 100} className="w-full" />
</div>
<Button
variant="outline"
className="w-full rounded-md"
onClick={handleCancel}
>
Cancel
</Button>
</div>
)}
</div>
</>
)}
</PopoverContent>
);
}
function isExportFormat(value: string): value is ExportFormat {
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
}
function isExportQuality(value: string): value is ExportQuality {
return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value);
} }
function ExportError({ function ExportError({
error, error,
onRetry, onRetry,
}: { }: {
error: string; error: string;
onRetry: () => void; onRetry: () => void;
}) { }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const handleCopy = async () => { const handleCopy = async () => {
await navigator.clipboard.writeText(error); await navigator.clipboard.writeText(error);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1000); setTimeout(() => setCopied(false), 1000);
}; };
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<p className="text-sm font-medium text-red-400">Export failed</p> <p className="text-destructive text-sm font-medium">Export failed</p>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs">{error}</p>
{error} </div>
</p>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="flex-1 text-xs h-8" className="h-8 flex-1 text-xs"
onClick={handleCopy} onClick={handleCopy}
> >
{copied ? <Check className="text-green-500" /> : <Copy />} {copied ? <Check className="text-constructive" /> : <Copy />}
Copy Copy
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="flex-1 text-xs h-8" className="h-8 flex-1 text-xs"
onClick={onRetry} onClick={onRetry}
> >
<RotateCcw /> <RotateCcw />
Retry Retry
</Button> </Button>
</div> </div>
</div> </div>
); );
} }

View File

@ -4,24 +4,24 @@ import { useEditorStore } from "@/stores/editor-store";
import Image from "next/image"; import Image from "next/image";
function TikTokGuide() { function TikTokGuide() {
return ( return (
<div className="absolute inset-0 pointer-events-none"> <div className="pointer-events-none absolute inset-0">
<Image <Image
src="/platform-guides/tiktok-blueprint.png" src="/platform-guides/tiktok-blueprint.png"
alt="TikTok layout guide" alt="TikTok layout guide"
className="absolute inset-0 w-full h-full object-contain" className="absolute inset-0 size-full object-contain"
draggable={false} draggable={false}
fill fill
/> />
</div> </div>
); );
} }
export function LayoutGuideOverlay() { export function LayoutGuideOverlay() {
const { layoutGuide } = useEditorStore(); const { layoutGuide } = useEditorStore();
if (layoutGuide.platform === null) return null; if (layoutGuide.platform === null) return null;
if (layoutGuide.platform === "tiktok") return <TikTokGuide />; if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
return null; return null;
} }

View File

@ -1,57 +0,0 @@
import { Upload, Plus, Image } from "lucide-react";
import { Button } from "@/components/ui/button";
interface MediaDragOverlayProps {
isVisible: boolean;
isProcessing?: boolean;
progress?: number;
onClick?: () => void;
isEmptyState?: boolean;
}
export function MediaDragOverlay({
isVisible,
isProcessing = false,
progress = 0,
onClick,
isEmptyState = false,
}: MediaDragOverlayProps) {
if (!isVisible) return null;
const handleClick = (e: React.MouseEvent) => {
if (isProcessing || !onClick) return;
e.preventDefault();
e.stopPropagation();
onClick();
};
return (
<div
className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8"
onClick={handleClick}
>
<div className="flex items-center justify-center">
<Upload className="h-10 w-10 text-foreground" />
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground max-w-sm">
{isProcessing
? `Processing your files (${progress}%)`
: "Drag and drop videos, photos, and audio files here"}
</p>
</div>
{isProcessing && (
<div className="w-full max-w-xs">
<div className="w-full bg-muted/50 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
</div>
);
}

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