codebase overhaul (#697)
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>`.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scannable Code Guidelines/Separating Concerns.
|
||||||
|
|
||||||
|
## Core Principle
|
||||||
|
|
||||||
|
Code should be scannable through proper abstraction, not comments. Use variables and helper functions to make intent clear at a glance.
|
||||||
|
|
||||||
|
## Good Scannable Code
|
||||||
|
|
||||||
|
- Extract complex logic into well-named variables
|
||||||
|
- Create helper functions for multi-step operations
|
||||||
|
- Use descriptive names that explain intent
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Scannable: Intent is clear from variable names
|
||||||
|
const isValidUser = user.isActive && user.hasPermissions;
|
||||||
|
const shouldProcessPayment = amount > 0 && !order.isPaid;
|
||||||
|
|
||||||
|
// ✅ Scannable: Complex logic extracted to helper
|
||||||
|
const searchParams = buildFreesoundSearchParams({ query, filters, pagination });
|
||||||
|
const transformedResults = transformFreesoundResults({ rawResults });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bad Unscannable Code
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ❌ Hard to scan: What does this condition mean?
|
||||||
|
if (type === "effects" || !type) {
|
||||||
|
params.append("filter", "duration:[* TO 30.0]");
|
||||||
|
params.append("filter", `avg_rating:[${min_rating} TO *]`);
|
||||||
|
if (commercial_only) {
|
||||||
|
params.append("filter", 'license:("Attribution" OR "Creative Commons 0")');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Hard to scan: Complex ternary
|
||||||
|
const sortParam = query
|
||||||
|
? sort === "score"
|
||||||
|
? "score"
|
||||||
|
: `${sort}_desc`
|
||||||
|
: `${sort}_desc`;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rule
|
||||||
|
|
||||||
|
Make code scannable by extracting intent into variables and helper functions. If you need to think about what code does, extract it. The reader should understand the flow without diving into implementation details.
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
!apps/web/.env.example
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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!
|
||||||
|
|
|
||||||
|
|
@ -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!
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
npx ultracite format
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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);
|
|
||||||
```
|
|
||||||
10
README.md
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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!
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
modal
|
|
||||||
openai-whisper
|
|
||||||
boto3
|
|
||||||
pydantic
|
|
||||||
cryptography
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1,2 +1,8 @@
|
||||||
# Turborepo
|
# Turborepo
|
||||||
.turbo
|
.turbo
|
||||||
|
|
||||||
|
# Env vars
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
.next/
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
CREATE TABLE "accounts" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"account_id" text NOT NULL,
|
|
||||||
"provider_id" text NOT NULL,
|
|
||||||
"user_id" text NOT NULL,
|
|
||||||
"access_token" text,
|
|
||||||
"refresh_token" text,
|
|
||||||
"id_token" text,
|
|
||||||
"access_token_expires_at" timestamp,
|
|
||||||
"refresh_token_expires_at" timestamp,
|
|
||||||
"scope" text,
|
|
||||||
"password" text,
|
|
||||||
"created_at" timestamp NOT NULL,
|
|
||||||
"updated_at" timestamp NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "accounts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
|
||||||
CREATE TABLE "sessions" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"expires_at" timestamp NOT NULL,
|
|
||||||
"token" text NOT NULL,
|
|
||||||
"created_at" timestamp NOT NULL,
|
|
||||||
"updated_at" timestamp NOT NULL,
|
|
||||||
"ip_address" text,
|
|
||||||
"user_agent" text,
|
|
||||||
"user_id" text NOT NULL,
|
|
||||||
CONSTRAINT "sessions_token_unique" UNIQUE("token")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "sessions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
|
||||||
CREATE TABLE "users" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"email" text NOT NULL,
|
|
||||||
"email_verified" boolean NOT NULL,
|
|
||||||
"image" text,
|
|
||||||
"created_at" timestamp NOT NULL,
|
|
||||||
"updated_at" timestamp NOT NULL,
|
|
||||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "users" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
|
||||||
CREATE TABLE "verifications" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"identifier" text NOT NULL,
|
|
||||||
"value" text NOT NULL,
|
|
||||||
"expires_at" timestamp NOT NULL,
|
|
||||||
"created_at" timestamp,
|
|
||||||
"updated_at" timestamp
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "verifications" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
|
||||||
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
CREATE TABLE "waitlist" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"email" text NOT NULL,
|
|
||||||
"created_at" timestamp NOT NULL,
|
|
||||||
CONSTRAINT "waitlist_email_unique" UNIQUE("email")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "waitlist" ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
ALTER TABLE "users" ALTER COLUMN "email_verified" SET DEFAULT false;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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": {},
|
||||||
|
|
|
||||||
|
|
@ -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": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
Before Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 362 B After Width: | Height: | Size: 362 B |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 });
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
export default function EditorLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return <div>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export function DeleteProjectDialog({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
projectNames,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
projectNames: string[];
|
||||||
|
}) {
|
||||||
|
const count = projectNames.length;
|
||||||
|
const isSingle = count === 1;
|
||||||
|
const singleName = isSingle ? projectNames[0] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
onOpenAutoFocus={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{singleName ? (
|
||||||
|
<>
|
||||||
|
{"Delete '"}
|
||||||
|
<span className="inline-block max-w-[300px] truncate align-bottom">
|
||||||
|
{singleName}
|
||||||
|
</span>
|
||||||
|
{"'?"}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`Delete ${count} projects?`
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody>
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Warning</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
This will permanently delete{" "}
|
||||||
|
{singleName ? `"${singleName}"` : `${count} projects`} and all
|
||||||
|
associated files.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Label className="text-xs font-semibold text-slate-500">
|
||||||
|
Type "DELETE" to confirm
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="DELETE"
|
||||||
|
size="lg"
|
||||||
|
variant="destructive"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={onConfirm}>
|
||||||
|
Delete project
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export function MigrationDialog() {
|
||||||
|
const editor = useEditor();
|
||||||
|
const migrationState = editor.project.getMigrationState();
|
||||||
|
|
||||||
|
if (!migrationState.isMigrating) return null;
|
||||||
|
|
||||||
|
const title = migrationState.projectName
|
||||||
|
? "Updating project"
|
||||||
|
: "Updating projects";
|
||||||
|
const description = migrationState.projectName
|
||||||
|
? `Upgrading "${migrationState.projectName}" from v${migrationState.fromVersion} to v${migrationState.toVersion}`
|
||||||
|
: `Upgrading projects from v${migrationState.fromVersion} to v${migrationState.toVersion}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={true}>
|
||||||
|
<DialogContent
|
||||||
|
className="sm:max-w-md"
|
||||||
|
onPointerDownOutside={(event) => event.preventDefault()}
|
||||||
|
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Loader2 className="text-muted-foreground size-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import type { TProjectMetadata } from "@/types/project";
|
||||||
|
import { formatDate } from "@/utils/date";
|
||||||
|
import { formatTimeCode } from "@/lib/time";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
function InfoRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center py-0 last:pb-0">
|
||||||
|
<span className="text-muted-foreground text-sm">{label}</span>
|
||||||
|
<span className="text-sm font-medium">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectInfoDialog({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
project,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
project: TProjectMetadata;
|
||||||
|
}) {
|
||||||
|
const durationFormatted =
|
||||||
|
project.duration > 0
|
||||||
|
? formatTimeCode({
|
||||||
|
timeInSeconds: project.duration,
|
||||||
|
format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS",
|
||||||
|
})
|
||||||
|
: "0:00";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent onOpenAutoFocus={(event) => event.preventDefault()}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="truncate max-w-[350px]">
|
||||||
|
{project.name}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody className="flex flex-col">
|
||||||
|
<InfoRow label="Duration" value={durationFormatted} />
|
||||||
|
<InfoRow
|
||||||
|
label="Created"
|
||||||
|
value={formatDate({ date: project.createdAt })}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="Modified"
|
||||||
|
value={formatDate({ date: project.updatedAt })}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="Project ID"
|
||||||
|
value={
|
||||||
|
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||||
|
{project.id.slice(0, 8)}
|
||||||
|
</code>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => onOpenChange(false)}>Done</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||