feat: add ultracite (#452)

* Init ultracite

* Update scripts and biome.jsonc

* Update biome.jsonc

* Update biome.jsonc

* Update biome.jsonc

* Run format
This commit is contained in:
Hayden Bleasel 2025-07-24 14:41:34 -07:00 committed by GitHub
parent 7b840e9b4e
commit 5931ddb77a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
113 changed files with 8033 additions and 7070 deletions

333
.cursor/rules/ultracite.mdc Normal file
View File

@ -0,0 +1,333 @@
---
description: Ultracite Rules - AI-Ready Formatter and Linter
globs: "**/*.{ts,tsx,js,jsx}"
alwaysApply: true
---
# Project Context
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter.
## Key Principles
- Zero configuration required
- Subsecond performance
- Maximum type safety
- AI-friendly code generation
## Before Writing Code
1. Analyze existing patterns in the codebase
2. Consider edge cases and error scenarios
3. Follow the rules below strictly
4. Validate accessibility requirements
## Rules
### Accessibility (a11y)
- Don't use `accessKey` attribute on any HTML element.
- Don't set `aria-hidden="true"` on focusable elements.
- Don't add ARIA roles, states, and properties to elements that don't support them.
- Don't use distracting elements like `<marquee>` or `<blink>`.
- Only use the `scope` prop on `<th>` elements.
- Don't assign non-interactive ARIA roles to interactive HTML elements.
- Make sure label elements have text content and are associated with an input.
- Don't assign interactive ARIA roles to non-interactive HTML elements.
- Don't assign `tabIndex` to non-interactive HTML elements.
- Don't use positive integers for `tabIndex` property.
- Don't include "image", "picture", or "photo" in img alt prop.
- Don't use explicit role property that's the same as the implicit/default role.
- Make static elements with click handlers use a valid role attribute.
- Always include a `title` element for SVG elements.
- Give all elements requiring alt text meaningful information for screen readers.
- Make sure anchors have content that's accessible to screen readers.
- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.
- Include all required ARIA attributes for elements with ARIA roles.
- Make sure ARIA properties are valid for the element's supported roles.
- Always include a `type` attribute for button elements.
- Make elements with interactive roles and handlers focusable.
- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).
- Always include a `lang` attribute on the html element.
- Always include a `title` attribute for iframe elements.
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
- Include caption tracks for audio and video elements.
- Use semantic elements instead of role attributes in JSX.
- Make sure all anchors are valid and navigable.
- Ensure all ARIA properties (`aria-*`) are valid.
- Use valid, non-abstract ARIA roles for elements with ARIA roles.
- Use valid ARIA state and property values.
- Use valid values for the `autocomplete` attribute on input elements.
- Use correct ISO language/country codes for the `lang` attribute.
### Code Complexity and Quality
- Don't use consecutive spaces in regular expression literals.
- Don't use the `arguments` object.
- Don't use primitive type aliases or misleading types.
- Don't use the comma operator.
- Don't use empty type parameters in type aliases and interfaces.
- Don't write functions that exceed a given Cognitive Complexity score.
- Don't nest describe() blocks too deeply in test files.
- Don't use unnecessary boolean casts.
- Don't use unnecessary callbacks with flatMap.
- Use for...of statements instead of Array.forEach.
- Don't create classes that only have static members (like a static namespace).
- Don't use this and super in static contexts.
- Don't use unnecessary catch clauses.
- Don't use unnecessary constructors.
- Don't use unnecessary continue statements.
- Don't export empty modules that don't change anything.
- Don't use unnecessary escape sequences in regular expression literals.
- Don't use unnecessary fragments.
- Don't use unnecessary labels.
- Don't use unnecessary nested block statements.
- Don't rename imports, exports, and destructured assignments to the same name.
- Don't use unnecessary string or template literal concatenation.
- Don't use String.raw in template literals when there are no escape sequences.
- Don't use useless case statements in switch statements.
- Don't use ternary operators when simpler alternatives exist.
- Don't use useless `this` aliasing.
- Don't use any or unknown as type constraints.
- Don't initialize variables to undefined.
- Don't use the void operators (they're not familiar).
- Use arrow functions instead of function expressions.
- Use Date.now() to get milliseconds since the Unix Epoch.
- Use .flatMap() instead of map().flat() when possible.
- Use literal property access instead of computed property access.
- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
- Use concise optional chaining instead of chained logical expressions.
- Use regular expression literals instead of the RegExp constructor when possible.
- Don't use number literal object member names that aren't base 10 or use underscore separators.
- Remove redundant terms from logical expressions.
- Use while loops instead of for loops when you don't need initializer and update expressions.
- Don't pass children as props.
- Don't reassign const variables.
- Don't use constant expressions in conditions.
- Don't use `Math.min` and `Math.max` to clamp values when the result is constant.
- Don't return a value from a constructor.
- Don't use empty character classes in regular expression literals.
- Don't use empty destructuring patterns.
- Don't call global object properties as functions.
- Don't declare functions and vars that are accessible outside their block.
- Make sure builtins are correctly instantiated.
- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.
- Don't use variables and function parameters before they're declared.
- Don't use 8 and 9 escape sequences in string literals.
- Don't use literal numbers that lose precision.
### React and JSX Best Practices
- Don't use the return value of React.render.
- Make sure all dependencies are correctly specified in React hooks.
- Make sure all React hooks are called from the top level of component functions.
- Don't forget key props in iterators and collection literals.
- Don't destructure props inside JSX components in Solid projects.
- Don't define React components inside other components.
- Don't use event handlers on non-interactive elements.
- Don't assign to React component props.
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
- Don't use dangerous JSX props.
- Don't use Array index in keys.
- Don't insert comments as text nodes.
- Don't assign JSX properties multiple times.
- Don't add extra closing tags for components without children.
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
- Watch out for possible "wrong" semicolons inside JSX elements.
### Correctness and Safety
- Don't assign a value to itself.
- Don't return a value from a setter.
- Don't compare expressions that modify string case with non-compliant values.
- Don't use lexical declarations in switch clauses.
- Don't use variables that haven't been declared in the document.
- Don't write unreachable code.
- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass.
- Don't use control flow statements in finally blocks.
- Don't use optional chaining where undefined values aren't allowed.
- Don't have unused function parameters.
- Don't have unused imports.
- Don't have unused labels.
- Don't have unused private class members.
- Don't have unused variables.
- Make sure void (self-closing) elements don't have children.
- Don't return a value from a function with the return type 'void'
- Use isNaN() when checking for NaN.
- Make sure "for" loop update clauses move the counter in the right direction.
- Make sure typeof expressions are compared to valid values.
- Make sure generator functions contain yield.
- Don't use await inside loops.
- Don't use bitwise operators.
- Don't use expressions where the operation doesn't change the value.
- Make sure Promise-like statements are handled appropriately.
- Don't use __dirname and __filename in the global scope.
- Prevent import cycles.
- Don't use configured elements.
- Don't hardcode sensitive data like API keys and tokens.
- Don't let variable declarations shadow variables from outer scopes.
- Don't use the TypeScript directive @ts-ignore.
- Prevent duplicate polyfills from Polyfill.io.
- Don't use useless backreferences in regular expressions that always match empty strings.
- Don't use unnecessary escapes in string literals.
- Don't use useless undefined.
- Make sure getters and setters for the same property are next to each other in class and object definitions.
- Make sure object literals are declared consistently (defaults to explicit definitions).
- Use static Response methods instead of new Response() constructor when possible.
- Make sure switch-case statements are exhaustive.
- Make sure the `preconnect` attribute is used when using Google Fonts.
- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.
- Make sure iterable callbacks return consistent values.
- Use `with { type: "json" }` for JSON module imports.
- Use numeric separators in numeric literals.
- Use object spread instead of `Object.assign()` when constructing new objects.
- Always use the radix argument when using `parseInt()`.
- Make sure JSDoc comment lines start with a single asterisk, except for the first one.
- Include a description parameter for `Symbol()`.
- Don't use spread (`...`) syntax on accumulators.
- Don't use the `delete` operator.
- Don't access namespace imports dynamically.
- Don't use namespace imports.
- Declare regex literals at the top level.
- Don't use `target="_blank"` without `rel="noopener"`.
### TypeScript Best Practices
- Don't use TypeScript enums.
- Don't export imported variables.
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
- Don't use TypeScript namespaces.
- Don't use non-null assertions with the `!` postfix operator.
- Don't use parameter properties in class constructors.
- Don't use user-defined types.
- Use `as const` instead of literal types and type annotations.
- Use either `T[]` or `Array<T>` consistently.
- Initialize each enum member value explicitly.
- Use `export type` for types.
- Use `import type` for types.
- Make sure all enum members are literal values.
- Don't use TypeScript const enum.
- Don't declare empty interfaces.
- Don't let variables evolve into any type through reassignments.
- Don't use the any type.
- Don't misuse the non-null assertion operator (!) in TypeScript files.
- Don't use implicit any type on variable declarations.
- Don't merge interfaces and classes unsafely.
- Don't use overload signatures that aren't next to each other.
- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
### Style and Consistency
- Don't use global `eval()`.
- Don't use callbacks in asynchronous tests and hooks.
- Don't use negation in `if` statements that have `else` clauses.
- Don't use nested ternary expressions.
- Don't reassign function parameters.
- This rule lets you specify global variable names you don't want to use in your application.
- Don't use specified modules when loaded by import or require.
- Don't use constants whose value is the upper-case version of their name.
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
- Don't use template literals if you don't need interpolation or special-character handling.
- Don't use `else` blocks when the `if` block breaks early.
- Don't use yoda expressions.
- Don't use Array constructors.
- Use `at()` instead of integer index access.
- Follow curly brace conventions.
- Use `else if` instead of nested `if` statements in `else` clauses.
- Use single `if` statements instead of nested `if` clauses.
- Use `new` for all builtins except `String`, `Number`, and `Boolean`.
- Use consistent accessibility modifiers on class properties and methods.
- Use `const` declarations for variables that are only assigned once.
- Put default function parameters and optional function parameters last.
- Include a `default` clause in switch statements.
- Use the `**` operator instead of `Math.pow`.
- Use `for-of` loops when you need the index to extract an item from the iterated array.
- Use `node:assert/strict` over `node:assert`.
- Use the `node:` protocol for Node.js builtin modules.
- Use Number properties instead of global ones.
- Use assignment operator shorthand where possible.
- Use function types instead of object types with call signatures.
- Use template literals over string concatenation.
- Use `new` when throwing an error.
- Don't throw non-Error values.
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
- Use standard constants instead of approximated literals.
- Don't assign values in expressions.
- Don't use async functions as Promise executors.
- Don't reassign exceptions in catch clauses.
- Don't reassign class members.
- Don't compare against -0.
- Don't use labeled statements that aren't loops.
- Don't use void type outside of generic or return types.
- Don't use console.
- Don't use control characters and escape sequences that match control characters in regular expression literals.
- Don't use debugger.
- Don't assign directly to document.cookie.
- Use `===` and `!==`.
- Don't use duplicate case labels.
- Don't use duplicate class members.
- Don't use duplicate conditions in if-else-if chains.
- Don't use two keys with the same name inside objects.
- Don't use duplicate function parameter names.
- Don't have duplicate hooks in describe blocks.
- Don't use empty block statements and static blocks.
- Don't let switch clauses fall through.
- Don't reassign function declarations.
- Don't allow assignments to native objects and read-only global variables.
- Use Number.isFinite instead of global isFinite.
- Use Number.isNaN instead of global isNaN.
- Don't assign to imported bindings.
- Don't use irregular whitespace characters.
- Don't use labels that share a name with a variable.
- Don't use characters made with multiple code points in character class syntax.
- Make sure to use new and constructor properly.
- Don't use shorthand assign when the variable appears on both sides.
- Don't use octal escape sequences in string literals.
- Don't use Object.prototype builtins directly.
- Don't redeclare variables, functions, classes, and types in the same scope.
- Don't have redundant "use strict".
- Don't compare things where both sides are exactly the same.
- Don't let identifiers shadow restricted names.
- Don't use sparse arrays (arrays with holes).
- Don't use template literal placeholder syntax in regular strings.
- Don't use the then property.
- Don't use unsafe negation.
- Don't use var.
- Don't use with statements in non-strict contexts.
- Make sure async functions actually use await.
- Make sure default clauses in switch statements come last.
- Make sure to pass a message value when creating a built-in error.
- Make sure get methods always return a value.
- Use a recommended display strategy with Google Fonts.
- Make sure for-in loops include an if statement.
- Use Array.isArray() instead of instanceof Array.
- Make sure to use the digits argument with Number#toFixed().
- Make sure to use the "use strict" directive in script files.
### Next.js Specific Rules
- Don't use `<img>` elements in Next.js projects.
- Don't use `<head>` elements in Next.js projects.
- Don't import next/document outside of pages/_document.jsx in Next.js projects.
- Don't use the next/head module in pages/_document.js on Next.js projects.
### Testing Best Practices
- Don't use export or module.exports in test files.
- Don't use focused tests.
- Make sure the assertion function, like expect, is placed inside an it() function call.
- Don't use disabled tests.
## Common Tasks
- `npx ultracite init` - Initialize Ultracite in your project
- `npx ultracite format` - Format and fix code automatically
- `npx ultracite lint` - Check for issues without fixing
## Example: Error Handling
```typescript
// ✅ Good: Comprehensive error handling
try {
const result = await fetchData();
return { success: true, data: result };
} catch (error) {
console.error('API call failed:', error);
return { success: false, error: error.message };
}
// ❌ Bad: Swallowing errors
try {
return await fetchData();
} catch (e) {
console.log(e);
}
```

331
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,331 @@
---
applyTo: "**/*.{ts,tsx,js,jsx}"
---
# Project Context
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter.
## Key Principles
- Zero configuration required
- Subsecond performance
- Maximum type safety
- AI-friendly code generation
## Before Writing Code
1. Analyze existing patterns in the codebase
2. Consider edge cases and error scenarios
3. Follow the rules below strictly
4. Validate accessibility requirements
## Rules
### Accessibility (a11y)
- Don't use `accessKey` attribute on any HTML element.
- Don't set `aria-hidden="true"` on focusable elements.
- Don't add ARIA roles, states, and properties to elements that don't support them.
- Don't use distracting elements like `<marquee>` or `<blink>`.
- Only use the `scope` prop on `<th>` elements.
- Don't assign non-interactive ARIA roles to interactive HTML elements.
- Make sure label elements have text content and are associated with an input.
- Don't assign interactive ARIA roles to non-interactive HTML elements.
- Don't assign `tabIndex` to non-interactive HTML elements.
- Don't use positive integers for `tabIndex` property.
- Don't include "image", "picture", or "photo" in img alt prop.
- Don't use explicit role property that's the same as the implicit/default role.
- Make static elements with click handlers use a valid role attribute.
- Always include a `title` element for SVG elements.
- Give all elements requiring alt text meaningful information for screen readers.
- Make sure anchors have content that's accessible to screen readers.
- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.
- Include all required ARIA attributes for elements with ARIA roles.
- Make sure ARIA properties are valid for the element's supported roles.
- Always include a `type` attribute for button elements.
- Make elements with interactive roles and handlers focusable.
- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).
- Always include a `lang` attribute on the html element.
- Always include a `title` attribute for iframe elements.
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
- Include caption tracks for audio and video elements.
- Use semantic elements instead of role attributes in JSX.
- Make sure all anchors are valid and navigable.
- Ensure all ARIA properties (`aria-*`) are valid.
- Use valid, non-abstract ARIA roles for elements with ARIA roles.
- Use valid ARIA state and property values.
- Use valid values for the `autocomplete` attribute on input elements.
- Use correct ISO language/country codes for the `lang` attribute.
### Code Complexity and Quality
- Don't use consecutive spaces in regular expression literals.
- Don't use the `arguments` object.
- Don't use primitive type aliases or misleading types.
- Don't use the comma operator.
- Don't use empty type parameters in type aliases and interfaces.
- Don't write functions that exceed a given Cognitive Complexity score.
- Don't nest describe() blocks too deeply in test files.
- Don't use unnecessary boolean casts.
- Don't use unnecessary callbacks with flatMap.
- Use for...of statements instead of Array.forEach.
- Don't create classes that only have static members (like a static namespace).
- Don't use this and super in static contexts.
- Don't use unnecessary catch clauses.
- Don't use unnecessary constructors.
- Don't use unnecessary continue statements.
- Don't export empty modules that don't change anything.
- Don't use unnecessary escape sequences in regular expression literals.
- Don't use unnecessary fragments.
- Don't use unnecessary labels.
- Don't use unnecessary nested block statements.
- Don't rename imports, exports, and destructured assignments to the same name.
- Don't use unnecessary string or template literal concatenation.
- Don't use String.raw in template literals when there are no escape sequences.
- Don't use useless case statements in switch statements.
- Don't use ternary operators when simpler alternatives exist.
- Don't use useless `this` aliasing.
- Don't use any or unknown as type constraints.
- Don't initialize variables to undefined.
- Don't use the void operators (they're not familiar).
- Use arrow functions instead of function expressions.
- Use Date.now() to get milliseconds since the Unix Epoch.
- Use .flatMap() instead of map().flat() when possible.
- Use literal property access instead of computed property access.
- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.
- Use concise optional chaining instead of chained logical expressions.
- Use regular expression literals instead of the RegExp constructor when possible.
- Don't use number literal object member names that aren't base 10 or use underscore separators.
- Remove redundant terms from logical expressions.
- Use while loops instead of for loops when you don't need initializer and update expressions.
- Don't pass children as props.
- Don't reassign const variables.
- Don't use constant expressions in conditions.
- Don't use `Math.min` and `Math.max` to clamp values when the result is constant.
- Don't return a value from a constructor.
- Don't use empty character classes in regular expression literals.
- Don't use empty destructuring patterns.
- Don't call global object properties as functions.
- Don't declare functions and vars that are accessible outside their block.
- Make sure builtins are correctly instantiated.
- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.
- Don't use variables and function parameters before they're declared.
- Don't use 8 and 9 escape sequences in string literals.
- Don't use literal numbers that lose precision.
### React and JSX Best Practices
- Don't use the return value of React.render.
- Make sure all dependencies are correctly specified in React hooks.
- Make sure all React hooks are called from the top level of component functions.
- Don't forget key props in iterators and collection literals.
- Don't destructure props inside JSX components in Solid projects.
- Don't define React components inside other components.
- Don't use event handlers on non-interactive elements.
- Don't assign to React component props.
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
- Don't use dangerous JSX props.
- Don't use Array index in keys.
- Don't insert comments as text nodes.
- Don't assign JSX properties multiple times.
- Don't add extra closing tags for components without children.
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
- Watch out for possible "wrong" semicolons inside JSX elements.
### Correctness and Safety
- Don't assign a value to itself.
- Don't return a value from a setter.
- Don't compare expressions that modify string case with non-compliant values.
- Don't use lexical declarations in switch clauses.
- Don't use variables that haven't been declared in the document.
- Don't write unreachable code.
- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass.
- Don't use control flow statements in finally blocks.
- Don't use optional chaining where undefined values aren't allowed.
- Don't have unused function parameters.
- Don't have unused imports.
- Don't have unused labels.
- Don't have unused private class members.
- Don't have unused variables.
- Make sure void (self-closing) elements don't have children.
- Don't return a value from a function with the return type 'void'
- Use isNaN() when checking for NaN.
- Make sure "for" loop update clauses move the counter in the right direction.
- Make sure typeof expressions are compared to valid values.
- Make sure generator functions contain yield.
- Don't use await inside loops.
- Don't use bitwise operators.
- Don't use expressions where the operation doesn't change the value.
- Make sure Promise-like statements are handled appropriately.
- Don't use __dirname and __filename in the global scope.
- Prevent import cycles.
- Don't use configured elements.
- Don't hardcode sensitive data like API keys and tokens.
- Don't let variable declarations shadow variables from outer scopes.
- Don't use the TypeScript directive @ts-ignore.
- Prevent duplicate polyfills from Polyfill.io.
- Don't use useless backreferences in regular expressions that always match empty strings.
- Don't use unnecessary escapes in string literals.
- Don't use useless undefined.
- Make sure getters and setters for the same property are next to each other in class and object definitions.
- Make sure object literals are declared consistently (defaults to explicit definitions).
- Use static Response methods instead of new Response() constructor when possible.
- Make sure switch-case statements are exhaustive.
- Make sure the `preconnect` attribute is used when using Google Fonts.
- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.
- Make sure iterable callbacks return consistent values.
- Use `with { type: "json" }` for JSON module imports.
- Use numeric separators in numeric literals.
- Use object spread instead of `Object.assign()` when constructing new objects.
- Always use the radix argument when using `parseInt()`.
- Make sure JSDoc comment lines start with a single asterisk, except for the first one.
- Include a description parameter for `Symbol()`.
- Don't use spread (`...`) syntax on accumulators.
- Don't use the `delete` operator.
- Don't access namespace imports dynamically.
- Don't use namespace imports.
- Declare regex literals at the top level.
- Don't use `target="_blank"` without `rel="noopener"`.
### TypeScript Best Practices
- Don't use TypeScript enums.
- Don't export imported variables.
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
- Don't use TypeScript namespaces.
- Don't use non-null assertions with the `!` postfix operator.
- Don't use parameter properties in class constructors.
- Don't use user-defined types.
- Use `as const` instead of literal types and type annotations.
- Use either `T[]` or `Array<T>` consistently.
- Initialize each enum member value explicitly.
- Use `export type` for types.
- Use `import type` for types.
- Make sure all enum members are literal values.
- Don't use TypeScript const enum.
- Don't declare empty interfaces.
- Don't let variables evolve into any type through reassignments.
- Don't use the any type.
- Don't misuse the non-null assertion operator (!) in TypeScript files.
- Don't use implicit any type on variable declarations.
- Don't merge interfaces and classes unsafely.
- Don't use overload signatures that aren't next to each other.
- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
### Style and Consistency
- Don't use global `eval()`.
- Don't use callbacks in asynchronous tests and hooks.
- Don't use negation in `if` statements that have `else` clauses.
- Don't use nested ternary expressions.
- Don't reassign function parameters.
- This rule lets you specify global variable names you don't want to use in your application.
- Don't use specified modules when loaded by import or require.
- Don't use constants whose value is the upper-case version of their name.
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
- Don't use template literals if you don't need interpolation or special-character handling.
- Don't use `else` blocks when the `if` block breaks early.
- Don't use yoda expressions.
- Don't use Array constructors.
- Use `at()` instead of integer index access.
- Follow curly brace conventions.
- Use `else if` instead of nested `if` statements in `else` clauses.
- Use single `if` statements instead of nested `if` clauses.
- Use `new` for all builtins except `String`, `Number`, and `Boolean`.
- Use consistent accessibility modifiers on class properties and methods.
- Use `const` declarations for variables that are only assigned once.
- Put default function parameters and optional function parameters last.
- Include a `default` clause in switch statements.
- Use the `**` operator instead of `Math.pow`.
- Use `for-of` loops when you need the index to extract an item from the iterated array.
- Use `node:assert/strict` over `node:assert`.
- Use the `node:` protocol for Node.js builtin modules.
- Use Number properties instead of global ones.
- Use assignment operator shorthand where possible.
- Use function types instead of object types with call signatures.
- Use template literals over string concatenation.
- Use `new` when throwing an error.
- Don't throw non-Error values.
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
- Use standard constants instead of approximated literals.
- Don't assign values in expressions.
- Don't use async functions as Promise executors.
- Don't reassign exceptions in catch clauses.
- Don't reassign class members.
- Don't compare against -0.
- Don't use labeled statements that aren't loops.
- Don't use void type outside of generic or return types.
- Don't use console.
- Don't use control characters and escape sequences that match control characters in regular expression literals.
- Don't use debugger.
- Don't assign directly to document.cookie.
- Use `===` and `!==`.
- Don't use duplicate case labels.
- Don't use duplicate class members.
- Don't use duplicate conditions in if-else-if chains.
- Don't use two keys with the same name inside objects.
- Don't use duplicate function parameter names.
- Don't have duplicate hooks in describe blocks.
- Don't use empty block statements and static blocks.
- Don't let switch clauses fall through.
- Don't reassign function declarations.
- Don't allow assignments to native objects and read-only global variables.
- Use Number.isFinite instead of global isFinite.
- Use Number.isNaN instead of global isNaN.
- Don't assign to imported bindings.
- Don't use irregular whitespace characters.
- Don't use labels that share a name with a variable.
- Don't use characters made with multiple code points in character class syntax.
- Make sure to use new and constructor properly.
- Don't use shorthand assign when the variable appears on both sides.
- Don't use octal escape sequences in string literals.
- Don't use Object.prototype builtins directly.
- Don't redeclare variables, functions, classes, and types in the same scope.
- Don't have redundant "use strict".
- Don't compare things where both sides are exactly the same.
- Don't let identifiers shadow restricted names.
- Don't use sparse arrays (arrays with holes).
- Don't use template literal placeholder syntax in regular strings.
- Don't use the then property.
- Don't use unsafe negation.
- Don't use var.
- Don't use with statements in non-strict contexts.
- Make sure async functions actually use await.
- Make sure default clauses in switch statements come last.
- Make sure to pass a message value when creating a built-in error.
- Make sure get methods always return a value.
- Use a recommended display strategy with Google Fonts.
- Make sure for-in loops include an if statement.
- Use Array.isArray() instead of instanceof Array.
- Make sure to use the digits argument with Number#toFixed().
- Make sure to use the "use strict" directive in script files.
### Next.js Specific Rules
- Don't use `<img>` elements in Next.js projects.
- Don't use `<head>` elements in Next.js projects.
- Don't import next/document outside of pages/_document.jsx in Next.js projects.
- Don't use the next/head module in pages/_document.js on Next.js projects.
### Testing Best Practices
- Don't use export or module.exports in test files.
- Don't use focused tests.
- Make sure the assertion function, like expect, is placed inside an it() function call.
- Don't use disabled tests.
## Common Tasks
- `npx ultracite init` - Initialize Ultracite in your project
- `npx ultracite format` - Format and fix code automatically
- `npx ultracite lint` - Check for issues without fixing
## Example: Error Handling
```typescript
// ✅ Good: Comprehensive error handling
try {
const result = await fetchData();
return { success: true, data: result };
} catch (error) {
console.error('API call failed:', error);
return { success: false, error: error.message };
}
// ❌ Bad: Swallowing errors
try {
return await fetchData();
} catch (e) {
console.log(e);
}
```

2
.gitignore vendored
View File

@ -27,6 +27,4 @@ node_modules
*.env
# cursor
.cursor/
bun.lockb

1
.husky/pre-commit Normal file
View File

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

14
.vscode/settings.json vendored Normal file
View File

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

View File

@ -18,4 +18,4 @@
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
}

View File

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

View File

@ -93,12 +93,8 @@
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -168,12 +164,8 @@
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -183,9 +175,7 @@
"sessions_token_unique": {
"name": "sessions_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
"columns": ["token"]
}
},
"policies": {},
@ -246,9 +236,7 @@
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@ -316,4 +304,4 @@
"schemas": {},
"tables": {}
}
}
}

View File

@ -93,12 +93,8 @@
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -168,12 +164,8 @@
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -183,9 +175,7 @@
"sessions_token_unique": {
"name": "sessions_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
"columns": ["token"]
}
},
"policies": {},
@ -246,9 +236,7 @@
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@ -334,9 +322,7 @@
"waitlist_email_unique": {
"name": "waitlist_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@ -355,4 +341,4 @@
"schemas": {},
"tables": {}
}
}
}

View File

@ -93,12 +93,8 @@
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -168,12 +164,8 @@
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@ -183,9 +175,7 @@
"sessions_token_unique": {
"name": "sessions_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
"columns": ["token"]
}
},
"policies": {},
@ -247,9 +237,7 @@
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@ -335,9 +323,7 @@
"waitlist_email_unique": {
"name": "waitlist_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
"columns": ["email"]
}
},
"policies": {},
@ -356,4 +342,4 @@
"schemas": {},
"tables": {}
}
}
}

View File

@ -24,4 +24,4 @@
"breakpoints": true
}
]
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,44 +1,44 @@
{
"name": "OpenCut",
"description": "A simple but powerful video editor that gets the job done. In your browser.",
"display": "standalone",
"start_url": "/",
"icons": [
{
"src": "/icons/android-icon-36x36.png",
"sizes": "36x36",
"type": "image\/png",
"density": "0.75"
},
{
"src": "/icons/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "/icons/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "/icons/android-icon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{
"src": "/icons/android-icon-144x144.png",
"sizes": "144x144",
"type": "image\/png",
"density": "3.0"
},
{
"src": "/icons/android-icon-192x192.png",
"sizes": "192x192",
"type": "image\/png",
"density": "4.0"
}
]
}
"name": "OpenCut",
"description": "A simple but powerful video editor that gets the job done. In your browser.",
"display": "standalone",
"start_url": "/",
"icons": [
{
"src": "/icons/android-icon-36x36.png",
"sizes": "36x36",
"type": "image\/png",
"density": "0.75"
},
{
"src": "/icons/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "/icons/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "/icons/android-icon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{
"src": "/icons/android-icon-144x144.png",
"sizes": "144x144",
"type": "image\/png",
"density": "3.0"
},
{
"src": "/icons/android-icon-192x192.png",
"sizes": "192x192",
"type": "image\/png",
"density": "4.0"
}
]
}

View File

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

View File

@ -1,83 +1,105 @@
import { NextRequest, NextResponse } from "next/server";
import { db, eq, waitlist } from "@opencut/db";
import { checkBotId } from "botid/server";
import { nanoid } from "nanoid";
import { waitlistRateLimit } from "@/lib/rate-limit";
import { z } from "zod";
import { env } from "@/env";
import { cookies } from "next/headers";
import crypto from "crypto";
const waitlistSchema = z.object({
email: z.string().email("Invalid email format").min(1, "Email is required"),
});
const CSRF_TOKEN_NAME = "waitlist-csrf";
const TOKEN_EXPIRY = 60 * 60 * 1000;
async function validateCSRFToken(request: NextRequest): Promise<boolean> {
const clientToken = request.headers.get("x-csrf-token");
if (!clientToken) return false;
const cookieStore = await cookies();
const cookieValue = cookieStore.get(CSRF_TOKEN_NAME)?.value;
if (!cookieValue) return false;
const [token, timestamp, signature] = cookieValue.split(":");
if (!token || !timestamp || !signature) return false;
if (clientToken !== token) return false;
const now = Date.now();
const tokenTime = parseInt(timestamp);
if (now - tokenTime > TOKEN_EXPIRY) return false;
const expectedSignature = crypto.createHmac("sha256", env.BETTER_AUTH_SECRET).update(`${token}:${timestamp}`).digest("hex");
return signature === expectedSignature;
}
export async function POST(request: NextRequest) {
const verification = await checkBotId();
if (verification.isBot) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
const identifier = request.headers.get("x-forwarded-for") ?? "127.0.0.1";
const { success } = await waitlistRateLimit.limit(identifier);
if (!success) {
return NextResponse.json({ error: "Too many requests. Please try again later." }, { status: 429 });
}
const isValidToken = await validateCSRFToken(request);
if (!isValidToken) {
return NextResponse.json({ error: "Invalid security token" }, { status: 403 });
}
try {
const body = await request.json();
const { email } = waitlistSchema.parse(body);
const existingEmail = await db.select().from(waitlist).where(eq(waitlist.email, email.toLowerCase())).limit(1);
if (existingEmail.length > 0) {
return NextResponse.json({ error: "Email already registered" }, { status: 409 });
}
await db.insert(waitlist).values({
id: nanoid(),
email: email.toLowerCase(),
});
return NextResponse.json({ message: "Successfully joined waitlist!" }, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
const firstError = error.errors[0];
return NextResponse.json({ error: firstError.message }, { status: 400 });
}
console.error("Waitlist signup error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
import { NextRequest, NextResponse } from "next/server";
import { db, eq, waitlist } from "@opencut/db";
import { checkBotId } from "botid/server";
import { nanoid } from "nanoid";
import { waitlistRateLimit } from "@/lib/rate-limit";
import { z } from "zod";
import { env } from "@/env";
import { cookies } from "next/headers";
import crypto from "crypto";
const waitlistSchema = z.object({
email: z.string().email("Invalid email format").min(1, "Email is required"),
});
const CSRF_TOKEN_NAME = "waitlist-csrf";
const TOKEN_EXPIRY = 60 * 60 * 1000;
async function validateCSRFToken(request: NextRequest): Promise<boolean> {
const clientToken = request.headers.get("x-csrf-token");
if (!clientToken) return false;
const cookieStore = await cookies();
const cookieValue = cookieStore.get(CSRF_TOKEN_NAME)?.value;
if (!cookieValue) return false;
const [token, timestamp, signature] = cookieValue.split(":");
if (!token || !timestamp || !signature) return false;
if (clientToken !== token) return false;
const now = Date.now();
const tokenTime = parseInt(timestamp);
if (now - tokenTime > TOKEN_EXPIRY) return false;
const expectedSignature = crypto
.createHmac("sha256", env.BETTER_AUTH_SECRET)
.update(`${token}:${timestamp}`)
.digest("hex");
return signature === expectedSignature;
}
export async function POST(request: NextRequest) {
const verification = await checkBotId();
if (verification.isBot) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
const identifier = request.headers.get("x-forwarded-for") ?? "127.0.0.1";
const { success } = await waitlistRateLimit.limit(identifier);
if (!success) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{ status: 429 }
);
}
const isValidToken = await validateCSRFToken(request);
if (!isValidToken) {
return NextResponse.json(
{ error: "Invalid security token" },
{ status: 403 }
);
}
try {
const body = await request.json();
const { email } = waitlistSchema.parse(body);
const existingEmail = await db
.select()
.from(waitlist)
.where(eq(waitlist.email, email.toLowerCase()))
.limit(1);
if (existingEmail.length > 0) {
return NextResponse.json(
{ error: "Email already registered" },
{ status: 409 }
);
}
await db.insert(waitlist).values({
id: nanoid(),
email: email.toLowerCase(),
});
return NextResponse.json(
{ message: "Successfully joined waitlist!" },
{ status: 201 }
);
} catch (error) {
if (error instanceof z.ZodError) {
const firstError = error.errors[0];
return NextResponse.json({ error: firstError.message }, { status: 400 });
}
console.error("Waitlist signup error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@ -5,7 +5,10 @@ import { env } from "@/env";
const CSRF_TOKEN_NAME = "waitlist-csrf";
const TOKEN_EXPIRY = 60 * 60 * 1000;
const allowedHosts = env.NODE_ENV === "development" ? ["localhost:3000", "127.0.0.1:3000"] : ["opencut.app", "www.opencut.app"];
const allowedHosts =
env.NODE_ENV === "development"
? ["localhost:3000", "127.0.0.1:3000"]
: ["opencut.app", "www.opencut.app"];
export async function GET(request: NextRequest) {
const referer = request.headers.get("referer");
@ -14,11 +17,20 @@ export async function GET(request: NextRequest) {
if (referer) {
const refererUrl = new URL(referer);
if (!allowedHosts.some((allowed) => refererUrl.host === allowed || refererUrl.host.endsWith(allowed))) {
if (
!allowedHosts.some(
(allowed) =>
refererUrl.host === allowed || refererUrl.host.endsWith(allowed)
)
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
} else if (host) {
if (!allowedHosts.some((allowed) => host === allowed || host.endsWith(allowed))) {
if (
!allowedHosts.some(
(allowed) => host === allowed || host.endsWith(allowed)
)
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
} else {
@ -31,7 +43,10 @@ export async function GET(request: NextRequest) {
const token = crypto.randomBytes(32).toString("hex");
const timestamp = Date.now();
const signature = crypto.createHmac("sha256", env.BETTER_AUTH_SECRET).update(`${token}:${timestamp}`).digest("hex");
const signature = crypto
.createHmac("sha256", env.BETTER_AUTH_SECRET)
.update(`${token}:${timestamp}`)
.digest("hex");
const cookieStore = await cookies();
cookieStore.set(CSRF_TOKEN_NAME, `${token}:${timestamp}:${signature}`, {

View File

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

View File

@ -1,253 +1,253 @@
import { Metadata } from "next";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { GithubIcon } from "@/components/icons";
import Link from "next/link";
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
export const metadata: Metadata = {
title: "Privacy Policy - OpenCut",
description:
"Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.",
openGraph: {
title: "Privacy Policy - OpenCut",
description:
"Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.",
type: "website",
},
};
export default function PrivacyPage() {
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-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-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-4xl mx-auto">
<div className="text-center mb-10">
<Link
href="https://github.com/OpenCut-app/OpenCut"
target="_blank"
>
<Badge variant="secondary" className="gap-2 mb-6">
<GithubIcon className="h-3 w-3" />
Open Source
</Badge>
</Link>
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
Privacy Policy
</h1>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
Learn how we handle your data and privacy. Contact us if you
have any questions.
</p>
</div>
<Card className="bg-background/80 backdrop-blur-sm border-2 border-muted/30">
<CardContent className="p-8 text-base leading-relaxed space-y-8">
<section>
<h2 className="text-2xl font-semibold mb-4">
Your Videos Stay Private
</h2>
<p className="mb-4">
<strong>
OpenCut processes all videos locally on your device.
</strong>{" "}
We never upload, store, or have access to your video files.
Your content remains completely private and under your
control at all times.
</p>
<p>
All video editing, rendering, and processing happens in your
browser using WebAssembly and local storage. No video data
is transmitted to our servers.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Account Information
</h2>
<p className="mb-4">
When you create an account, we only collect:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>Email address (for account access)</li>
<li>
Profile information from Google OAuth (if you choose to
sign in with Google)
</li>
</ul>
<p className="mb-4">
<strong>
We do NOT store your projects on our servers.
</strong>{" "}
All project data, including names, thumbnails, and creation
dates, is stored locally in your browser using IndexedDB.
</p>
<p>
We use{" "}
<a
href="https://www.better-auth.com"
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
Better Auth
</a>{" "}
for secure authentication and follow industry-standard
security practices.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">Analytics</h2>
<p className="mb-4">
We use{" "}
<a
href="https://www.databuddy.cc"
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
Databuddy
</a>{" "}
for completely anonymized and non-invasive analytics to
understand how people use OpenCut.
</p>
<p>
This helps us improve the editor, but we never collect
personal information, track individual users, or store any
data that could identify you.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Local Storage & Cookies
</h2>
<p className="mb-4">
We use browser local storage and IndexedDB to:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>Save your projects locally on your device</li>
<li>Remember your editor preferences and settings</li>
<li>Keep you logged in across browser sessions</li>
</ul>
<p>
All data stays on your device and can be cleared at any time
through your browser settings.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Third-Party Services
</h2>
<p className="mb-4">
OpenCut integrates with these services:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>
<strong>Google OAuth:</strong> For optional Google sign-in
(governed by Google's privacy policy)
</li>
<li>
<strong>Vercel:</strong> For hosting and content delivery
</li>
<li>
<strong>Databuddy:</strong> For anonymized analytics
</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">Your Rights</h2>
<p className="mb-4">
You have complete control over your data:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>
Delete your account and all associated data at any time
</li>
<li>Export your project data</li>
<li>Clear local storage to remove all saved projects</li>
<li>Contact us with any privacy concerns</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Open Source Transparency
</h2>
<p className="mb-4">
OpenCut is completely open source. You can review our code,
see exactly how we handle data, and even self-host the
application if you prefer.
</p>
<p>
View our source code on{" "}
<a
href="https://github.com/OpenCut-app/OpenCut"
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
GitHub
</a>
.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">Contact Us</h2>
<p className="mb-4">
Questions about this privacy policy or how we handle your
data?
</p>
<p>
Open an issue on our{" "}
<a
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>
</section>
<p className="text-sm text-muted-foreground mt-8 pt-8 border-t border-muted/20">
Last updated: July 14, 2025
</p>
</CardContent>
</Card>
</div>
</div>
</main>
<Footer />
</div>
);
}
import { Metadata } from "next";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { GithubIcon } from "@/components/icons";
import Link from "next/link";
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
export const metadata: Metadata = {
title: "Privacy Policy - OpenCut",
description:
"Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.",
openGraph: {
title: "Privacy Policy - OpenCut",
description:
"Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.",
type: "website",
},
};
export default function PrivacyPage() {
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-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-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-4xl mx-auto">
<div className="text-center mb-10">
<Link
href="https://github.com/OpenCut-app/OpenCut"
target="_blank"
>
<Badge variant="secondary" className="gap-2 mb-6">
<GithubIcon className="h-3 w-3" />
Open Source
</Badge>
</Link>
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
Privacy Policy
</h1>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
Learn how we handle your data and privacy. Contact us if you
have any questions.
</p>
</div>
<Card className="bg-background/80 backdrop-blur-sm border-2 border-muted/30">
<CardContent className="p-8 text-base leading-relaxed space-y-8">
<section>
<h2 className="text-2xl font-semibold mb-4">
Your Videos Stay Private
</h2>
<p className="mb-4">
<strong>
OpenCut processes all videos locally on your device.
</strong>{" "}
We never upload, store, or have access to your video files.
Your content remains completely private and under your
control at all times.
</p>
<p>
All video editing, rendering, and processing happens in your
browser using WebAssembly and local storage. No video data
is transmitted to our servers.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Account Information
</h2>
<p className="mb-4">
When you create an account, we only collect:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>Email address (for account access)</li>
<li>
Profile information from Google OAuth (if you choose to
sign in with Google)
</li>
</ul>
<p className="mb-4">
<strong>
We do NOT store your projects on our servers.
</strong>{" "}
All project data, including names, thumbnails, and creation
dates, is stored locally in your browser using IndexedDB.
</p>
<p>
We use{" "}
<a
href="https://www.better-auth.com"
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
Better Auth
</a>{" "}
for secure authentication and follow industry-standard
security practices.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">Analytics</h2>
<p className="mb-4">
We use{" "}
<a
href="https://www.databuddy.cc"
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
Databuddy
</a>{" "}
for completely anonymized and non-invasive analytics to
understand how people use OpenCut.
</p>
<p>
This helps us improve the editor, but we never collect
personal information, track individual users, or store any
data that could identify you.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Local Storage & Cookies
</h2>
<p className="mb-4">
We use browser local storage and IndexedDB to:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>Save your projects locally on your device</li>
<li>Remember your editor preferences and settings</li>
<li>Keep you logged in across browser sessions</li>
</ul>
<p>
All data stays on your device and can be cleared at any time
through your browser settings.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Third-Party Services
</h2>
<p className="mb-4">
OpenCut integrates with these services:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>
<strong>Google OAuth:</strong> For optional Google sign-in
(governed by Google's privacy policy)
</li>
<li>
<strong>Vercel:</strong> For hosting and content delivery
</li>
<li>
<strong>Databuddy:</strong> For anonymized analytics
</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">Your Rights</h2>
<p className="mb-4">
You have complete control over your data:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>
Delete your account and all associated data at any time
</li>
<li>Export your project data</li>
<li>Clear local storage to remove all saved projects</li>
<li>Contact us with any privacy concerns</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Open Source Transparency
</h2>
<p className="mb-4">
OpenCut is completely open source. You can review our code,
see exactly how we handle data, and even self-host the
application if you prefer.
</p>
<p>
View our source code on{" "}
<a
href="https://github.com/OpenCut-app/OpenCut"
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
GitHub
</a>
.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">Contact Us</h2>
<p className="mb-4">
Questions about this privacy policy or how we handle your
data?
</p>
<p>
Open an issue on our{" "}
<a
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>
</section>
<p className="text-sm text-muted-foreground mt-8 pt-8 border-t border-muted/20">
Last updated: July 14, 2025
</p>
</CardContent>
</Card>
</div>
</div>
</main>
<Footer />
</div>
);
}

View File

@ -1,247 +1,247 @@
import { Metadata } from "next";
import { Badge } from "@/components/ui/badge";
import { GithubIcon } from "@/components/icons";
import Link from "next/link";
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
import ReactMarkdown from "react-markdown";
import { cn } from "@/lib/utils";
const roadmapItems: {
title: string;
description: string;
status: {
text: string;
type: "complete" | "pending" | "default" | "info";
};
}[] = [
{
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.",
status: {
text: "Completed",
type: "complete",
},
},
{
title: "Core UI",
description:
"Built the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.",
status: {
text: "Completed",
type: "complete",
},
},
{
title: "Basic Functionality",
description:
"The heart of any video editor. Timeline zoom in/out, making clips longer/shorter, dragging elements around, selection, playhead scrubbing. **This part has to be fucking perfect** because it's what users interact with 99% of the time.",
status: {
text: "In Progress",
type: "pending",
},
},
{
title: "Export/Preview Logic",
description:
"The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.",
status: {
text: "In Progress",
type: "pending",
},
},
{
title: "Text",
description:
"After media, text is the next most important thing. Font selection with custom font imports, text stroke, colors. All the text essential text properties.",
status: {
text: "Not Started",
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 = {
title: "Roadmap - OpenCut",
description:
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
openGraph: {
title: "OpenCut Roadmap - What's Coming Next",
description:
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
type: "website",
images: [
{
url: "/open-graph/roadmap.jpg",
width: 1200,
height: 630,
alt: "OpenCut Roadmap",
},
],
},
twitter: {
card: "summary_large_image",
title: "OpenCut Roadmap - What's Coming Next",
description:
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
images: ["/open-graph/roadmap.jpg"],
},
};
export default function RoadmapPage() {
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-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-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-4xl mx-auto">
<div className="text-center mb-10">
<Link
href="https://github.com/OpenCut-app/OpenCut"
target="_blank"
>
<Badge variant="secondary" className="gap-2 mb-6">
<GithubIcon className="h-3 w-3" />
Open Source
</Badge>
</Link>
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
Roadmap
</h1>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
What's coming next for OpenCut (last updated: July 14, 2025)
</p>
</div>
<div className="space-y-6">
{roadmapItems.map((item, index) => (
<div key={index} className="relative">
<div className="flex items-start gap-2">
<span className="text-lg font-medium text-muted-foreground select-none leading-[1.5]">
{index + 1}.
</span>
<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
className={cn("shadow-none", {
"!bg-green-500 text-white":
item.status.type === "complete",
"!bg-yellow-500 text-white":
item.status.type === "pending",
"!bg-blue-500 text-white":
item.status.type === "info",
"!bg-foreground/10 text-accent-foreground":
item.status.type === "default",
})}
>
{item.status.text}
</Badge>
</div>
<div className="text-foreground/70 leading-relaxed">
<ReactMarkdown
components={{
a: ({ className, children, ...props }) => (
<a
className={cn(
"text-primary hover:underline",
className
)}
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>
);
}
import { Metadata } from "next";
import { Badge } from "@/components/ui/badge";
import { GithubIcon } from "@/components/icons";
import Link from "next/link";
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
import ReactMarkdown from "react-markdown";
import { cn } from "@/lib/utils";
const roadmapItems: {
title: string;
description: string;
status: {
text: string;
type: "complete" | "pending" | "default" | "info";
};
}[] = [
{
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.",
status: {
text: "Completed",
type: "complete",
},
},
{
title: "Core UI",
description:
"Built the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.",
status: {
text: "Completed",
type: "complete",
},
},
{
title: "Basic Functionality",
description:
"The heart of any video editor. Timeline zoom in/out, making clips longer/shorter, dragging elements around, selection, playhead scrubbing. **This part has to be fucking perfect** because it's what users interact with 99% of the time.",
status: {
text: "In Progress",
type: "pending",
},
},
{
title: "Export/Preview Logic",
description:
"The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.",
status: {
text: "In Progress",
type: "pending",
},
},
{
title: "Text",
description:
"After media, text is the next most important thing. Font selection with custom font imports, text stroke, colors. All the text essential text properties.",
status: {
text: "Not Started",
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 = {
title: "Roadmap - OpenCut",
description:
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
openGraph: {
title: "OpenCut Roadmap - What's Coming Next",
description:
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
type: "website",
images: [
{
url: "/open-graph/roadmap.jpg",
width: 1200,
height: 630,
alt: "OpenCut Roadmap",
},
],
},
twitter: {
card: "summary_large_image",
title: "OpenCut Roadmap - What's Coming Next",
description:
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
images: ["/open-graph/roadmap.jpg"],
},
};
export default function RoadmapPage() {
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-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-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-4xl mx-auto">
<div className="text-center mb-10">
<Link
href="https://github.com/OpenCut-app/OpenCut"
target="_blank"
>
<Badge variant="secondary" className="gap-2 mb-6">
<GithubIcon className="h-3 w-3" />
Open Source
</Badge>
</Link>
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
Roadmap
</h1>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
What's coming next for OpenCut (last updated: July 14, 2025)
</p>
</div>
<div className="space-y-6">
{roadmapItems.map((item, index) => (
<div key={index} className="relative">
<div className="flex items-start gap-2">
<span className="text-lg font-medium text-muted-foreground select-none leading-[1.5]">
{index + 1}.
</span>
<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
className={cn("shadow-none", {
"!bg-green-500 text-white":
item.status.type === "complete",
"!bg-yellow-500 text-white":
item.status.type === "pending",
"!bg-blue-500 text-white":
item.status.type === "info",
"!bg-foreground/10 text-accent-foreground":
item.status.type === "default",
})}
>
{item.status.text}
</Badge>
</div>
<div className="text-foreground/70 leading-relaxed">
<ReactMarkdown
components={{
a: ({ className, children, ...props }) => (
<a
className={cn(
"text-primary hover:underline",
className
)}
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
),
strong: ({ children }) => (
<strong className="font-semibold text-foreground">
{children}
</strong>
),
}}
>
{item.description}
</ReactMarkdown>
</div>
</div>
</div>
</div>
))}
</div>
<div className="mt-12 pt-8 border-t border-muted/20">
<div className="text-center space-y-4">
<h3 className="text-xl font-semibold">Want to Help?</h3>
<p className="text-muted-foreground max-w-2xl mx-auto">
OpenCut is open source and built by the community. Every
contribution, no matter how small, helps us build the best
free video editor possible.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center mt-6">
<Link
href="https://github.com/OpenCut-app/OpenCut/blob/main/.github/CONTRIBUTING.md"
target="_blank"
rel="noopener noreferrer"
>
<Badge
variant="outline"
className="text-sm px-4 py-2 hover:bg-muted/50 transition-colors"
>
<GithubIcon className="h-4 w-4 mr-2" />
Start Contributing
</Badge>
</Link>
<Link
href="https://github.com/OpenCut-app/OpenCut/issues"
target="_blank"
rel="noopener noreferrer"
>
<Badge
variant="outline"
className="text-sm px-4 py-2 hover:bg-muted/50 transition-colors"
>
Report Issues
</Badge>
</Link>
</div>
</div>
</div>
</div>
</div>
</main>
<Footer />
</div>
);
}

View File

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

View File

@ -1,317 +1,317 @@
import { Metadata } from "next";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { GithubIcon } from "@/components/icons";
import Link from "next/link";
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
export const metadata: Metadata = {
title: "Terms of Service - OpenCut",
description:
"OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.",
openGraph: {
title: "Terms of Service - OpenCut",
description:
"OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.",
type: "website",
},
};
export default function TermsPage() {
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-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-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-4xl mx-auto">
<div className="text-center mb-10">
<Link
href="https://github.com/OpenCut-app/OpenCut"
target="_blank"
>
<Badge variant="secondary" className="gap-2 mb-6">
<GithubIcon className="h-3 w-3" />
Open Source
</Badge>
</Link>
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
Terms of Service
</h1>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
Fair and transparent terms for our free, open-source video
editor.
</p>
</div>
<Card className="bg-background/80 backdrop-blur-sm border-2 border-muted/30">
<CardContent className="p-8 text-base leading-relaxed space-y-8">
<section>
<h2 className="text-2xl font-semibold mb-4">
Welcome to OpenCut
</h2>
<p className="mb-4">
OpenCut is a free, open-source video editor that runs in
your browser. By using our service, you agree to these
terms. We've designed these terms to be fair and protect
both you and our project.
</p>
<p>
<strong>Key principle:</strong> Your content stays on your
device. We never claim ownership of your videos or projects.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Your Content, Your Rights
</h2>
<p className="mb-4">
<strong>You own everything you create.</strong> OpenCut
processes your videos locally on your device, so we never
have access to your content. We make no claims to ownership,
licensing, or rights over your videos, projects, or any
content you create using OpenCut.
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>
Your videos remain completely private and under your
control
</li>
<li>
You retain all intellectual property rights to your
content
</li>
<li>
You can export and use your content however you choose
</li>
<li>
No watermarks, no licensing restrictions from OpenCut
</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
How You Can Use OpenCut
</h2>
<p className="mb-4">
OpenCut is free for personal and commercial use. You can:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>
Create videos for personal, educational, or commercial
purposes
</li>
<li>Use OpenCut for client work and paid projects</li>
<li>Share and distribute videos created with OpenCut</li>
<li>
Modify and distribute the OpenCut software (under MIT
license)
</li>
</ul>
<p>
<strong>What we ask:</strong> Don't use OpenCut for illegal
activities, harassment, or creating harmful content. Be
respectful of others and follow applicable laws.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Account and Service
</h2>
<p className="mb-4">
To use certain features, you may create an account:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>Provide accurate information when signing up</li>
<li>
Keep your account secure and don't share credentials
</li>
<li>You're responsible for activity under your account</li>
<li>You can delete your account at any time</li>
</ul>
<p>
OpenCut is provided "as is" without warranties. While we
strive for reliability, we can't guarantee uninterrupted
service.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Open Source Benefits
</h2>
<p className="mb-4">
Because OpenCut is open source, you have additional rights:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>
Review our code to see exactly how we handle your data
</li>
<li>Self-host OpenCut on your own servers</li>
<li>Modify the software to suit your needs</li>
<li>Contribute improvements back to the community</li>
</ul>
<p>
View our source code and license on{" "}
<a
href="https://github.com/OpenCut-app/OpenCut"
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
GitHub
</a>
.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Third-Party Content
</h2>
<p className="mb-4">
When using OpenCut, make sure you have the right to use any
content you import:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>
Only upload content you own or have permission to use
</li>
<li>
Respect copyright, trademarks, and other intellectual
property
</li>
<li>
Don't use copyrighted music, images, or videos without
permission
</li>
<li>
You're responsible for any claims related to your content
</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Limitations and Liability
</h2>
<p className="mb-4">
OpenCut is provided free of charge. To the extent permitted
by law:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>We're not liable for any loss of data or content</li>
<li>
Projects are stored in your browser and may be lost if you
clear browser data
</li>
<li>We're not responsible for how you use the service</li>
<li>
Our liability is limited to the maximum extent allowed by
law
</li>
</ul>
<p>
Since your content stays on your device, we have no way to
recover lost projects. Consider exporting important videos
when finished editing.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Service Changes
</h2>
<p className="mb-4">We may update OpenCut and these terms:</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>
We'll notify you of significant changes to these terms
</li>
<li>Continued use means you accept any updates</li>
<li>
You can always self-host an older version if you prefer
</li>
<li>
Major changes will be discussed with the community on
GitHub
</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">Termination</h2>
<p className="mb-4">
You can stop using OpenCut at any time:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>Delete your account through your profile settings</li>
<li>Clear your browser data to remove local projects</li>
<li>
Your content remains yours even if you stop using OpenCut
</li>
<li>
We may suspend accounts for violations of these terms
</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Contact and Disputes
</h2>
<p className="mb-4">
Questions about these terms or need to report an issue?
</p>
<p className="mb-4">
Contact us through our{" "}
<a
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">
Last updated: July 14, 2025
</p>
</CardContent>
</Card>
</div>
</div>
</main>
<Footer />
</div>
);
}
import { Metadata } from "next";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { GithubIcon } from "@/components/icons";
import Link from "next/link";
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
export const metadata: Metadata = {
title: "Terms of Service - OpenCut",
description:
"OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.",
openGraph: {
title: "Terms of Service - OpenCut",
description:
"OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.",
type: "website",
},
};
export default function TermsPage() {
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-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-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-4xl mx-auto">
<div className="text-center mb-10">
<Link
href="https://github.com/OpenCut-app/OpenCut"
target="_blank"
>
<Badge variant="secondary" className="gap-2 mb-6">
<GithubIcon className="h-3 w-3" />
Open Source
</Badge>
</Link>
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
Terms of Service
</h1>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
Fair and transparent terms for our free, open-source video
editor.
</p>
</div>
<Card className="bg-background/80 backdrop-blur-sm border-2 border-muted/30">
<CardContent className="p-8 text-base leading-relaxed space-y-8">
<section>
<h2 className="text-2xl font-semibold mb-4">
Welcome to OpenCut
</h2>
<p className="mb-4">
OpenCut is a free, open-source video editor that runs in
your browser. By using our service, you agree to these
terms. We've designed these terms to be fair and protect
both you and our project.
</p>
<p>
<strong>Key principle:</strong> Your content stays on your
device. We never claim ownership of your videos or projects.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Your Content, Your Rights
</h2>
<p className="mb-4">
<strong>You own everything you create.</strong> OpenCut
processes your videos locally on your device, so we never
have access to your content. We make no claims to ownership,
licensing, or rights over your videos, projects, or any
content you create using OpenCut.
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>
Your videos remain completely private and under your
control
</li>
<li>
You retain all intellectual property rights to your
content
</li>
<li>
You can export and use your content however you choose
</li>
<li>
No watermarks, no licensing restrictions from OpenCut
</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
How You Can Use OpenCut
</h2>
<p className="mb-4">
OpenCut is free for personal and commercial use. You can:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>
Create videos for personal, educational, or commercial
purposes
</li>
<li>Use OpenCut for client work and paid projects</li>
<li>Share and distribute videos created with OpenCut</li>
<li>
Modify and distribute the OpenCut software (under MIT
license)
</li>
</ul>
<p>
<strong>What we ask:</strong> Don't use OpenCut for illegal
activities, harassment, or creating harmful content. Be
respectful of others and follow applicable laws.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Account and Service
</h2>
<p className="mb-4">
To use certain features, you may create an account:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>Provide accurate information when signing up</li>
<li>
Keep your account secure and don't share credentials
</li>
<li>You're responsible for activity under your account</li>
<li>You can delete your account at any time</li>
</ul>
<p>
OpenCut is provided "as is" without warranties. While we
strive for reliability, we can't guarantee uninterrupted
service.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Open Source Benefits
</h2>
<p className="mb-4">
Because OpenCut is open source, you have additional rights:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>
Review our code to see exactly how we handle your data
</li>
<li>Self-host OpenCut on your own servers</li>
<li>Modify the software to suit your needs</li>
<li>Contribute improvements back to the community</li>
</ul>
<p>
View our source code and license on{" "}
<a
href="https://github.com/OpenCut-app/OpenCut"
target="_blank"
rel="noopener"
className="text-primary hover:underline"
>
GitHub
</a>
.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Third-Party Content
</h2>
<p className="mb-4">
When using OpenCut, make sure you have the right to use any
content you import:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>
Only upload content you own or have permission to use
</li>
<li>
Respect copyright, trademarks, and other intellectual
property
</li>
<li>
Don't use copyrighted music, images, or videos without
permission
</li>
<li>
You're responsible for any claims related to your content
</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Limitations and Liability
</h2>
<p className="mb-4">
OpenCut is provided free of charge. To the extent permitted
by law:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>We're not liable for any loss of data or content</li>
<li>
Projects are stored in your browser and may be lost if you
clear browser data
</li>
<li>We're not responsible for how you use the service</li>
<li>
Our liability is limited to the maximum extent allowed by
law
</li>
</ul>
<p>
Since your content stays on your device, we have no way to
recover lost projects. Consider exporting important videos
when finished editing.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Service Changes
</h2>
<p className="mb-4">We may update OpenCut and these terms:</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>
We'll notify you of significant changes to these terms
</li>
<li>Continued use means you accept any updates</li>
<li>
You can always self-host an older version if you prefer
</li>
<li>
Major changes will be discussed with the community on
GitHub
</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">Termination</h2>
<p className="mb-4">
You can stop using OpenCut at any time:
</p>
<ul className="list-disc pl-6 mb-4 space-y-2">
<li>Delete your account through your profile settings</li>
<li>Clear your browser data to remove local projects</li>
<li>
Your content remains yours even if you stop using OpenCut
</li>
<li>
We may suspend accounts for violations of these terms
</li>
</ul>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">
Contact and Disputes
</h2>
<p className="mb-4">
Questions about these terms or need to report an issue?
</p>
<p className="mb-4">
Contact us through our{" "}
<a
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">
Last updated: July 14, 2025
</p>
</CardContent>
</Card>
</div>
</div>
</main>
<Footer />
</div>
);
}

View File

@ -1,191 +1,191 @@
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-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-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>
);
}
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-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
</div>
<div className="relative container mx-auto px-4 py-16">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-20">
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
Fuck CapCut
</h1>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
Roasting time, so get ready motherfucker.
</p>
</div>
<div className="max-w-4xl mx-auto space-y-12">
<div>
<h2 className="text-3xl font-bold mb-6">
Seriously, what the fuck else do you want?
</h2>
<p className="text-lg mb-6">
You probably use CapCut and think your video editing is
special. You think your fucking TikTok with 47 transitions and
12 different fonts is going to get you some viral fame. You
think loading up every goddamn effect in their library makes
your content better. Wrong, motherfucker. Let me describe what
CapCut actually gives you:
</p>
<ul className="text-lg space-y-2 mb-6 list-disc list-inside">
<li>A paywall every time you breathe</li>
<li>Terms of service that steal your shit</li>
<li>
More "Get Pro" dialogs than a Windows 95 error message
</li>
<li>
Features that disappear behind paywalls while you're fucking
using them
</li>
<li>Bugs disguised as "premium features"</li>
</ul>
<p className="text-lg mb-6">
<strong>Well guess what, motherfucker:</strong>
</p>
<p className="text-lg mb-6">
You. Are. Getting. Scammed. Look at this shit. It's a fucking
video editor. Why the fuck do you need to pay $20/month just
to remove a goddamn watermark? You spent hours editing your
video and they slap their logo on it like they fucking made
it.
</p>
</div>
<div>
<h2 className="text-3xl font-bold mb-6">
The "Get Pro" dialog is everywhere
</h2>
<p className="text-lg mb-6">
This motherfucking dialog pops up more than ads on a pirated
movie site. Want to add a transition? Get Pro. Want to export
without their watermark? Get Pro. Want to use more than 2
fonts? Get fucking Pro, peasant.
</p>
<p className="text-lg mb-6">
Did you seriously think you could edit a video without seeing
this dialog 47 times? You click one button and BAM - there it
is again, asking for your credit card like a desperate ex
asking for money.
</p>
</div>
<div>
<h2 className="text-3xl font-bold mb-6">
Everything costs money now
</h2>
<p className="text-lg mb-6">
You dumbass. You thought CapCut was free, but no. Free means
they let you open the app. Everything else costs money. Basic
shake effect? That'll be $20/month. A decent transition that
isn't "fade"? Pay up, motherfucker.
</p>
<p className="text-lg mb-6">
Here's my favorite piece of bullshit: You import an MP3 file -
you know, AUDIO - and try to export. "Sorry, can't export
because you're using our premium extract audio feature!"
</p>
<p className="text-lg mb-6">
<strong>
My MP3 was already fucking audio, you absolute morons.
</strong>
</p>
<p className="text-lg mb-6">
But wait, there's more! If you drag that same MP3 to their
media panel first, then to the timeline, it magically works.
This isn't a bug, it's a fucking scam disguised as software
engineering.
</p>
</div>
<div>
<h2 className="text-3xl font-bold mb-6">
Their Terms of Service are insane
</h2>
<p className="text-lg mb-6">
Look at this shit. You upload your content and they basically
say "thanks for the free content, we own it now, but if Disney
sues anyone, that's your problem."
</p>
<p className="text-lg mb-6">
<strong>CapCut's Terms of Service:</strong> We get full rights
to use, modify, distribute, and monetize everything you upload
- permanently and without paying you shit. But you're still
responsible if anything goes wrong.
</p>
<p className="text-lg mb-6">
Translation: "We'll make money off your viral video, you
handle the lawsuits." Brilliant legal strategy, you fucks.
</p>
</div>
<div>
<h2 className="text-3xl font-bold mb-6">
The editor is actually good
</h2>
<p className="text-lg mb-6">
Here's the thing that makes me want to punch my monitor: the
actual video editor is fucking good. It's intuitive, powerful,
and anyone can figure it out. When it's not begging for money
every 30 seconds, it actually works well.
</p>
<p className="text-lg mb-6">
Which makes everything else so much worse. They built
something people want to use, then turned it into a digital
slot machine. Every click might trigger a payment request.
</p>
</div>
<div>
<h2 className="text-3xl font-bold mb-6">
This is a video editor. Look at it. You've never seen one
before.
</h2>
<p className="text-lg mb-6">
Like the person who's never used software that doesn't
constantly beg for money, you have no fucking idea what a
video editor should be. All you've ever seen are predatory
apps disguised as creative tools.
</p>
<p className="text-lg mb-6">
A real video editor lets you edit videos. It doesn't steal
your content. It doesn't pop up payment dialogs every 5
seconds. It doesn't charge you separately for basic features
that should be free.
</p>
</div>
<div>
<h2 className="text-3xl font-bold mb-6">
Yes, this is fucking satire, you fuck
</h2>
<p className="text-lg mb-6">
I'm not actually saying all video editors should be basic as
shit. What I'm saying is that all the problems we have with
video editing apps are{" "}
<strong>ones they create themselves</strong>. Video editors
aren't broken by default - they edit videos, export them, and
let you use basic features without constantly begging for
money. CapCut breaks them. They turn them into payment
processors with video editing as a side feature.
</p>
<p className="text-lg">
<em>"Good software gets out of your way."</em>
<br />- Some smart motherfucker who definitely wasn't working
at CapCut
</p>
</div>
</div>
</div>
</div>
</main>
</div>
);
}

View File

@ -1,184 +1,184 @@
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Button } from "./ui/button";
import { BackgroundIcon } from "./icons";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { colors } from "@/data/colors";
import { useProjectStore } from "@/stores/project-store";
import { PipetteIcon } from "lucide-react";
type BackgroundTab = "color" | "blur";
export function BackgroundSettings() {
const { activeProject, updateBackgroundType } = useProjectStore();
// ✅ Good: derive activeTab from activeProject during rendering
const activeTab = activeProject?.backgroundType || "color";
const handleColorSelect = (color: string) => {
updateBackgroundType("color", { backgroundColor: color });
};
const handleBlurSelect = (blurIntensity: number) => {
updateBackgroundType("blur", { blurIntensity });
};
const tabs = [
{
label: "Color",
value: "color",
},
{
label: "Blur",
value: "blur",
},
];
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="text"
size="icon"
className="!size-4 border border-muted-foreground"
>
<BackgroundIcon className="!size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0">
<div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3">
<h2 className="text-sm">Background</h2>
<div className="flex items-center gap-2 text-sm">
{tabs.map((tab) => (
<span
key={tab.value}
onClick={() => {
// Switch to the background type when clicking tabs
if (tab.value === "color") {
updateBackgroundType("color", {
backgroundColor:
activeProject?.backgroundColor || "#000000",
});
} else {
updateBackgroundType("blur", {
blurIntensity: activeProject?.blurIntensity || 8,
});
}
}}
className={cn(
"text-muted-foreground cursor-pointer",
activeTab === tab.value && "text-foreground"
)}
>
{tab.label}
</span>
))}
</div>
</div>
{activeTab === "color" ? (
<ColorView
selectedColor={activeProject?.backgroundColor || "#000000"}
onColorSelect={handleColorSelect}
/>
) : (
<BlurView
selectedBlur={activeProject?.blurIntensity || 8}
onBlurSelect={handleBlurSelect}
/>
)}
</PopoverContent>
</Popover>
);
}
function ColorView({
selectedColor,
onColorSelect,
}: {
selectedColor: string;
onColorSelect: (color: string) => void;
}) {
return (
<div className="w-full h-full">
<div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-gradient-to-b from-popover to-transparent pointer-events-none"></div>
<div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto">
<div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
<PipetteIcon className="size-4" />
</div>
{colors.map((color) => (
<ColorItem
key={color}
color={color}
isSelected={color === selectedColor}
onClick={() => onColorSelect(color)}
/>
))}
</div>
</div>
);
}
function ColorItem({
color,
isSelected,
onClick,
}: {
color: string;
isSelected: boolean;
onClick: () => void;
}) {
return (
<div
className={cn(
"w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary",
isSelected && "border-2 border-primary"
)}
style={{ backgroundColor: color }}
onClick={onClick}
/>
);
}
function BlurView({
selectedBlur,
onBlurSelect,
}: {
selectedBlur: number;
onBlurSelect: (blurIntensity: number) => void;
}) {
const blurLevels = [
{ label: "Light", value: 4 },
{ label: "Medium", value: 8 },
{ label: "Heavy", value: 18 },
];
const blurImage =
"https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";
return (
<div className="grid grid-cols-3 gap-2 w-full p-3 pt-0">
{blurLevels.map((blur) => (
<div
key={blur.value}
className={cn(
"w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden",
selectedBlur === blur.value && "border-2 border-primary"
)}
onClick={() => onBlurSelect(blur.value)}
>
<Image
src={blurImage}
alt={`Blur preview ${blur.label}`}
fill
className="object-cover"
style={{ filter: `blur(${blur.value}px)` }}
/>
<div className="absolute bottom-1 left-1 right-1 text-center">
<span className="text-xs text-white bg-black/50 px-1 rounded">
{blur.label}
</span>
</div>
</div>
))}
</div>
);
}
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Button } from "./ui/button";
import { BackgroundIcon } from "./icons";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { colors } from "@/data/colors";
import { useProjectStore } from "@/stores/project-store";
import { PipetteIcon } from "lucide-react";
type BackgroundTab = "color" | "blur";
export function BackgroundSettings() {
const { activeProject, updateBackgroundType } = useProjectStore();
// ✅ Good: derive activeTab from activeProject during rendering
const activeTab = activeProject?.backgroundType || "color";
const handleColorSelect = (color: string) => {
updateBackgroundType("color", { backgroundColor: color });
};
const handleBlurSelect = (blurIntensity: number) => {
updateBackgroundType("blur", { blurIntensity });
};
const tabs = [
{
label: "Color",
value: "color",
},
{
label: "Blur",
value: "blur",
},
];
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="text"
size="icon"
className="!size-4 border border-muted-foreground"
>
<BackgroundIcon className="!size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0">
<div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3">
<h2 className="text-sm">Background</h2>
<div className="flex items-center gap-2 text-sm">
{tabs.map((tab) => (
<span
key={tab.value}
onClick={() => {
// Switch to the background type when clicking tabs
if (tab.value === "color") {
updateBackgroundType("color", {
backgroundColor:
activeProject?.backgroundColor || "#000000",
});
} else {
updateBackgroundType("blur", {
blurIntensity: activeProject?.blurIntensity || 8,
});
}
}}
className={cn(
"text-muted-foreground cursor-pointer",
activeTab === tab.value && "text-foreground"
)}
>
{tab.label}
</span>
))}
</div>
</div>
{activeTab === "color" ? (
<ColorView
selectedColor={activeProject?.backgroundColor || "#000000"}
onColorSelect={handleColorSelect}
/>
) : (
<BlurView
selectedBlur={activeProject?.blurIntensity || 8}
onBlurSelect={handleBlurSelect}
/>
)}
</PopoverContent>
</Popover>
);
}
function ColorView({
selectedColor,
onColorSelect,
}: {
selectedColor: string;
onColorSelect: (color: string) => void;
}) {
return (
<div className="w-full h-full">
<div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-gradient-to-b from-popover to-transparent pointer-events-none" />
<div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto">
<div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
<PipetteIcon className="size-4" />
</div>
{colors.map((color) => (
<ColorItem
key={color}
color={color}
isSelected={color === selectedColor}
onClick={() => onColorSelect(color)}
/>
))}
</div>
</div>
);
}
function ColorItem({
color,
isSelected,
onClick,
}: {
color: string;
isSelected: boolean;
onClick: () => void;
}) {
return (
<div
className={cn(
"w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary",
isSelected && "border-2 border-primary"
)}
style={{ backgroundColor: color }}
onClick={onClick}
/>
);
}
function BlurView({
selectedBlur,
onBlurSelect,
}: {
selectedBlur: number;
onBlurSelect: (blurIntensity: number) => void;
}) {
const blurLevels = [
{ label: "Light", value: 4 },
{ label: "Medium", value: 8 },
{ label: "Heavy", value: 18 },
];
const blurImage =
"https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";
return (
<div className="grid grid-cols-3 gap-2 w-full p-3 pt-0">
{blurLevels.map((blur) => (
<div
key={blur.value}
className={cn(
"w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden",
selectedBlur === blur.value && "border-2 border-primary"
)}
onClick={() => onBlurSelect(blur.value)}
>
<Image
src={blurImage}
alt={`Blur preview ${blur.label}`}
fill
className="object-cover"
style={{ filter: `blur(${blur.value}px)` }}
/>
<div className="absolute bottom-1 left-1 right-1 text-center">
<span className="text-xs text-white bg-black/50 px-1 rounded">
{blur.label}
</span>
</div>
</div>
))}
</div>
);
}

View File

@ -1,125 +1,125 @@
"use client";
import Link from "next/link";
import { Button } from "./ui/button";
import { ChevronLeft, Download } from "lucide-react";
import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base";
import { formatTimeCode } from "@/lib/time";
import { useProjectStore } from "@/stores/project-store";
import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help";
import { useState, useRef } from "react";
import { Input } from "@/components/ui/input";
export function EditorHeader() {
const { getTotalDuration } = useTimelineStore();
const { activeProject, renameProject } = useProjectStore();
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(activeProject?.name || "");
const inputRef = useRef<HTMLInputElement>(null);
const handleExport = () => {
// TODO: Implement export functionality
// NOTE: This is already being worked on
console.log("Export project");
};
const handleNameClick = () => {
if (!activeProject) return;
setNewName(activeProject.name);
setIsEditing(true);
};
const handleNameSave = async () => {
if (activeProject && newName.trim() && newName !== activeProject.name) {
try {
await renameProject(activeProject.id, newName.trim());
} catch (error) {
console.error("Failed to rename project:", error);
setNewName(activeProject.name);
}
}
setIsEditing(false);
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") handleNameSave();
else if (e.key === "Escape") setIsEditing(false);
};
const leftContent = (
<div className="flex items-center gap-2">
<Link
href="/projects"
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<ChevronLeft className="h-4 w-4" />
</Link>
<div className="w-[14rem] h-9 flex items-center">
{isEditing ? (
<Input
ref={inputRef}
className="text-sm font-medium px-2 py-1 h-9 truncate"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onBlur={handleNameSave}
onKeyDown={handleInputKeyDown}
onFocus={(e) => e.target.select()}
maxLength={64}
aria-label="Project name"
autoFocus
/>
) : (
<span
className="text-sm font-medium cursor-pointer hover:underline"
title="Click to rename"
role="button"
tabIndex={0}
onClick={handleNameClick}
onKeyDown={(e) => e.key === "Enter" && handleNameClick()}
>
<div className="truncate text-ellipsis overflow-clip w-40">
{activeProject?.name}
</div>
</span>
)}
</div>
</div>
);
const centerContent = (
<div className="flex items-center gap-2 text-xs">
<span>
{formatTimeCode(
getTotalDuration(),
"HH:MM:SS:FF",
activeProject?.fps || 30
)}
</span>
</div>
);
const rightContent = (
<nav className="flex items-center gap-2">
<KeyboardShortcutsHelp />
<Button
size="sm"
variant="primary"
className="h-7 text-xs"
onClick={handleExport}
>
<Download className="h-4 w-4" />
<span className="text-sm">Export</span>
</Button>
</nav>
);
return (
<HeaderBase
leftContent={leftContent}
centerContent={centerContent}
rightContent={rightContent}
className="bg-background h-[3.2rem] px-4 items-center"
/>
);
}
"use client";
import Link from "next/link";
import { Button } from "./ui/button";
import { ChevronLeft, Download } from "lucide-react";
import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base";
import { formatTimeCode } from "@/lib/time";
import { useProjectStore } from "@/stores/project-store";
import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help";
import { useState, useRef } from "react";
import { Input } from "@/components/ui/input";
export function EditorHeader() {
const { getTotalDuration } = useTimelineStore();
const { activeProject, renameProject } = useProjectStore();
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState(activeProject?.name || "");
const inputRef = useRef<HTMLInputElement>(null);
const handleExport = () => {
// TODO: Implement export functionality
// NOTE: This is already being worked on
console.log("Export project");
};
const handleNameClick = () => {
if (!activeProject) return;
setNewName(activeProject.name);
setIsEditing(true);
};
const handleNameSave = async () => {
if (activeProject && newName.trim() && newName !== activeProject.name) {
try {
await renameProject(activeProject.id, newName.trim());
} catch (error) {
console.error("Failed to rename project:", error);
setNewName(activeProject.name);
}
}
setIsEditing(false);
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") handleNameSave();
else if (e.key === "Escape") setIsEditing(false);
};
const leftContent = (
<div className="flex items-center gap-2">
<Link
href="/projects"
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<ChevronLeft className="h-4 w-4" />
</Link>
<div className="w-[14rem] h-9 flex items-center">
{isEditing ? (
<Input
ref={inputRef}
className="text-sm font-medium px-2 py-1 h-9 truncate"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onBlur={handleNameSave}
onKeyDown={handleInputKeyDown}
onFocus={(e) => e.target.select()}
maxLength={64}
aria-label="Project name"
autoFocus
/>
) : (
<span
className="text-sm font-medium cursor-pointer hover:underline"
title="Click to rename"
role="button"
tabIndex={0}
onClick={handleNameClick}
onKeyDown={(e) => e.key === "Enter" && handleNameClick()}
>
<div className="truncate text-ellipsis overflow-clip w-40">
{activeProject?.name}
</div>
</span>
)}
</div>
</div>
);
const centerContent = (
<div className="flex items-center gap-2 text-xs">
<span>
{formatTimeCode(
getTotalDuration(),
"HH:MM:SS:FF",
activeProject?.fps || 30
)}
</span>
</div>
);
const rightContent = (
<nav className="flex items-center gap-2">
<KeyboardShortcutsHelp />
<Button
size="sm"
variant="primary"
className="h-7 text-xs"
onClick={handleExport}
>
<Download className="h-4 w-4" />
<span className="text-sm">Export</span>
</Button>
</nav>
);
return (
<HeaderBase
leftContent={leftContent}
centerContent={centerContent}
rightContent={rightContent}
className="bg-background h-[3.2rem] px-4 items-center"
/>
);
}

View File

@ -1,53 +1,53 @@
"use client";
import { useEffect } from "react";
import { Loader2 } from "lucide-react";
import { useEditorStore } from "@/stores/editor-store";
import {
useKeybindingsListener,
useKeybindingDisabler,
} from "@/hooks/use-keybindings";
import { useEditorActions } from "@/hooks/use-editor-actions";
interface EditorProviderProps {
children: React.ReactNode;
}
export function EditorProvider({ children }: EditorProviderProps) {
const { isInitializing, isPanelsReady, initializeApp } = useEditorStore();
const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
// Set up action handlers
useEditorActions();
// Set up keybinding listener
useKeybindingsListener();
// Disable keybindings when initializing
useEffect(() => {
if (isInitializing || !isPanelsReady) {
disableKeybindings();
} else {
enableKeybindings();
}
}, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]);
useEffect(() => {
initializeApp();
}, [initializeApp]);
// Show loading screen while initializing
if (isInitializing || !isPanelsReady) {
return (
<div className="h-screen w-screen flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading editor...</p>
</div>
</div>
);
}
// App is ready, render children
return <>{children}</>;
}
"use client";
import { useEffect } from "react";
import { Loader2 } from "lucide-react";
import { useEditorStore } from "@/stores/editor-store";
import {
useKeybindingsListener,
useKeybindingDisabler,
} from "@/hooks/use-keybindings";
import { useEditorActions } from "@/hooks/use-editor-actions";
interface EditorProviderProps {
children: React.ReactNode;
}
export function EditorProvider({ children }: EditorProviderProps) {
const { isInitializing, isPanelsReady, initializeApp } = useEditorStore();
const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
// Set up action handlers
useEditorActions();
// Set up keybinding listener
useKeybindingsListener();
// Disable keybindings when initializing
useEffect(() => {
if (isInitializing || !isPanelsReady) {
disableKeybindings();
} else {
enableKeybindings();
}
}, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]);
useEffect(() => {
initializeApp();
}, [initializeApp]);
// Show loading screen while initializing
if (isInitializing || !isPanelsReady) {
return (
<div className="h-screen w-screen flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading editor...</p>
</div>
</div>
);
}
// App is ready, render children
return <>{children}</>;
}

View File

@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
import React, { useEffect, useRef, useState } from "react";
import WaveSurfer from "wavesurfer.js";
interface AudioWaveformProps {
audioUrl: string;
@ -7,10 +7,10 @@ interface AudioWaveformProps {
className?: string;
}
const AudioWaveform: React.FC<AudioWaveformProps> = ({
audioUrl,
height = 32,
className = ''
const AudioWaveform: React.FC<AudioWaveformProps> = ({
audioUrl,
height = 32,
className = "",
}) => {
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurfer = useRef<WaveSurfer | null>(null);
@ -36,26 +36,26 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
wavesurfer.current = WaveSurfer.create({
container: waveformRef.current,
waveColor: 'rgba(255, 255, 255, 0.6)',
progressColor: 'rgba(255, 255, 255, 0.9)',
cursorColor: 'transparent',
waveColor: "rgba(255, 255, 255, 0.6)",
progressColor: "rgba(255, 255, 255, 0.9)",
cursorColor: "transparent",
barWidth: 2,
barGap: 1,
height: height,
height,
normalize: true,
interact: false,
});
// Event listeners
wavesurfer.current.on('ready', () => {
wavesurfer.current.on("ready", () => {
if (mounted) {
setIsLoading(false);
setError(false);
}
});
wavesurfer.current.on('error', (err) => {
console.error('WaveSurfer error:', err);
wavesurfer.current.on("error", (err) => {
console.error("WaveSurfer error:", err);
if (mounted) {
setError(true);
setIsLoading(false);
@ -63,9 +63,8 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
});
await wavesurfer.current.load(audioUrl);
} catch (err) {
console.error('Failed to initialize WaveSurfer:', err);
console.error("Failed to initialize WaveSurfer:", err);
if (mounted) {
setError(true);
setIsLoading(false);
@ -90,7 +89,10 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
if (error) {
return (
<div className={`flex items-center justify-center ${className}`} style={{ height }}>
<div
className={`flex items-center justify-center ${className}`}
style={{ height }}
>
<span className="text-xs text-foreground/60">Audio unavailable</span>
</div>
);
@ -103,13 +105,13 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
<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'}`}
<div
ref={waveformRef}
className={`w-full transition-opacity duration-200 ${isLoading ? "opacity-0" : "opacity-100"}`}
style={{ height }}
/>
</div>
);
};
export default AudioWaveform;
export default AudioWaveform;

View File

@ -1,57 +1,57 @@
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>
);
}
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>
);
}

View File

@ -1,54 +1,54 @@
"use client";
import { TabBar } from "./tabbar";
import { MediaView } from "./views/media";
import { useMediaPanelStore, Tab } from "./store";
import { TextView } from "./views/text";
import { AudioView } from "./views/audio";
export function MediaPanel() {
const { activeTab } = useMediaPanelStore();
const viewMap: Record<Tab, React.ReactNode> = {
media: <MediaView />,
audio: <AudioView />,
text: <TextView />,
stickers: (
<div className="p-4 text-muted-foreground">
Stickers view coming soon...
</div>
),
effects: (
<div className="p-4 text-muted-foreground">
Effects view coming soon...
</div>
),
transitions: (
<div className="p-4 text-muted-foreground">
Transitions view coming soon...
</div>
),
captions: (
<div className="p-4 text-muted-foreground">
Captions view coming soon...
</div>
),
filters: (
<div className="p-4 text-muted-foreground">
Filters view coming soon...
</div>
),
adjustment: (
<div className="p-4 text-muted-foreground">
Adjustment view coming soon...
</div>
),
};
return (
<div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden">
<TabBar />
<div className="flex-1">{viewMap[activeTab]}</div>
</div>
);
}
"use client";
import { TabBar } from "./tabbar";
import { MediaView } from "./views/media";
import { useMediaPanelStore, Tab } from "./store";
import { TextView } from "./views/text";
import { AudioView } from "./views/audio";
export function MediaPanel() {
const { activeTab } = useMediaPanelStore();
const viewMap: Record<Tab, React.ReactNode> = {
media: <MediaView />,
audio: <AudioView />,
text: <TextView />,
stickers: (
<div className="p-4 text-muted-foreground">
Stickers view coming soon...
</div>
),
effects: (
<div className="p-4 text-muted-foreground">
Effects view coming soon...
</div>
),
transitions: (
<div className="p-4 text-muted-foreground">
Transitions view coming soon...
</div>
),
captions: (
<div className="p-4 text-muted-foreground">
Captions view coming soon...
</div>
),
filters: (
<div className="p-4 text-muted-foreground">
Filters view coming soon...
</div>
),
adjustment: (
<div className="p-4 text-muted-foreground">
Adjustment view coming soon...
</div>
),
};
return (
<div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden">
<TabBar />
<div className="flex-1">{viewMap[activeTab]}</div>
</div>
);
}

View File

@ -1,73 +1,73 @@
import {
CaptionsIcon,
ArrowLeftRightIcon,
SparklesIcon,
StickerIcon,
MusicIcon,
VideoIcon,
BlendIcon,
SlidersHorizontalIcon,
LucideIcon,
TypeIcon,
} from "lucide-react";
import { create } from "zustand";
export type Tab =
| "media"
| "audio"
| "text"
| "stickers"
| "effects"
| "transitions"
| "captions"
| "filters"
| "adjustment";
export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
media: {
icon: VideoIcon,
label: "Media",
},
audio: {
icon: MusicIcon,
label: "Audio",
},
text: {
icon: TypeIcon,
label: "Text",
},
stickers: {
icon: StickerIcon,
label: "Stickers",
},
effects: {
icon: SparklesIcon,
label: "Effects",
},
transitions: {
icon: ArrowLeftRightIcon,
label: "Transitions",
},
captions: {
icon: CaptionsIcon,
label: "Captions",
},
filters: {
icon: BlendIcon,
label: "Filters",
},
adjustment: {
icon: SlidersHorizontalIcon,
label: "Adjustment",
},
};
interface MediaPanelStore {
activeTab: Tab;
setActiveTab: (tab: Tab) => void;
}
export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
activeTab: "media",
setActiveTab: (tab) => set({ activeTab: tab }),
}));
import {
CaptionsIcon,
ArrowLeftRightIcon,
SparklesIcon,
StickerIcon,
MusicIcon,
VideoIcon,
BlendIcon,
SlidersHorizontalIcon,
LucideIcon,
TypeIcon,
} from "lucide-react";
import { create } from "zustand";
export type Tab =
| "media"
| "audio"
| "text"
| "stickers"
| "effects"
| "transitions"
| "captions"
| "filters"
| "adjustment";
export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
media: {
icon: VideoIcon,
label: "Media",
},
audio: {
icon: MusicIcon,
label: "Audio",
},
text: {
icon: TypeIcon,
label: "Text",
},
stickers: {
icon: StickerIcon,
label: "Stickers",
},
effects: {
icon: SparklesIcon,
label: "Effects",
},
transitions: {
icon: ArrowLeftRightIcon,
label: "Transitions",
},
captions: {
icon: CaptionsIcon,
label: "Captions",
},
filters: {
icon: BlendIcon,
label: "Filters",
},
adjustment: {
icon: SlidersHorizontalIcon,
label: "Adjustment",
},
};
interface MediaPanelStore {
activeTab: Tab;
setActiveTab: (tab: Tab) => void;
}
export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
activeTab: "media",
setActiveTab: (tab) => set({ activeTab: tab }),
}));

View File

@ -1,124 +1,124 @@
"use client";
import { cn } from "@/lib/utils";
import { Tab, tabs, useMediaPanelStore } from "./store";
import { Button } from "@/components/ui/button";
import { ChevronRight, ChevronLeft } from "lucide-react";
import { useRef, useState, useEffect } from "react";
export function TabBar() {
const { activeTab, setActiveTab } = useMediaPanelStore();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isAtEnd, setIsAtEnd] = useState(false);
const [isAtStart, setIsAtStart] = useState(true);
const scrollToEnd = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: scrollContainerRef.current.scrollWidth,
});
setIsAtEnd(true);
setIsAtStart(false);
}
};
const scrollToStart = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: 0,
});
setIsAtStart(true);
setIsAtEnd(false);
}
};
const checkScrollPosition = () => {
if (scrollContainerRef.current) {
const { scrollLeft, scrollWidth, clientWidth } =
scrollContainerRef.current;
const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1;
const isAtStartNow = scrollLeft <= 1;
setIsAtEnd(isAtEndNow);
setIsAtStart(isAtStartNow);
}
};
// We're using useEffect because we need to sync with external DOM scroll events
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
checkScrollPosition();
container.addEventListener("scroll", checkScrollPosition);
const resizeObserver = new ResizeObserver(checkScrollPosition);
resizeObserver.observe(container);
return () => {
container.removeEventListener("scroll", checkScrollPosition);
resizeObserver.disconnect();
};
}, []);
return (
<div className="flex">
<ScrollButton
direction="left"
onClick={scrollToStart}
isVisible={!isAtStart}
/>
<div
ref={scrollContainerRef}
className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full"
>
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
const tab = tabs[tabKey];
return (
<div
className={cn(
"flex flex-col gap-0.5 items-center cursor-pointer",
activeTab === tabKey ? "text-primary" : "text-muted-foreground"
)}
onClick={() => setActiveTab(tabKey)}
key={tabKey}
>
<tab.icon className="!size-[1.1rem]" />
<span className="text-[0.65rem]">{tab.label}</span>
</div>
);
})}
</div>
<ScrollButton
direction="right"
onClick={scrollToEnd}
isVisible={!isAtEnd}
/>
</div>
);
}
function ScrollButton({
direction,
onClick,
isVisible,
}: {
direction: "left" | "right";
onClick: () => void;
isVisible: boolean;
}) {
if (!isVisible) return null;
const Icon = direction === "left" ? ChevronLeft : ChevronRight;
return (
<div className="bg-panel-accent w-12 h-full flex items-center justify-center">
<Button
size="icon"
className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
onClick={onClick}
>
<Icon className="!size-4 text-foreground" />
</Button>
</div>
);
}
"use client";
import { cn } from "@/lib/utils";
import { Tab, tabs, useMediaPanelStore } from "./store";
import { Button } from "@/components/ui/button";
import { ChevronRight, ChevronLeft } from "lucide-react";
import { useRef, useState, useEffect } from "react";
export function TabBar() {
const { activeTab, setActiveTab } = useMediaPanelStore();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isAtEnd, setIsAtEnd] = useState(false);
const [isAtStart, setIsAtStart] = useState(true);
const scrollToEnd = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: scrollContainerRef.current.scrollWidth,
});
setIsAtEnd(true);
setIsAtStart(false);
}
};
const scrollToStart = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: 0,
});
setIsAtStart(true);
setIsAtEnd(false);
}
};
const checkScrollPosition = () => {
if (scrollContainerRef.current) {
const { scrollLeft, scrollWidth, clientWidth } =
scrollContainerRef.current;
const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1;
const isAtStartNow = scrollLeft <= 1;
setIsAtEnd(isAtEndNow);
setIsAtStart(isAtStartNow);
}
};
// We're using useEffect because we need to sync with external DOM scroll events
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
checkScrollPosition();
container.addEventListener("scroll", checkScrollPosition);
const resizeObserver = new ResizeObserver(checkScrollPosition);
resizeObserver.observe(container);
return () => {
container.removeEventListener("scroll", checkScrollPosition);
resizeObserver.disconnect();
};
}, []);
return (
<div className="flex">
<ScrollButton
direction="left"
onClick={scrollToStart}
isVisible={!isAtStart}
/>
<div
ref={scrollContainerRef}
className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full"
>
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
const tab = tabs[tabKey];
return (
<div
className={cn(
"flex flex-col gap-0.5 items-center cursor-pointer",
activeTab === tabKey ? "text-primary" : "text-muted-foreground"
)}
onClick={() => setActiveTab(tabKey)}
key={tabKey}
>
<tab.icon className="!size-[1.1rem]" />
<span className="text-[0.65rem]">{tab.label}</span>
</div>
);
})}
</div>
<ScrollButton
direction="right"
onClick={scrollToEnd}
isVisible={!isAtEnd}
/>
</div>
);
}
function ScrollButton({
direction,
onClick,
isVisible,
}: {
direction: "left" | "right";
onClick: () => void;
isVisible: boolean;
}) {
if (!isVisible) return null;
const Icon = direction === "left" ? ChevronLeft : ChevronRight;
return (
<div className="bg-panel-accent w-12 h-full flex items-center justify-center">
<Button
size="icon"
className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
onClick={onClick}
>
<Icon className="!size-4 text-foreground" />
</Button>
</div>
);
}

View File

@ -1,18 +1,18 @@
"use client";
import { Input } from "@/components/ui/input";
import { useState } from "react";
export function AudioView() {
const [search, setSearch] = useState("");
return (
<div className="h-full flex flex-col gap-2 p-4">
<Input
placeholder="Search songs and artists"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex flex-col gap-2"></div>
</div>
);
}
"use client";
import { Input } from "@/components/ui/input";
import { useState } from "react";
export function AudioView() {
const [search, setSearch] = useState("");
return (
<div className="h-full flex flex-col gap-2 p-4">
<Input
placeholder="Search songs and artists"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex flex-col gap-2" />
</div>
);
}

View File

@ -1,298 +1,298 @@
"use client";
import { useDragDrop } from "@/hooks/use-drag-drop";
import { processMediaFiles } from "@/lib/media-processing";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { Image, Loader2, Music, Plus, Video } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
export function MediaView() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
const { activeProject } = useProjectStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [mediaFilter, setMediaFilter] = useState("all");
const processFiles = async (files: FileList | File[]) => {
if (!files || files.length === 0) return;
if (!activeProject) {
toast.error("No active project");
return;
}
setIsProcessing(true);
setProgress(0);
try {
// Process files (extract metadata, generate thumbnails, etc.)
const processedItems = await processMediaFiles(files, (p) =>
setProgress(p)
);
// Add each processed media item to the store
for (const item of processedItems) {
await addMediaItem(activeProject.id, item);
}
} catch (error) {
// Show error toast if processing fails
console.error("Error processing files:", error);
toast.error("Failed to process files");
} finally {
setIsProcessing(false);
setProgress(0);
}
};
const { isDragOver, dragProps } = useDragDrop({
// When files are dropped, process them
onDrop: processFiles,
});
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// When files are selected via file picker, process them
if (e.target.files) processFiles(e.target.files);
e.target.value = ""; // Reset input
};
const handleRemove = async (e: React.MouseEvent, id: string) => {
// Remove a media item from the store
e.stopPropagation();
if (!activeProject) {
toast.error("No active project");
return;
}
// Media store now handles cascade deletion automatically
await removeMediaItem(activeProject.id, id);
};
const formatDuration = (duration: number) => {
// Format seconds as mm:ss
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
};
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
useEffect(() => {
const filtered = mediaItems.filter((item) => {
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
return false;
}
if (
searchQuery &&
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
return true;
});
setFilteredMediaItems(filtered);
}, [mediaItems, mediaFilter, searchQuery]);
const renderPreview = (item: MediaItem) => {
// Render a preview for each media type (image, video, audio, unknown)
if (item.type === "image") {
return (
<div className="w-full h-full flex items-center justify-center">
<img
src={item.url}
alt={item.name}
className="max-w-full max-h-full object-contain"
loading="lazy"
/>
</div>
);
}
if (item.type === "video") {
if (item.thumbnailUrl) {
return (
<div className="relative w-full h-full">
<img
src={item.thumbnailUrl}
alt={item.name}
className="w-full h-full object-cover rounded"
loading="lazy"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
<Video className="h-6 w-6 text-white drop-shadow-md" />
</div>
{item.duration && (
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
{formatDuration(item.duration)}
</div>
)}
</div>
);
}
return (
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
<Video className="h-6 w-6 mb-1" />
<span className="text-xs">Video</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
);
}
if (item.type === "audio") {
return (
<div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
<Music className="h-6 w-6 mb-1" />
<span className="text-xs">Audio</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
);
}
return (
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
<Image className="h-6 w-6" />
<span className="text-xs mt-1">Unknown</span>
</div>
);
};
return (
<>
{/* Hidden file input for uploading media */}
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*,audio/*"
multiple
className="hidden"
onChange={handleFileChange}
/>
<div
className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
{...dragProps}
>
<div className="p-3 pb-2">
{/* Search and filter controls */}
<div className="flex gap-2">
<Select value={mediaFilter} onValueChange={setMediaFilter}>
<SelectTrigger className="w-[80px] h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
<SelectItem value="image">Image</SelectItem>
</SelectContent>
</Select>
<Input
type="text"
placeholder="Search media..."
className="min-w-[60px] flex-1 h-9 text-xs"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Button
variant="outline"
size="lg"
onClick={handleFileSelect}
disabled={isProcessing}
className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center h-9"
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 pt-0">
{isDragOver || filteredMediaItems.length === 0 ? (
<MediaDragOverlay
isVisible={true}
isProcessing={isProcessing}
progress={progress}
onClick={handleFileSelect}
isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
/>
) : (
<div
className="grid gap-2"
style={{
gridTemplateColumns: "repeat(auto-fill, 160px)",
}}
>
{/* Render each media item as a draggable button */}
{filteredMediaItems.map((item) => (
<ContextMenu key={item.id}>
<ContextMenuTrigger>
<DraggableMediaItem
name={item.name}
preview={renderPreview(item)}
dragData={{
id: item.id,
type: item.type,
name: item.name,
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
useTimelineStore
.getState()
.addMediaAtTime(item, currentTime)
}
rounded={false}
/>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Export clips</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onClick={(e) => handleRemove(e, item.id)}
>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
)}
</div>
</div>
</>
);
}
"use client";
import { useDragDrop } from "@/hooks/use-drag-drop";
import { processMediaFiles } from "@/lib/media-processing";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { Image, Loader2, Music, Plus, Video } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
export function MediaView() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
const { activeProject } = useProjectStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [mediaFilter, setMediaFilter] = useState("all");
const processFiles = async (files: FileList | File[]) => {
if (!files || files.length === 0) return;
if (!activeProject) {
toast.error("No active project");
return;
}
setIsProcessing(true);
setProgress(0);
try {
// Process files (extract metadata, generate thumbnails, etc.)
const processedItems = await processMediaFiles(files, (p) =>
setProgress(p)
);
// Add each processed media item to the store
for (const item of processedItems) {
await addMediaItem(activeProject.id, item);
}
} catch (error) {
// Show error toast if processing fails
console.error("Error processing files:", error);
toast.error("Failed to process files");
} finally {
setIsProcessing(false);
setProgress(0);
}
};
const { isDragOver, dragProps } = useDragDrop({
// When files are dropped, process them
onDrop: processFiles,
});
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// When files are selected via file picker, process them
if (e.target.files) processFiles(e.target.files);
e.target.value = ""; // Reset input
};
const handleRemove = async (e: React.MouseEvent, id: string) => {
// Remove a media item from the store
e.stopPropagation();
if (!activeProject) {
toast.error("No active project");
return;
}
// Media store now handles cascade deletion automatically
await removeMediaItem(activeProject.id, id);
};
const formatDuration = (duration: number) => {
// Format seconds as mm:ss
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
};
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
useEffect(() => {
const filtered = mediaItems.filter((item) => {
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
return false;
}
if (
searchQuery &&
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
return true;
});
setFilteredMediaItems(filtered);
}, [mediaItems, mediaFilter, searchQuery]);
const renderPreview = (item: MediaItem) => {
// Render a preview for each media type (image, video, audio, unknown)
if (item.type === "image") {
return (
<div className="w-full h-full flex items-center justify-center">
<img
src={item.url}
alt={item.name}
className="max-w-full max-h-full object-contain"
loading="lazy"
/>
</div>
);
}
if (item.type === "video") {
if (item.thumbnailUrl) {
return (
<div className="relative w-full h-full">
<img
src={item.thumbnailUrl}
alt={item.name}
className="w-full h-full object-cover rounded"
loading="lazy"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
<Video className="h-6 w-6 text-white drop-shadow-md" />
</div>
{item.duration && (
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
{formatDuration(item.duration)}
</div>
)}
</div>
);
}
return (
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
<Video className="h-6 w-6 mb-1" />
<span className="text-xs">Video</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
);
}
if (item.type === "audio") {
return (
<div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
<Music className="h-6 w-6 mb-1" />
<span className="text-xs">Audio</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
);
}
return (
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
<Image className="h-6 w-6" />
<span className="text-xs mt-1">Unknown</span>
</div>
);
};
return (
<>
{/* Hidden file input for uploading media */}
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*,audio/*"
multiple
className="hidden"
onChange={handleFileChange}
/>
<div
className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
{...dragProps}
>
<div className="p-3 pb-2">
{/* Search and filter controls */}
<div className="flex gap-2">
<Select value={mediaFilter} onValueChange={setMediaFilter}>
<SelectTrigger className="w-[80px] h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
<SelectItem value="image">Image</SelectItem>
</SelectContent>
</Select>
<Input
type="text"
placeholder="Search media..."
className="min-w-[60px] flex-1 h-9 text-xs"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Button
variant="outline"
size="lg"
onClick={handleFileSelect}
disabled={isProcessing}
className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center h-9"
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 pt-0">
{isDragOver || filteredMediaItems.length === 0 ? (
<MediaDragOverlay
isVisible={true}
isProcessing={isProcessing}
progress={progress}
onClick={handleFileSelect}
isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
/>
) : (
<div
className="grid gap-2"
style={{
gridTemplateColumns: "repeat(auto-fill, 160px)",
}}
>
{/* Render each media item as a draggable button */}
{filteredMediaItems.map((item) => (
<ContextMenu key={item.id}>
<ContextMenuTrigger>
<DraggableMediaItem
name={item.name}
preview={renderPreview(item)}
dragData={{
id: item.id,
type: item.type,
name: item.name,
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
useTimelineStore
.getState()
.addMediaAtTime(item, currentTime)
}
rounded={false}
/>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Export clips</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onClick={(e) => handleRemove(e, item.id)}
>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
)}
</div>
</div>
</>
);
}

View File

@ -1,53 +1,53 @@
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useTimelineStore } from "@/stores/timeline-store";
import { type TextElement } from "@/types/timeline";
let textData: TextElement = {
id: "default-text",
type: "text",
name: "Default text",
content: "Default text",
fontSize: 48,
fontFamily: "Arial",
color: "#ffffff",
backgroundColor: "transparent",
textAlign: "center" as const,
fontWeight: "normal" as const,
fontStyle: "normal" as const,
textDecoration: "none" as const,
x: 0,
y: 0,
rotation: 0,
opacity: 1,
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
};
export function TextView() {
return (
<div className="p-4">
<DraggableMediaItem
name="Default text"
preview={
<div className="flex items-center justify-center w-full h-full bg-accent rounded">
<span className="text-xs select-none">Default text</span>
</div>
}
dragData={{
id: textData.id,
type: textData.type,
name: textData.name,
content: textData.content,
}}
aspectRatio={1}
onAddToTimeline={(currentTime) =>
useTimelineStore.getState().addTextAtTime(textData, currentTime)
}
showLabel={false}
/>
</div>
);
}
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useTimelineStore } from "@/stores/timeline-store";
import { type TextElement } from "@/types/timeline";
const textData: TextElement = {
id: "default-text",
type: "text",
name: "Default text",
content: "Default text",
fontSize: 48,
fontFamily: "Arial",
color: "#ffffff",
backgroundColor: "transparent",
textAlign: "center" as const,
fontWeight: "normal" as const,
fontStyle: "normal" as const,
textDecoration: "none" as const,
x: 0,
y: 0,
rotation: 0,
opacity: 1,
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
};
export function TextView() {
return (
<div className="p-4">
<DraggableMediaItem
name="Default text"
preview={
<div className="flex items-center justify-center w-full h-full bg-accent rounded">
<span className="text-xs select-none">Default text</span>
</div>
}
dragData={{
id: textData.id,
type: textData.type,
name: textData.name,
content: textData.content,
}}
aspectRatio={1}
onAddToTimeline={(currentTime) =>
useTimelineStore.getState().addTextAtTime(textData, currentTime)
}
showLabel={false}
/>
</div>
);
}

View File

@ -368,7 +368,7 @@ export function PreviewPanel() {
ref={containerRef}
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0"
>
<div className="flex-1"></div>
<div className="flex-1" />
{hasAnyElements ? (
<div
ref={previewRef}
@ -402,7 +402,7 @@ export function PreviewPanel() {
</div>
) : null}
<div className="flex-1"></div>
<div className="flex-1" />
<PreviewToolbar
hasAnyElements={hasAnyElements}

View File

@ -1,5 +1,5 @@
import { MediaElement } from "@/types/timeline";
export function AudioProperties({ element }: { element: MediaElement }) {
return <div className="space-y-4 p-5">Audio properties</div>;
}
import { MediaElement } from "@/types/timeline";
export function AudioProperties({ element }: { element: MediaElement }) {
return <div className="space-y-4 p-5">Audio properties</div>;
}

View File

@ -1,5 +1,5 @@
import { MediaElement } from "@/types/timeline";
export function MediaProperties({ element }: { element: MediaElement }) {
return <div className="space-y-4 p-5">Media properties</div>;
}
import { MediaElement } from "@/types/timeline";
export function MediaProperties({ element }: { element: MediaElement }) {
return <div className="space-y-4 p-5">Media properties</div>;
}

View File

@ -1,47 +1,47 @@
import { cn } from "@/lib/utils";
interface PropertyItemProps {
direction?: "row" | "column";
children: React.ReactNode;
className?: string;
}
export function PropertyItem({
direction = "row",
children,
className,
}: PropertyItemProps) {
return (
<div
className={cn(
"flex gap-2",
direction === "row"
? "items-center justify-between gap-6"
: "flex-col gap-1",
className
)}
>
{children}
</div>
);
}
export function PropertyItemLabel({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <label className={cn("text-xs", className)}>{children}</label>;
}
export function PropertyItemValue({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("flex-1", className)}>{children}</div>;
}
import { cn } from "@/lib/utils";
interface PropertyItemProps {
direction?: "row" | "column";
children: React.ReactNode;
className?: string;
}
export function PropertyItem({
direction = "row",
children,
className,
}: PropertyItemProps) {
return (
<div
className={cn(
"flex gap-2",
direction === "row"
? "items-center justify-between gap-6"
: "flex-col gap-1",
className
)}
>
{children}
</div>
);
}
export function PropertyItemLabel({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <label className={cn("text-xs", className)}>{children}</label>;
}
export function PropertyItemValue({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("flex-1", className)}>{children}</div>;
}

View File

@ -1,76 +1,76 @@
import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker";
import { FontFamily } from "@/constants/font-constants";
import { TextElement } from "@/types/timeline";
import { useTimelineStore } from "@/stores/timeline-store";
import { Slider } from "@/components/ui/slider";
import { Input } from "@/components/ui/input";
import {
PropertyItem,
PropertyItemLabel,
PropertyItemValue,
} from "./property-item";
export function TextProperties({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const { updateTextElement } = useTimelineStore();
return (
<div className="space-y-6 p-5">
<Textarea
placeholder="Name"
defaultValue={element.content}
className="min-h-[4.5rem] resize-none bg-background/50"
onChange={(e) =>
updateTextElement(trackId, element.id, { content: e.target.value })
}
/>
<PropertyItem direction="row">
<PropertyItemLabel>Font</PropertyItemLabel>
<PropertyItemValue>
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value: FontFamily) =>
updateTextElement(trackId, element.id, { fontFamily: value })
}
/>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Font size</PropertyItemLabel>
<PropertyItemValue>
<div className="flex items-center gap-2">
<Slider
defaultValue={[element.fontSize]}
min={8}
max={300}
step={1}
onValueChange={([value]) =>
updateTextElement(trackId, element.id, { fontSize: value })
}
className="w-full"
/>
<Input
type="number"
value={element.fontSize}
onChange={(e) =>
updateTextElement(trackId, element.id, {
fontSize: parseInt(e.target.value),
})
}
className="w-12 !text-xs h-7 rounded-sm text-center
[appearance:textfield]
[&::-webkit-outer-spin-button]:appearance-none
[&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</PropertyItemValue>
</PropertyItem>
</div>
);
}
import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker";
import { FontFamily } from "@/constants/font-constants";
import { TextElement } from "@/types/timeline";
import { useTimelineStore } from "@/stores/timeline-store";
import { Slider } from "@/components/ui/slider";
import { Input } from "@/components/ui/input";
import {
PropertyItem,
PropertyItemLabel,
PropertyItemValue,
} from "./property-item";
export function TextProperties({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const { updateTextElement } = useTimelineStore();
return (
<div className="space-y-6 p-5">
<Textarea
placeholder="Name"
defaultValue={element.content}
className="min-h-[4.5rem] resize-none bg-background/50"
onChange={(e) =>
updateTextElement(trackId, element.id, { content: e.target.value })
}
/>
<PropertyItem direction="row">
<PropertyItemLabel>Font</PropertyItemLabel>
<PropertyItemValue>
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value: FontFamily) =>
updateTextElement(trackId, element.id, { fontFamily: value })
}
/>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Font size</PropertyItemLabel>
<PropertyItemValue>
<div className="flex items-center gap-2">
<Slider
defaultValue={[element.fontSize]}
min={8}
max={300}
step={1}
onValueChange={([value]) =>
updateTextElement(trackId, element.id, { fontSize: value })
}
className="w-full"
/>
<Input
type="number"
value={element.fontSize}
onChange={(e) =>
updateTextElement(trackId, element.id, {
fontSize: parseInt(e.target.value),
})
}
className="w-12 !text-xs h-7 rounded-sm text-center
[appearance:textfield]
[&::-webkit-outer-spin-button]:appearance-none
[&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</PropertyItemValue>
</PropertyItem>
</div>
);
}

View File

@ -1,58 +1,58 @@
"use client";
import { useEffect, useRef } from "react";
interface SelectionBoxProps {
startPos: { x: number; y: number } | null;
currentPos: { x: number; y: number } | null;
containerRef: React.RefObject<HTMLElement>;
isActive: boolean;
}
export function SelectionBox({
startPos,
currentPos,
containerRef,
isActive,
}: SelectionBoxProps) {
const selectionBoxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isActive || !startPos || !currentPos || !containerRef.current) return;
const container = containerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculate relative positions within the container
const startX = startPos.x - containerRect.left;
const startY = startPos.y - containerRect.top;
const currentX = currentPos.x - containerRect.left;
const currentY = currentPos.y - containerRect.top;
// Calculate the selection rectangle bounds
const left = Math.min(startX, currentX);
const top = Math.min(startY, currentY);
const width = Math.abs(currentX - startX);
const height = Math.abs(currentY - startY);
// Update the selection box position and size
if (selectionBoxRef.current) {
selectionBoxRef.current.style.left = `${left}px`;
selectionBoxRef.current.style.top = `${top}px`;
selectionBoxRef.current.style.width = `${width}px`;
selectionBoxRef.current.style.height = `${height}px`;
}
}, [startPos, currentPos, isActive, containerRef]);
if (!isActive || !startPos || !currentPos) return null;
return (
<div
ref={selectionBoxRef}
className="absolute pointer-events-none z-50"
style={{
backgroundColor: "hsl(var(--foreground) / 0.1)",
}}
/>
);
}
"use client";
import { useEffect, useRef } from "react";
interface SelectionBoxProps {
startPos: { x: number; y: number } | null;
currentPos: { x: number; y: number } | null;
containerRef: React.RefObject<HTMLElement>;
isActive: boolean;
}
export function SelectionBox({
startPos,
currentPos,
containerRef,
isActive,
}: SelectionBoxProps) {
const selectionBoxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isActive || !startPos || !currentPos || !containerRef.current) return;
const container = containerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculate relative positions within the container
const startX = startPos.x - containerRect.left;
const startY = startPos.y - containerRect.top;
const currentX = currentPos.x - containerRect.left;
const currentY = currentPos.y - containerRect.top;
// Calculate the selection rectangle bounds
const left = Math.min(startX, currentX);
const top = Math.min(startY, currentY);
const width = Math.abs(currentX - startX);
const height = Math.abs(currentY - startY);
// Update the selection box position and size
if (selectionBoxRef.current) {
selectionBoxRef.current.style.left = `${left}px`;
selectionBoxRef.current.style.top = `${top}px`;
selectionBoxRef.current.style.width = `${width}px`;
selectionBoxRef.current.style.height = `${height}px`;
}
}, [startPos, currentPos, isActive, containerRef]);
if (!isActive || !startPos || !currentPos) return null;
return (
<div
ref={selectionBoxRef}
className="absolute pointer-events-none z-50"
style={{
backgroundColor: "hsl(var(--foreground) / 0.1)",
}}
/>
);
}

View File

@ -48,7 +48,7 @@ export function SnapIndicator({
width: "2px",
}}
>
<div className={`w-0.5 h-full bg-primary/40 opacity-80`} />
<div className={"w-0.5 h-full bg-primary/40 opacity-80"} />
</div>
);
}

View File

@ -43,4 +43,4 @@ export function SpeedControl() {
</div>
</div>
);
}
}

View File

@ -278,7 +278,7 @@ export function Timeline() {
Math.min(
duration,
(mouseX + scrollLeft) /
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
)
);
@ -501,7 +501,9 @@ export function Timeline() {
return (
<div
className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`}
className={
"h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden"
}
{...dragProps}
onMouseEnter={() => setIsInTimeline(true)}
onMouseLeave={() => setIsInTimeline(false)}
@ -598,19 +600,21 @@ export function Timeline() {
return (
<div
key={i}
className={`absolute top-0 bottom-0 ${isMainMarker
className={`absolute top-0 bottom-0 ${
isMainMarker
? "border-l border-muted-foreground/40"
: "border-l border-muted-foreground/20"
}`}
}`}
style={{
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
}}
>
<span
className={`absolute top-1 left-1 text-[0.6rem] ${isMainMarker
className={`absolute top-1 left-1 text-[0.6rem] ${
isMainMarker
? "text-muted-foreground font-medium"
: "text-muted-foreground/70"
}`}
}`}
>
{(() => {
const formatTime = (seconds: number) => {
@ -620,13 +624,14 @@ export function Timeline() {
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`;
} else if (minutes > 0) {
return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`;
} else if (interval >= 1) {
return `${Math.floor(secs)}s`;
} else {
return `${secs.toFixed(1)}s`;
}
if (minutes > 0) {
return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`;
}
if (interval >= 1) {
return `${Math.floor(secs)}s`;
}
return `${secs.toFixed(1)}s`;
};
return formatTime(time);
})()}
@ -709,7 +714,7 @@ export function Timeline() {
}}
>
{tracks.length === 0 ? (
<div></div>
<div />
) : (
<>
{tracks.map((track, index) => (

View File

@ -1,116 +1,116 @@
"use client";
import { useRef } from "react";
import { TimelineTrack } from "@/types/timeline";
import {
TIMELINE_CONSTANTS,
getTotalTracksHeight,
} from "@/constants/timeline-constants";
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
interface TimelinePlayheadProps {
currentTime: number;
duration: number;
zoomLevel: number;
tracks: TimelineTrack[];
seek: (time: number) => void;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
trackLabelsRef?: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
isSnappingToPlayhead?: boolean;
}
export function TimelinePlayhead({
currentTime,
duration,
zoomLevel,
tracks,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
trackLabelsRef,
timelineRef,
playheadRef: externalPlayheadRef,
isSnappingToPlayhead = false,
}: TimelinePlayheadProps) {
const internalPlayheadRef = useRef<HTMLDivElement>(null);
const playheadRef = externalPlayheadRef || internalPlayheadRef;
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
});
// Use timeline container height minus a few pixels for breathing room
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
// Get dynamic track labels width, fallback to 0 if no tracks or no ref
const trackLabelsWidth =
tracks.length > 0 && trackLabelsRef?.current
? trackLabelsRef.current.offsetWidth
: 0;
const leftPosition =
trackLabelsWidth +
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
return (
<div
ref={playheadRef}
className="absolute pointer-events-auto z-[150]"
style={{
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
width: "2px", // Slightly wider for better click target
}}
onMouseDown={handlePlayheadMouseDown}
>
{/* The playhead line spanning full height */}
<div
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
/>
{/* Playhead dot indicator at the top (in ruler area) */}
<div
className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
/>
</div>
);
}
// Also export a hook for getting ruler handlers
export function useTimelinePlayheadRuler({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
});
return { handleRulerMouseDown, isDraggingRuler };
}
export { TimelinePlayhead as default };
"use client";
import { useRef } from "react";
import { TimelineTrack } from "@/types/timeline";
import {
TIMELINE_CONSTANTS,
getTotalTracksHeight,
} from "@/constants/timeline-constants";
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
interface TimelinePlayheadProps {
currentTime: number;
duration: number;
zoomLevel: number;
tracks: TimelineTrack[];
seek: (time: number) => void;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
trackLabelsRef?: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
isSnappingToPlayhead?: boolean;
}
export function TimelinePlayhead({
currentTime,
duration,
zoomLevel,
tracks,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
trackLabelsRef,
timelineRef,
playheadRef: externalPlayheadRef,
isSnappingToPlayhead = false,
}: TimelinePlayheadProps) {
const internalPlayheadRef = useRef<HTMLDivElement>(null);
const playheadRef = externalPlayheadRef || internalPlayheadRef;
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
});
// Use timeline container height minus a few pixels for breathing room
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
// Get dynamic track labels width, fallback to 0 if no tracks or no ref
const trackLabelsWidth =
tracks.length > 0 && trackLabelsRef?.current
? trackLabelsRef.current.offsetWidth
: 0;
const leftPosition =
trackLabelsWidth +
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
return (
<div
ref={playheadRef}
className="absolute pointer-events-auto z-[150]"
style={{
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
width: "2px", // Slightly wider for better click target
}}
onMouseDown={handlePlayheadMouseDown}
>
{/* The playhead line spanning full height */}
<div
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
/>
{/* Playhead dot indicator at the top (in ruler area) */}
<div
className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
/>
</div>
);
}
// Also export a hook for getting ruler handlers
export function useTimelinePlayheadRuler({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
});
return { handleRulerMouseDown, isDraggingRuler };
}
export { TimelinePlayhead as default };

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +1,41 @@
"use client";
import { cn } from "@/lib/utils";
import { ReactNode } from "react";
interface HeaderBaseProps {
leftContent?: ReactNode;
centerContent?: ReactNode;
rightContent?: ReactNode;
className?: string;
children?: ReactNode;
}
export function HeaderBase({
leftContent,
centerContent,
rightContent,
className,
children,
}: HeaderBaseProps) {
// If children is provided, render it directly without the grid layout
if (children) {
return (
<header className={cn("px-6 h-16 flex items-center", className)}>
{children}
</header>
);
}
return (
<header
className={cn("px-6 h-14 flex justify-between items-center", className)}
>
{leftContent && <div className="flex items-center">{leftContent}</div>}
{centerContent && (
<div className="flex items-center">{centerContent}</div>
)}
{rightContent && <div className="flex items-center">{rightContent}</div>}
</header>
);
}
"use client";
import { cn } from "@/lib/utils";
import { ReactNode } from "react";
interface HeaderBaseProps {
leftContent?: ReactNode;
centerContent?: ReactNode;
rightContent?: ReactNode;
className?: string;
children?: ReactNode;
}
export function HeaderBase({
leftContent,
centerContent,
rightContent,
className,
children,
}: HeaderBaseProps) {
// If children is provided, render it directly without the grid layout
if (children) {
return (
<header className={cn("px-6 h-16 flex items-center", className)}>
{children}
</header>
);
}
return (
<header
className={cn("px-6 h-14 flex justify-between items-center", className)}
>
{leftContent && <div className="flex items-center">{leftContent}</div>}
{centerContent && (
<div className="flex items-center">{centerContent}</div>
)}
{rightContent && <div className="flex items-center">{rightContent}</div>}
</header>
);
}

View File

@ -1,47 +1,47 @@
"use client";
import Link from "next/link";
import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base";
import Image from "next/image";
export function Header() {
const leftContent = (
<Link href="/" className="flex items-center gap-3">
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
<span className="text-xl font-medium hidden md:block">OpenCut</span>
</Link>
);
const rightContent = (
<nav className="flex items-center gap-3">
<Link href="/blog">
<Button variant="text" className="text-sm p-0">
Blog
</Button>
</Link>
<Link href="/contributors">
<Button variant="text" className="text-sm p-0">
Contributors
</Button>
</Link>
<Link href="/projects">
<Button size="sm" className="text-sm ml-4">
Projects
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</nav>
);
return (
<div className="mx-4 md:mx-0">
<HeaderBase
className="bg-accent border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
leftContent={leftContent}
rightContent={rightContent}
/>
</div>
);
}
"use client";
import Link from "next/link";
import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base";
import Image from "next/image";
export function Header() {
const leftContent = (
<Link href="/" className="flex items-center gap-3">
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
<span className="text-xl font-medium hidden md:block">OpenCut</span>
</Link>
);
const rightContent = (
<nav className="flex items-center gap-3">
<Link href="/blog">
<Button variant="text" className="text-sm p-0">
Blog
</Button>
</Link>
<Link href="/contributors">
<Button variant="text" className="text-sm p-0">
Contributors
</Button>
</Link>
<Link href="/projects">
<Button size="sm" className="text-sm ml-4">
Projects
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</nav>
);
return (
<div className="mx-4 md:mx-0">
<HeaderBase
className="bg-accent border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
leftContent={leftContent}
rightContent={rightContent}
/>
</div>
);
}

View File

@ -40,8 +40,15 @@ export function GithubIcon({ className }: { className?: string }) {
export function VercelIcon({ className }: { className?: string }) {
return (
<svg className={className} width="20" height="18" viewBox="0 0 76 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor"/>
<svg
className={className}
width="20"
height="18"
viewBox="0 0 76 65"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor" />
</svg>
);
}
@ -105,4 +112,4 @@ export function BackgroundIcon({ className }: { className?: string }) {
</defs>
</svg>
);
}
}

View File

@ -112,7 +112,7 @@ export function Handlebars({
whileDrag={{ scale: 1.1 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
>
<div className="w-2 h-8 rounded-full bg-yellow-500"></div>
<div className="w-2 h-8 rounded-full bg-yellow-500" />
</motion.div>
<motion.div
@ -135,7 +135,7 @@ export function Handlebars({
whileDrag={{ scale: 1.1 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
>
<div className="w-2 h-8 rounded-full bg-yellow-500"></div>
<div className="w-2 h-8 rounded-full bg-yellow-500" />
</motion.div>
</div>
@ -161,4 +161,4 @@ export function Handlebars({
</div>
</div>
);
};
}

View File

@ -1,121 +1,121 @@
"use client";
import { Dialog, DialogContent } from "./ui/dialog";
import { Button } from "./ui/button";
import { ArrowRightIcon } from "lucide-react";
import { useState, useEffect } from "react";
import ReactMarkdown from "react-markdown";
export function Onboarding() {
const [step, setStep] = useState(0);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding");
if (!hasSeenOnboarding) {
setIsOpen(true);
}
}, []);
const handleNext = () => {
setStep(step + 1);
};
const handleClose = () => {
setIsOpen(false);
localStorage.setItem("hasSeenOnboarding", "true");
};
const renderStepContent = () => {
switch (step) {
case 0:
return (
<div className="space-y-5">
<div className="space-y-3">
<Title title="Welcome to OpenCut Beta! 🎉" />
<Description description="You're among the first to try OpenCut - the fully open source CapCut alternative." />
</div>
<NextButton onClick={handleNext}>Next</NextButton>
</div>
);
case 1:
return (
<div className="space-y-5">
<div className="space-y-3">
<Title title="⚠️ This is a super early beta!" />
<Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." />
<Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" />
</div>
<NextButton onClick={handleNext}>Next</NextButton>
</div>
);
case 2:
return (
<div className="space-y-5">
<div className="space-y-3">
<Title title="🦋 Have fun testing!" />
<Description description="Join our [Discord](https://discord.gg/zmR9N35cjK), chat with cool people and share feedback to help make OpenCut the best editor ever." />
</div>
<NextButton onClick={handleClose}>Finish</NextButton>
</div>
);
default:
return null;
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px] !outline-none">
{renderStepContent()}
</DialogContent>
</Dialog>
);
}
function Title({ title }: { title: string }) {
return <h2 className="text-lg md:text-xl font-bold">{title}</h2>;
}
function Subtitle({ subtitle }: { subtitle: string }) {
return <h3 className="text-lg font-medium">{subtitle}</h3>;
}
function Description({ description }: { description: string }) {
return (
<div className="text-muted-foreground">
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-0">{children}</p>,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:text-foreground/80 underline"
>
{children}
</a>
),
}}
>
{description}
</ReactMarkdown>
</div>
);
}
function NextButton({
children,
onClick,
}: {
children: React.ReactNode;
onClick: () => void;
}) {
return (
<Button onClick={onClick} variant="default" className="w-full">
{children}
<ArrowRightIcon className="w-4 h-4" />
</Button>
);
}
"use client";
import { Dialog, DialogContent } from "./ui/dialog";
import { Button } from "./ui/button";
import { ArrowRightIcon } from "lucide-react";
import { useState, useEffect } from "react";
import ReactMarkdown from "react-markdown";
export function Onboarding() {
const [step, setStep] = useState(0);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding");
if (!hasSeenOnboarding) {
setIsOpen(true);
}
}, []);
const handleNext = () => {
setStep(step + 1);
};
const handleClose = () => {
setIsOpen(false);
localStorage.setItem("hasSeenOnboarding", "true");
};
const renderStepContent = () => {
switch (step) {
case 0:
return (
<div className="space-y-5">
<div className="space-y-3">
<Title title="Welcome to OpenCut Beta! 🎉" />
<Description description="You're among the first to try OpenCut - the fully open source CapCut alternative." />
</div>
<NextButton onClick={handleNext}>Next</NextButton>
</div>
);
case 1:
return (
<div className="space-y-5">
<div className="space-y-3">
<Title title="⚠️ This is a super early beta!" />
<Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." />
<Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" />
</div>
<NextButton onClick={handleNext}>Next</NextButton>
</div>
);
case 2:
return (
<div className="space-y-5">
<div className="space-y-3">
<Title title="🦋 Have fun testing!" />
<Description description="Join our [Discord](https://discord.gg/zmR9N35cjK), chat with cool people and share feedback to help make OpenCut the best editor ever." />
</div>
<NextButton onClick={handleClose}>Finish</NextButton>
</div>
);
default:
return null;
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px] !outline-none">
{renderStepContent()}
</DialogContent>
</Dialog>
);
}
function Title({ title }: { title: string }) {
return <h2 className="text-lg md:text-xl font-bold">{title}</h2>;
}
function Subtitle({ subtitle }: { subtitle: string }) {
return <h3 className="text-lg font-medium">{subtitle}</h3>;
}
function Description({ description }: { description: string }) {
return (
<div className="text-muted-foreground">
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-0">{children}</p>,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:text-foreground/80 underline"
>
{children}
</a>
),
}}
>
{description}
</ReactMarkdown>
</div>
);
}
function NextButton({
children,
onClick,
}: {
children: React.ReactNode;
onClick: () => void;
}) {
return (
<Button onClick={onClick} variant="default" className="w-full">
{children}
<ArrowRightIcon className="w-4 h-4" />
</Button>
);
}

View File

@ -1,73 +1,73 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useState } from "react";
export function RenameProjectDialog({
isOpen,
onOpenChange,
onConfirm,
projectName,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (name: string) => void;
projectName: string;
}) {
const [name, setName] = useState(projectName);
// Reset the name when dialog opens - this is better UX than syncing with every prop change
const handleOpenChange = (open: boolean) => {
if (open) {
setName(projectName);
}
onOpenChange(open);
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Project</DialogTitle>
<DialogDescription>
Enter a new name for your project.
</DialogDescription>
</DialogHeader>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm(name);
}
}}
placeholder="Enter a new name"
className="mt-4"
/>
<DialogFooter>
<Button
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenChange(false);
}}
>
Cancel
</Button>
<Button onClick={() => onConfirm(name)}>Rename</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useState } from "react";
export function RenameProjectDialog({
isOpen,
onOpenChange,
onConfirm,
projectName,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (name: string) => void;
projectName: string;
}) {
const [name, setName] = useState(projectName);
// Reset the name when dialog opens - this is better UX than syncing with every prop change
const handleOpenChange = (open: boolean) => {
if (open) {
setName(projectName);
}
onOpenChange(open);
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Project</DialogTitle>
<DialogDescription>
Enter a new name for your project.
</DialogDescription>
</DialogHeader>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm(name);
}
}}
placeholder="Enter a new name"
className="mt-4"
/>
<DialogFooter>
<Button
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenChange(false);
}}
>
Cancel
</Button>
<Button onClick={() => onConfirm(name)}>Rename</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,80 +1,80 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { useProjectStore } from "@/stores/project-store";
import { useMediaStore } from "@/stores/media-store";
import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
interface StorageContextType {
isInitialized: boolean;
isLoading: boolean;
hasSupport: boolean;
error: string | null;
}
const StorageContext = createContext<StorageContextType | null>(null);
export function useStorage() {
const context = useContext(StorageContext);
if (!context) {
throw new Error("useStorage must be used within StorageProvider");
}
return context;
}
interface StorageProviderProps {
children: React.ReactNode;
}
export function StorageProvider({ children }: StorageProviderProps) {
const [status, setStatus] = useState<StorageContextType>({
isInitialized: false,
isLoading: true,
hasSupport: false,
error: null,
});
const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
useEffect(() => {
const initializeStorage = async () => {
setStatus((prev) => ({ ...prev, isLoading: true }));
try {
// Check browser support
const hasSupport = storageService.isFullySupported();
if (!hasSupport) {
toast.warning(
"Storage not fully supported. Some features may not work."
);
}
// Load saved projects (media will be loaded when a project is loaded)
await loadAllProjects();
setStatus({
isInitialized: true,
isLoading: false,
hasSupport,
error: null,
});
} catch (error) {
console.error("Failed to initialize storage:", error);
setStatus({
isInitialized: false,
isLoading: false,
hasSupport: storageService.isFullySupported(),
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
initializeStorage();
}, [loadAllProjects]);
return (
<StorageContext.Provider value={status}>{children}</StorageContext.Provider>
);
}
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { useProjectStore } from "@/stores/project-store";
import { useMediaStore } from "@/stores/media-store";
import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
interface StorageContextType {
isInitialized: boolean;
isLoading: boolean;
hasSupport: boolean;
error: string | null;
}
const StorageContext = createContext<StorageContextType | null>(null);
export function useStorage() {
const context = useContext(StorageContext);
if (!context) {
throw new Error("useStorage must be used within StorageProvider");
}
return context;
}
interface StorageProviderProps {
children: React.ReactNode;
}
export function StorageProvider({ children }: StorageProviderProps) {
const [status, setStatus] = useState<StorageContextType>({
isInitialized: false,
isLoading: true,
hasSupport: false,
error: null,
});
const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
useEffect(() => {
const initializeStorage = async () => {
setStatus((prev) => ({ ...prev, isLoading: true }));
try {
// Check browser support
const hasSupport = storageService.isFullySupported();
if (!hasSupport) {
toast.warning(
"Storage not fully supported. Some features may not work."
);
}
// Load saved projects (media will be loaded when a project is loaded)
await loadAllProjects();
setStatus({
isInitialized: true,
isLoading: false,
hasSupport,
error: null,
});
} catch (error) {
console.error("Failed to initialize storage:", error);
setStatus({
isInitialized: false,
isLoading: false,
hasSupport: storageService.isFullySupported(),
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
initializeStorage();
}, [loadAllProjects]);
return (
<StorageContext.Provider value={status}>{children}</StorageContext.Provider>
);
}

View File

@ -1,7 +1,7 @@
"use client"
"use client";
import { AspectRatio as AspectRatioPrimitive } from "radix-ui"
import { AspectRatio as AspectRatioPrimitive } from "radix-ui";
const AspectRatio = AspectRatioPrimitive.Root
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio }
export { AspectRatio };

View File

@ -1,127 +1,127 @@
"use client";
import { useRef, useEffect } from "react";
import { usePlaybackStore } from "@/stores/playback-store";
interface AudioPlayerProps {
src: string;
className?: string;
clipStartTime: number;
trimStart: number;
trimEnd: number;
clipDuration: number;
trackMuted?: boolean;
}
export function AudioPlayer({
src,
className = "",
clipStartTime,
trimStart,
trimEnd,
clipDuration,
trackMuted = false,
}: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
// Calculate if we're within this clip's timeline range
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
const isInClipRange =
currentTime >= clipStartTime && currentTime < clipEndTime;
// Sync playback events
useEffect(() => {
const audio = audioRef.current;
if (!audio || !isInClipRange) return;
const handleSeekEvent = (e: CustomEvent) => {
// Always update audio time, even if outside clip range
const timelineTime = e.detail.time;
const audioTime = Math.max(
trimStart,
Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
audio.currentTime = audioTime;
};
const handleUpdateEvent = (e: CustomEvent) => {
// Always update audio time, even if outside clip range
const timelineTime = e.detail.time;
const targetTime = Math.max(
trimStart,
Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
if (Math.abs(audio.currentTime - targetTime) > 0.5) {
audio.currentTime = targetTime;
}
};
const handleSpeed = (e: CustomEvent) => {
audio.playbackRate = e.detail.speed;
};
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
window.addEventListener(
"playback-update",
handleUpdateEvent as EventListener
);
window.addEventListener("playback-speed", handleSpeed as EventListener);
return () => {
window.removeEventListener(
"playback-seek",
handleSeekEvent as EventListener
);
window.removeEventListener(
"playback-update",
handleUpdateEvent as EventListener
);
window.removeEventListener(
"playback-speed",
handleSpeed as EventListener
);
};
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
// Sync playback state
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying && isInClipRange && !trackMuted) {
audio.play().catch(() => {});
} else {
audio.pause();
}
}, [isPlaying, isInClipRange, trackMuted]);
// Sync volume and speed
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
audio.volume = volume;
audio.muted = muted || trackMuted;
audio.playbackRate = speed;
}, [volume, speed, muted, trackMuted]);
return (
<audio
ref={audioRef}
src={src}
className={className}
preload="auto"
controls={false}
style={{ display: "none" }} // Audio elements don't need visual representation
onContextMenu={(e) => e.preventDefault()}
/>
);
}
"use client";
import { useRef, useEffect } from "react";
import { usePlaybackStore } from "@/stores/playback-store";
interface AudioPlayerProps {
src: string;
className?: string;
clipStartTime: number;
trimStart: number;
trimEnd: number;
clipDuration: number;
trackMuted?: boolean;
}
export function AudioPlayer({
src,
className = "",
clipStartTime,
trimStart,
trimEnd,
clipDuration,
trackMuted = false,
}: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
// Calculate if we're within this clip's timeline range
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
const isInClipRange =
currentTime >= clipStartTime && currentTime < clipEndTime;
// Sync playback events
useEffect(() => {
const audio = audioRef.current;
if (!audio || !isInClipRange) return;
const handleSeekEvent = (e: CustomEvent) => {
// Always update audio time, even if outside clip range
const timelineTime = e.detail.time;
const audioTime = Math.max(
trimStart,
Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
audio.currentTime = audioTime;
};
const handleUpdateEvent = (e: CustomEvent) => {
// Always update audio time, even if outside clip range
const timelineTime = e.detail.time;
const targetTime = Math.max(
trimStart,
Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
if (Math.abs(audio.currentTime - targetTime) > 0.5) {
audio.currentTime = targetTime;
}
};
const handleSpeed = (e: CustomEvent) => {
audio.playbackRate = e.detail.speed;
};
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
window.addEventListener(
"playback-update",
handleUpdateEvent as EventListener
);
window.addEventListener("playback-speed", handleSpeed as EventListener);
return () => {
window.removeEventListener(
"playback-seek",
handleSeekEvent as EventListener
);
window.removeEventListener(
"playback-update",
handleUpdateEvent as EventListener
);
window.removeEventListener(
"playback-speed",
handleSpeed as EventListener
);
};
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
// Sync playback state
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying && isInClipRange && !trackMuted) {
audio.play().catch(() => {});
} else {
audio.pause();
}
}, [isPlaying, isInClipRange, trackMuted]);
// Sync volume and speed
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
audio.volume = volume;
audio.muted = muted || trackMuted;
audio.playbackRate = speed;
}, [volume, speed, muted, trackMuted]);
return (
<audio
ref={audioRef}
src={src}
className={className}
preload="auto"
controls={false}
style={{ display: "none" }} // Audio elements don't need visual representation
onContextMenu={(e) => e.preventDefault()}
/>
);
}

View File

@ -9,8 +9,7 @@ const buttonVariants = cva(
{
variants: {
variant: {
default:
"bg-foreground text-background shadow hover:bg-foreground/90",
default: "bg-foreground text-background shadow hover:bg-foreground/90",
primary:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:

View File

@ -8,10 +8,7 @@ const Card = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground",
className
)}
className={cn("rounded-xl border bg-card text-card-foreground", className)}
{...props}
/>
));

View File

@ -124,7 +124,7 @@ const Carousel = React.forwardRef<
<CarouselContext.Provider
value={{
carouselRef,
api: api,
api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),

View File

@ -188,7 +188,7 @@ const ChartTooltipContent = React.forwardRef<
className
)}
>
{!nestLabel ? tooltipLabel : null}
{nestLabel ? null : tooltipLabel}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
@ -328,7 +328,7 @@ function getPayloadConfigFromPayload(
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
return;
}
const payloadPayload =

View File

@ -1,11 +1,11 @@
"use client"
"use client";
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
const Collapsible = CollapsiblePrimitive.Root
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@ -1,40 +1,40 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FONT_OPTIONS, FontFamily } from "@/constants/font-constants";
interface FontPickerProps {
defaultValue?: FontFamily;
onValueChange?: (value: FontFamily) => void;
className?: string;
}
export function FontPicker({
defaultValue,
onValueChange,
className,
}: FontPickerProps) {
return (
<Select defaultValue={defaultValue} onValueChange={onValueChange}>
<SelectTrigger className={`w-full text-xs ${className || ""}`}>
<SelectValue placeholder="Select a font" />
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((font) => (
<SelectItem
key={font.value}
value={font.value}
className="text-xs"
style={{ fontFamily: font.value }}
>
{font.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FONT_OPTIONS, FontFamily } from "@/constants/font-constants";
interface FontPickerProps {
defaultValue?: FontFamily;
onValueChange?: (value: FontFamily) => void;
className?: string;
}
export function FontPicker({
defaultValue,
onValueChange,
className,
}: FontPickerProps) {
return (
<Select defaultValue={defaultValue} onValueChange={onValueChange}>
<SelectTrigger className={`w-full text-xs ${className || ""}`}>
<SelectValue placeholder="Select a font" />
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((font) => (
<SelectItem
key={font.value}
value={font.value}
className="text-xs"
style={{ fontFamily: font.value }}
>
{font.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@ -115,9 +115,7 @@ const FormControl = React.forwardRef<
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
error ? `${formDescriptionId} ${formMessageId}` : `${formDescriptionId}`
}
aria-invalid={!!error}
{...props}

View File

@ -1,102 +1,102 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import type { BackgroundType } from "@/types/editor";
interface ImageTimelineTreatmentProps {
src: string;
alt: string;
targetAspectRatio?: number; // Default to 16:9 for video
className?: string;
backgroundType?: BackgroundType;
backgroundColor?: string;
}
export function ImageTimelineTreatment({
src,
alt,
targetAspectRatio = 16 / 9,
className,
backgroundType = "blur",
backgroundColor = "#000000",
}: ImageTimelineTreatmentProps) {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageDimensions, setImageDimensions] = useState<{
width: number;
height: number;
} | null>(null);
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
setImageLoaded(true);
};
const imageAspectRatio = imageDimensions
? imageDimensions.width / imageDimensions.height
: 1;
const needsAspectRatioTreatment = imageAspectRatio !== targetAspectRatio;
return (
<div
className={cn("relative overflow-hidden", className)}
style={{ aspectRatio: targetAspectRatio }}
>
{/* Background Layer */}
{needsAspectRatioTreatment && imageLoaded && (
<>
{backgroundType === "blur" && (
<div className="absolute inset-0">
<img
src={src}
alt=""
className="w-full h-full object-cover filter blur-xl scale-110 opacity-60"
aria-hidden="true"
/>
<div className="absolute inset-0 bg-black/20" />
</div>
)}
{backgroundType === "mirror" && (
<div className="absolute inset-0">
<img
src={src}
alt=""
className="w-full h-full object-cover opacity-30"
aria-hidden="true"
/>
</div>
)}
{backgroundType === "color" && (
<div className="absolute inset-0" style={{ backgroundColor }} />
)}
</>
)}
{/* Main Image Layer */}
<div className="absolute inset-0">
<img
src={src}
alt={alt}
className="w-full h-full object-cover"
onLoad={handleImageLoad}
/>
</div>
{/* Loading state */}
{!imageLoaded && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/30">
<div className="animate-pulse text-xs text-muted-foreground">
Loading...
</div>
</div>
)}
</div>
);
}
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import type { BackgroundType } from "@/types/editor";
interface ImageTimelineTreatmentProps {
src: string;
alt: string;
targetAspectRatio?: number; // Default to 16:9 for video
className?: string;
backgroundType?: BackgroundType;
backgroundColor?: string;
}
export function ImageTimelineTreatment({
src,
alt,
targetAspectRatio = 16 / 9,
className,
backgroundType = "blur",
backgroundColor = "#000000",
}: ImageTimelineTreatmentProps) {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageDimensions, setImageDimensions] = useState<{
width: number;
height: number;
} | null>(null);
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
setImageLoaded(true);
};
const imageAspectRatio = imageDimensions
? imageDimensions.width / imageDimensions.height
: 1;
const needsAspectRatioTreatment = imageAspectRatio !== targetAspectRatio;
return (
<div
className={cn("relative overflow-hidden", className)}
style={{ aspectRatio: targetAspectRatio }}
>
{/* Background Layer */}
{needsAspectRatioTreatment && imageLoaded && (
<>
{backgroundType === "blur" && (
<div className="absolute inset-0">
<img
src={src}
alt=""
className="w-full h-full object-cover filter blur-xl scale-110 opacity-60"
aria-hidden="true"
/>
<div className="absolute inset-0 bg-black/20" />
</div>
)}
{backgroundType === "mirror" && (
<div className="absolute inset-0">
<img
src={src}
alt=""
className="w-full h-full object-cover opacity-30"
aria-hidden="true"
/>
</div>
)}
{backgroundType === "color" && (
<div className="absolute inset-0" style={{ backgroundColor }} />
)}
</>
)}
{/* Main Image Layer */}
<div className="absolute inset-0">
<img
src={src}
alt={alt}
className="w-full h-full object-cover"
onLoad={handleImageLoad}
/>
</div>
{/* Loading state */}
{!imageLoaded && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/30">
<div className="animate-pulse text-xs text-muted-foreground">
Loading...
</div>
</div>
)}
</div>
);
}

View File

@ -1,167 +1,167 @@
import * as React from "react";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import * as RPNInput from "react-phone-number-input";
import flags from "react-phone-number-input/flags";
import { Button } from "./button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "./command";
import { Input } from "./input";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
import { ScrollArea } from "./scroll-area";
import { cn } from "../../lib/utils";
type PhoneInputProps = Omit<
React.ComponentProps<"input">,
"onChange" | "value" | "ref"
> &
Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
onChange?: (value: RPNInput.Value) => void;
};
const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =
React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>(
({ className, onChange, defaultCountry = "US", ...props }, ref) => {
return (
<RPNInput.default
ref={ref}
className={cn("flex", className)}
flagComponent={FlagComponent}
countrySelectComponent={CountrySelect}
inputComponent={InputComponent}
defaultCountry={defaultCountry}
international
addInternationalOption
smartCaret={false}
/**
* Handles the onChange event.
*
* react-phone-number-input might trigger the onChange event as undefined
* when a valid phone number is not entered. To prevent this,
* the value is coerced to an empty string.
*
* @param {E164Number | undefined} value - The entered value
*/
onChange={(value) => onChange?.(value || ("" as RPNInput.Value))}
{...props}
/>
);
}
);
PhoneInput.displayName = "PhoneInput";
const InputComponent = React.forwardRef<
HTMLInputElement,
React.ComponentProps<"input">
>(({ className, ...props }, ref) => (
<Input
className={cn("rounded-e-lg rounded-s-none", className)}
{...props}
ref={ref}
/>
));
InputComponent.displayName = "InputComponent";
type CountryEntry = { label: string; value: RPNInput.Country | undefined };
type CountrySelectProps = {
disabled?: boolean;
value: RPNInput.Country;
options: CountryEntry[];
onChange: (country: RPNInput.Country) => void;
};
const CountrySelect = ({
disabled,
value: selectedCountry,
options: countryList,
onChange,
}: CountrySelectProps) => {
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="flex gap-1 rounded-e-none rounded-s-lg border-r-0 px-3 focus:z-10"
disabled={disabled}
>
<FlagComponent
country={selectedCountry}
countryName={selectedCountry}
/>
<ChevronsUpDown
className={cn(
"-mr-2 size-4 opacity-50",
disabled ? "hidden" : "opacity-100"
)}
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<ScrollArea className="h-72">
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countryList.map(({ value, label }) =>
value ? (
<CountrySelectOption
key={value}
country={value}
countryName={label}
selectedCountry={selectedCountry}
onChange={onChange}
/>
) : null
)}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
interface CountrySelectOptionProps extends RPNInput.FlagProps {
selectedCountry: RPNInput.Country;
onChange: (country: RPNInput.Country) => void;
}
const CountrySelectOption = ({
country,
countryName,
selectedCountry,
onChange,
}: CountrySelectOptionProps) => {
return (
<CommandItem className="gap-2" onSelect={() => onChange(country)}>
<FlagComponent country={country} countryName={countryName} />
<span className="flex-1 text-sm">{countryName}</span>
<span className="text-sm text-foreground/50">{`+${RPNInput.getCountryCallingCode(country)}`}</span>
<CheckIcon
className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`}
/>
</CommandItem>
);
};
const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
const Flag = flags[country];
return (
<span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20 [&_svg]:size-full">
{Flag && <Flag title={countryName} />}
</span>
);
};
export { PhoneInput };
import * as React from "react";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import * as RPNInput from "react-phone-number-input";
import flags from "react-phone-number-input/flags";
import { Button } from "./button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "./command";
import { Input } from "./input";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
import { ScrollArea } from "./scroll-area";
import { cn } from "../../lib/utils";
type PhoneInputProps = Omit<
React.ComponentProps<"input">,
"onChange" | "value" | "ref"
> &
Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
onChange?: (value: RPNInput.Value) => void;
};
const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =
React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>(
({ className, onChange, defaultCountry = "US", ...props }, ref) => {
return (
<RPNInput.default
ref={ref}
className={cn("flex", className)}
flagComponent={FlagComponent}
countrySelectComponent={CountrySelect}
inputComponent={InputComponent}
defaultCountry={defaultCountry}
international
addInternationalOption
smartCaret={false}
/**
* Handles the onChange event.
*
* react-phone-number-input might trigger the onChange event as undefined
* when a valid phone number is not entered. To prevent this,
* the value is coerced to an empty string.
*
* @param {E164Number | undefined} value - The entered value
*/
onChange={(value) => onChange?.(value || ("" as RPNInput.Value))}
{...props}
/>
);
}
);
PhoneInput.displayName = "PhoneInput";
const InputComponent = React.forwardRef<
HTMLInputElement,
React.ComponentProps<"input">
>(({ className, ...props }, ref) => (
<Input
className={cn("rounded-e-lg rounded-s-none", className)}
{...props}
ref={ref}
/>
));
InputComponent.displayName = "InputComponent";
type CountryEntry = { label: string; value: RPNInput.Country | undefined };
type CountrySelectProps = {
disabled?: boolean;
value: RPNInput.Country;
options: CountryEntry[];
onChange: (country: RPNInput.Country) => void;
};
const CountrySelect = ({
disabled,
value: selectedCountry,
options: countryList,
onChange,
}: CountrySelectProps) => {
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="flex gap-1 rounded-e-none rounded-s-lg border-r-0 px-3 focus:z-10"
disabled={disabled}
>
<FlagComponent
country={selectedCountry}
countryName={selectedCountry}
/>
<ChevronsUpDown
className={cn(
"-mr-2 size-4 opacity-50",
disabled ? "hidden" : "opacity-100"
)}
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput placeholder="Search country..." />
<CommandList>
<ScrollArea className="h-72">
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countryList.map(({ value, label }) =>
value ? (
<CountrySelectOption
key={value}
country={value}
countryName={label}
selectedCountry={selectedCountry}
onChange={onChange}
/>
) : null
)}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
interface CountrySelectOptionProps extends RPNInput.FlagProps {
selectedCountry: RPNInput.Country;
onChange: (country: RPNInput.Country) => void;
}
const CountrySelectOption = ({
country,
countryName,
selectedCountry,
onChange,
}: CountrySelectOptionProps) => {
return (
<CommandItem className="gap-2" onSelect={() => onChange(country)}>
<FlagComponent country={country} countryName={countryName} />
<span className="flex-1 text-sm">{countryName}</span>
<span className="text-sm text-foreground/50">{`+${RPNInput.getCountryCallingCode(country)}`}</span>
<CheckIcon
className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`}
/>
</CommandItem>
);
};
const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
const Flag = flags[country];
return (
<span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20 [&_svg]:size-full">
{Flag && <Flag title={countryName} />}
</span>
);
};
export { PhoneInput };

View File

@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
@ -25,7 +25,7 @@ const Separator = React.forwardRef<
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator }
export { Separator };

View File

@ -1,12 +1,12 @@
"use client"
"use client";
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
import { useTheme } from "next-themes";
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
const { theme = "system" } = useTheme();
return (
<Sonner
@ -25,7 +25,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
}}
{...props}
/>
)
}
);
};
export { Toaster }
export { Toaster };

View File

@ -14,7 +14,6 @@ export function SponsorButton({
companyName,
className = "",
}: SponsorButtonProps) {
return (
<motion.a
href={href}

View File

@ -15,20 +15,16 @@ export function Toaster() {
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
{toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
))}
<ToastViewport />
</ToastProvider>
);

View File

@ -191,7 +191,7 @@ export function isActionBound(action: Action): boolean {
export function useActionHandler<A extends Action>(
action: A,
handler: ActionFunc<A>,
isActive: MutableRefObject<boolean> | boolean | undefined = undefined
isActive: MutableRefObject<boolean> | boolean | undefined
) {
const handlerRef = useRef(handler);
const [isBound, setIsBound] = useState(false);

View File

@ -1,79 +1,79 @@
export interface FontOption {
value: string;
label: string;
category: "system" | "google" | "custom";
weights?: number[];
hasClassName?: boolean;
}
export const FONT_OPTIONS: FontOption[] = [
// System fonts (always available)
{ value: "Arial", label: "Arial", category: "system", hasClassName: false },
{
value: "Helvetica",
label: "Helvetica",
category: "system",
hasClassName: false,
},
{
value: "Times New Roman",
label: "Times New Roman",
category: "system",
hasClassName: false,
},
{
value: "Georgia",
label: "Georgia",
category: "system",
hasClassName: false,
},
// Google Fonts (loaded in layout.tsx)
{
value: "Inter",
label: "Inter",
category: "google",
weights: [400, 700],
hasClassName: true,
},
{
value: "Roboto",
label: "Roboto",
category: "google",
weights: [400, 700],
hasClassName: true,
},
{
value: "Open Sans",
label: "Open Sans",
category: "google",
hasClassName: true,
},
{
value: "Playfair Display",
label: "Playfair Display",
category: "google",
hasClassName: true,
},
{
value: "Comic Neue",
label: "Comic Neue",
category: "google",
hasClassName: false,
},
] as const;
export const DEFAULT_FONT = "Arial";
// Type-safe font family union
export type FontFamily = (typeof FONT_OPTIONS)[number]["value"];
// Helper functions
export const getFontByValue = (value: string): FontOption | undefined =>
FONT_OPTIONS.find((font) => font.value === value);
export const getGoogleFonts = (): FontOption[] =>
FONT_OPTIONS.filter((font) => font.category === "google");
export const getSystemFonts = (): FontOption[] =>
FONT_OPTIONS.filter((font) => font.category === "system");
export interface FontOption {
value: string;
label: string;
category: "system" | "google" | "custom";
weights?: number[];
hasClassName?: boolean;
}
export const FONT_OPTIONS: FontOption[] = [
// System fonts (always available)
{ value: "Arial", label: "Arial", category: "system", hasClassName: false },
{
value: "Helvetica",
label: "Helvetica",
category: "system",
hasClassName: false,
},
{
value: "Times New Roman",
label: "Times New Roman",
category: "system",
hasClassName: false,
},
{
value: "Georgia",
label: "Georgia",
category: "system",
hasClassName: false,
},
// Google Fonts (loaded in layout.tsx)
{
value: "Inter",
label: "Inter",
category: "google",
weights: [400, 700],
hasClassName: true,
},
{
value: "Roboto",
label: "Roboto",
category: "google",
weights: [400, 700],
hasClassName: true,
},
{
value: "Open Sans",
label: "Open Sans",
category: "google",
hasClassName: true,
},
{
value: "Playfair Display",
label: "Playfair Display",
category: "google",
hasClassName: true,
},
{
value: "Comic Neue",
label: "Comic Neue",
category: "google",
hasClassName: false,
},
] as const;
export const DEFAULT_FONT = "Arial";
// Type-safe font family union
export type FontFamily = (typeof FONT_OPTIONS)[number]["value"];
// Helper functions
export const getFontByValue = (value: string): FontOption | undefined =>
FONT_OPTIONS.find((font) => font.value === value);
export const getGoogleFonts = (): FontOption[] =>
FONT_OPTIONS.filter((font) => font.category === "google");
export const getSystemFonts = (): FontOption[] =>
FONT_OPTIONS.filter((font) => font.category === "system");

View File

@ -1,11 +1,11 @@
export const SITE_URL = "https://opencut.app";
export const SITE_INFO = {
title: "OpenCut",
description:
"A simple but powerful video editor that gets the job done. In your browser.",
url: SITE_URL,
openGraphImage: "/open-graph/default.jpg",
twitterImage: "/open-graph/default.jpg",
favicon: "/favicon.ico",
};
title: "OpenCut",
description:
"A simple but powerful video editor that gets the job done. In your browser.",
url: SITE_URL,
openGraphImage: "/open-graph/default.jpg",
twitterImage: "/open-graph/default.jpg",
favicon: "/favicon.ico",
};

View File

@ -1,244 +1,244 @@
export const colors = [
"#ffffff",
"#000000",
"#ffe2e2",
"#ffc9c9",
"#ffa2a2",
"#ff6467",
"#fb2c36",
"#e7000b",
"#c10007",
"#9f0712",
"#82181a",
"#460809",
"#fff7ed",
"#ffedd4",
"#ffd6a7",
"#ffb86a",
"#ff8904",
"#ff6900",
"#f54900",
"#ca3500",
"#9f2d00",
"#7e2a0c",
"#441306",
"#fffbeb",
"#fef3c6",
"#fee685",
"#ffd230",
"#ffb900",
"#fe9a00",
"#e17100",
"#bb4d00",
"#973c00",
"#7b3306",
"#461901",
"#fefce8",
"#fef9c2",
"#fff085",
"#ffdf20",
"#fdc700",
"#f0b100",
"#d08700",
"#a65f00",
"#894b00",
"#733e0a",
"#432004",
"#f7fee7",
"#ecfcca",
"#d8f999",
"#bbf451",
"#9ae600",
"#7ccf00",
"#5ea500",
"#497d00",
"#3c6300",
"#35530e",
"#192e03",
"#f0fdf4",
"#dcfce7",
"#b9f8cf",
"#7bf1a8",
"#05df72",
"#00c950",
"#00a63e",
"#008236",
"#016630",
"#0d542b",
"#032e15",
"#ecfdf5",
"#d0fae5",
"#a4f4cf",
"#5ee9b5",
"#00d492",
"#00bc7d",
"#009966",
"#007a55",
"#006045",
"#004f3b",
"#002c22",
"#f0fdfa",
"#cbfbf1",
"#96f7e4",
"#46ecd5",
"#00d5be",
"#00bba7",
"#009689",
"#00786f",
"#005f5a",
"#0b4f4a",
"#022f2e",
"#ecfeff",
"#cefafe",
"#a2f4fd",
"#53eafd",
"#00d3f2",
"#00b8db",
"#0092b8",
"#007595",
"#005f78",
"#104e64",
"#053345",
"#f0f9ff",
"#dff2fe",
"#b8e6fe",
"#74d4ff",
"#00bcff",
"#00a6f4",
"#0084d1",
"#0069a8",
"#00598a",
"#024a70",
"#052f4a",
"#eff6ff",
"#dbeafe",
"#bedbff",
"#8ec5ff",
"#51a2ff",
"#2b7fff",
"#155dfc",
"#1447e6",
"#193cb8",
"#1c398e",
"#162456",
"#eef2ff",
"#e0e7ff",
"#c6d2ff",
"#a3b3ff",
"#7c86ff",
"#615fff",
"#4f39f6",
"#432dd7",
"#372aac",
"#312c85",
"#1e1a4d",
"#f5f3ff",
"#ede9fe",
"#ddd6ff",
"#c4b4ff",
"#a684ff",
"#8e51ff",
"#7f22fe",
"#7008e7",
"#5d0ec0",
"#4d179a",
"#2f0d68",
"#faf5ff",
"#f3e8ff",
"#e9d4ff",
"#dab2ff",
"#c27aff",
"#ad46ff",
"#9810fa",
"#8200db",
"#6e11b0",
"#59168b",
"#3c0366",
"#fdf4ff",
"#fae8ff",
"#f6cfff",
"#f4a8ff",
"#ed6aff",
"#e12afb",
"#c800de",
"#a800b7",
"#8a0194",
"#721378",
"#4b004f",
"#fdf2f8",
"#fce7f3",
"#fccee8",
"#fda5d5",
"#fb64b6",
"#f6339a",
"#e60076",
"#c6005c",
"#a3004c",
"#861043",
"#510424",
"#fff1f2",
"#ffe4e6",
"#ffccd3",
"#ffa1ad",
"#ff637e",
"#ff2056",
"#ec003f",
"#c70036",
"#a50036",
"#8b0836",
"#4d0218",
"#f8fafc",
"#f1f5f9",
"#e2e8f0",
"#cad5e2",
"#90a1b9",
"#62748e",
"#45556c",
"#314158",
"#1d293d",
"#0f172b",
"#020618",
"#f9fafb",
"#f3f4f6",
"#e5e7eb",
"#d1d5dc",
"#99a1af",
"#6a7282",
"#4a5565",
"#364153",
"#1e2939",
"#101828",
"#030712",
"#fafafa",
"#f4f4f5",
"#e4e4e7",
"#d4d4d8",
"#9f9fa9",
"#71717b",
"#52525c",
"#3f3f46",
"#27272a",
"#18181b",
"#09090b",
"#f5f5f5",
"#e5e5e5",
"#d4d4d4",
"#a1a1a1",
"#737373",
"#525252",
"#404040",
"#262626",
"#171717",
"#0a0a0a",
"#fafaf9",
"#f5f5f4",
"#e7e5e4",
"#d6d3d1",
"#a6a09b",
"#79716b",
"#57534d",
"#44403b",
"#292524",
"#1c1917",
"#0c0a09",
];
export const colors = [
"#ffffff",
"#000000",
"#ffe2e2",
"#ffc9c9",
"#ffa2a2",
"#ff6467",
"#fb2c36",
"#e7000b",
"#c10007",
"#9f0712",
"#82181a",
"#460809",
"#fff7ed",
"#ffedd4",
"#ffd6a7",
"#ffb86a",
"#ff8904",
"#ff6900",
"#f54900",
"#ca3500",
"#9f2d00",
"#7e2a0c",
"#441306",
"#fffbeb",
"#fef3c6",
"#fee685",
"#ffd230",
"#ffb900",
"#fe9a00",
"#e17100",
"#bb4d00",
"#973c00",
"#7b3306",
"#461901",
"#fefce8",
"#fef9c2",
"#fff085",
"#ffdf20",
"#fdc700",
"#f0b100",
"#d08700",
"#a65f00",
"#894b00",
"#733e0a",
"#432004",
"#f7fee7",
"#ecfcca",
"#d8f999",
"#bbf451",
"#9ae600",
"#7ccf00",
"#5ea500",
"#497d00",
"#3c6300",
"#35530e",
"#192e03",
"#f0fdf4",
"#dcfce7",
"#b9f8cf",
"#7bf1a8",
"#05df72",
"#00c950",
"#00a63e",
"#008236",
"#016630",
"#0d542b",
"#032e15",
"#ecfdf5",
"#d0fae5",
"#a4f4cf",
"#5ee9b5",
"#00d492",
"#00bc7d",
"#009966",
"#007a55",
"#006045",
"#004f3b",
"#002c22",
"#f0fdfa",
"#cbfbf1",
"#96f7e4",
"#46ecd5",
"#00d5be",
"#00bba7",
"#009689",
"#00786f",
"#005f5a",
"#0b4f4a",
"#022f2e",
"#ecfeff",
"#cefafe",
"#a2f4fd",
"#53eafd",
"#00d3f2",
"#00b8db",
"#0092b8",
"#007595",
"#005f78",
"#104e64",
"#053345",
"#f0f9ff",
"#dff2fe",
"#b8e6fe",
"#74d4ff",
"#00bcff",
"#00a6f4",
"#0084d1",
"#0069a8",
"#00598a",
"#024a70",
"#052f4a",
"#eff6ff",
"#dbeafe",
"#bedbff",
"#8ec5ff",
"#51a2ff",
"#2b7fff",
"#155dfc",
"#1447e6",
"#193cb8",
"#1c398e",
"#162456",
"#eef2ff",
"#e0e7ff",
"#c6d2ff",
"#a3b3ff",
"#7c86ff",
"#615fff",
"#4f39f6",
"#432dd7",
"#372aac",
"#312c85",
"#1e1a4d",
"#f5f3ff",
"#ede9fe",
"#ddd6ff",
"#c4b4ff",
"#a684ff",
"#8e51ff",
"#7f22fe",
"#7008e7",
"#5d0ec0",
"#4d179a",
"#2f0d68",
"#faf5ff",
"#f3e8ff",
"#e9d4ff",
"#dab2ff",
"#c27aff",
"#ad46ff",
"#9810fa",
"#8200db",
"#6e11b0",
"#59168b",
"#3c0366",
"#fdf4ff",
"#fae8ff",
"#f6cfff",
"#f4a8ff",
"#ed6aff",
"#e12afb",
"#c800de",
"#a800b7",
"#8a0194",
"#721378",
"#4b004f",
"#fdf2f8",
"#fce7f3",
"#fccee8",
"#fda5d5",
"#fb64b6",
"#f6339a",
"#e60076",
"#c6005c",
"#a3004c",
"#861043",
"#510424",
"#fff1f2",
"#ffe4e6",
"#ffccd3",
"#ffa1ad",
"#ff637e",
"#ff2056",
"#ec003f",
"#c70036",
"#a50036",
"#8b0836",
"#4d0218",
"#f8fafc",
"#f1f5f9",
"#e2e8f0",
"#cad5e2",
"#90a1b9",
"#62748e",
"#45556c",
"#314158",
"#1d293d",
"#0f172b",
"#020618",
"#f9fafb",
"#f3f4f6",
"#e5e7eb",
"#d1d5dc",
"#99a1af",
"#6a7282",
"#4a5565",
"#364153",
"#1e2939",
"#101828",
"#030712",
"#fafafa",
"#f4f4f5",
"#e4e4e7",
"#d4d4d8",
"#9f9fa9",
"#71717b",
"#52525c",
"#3f3f46",
"#27272a",
"#18181b",
"#09090b",
"#f5f5f5",
"#e5e5e5",
"#d4d4d4",
"#a1a1a1",
"#737373",
"#525252",
"#404040",
"#262626",
"#171717",
"#0a0a0a",
"#fafaf9",
"#f5f5f4",
"#e7e5e4",
"#d6d3d1",
"#a6a09b",
"#79716b",
"#57534d",
"#44403b",
"#292524",
"#1c1917",
"#0c0a09",
];

View File

@ -3,58 +3,58 @@ import { useRouter } from "next/navigation";
import { signIn } from "@opencut/auth/client";
export function useLogin() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const handleLogin = useCallback(async () => {
setError(null);
setIsEmailLoading(true);
const handleLogin = useCallback(async () => {
setError(null);
setIsEmailLoading(true);
const { error } = await signIn.email({
email,
password,
});
const { error } = await signIn.email({
email,
password,
});
if (error) {
setError(error.message || "An unexpected error occurred.");
setIsEmailLoading(false);
return;
}
if (error) {
setError(error.message || "An unexpected error occurred.");
setIsEmailLoading(false);
return;
}
router.push("/projects");
}, [router, email, password]);
router.push("/projects");
}, [router, email, password]);
const handleGoogleLogin = async () => {
setError(null);
setIsGoogleLoading(true);
const handleGoogleLogin = async () => {
setError(null);
setIsGoogleLoading(true);
try {
await signIn.social({
provider: "google",
callbackURL: "/projects",
});
} catch (error) {
setError("Failed to sign in with Google. Please try again.");
setIsGoogleLoading(false);
}
};
try {
await signIn.social({
provider: "google",
callbackURL: "/projects",
});
} catch (error) {
setError("Failed to sign in with Google. Please try again.");
setIsGoogleLoading(false);
}
};
const isAnyLoading = isEmailLoading || isGoogleLoading;
const isAnyLoading = isEmailLoading || isGoogleLoading;
return {
email,
setEmail,
password,
setPassword,
error,
isEmailLoading,
isGoogleLoading,
isAnyLoading,
handleLogin,
handleGoogleLogin,
};
return {
email,
setEmail,
password,
setPassword,
error,
isEmailLoading,
isGoogleLoading,
isAnyLoading,
handleLogin,
handleGoogleLogin,
};
}

View File

@ -3,63 +3,63 @@ import { useRouter } from "next/navigation";
import { signUp, signIn } from "@opencut/auth/client";
export function useSignUp() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const handleSignUp = useCallback(async () => {
setError(null);
setIsEmailLoading(true);
const handleSignUp = useCallback(async () => {
setError(null);
setIsEmailLoading(true);
const { error } = await signUp.email({
name,
email,
password,
});
const { error } = await signUp.email({
name,
email,
password,
});
if (error) {
setError(error.message || "An unexpected error occurred.");
setIsEmailLoading(false);
return;
}
if (error) {
setError(error.message || "An unexpected error occurred.");
setIsEmailLoading(false);
return;
}
router.push("/login");
}, [name, email, password, router]);
router.push("/login");
}, [name, email, password, router]);
const handleGoogleSignUp = useCallback(async () => {
setError(null);
setIsGoogleLoading(true);
const handleGoogleSignUp = useCallback(async () => {
setError(null);
setIsGoogleLoading(true);
try {
await signIn.social({
provider: "google",
});
try {
await signIn.social({
provider: "google",
});
router.push("/editor");
} catch (error) {
setError("Failed to sign up with Google. Please try again.");
setIsGoogleLoading(false);
}
}, [router]);
router.push("/editor");
} catch (error) {
setError("Failed to sign up with Google. Please try again.");
setIsGoogleLoading(false);
}
}, [router]);
const isAnyLoading = isEmailLoading || isGoogleLoading;
const isAnyLoading = isEmailLoading || isGoogleLoading;
return {
name,
setName,
email,
setEmail,
password,
setPassword,
error,
isEmailLoading,
isGoogleLoading,
isAnyLoading,
handleSignUp,
handleGoogleSignUp,
};
}
return {
name,
setName,
email,
setEmail,
password,
setPassword,
error,
isEmailLoading,
isGoogleLoading,
isAnyLoading,
handleSignUp,
handleGoogleSignUp,
};
}

View File

@ -1,92 +1,92 @@
import { useEditorStore } from "@/stores/editor-store";
import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
export function useAspectRatio() {
const { canvasSize, canvasMode, canvasPresets } = useEditorStore();
const { mediaItems } = useMediaStore();
const { tracks } = useTimelineStore();
// Find the current preset based on canvas size
const currentPreset = canvasPresets.find(
(preset) =>
preset.width === canvasSize.width && preset.height === canvasSize.height
);
// Get the original aspect ratio from the first video/image in timeline
const getOriginalAspectRatio = (): number => {
// Find first video or image in timeline
for (const track of tracks) {
for (const element of track.elements) {
if (element.type === "media") {
const mediaItem = mediaItems.find(
(item) => item.id === element.mediaId
);
if (
mediaItem &&
(mediaItem.type === "video" || mediaItem.type === "image")
) {
return getMediaAspectRatio(mediaItem);
}
}
}
}
return 16 / 9; // Default aspect ratio
};
// Get current aspect ratio
const getCurrentAspectRatio = (): number => {
return canvasSize.width / canvasSize.height;
};
// Format aspect ratio as a readable string
const formatAspectRatio = (aspectRatio: number): string => {
// Check if it matches a common aspect ratio
const ratios = [
{ ratio: 16 / 9, label: "16:9" },
{ ratio: 9 / 16, label: "9:16" },
{ ratio: 1, label: "1:1" },
{ ratio: 4 / 3, label: "4:3" },
{ ratio: 3 / 4, label: "3:4" },
{ ratio: 21 / 9, label: "21:9" },
];
for (const { ratio, label } of ratios) {
if (Math.abs(aspectRatio - ratio) < 0.01) {
return label;
}
}
// If not a common ratio, format as decimal
return aspectRatio.toFixed(2);
};
// Check if current mode is "Original"
const isOriginal = canvasMode === "original";
// Get display name for current aspect ratio
const getDisplayName = (): string => {
// If explicitly set to original mode, always show "Original"
if (canvasMode === "original") {
return "Original";
}
if (currentPreset) {
return currentPreset.name;
}
return formatAspectRatio(getCurrentAspectRatio());
};
return {
currentPreset,
canvasMode,
isOriginal,
getCurrentAspectRatio,
getOriginalAspectRatio,
formatAspectRatio,
getDisplayName,
canvasSize,
canvasPresets,
};
}
import { useEditorStore } from "@/stores/editor-store";
import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
export function useAspectRatio() {
const { canvasSize, canvasMode, canvasPresets } = useEditorStore();
const { mediaItems } = useMediaStore();
const { tracks } = useTimelineStore();
// Find the current preset based on canvas size
const currentPreset = canvasPresets.find(
(preset) =>
preset.width === canvasSize.width && preset.height === canvasSize.height
);
// Get the original aspect ratio from the first video/image in timeline
const getOriginalAspectRatio = (): number => {
// Find first video or image in timeline
for (const track of tracks) {
for (const element of track.elements) {
if (element.type === "media") {
const mediaItem = mediaItems.find(
(item) => item.id === element.mediaId
);
if (
mediaItem &&
(mediaItem.type === "video" || mediaItem.type === "image")
) {
return getMediaAspectRatio(mediaItem);
}
}
}
}
return 16 / 9; // Default aspect ratio
};
// Get current aspect ratio
const getCurrentAspectRatio = (): number => {
return canvasSize.width / canvasSize.height;
};
// Format aspect ratio as a readable string
const formatAspectRatio = (aspectRatio: number): string => {
// Check if it matches a common aspect ratio
const ratios = [
{ ratio: 16 / 9, label: "16:9" },
{ ratio: 9 / 16, label: "9:16" },
{ ratio: 1, label: "1:1" },
{ ratio: 4 / 3, label: "4:3" },
{ ratio: 3 / 4, label: "3:4" },
{ ratio: 21 / 9, label: "21:9" },
];
for (const { ratio, label } of ratios) {
if (Math.abs(aspectRatio - ratio) < 0.01) {
return label;
}
}
// If not a common ratio, format as decimal
return aspectRatio.toFixed(2);
};
// Check if current mode is "Original"
const isOriginal = canvasMode === "original";
// Get display name for current aspect ratio
const getDisplayName = (): string => {
// If explicitly set to original mode, always show "Original"
if (canvasMode === "original") {
return "Original";
}
if (currentPreset) {
return currentPreset.name;
}
return formatAspectRatio(getCurrentAspectRatio());
};
return {
currentPreset,
canvasMode,
isOriginal,
getCurrentAspectRatio,
getOriginalAspectRatio,
formatAspectRatio,
getDisplayName,
canvasSize,
canvasPresets,
};
}

View File

@ -1,85 +1,85 @@
import { useState, useRef } from "react";
interface UseDragDropOptions {
onDrop?: (files: FileList) => void;
}
// Helper function to check if drag contains files from external sources (not internal app drags)
const containsFiles = (dataTransfer: DataTransfer): boolean => {
// Check if this is an internal app drag (media item)
if (dataTransfer.types.includes("application/x-media-item")) {
return false;
}
// Only show overlay for external file drags
return dataTransfer.types.includes("Files");
};
export function useDragDrop(options: UseDragDropOptions = {}) {
const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0);
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
// Only handle external file drags, not internal app element drags
if (!containsFiles(e.dataTransfer)) {
return;
}
dragCounterRef.current += 1;
if (!isDragOver) {
setIsDragOver(true);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
// Only handle file drags
if (!containsFiles(e.dataTransfer)) {
return;
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
// Only handle file drags
if (!containsFiles(e.dataTransfer)) {
return;
}
dragCounterRef.current -= 1;
if (dragCounterRef.current === 0) {
setIsDragOver(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
dragCounterRef.current = 0;
// Only handle file drops
if (
options.onDrop &&
e.dataTransfer.files &&
containsFiles(e.dataTransfer)
) {
options.onDrop(e.dataTransfer.files);
}
};
const dragProps = {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
};
return {
isDragOver,
dragProps,
};
}
import { useState, useRef } from "react";
interface UseDragDropOptions {
onDrop?: (files: FileList) => void;
}
// Helper function to check if drag contains files from external sources (not internal app drags)
const containsFiles = (dataTransfer: DataTransfer): boolean => {
// Check if this is an internal app drag (media item)
if (dataTransfer.types.includes("application/x-media-item")) {
return false;
}
// Only show overlay for external file drags
return dataTransfer.types.includes("Files");
};
export function useDragDrop(options: UseDragDropOptions = {}) {
const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0);
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
// Only handle external file drags, not internal app element drags
if (!containsFiles(e.dataTransfer)) {
return;
}
dragCounterRef.current += 1;
if (!isDragOver) {
setIsDragOver(true);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
// Only handle file drags
if (!containsFiles(e.dataTransfer)) {
return;
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
// Only handle file drags
if (!containsFiles(e.dataTransfer)) {
return;
}
dragCounterRef.current -= 1;
if (dragCounterRef.current === 0) {
setIsDragOver(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
dragCounterRef.current = 0;
// Only handle file drops
if (
options.onDrop &&
e.dataTransfer.files &&
containsFiles(e.dataTransfer)
) {
options.onDrop(e.dataTransfer.files);
}
};
const dragProps = {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
};
return {
isDragOver,
dragProps,
};
}

View File

@ -25,7 +25,7 @@ export function useKeybindingsListener() {
ev.preventDefault();
// Handle actions with default arguments
let actionArgs: any = undefined;
let actionArgs: any;
if (boundAction === "seek-forward") {
actionArgs = { seconds: 1 };

View File

@ -1,19 +1,21 @@
import * as React from "react"
import * as React from "react";
const MOBILE_BREAKPOINT = 768
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile
return !!isMobile;
}

View File

@ -1,199 +1,199 @@
import { useState, useEffect, useCallback } from "react";
interface UseSelectionBoxProps {
containerRef: React.RefObject<HTMLElement>;
playheadRef?: React.RefObject<HTMLElement>;
onSelectionComplete: (
elements: { trackId: string; elementId: string }[]
) => void;
isEnabled?: boolean;
}
interface SelectionBoxState {
startPos: { x: number; y: number };
currentPos: { x: number; y: number };
isActive: boolean;
}
export function useSelectionBox({
containerRef,
playheadRef,
onSelectionComplete,
isEnabled = true,
}: UseSelectionBoxProps) {
const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
null
);
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
// Mouse down handler to start selection
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!isEnabled) return;
// Only start selection on empty space clicks
if ((e.target as HTMLElement).closest(".timeline-element")) {
return;
}
if (playheadRef?.current?.contains(e.target as Node)) {
return;
}
if ((e.target as HTMLElement).closest("[data-track-labels]")) {
return;
}
// Don't start selection when clicking in the ruler area - this interferes with playhead dragging
if ((e.target as HTMLElement).closest("[data-ruler-area]")) {
return;
}
setSelectionBox({
startPos: { x: e.clientX, y: e.clientY },
currentPos: { x: e.clientX, y: e.clientY },
isActive: false, // Will become active when mouse moves
});
},
[isEnabled, playheadRef]
);
// Function to select elements within the selection box
const selectElementsInBox = useCallback(
(startPos: { x: number; y: number }, endPos: { x: number; y: number }) => {
if (!containerRef.current) return;
const container = containerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculate selection rectangle in container coordinates
const startX = startPos.x - containerRect.left;
const startY = startPos.y - containerRect.top;
const endX = endPos.x - containerRect.left;
const endY = endPos.y - containerRect.top;
const selectionRect = {
left: Math.min(startX, endX),
top: Math.min(startY, endY),
right: Math.max(startX, endX),
bottom: Math.max(startY, endY),
};
// Find all timeline elements within the selection rectangle
const timelineElements = container.querySelectorAll(".timeline-element");
const selectedElements: { trackId: string; elementId: string }[] = [];
timelineElements.forEach((element) => {
const elementRect = element.getBoundingClientRect();
// Use absolute coordinates for more accurate intersection detection
const elementAbsolute = {
left: elementRect.left,
top: elementRect.top,
right: elementRect.right,
bottom: elementRect.bottom,
};
const selectionAbsolute = {
left: startPos.x,
top: startPos.y,
right: endPos.x,
bottom: endPos.y,
};
// Normalize selection rectangle (handle dragging in any direction)
const normalizedSelection = {
left: Math.min(selectionAbsolute.left, selectionAbsolute.right),
top: Math.min(selectionAbsolute.top, selectionAbsolute.bottom),
right: Math.max(selectionAbsolute.left, selectionAbsolute.right),
bottom: Math.max(selectionAbsolute.top, selectionAbsolute.bottom),
};
const elementId = element.getAttribute("data-element-id");
const trackId = element.getAttribute("data-track-id");
// Check if element intersects with selection rectangle (any overlap)
// Using absolute coordinates for more precise detection
const intersects = !(
elementAbsolute.right < normalizedSelection.left ||
elementAbsolute.left > normalizedSelection.right ||
elementAbsolute.bottom < normalizedSelection.top ||
elementAbsolute.top > normalizedSelection.bottom
);
if (intersects && elementId && trackId) {
selectedElements.push({ trackId, elementId });
}
});
// Always call the callback - with elements or empty array to clear selection
console.log(
JSON.stringify({ selectElementsInBox: selectedElements.length })
);
onSelectionComplete(selectedElements);
},
[containerRef, onSelectionComplete]
);
// Effect to track selection box movement
useEffect(() => {
if (!selectionBox) return;
const handleMouseMove = (e: MouseEvent) => {
const deltaX = Math.abs(e.clientX - selectionBox.startPos.x);
const deltaY = Math.abs(e.clientY - selectionBox.startPos.y);
// Start selection if mouse moved more than 5px
const shouldActivate = deltaX > 5 || deltaY > 5;
const newSelectionBox = {
...selectionBox,
currentPos: { x: e.clientX, y: e.clientY },
isActive: shouldActivate || selectionBox.isActive,
};
setSelectionBox(newSelectionBox);
// Real-time visual feedback: update selection as we drag
if (newSelectionBox.isActive) {
selectElementsInBox(
newSelectionBox.startPos,
newSelectionBox.currentPos
);
}
};
const handleMouseUp = () => {
console.log(
JSON.stringify({ mouseUp: { wasActive: selectionBox?.isActive } })
);
// If we had an active selection, mark that we just finished selecting
if (selectionBox?.isActive) {
console.log(JSON.stringify({ settingJustFinishedSelecting: true }));
setJustFinishedSelecting(true);
// Clear the flag after a short delay to allow click events to check it
setTimeout(() => {
console.log(JSON.stringify({ clearingJustFinishedSelecting: true }));
setJustFinishedSelecting(false);
}, 50);
}
// Don't call selectElementsInBox again - real-time selection already handled it
// Just clean up the selection box visual
setSelectionBox(null);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [selectionBox, selectElementsInBox]);
return {
selectionBox,
handleMouseDown,
isSelecting: selectionBox?.isActive || false,
justFinishedSelecting,
};
}
import { useState, useEffect, useCallback } from "react";
interface UseSelectionBoxProps {
containerRef: React.RefObject<HTMLElement>;
playheadRef?: React.RefObject<HTMLElement>;
onSelectionComplete: (
elements: { trackId: string; elementId: string }[]
) => void;
isEnabled?: boolean;
}
interface SelectionBoxState {
startPos: { x: number; y: number };
currentPos: { x: number; y: number };
isActive: boolean;
}
export function useSelectionBox({
containerRef,
playheadRef,
onSelectionComplete,
isEnabled = true,
}: UseSelectionBoxProps) {
const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
null
);
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
// Mouse down handler to start selection
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!isEnabled) return;
// Only start selection on empty space clicks
if ((e.target as HTMLElement).closest(".timeline-element")) {
return;
}
if (playheadRef?.current?.contains(e.target as Node)) {
return;
}
if ((e.target as HTMLElement).closest("[data-track-labels]")) {
return;
}
// Don't start selection when clicking in the ruler area - this interferes with playhead dragging
if ((e.target as HTMLElement).closest("[data-ruler-area]")) {
return;
}
setSelectionBox({
startPos: { x: e.clientX, y: e.clientY },
currentPos: { x: e.clientX, y: e.clientY },
isActive: false, // Will become active when mouse moves
});
},
[isEnabled, playheadRef]
);
// Function to select elements within the selection box
const selectElementsInBox = useCallback(
(startPos: { x: number; y: number }, endPos: { x: number; y: number }) => {
if (!containerRef.current) return;
const container = containerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculate selection rectangle in container coordinates
const startX = startPos.x - containerRect.left;
const startY = startPos.y - containerRect.top;
const endX = endPos.x - containerRect.left;
const endY = endPos.y - containerRect.top;
const selectionRect = {
left: Math.min(startX, endX),
top: Math.min(startY, endY),
right: Math.max(startX, endX),
bottom: Math.max(startY, endY),
};
// Find all timeline elements within the selection rectangle
const timelineElements = container.querySelectorAll(".timeline-element");
const selectedElements: { trackId: string; elementId: string }[] = [];
timelineElements.forEach((element) => {
const elementRect = element.getBoundingClientRect();
// Use absolute coordinates for more accurate intersection detection
const elementAbsolute = {
left: elementRect.left,
top: elementRect.top,
right: elementRect.right,
bottom: elementRect.bottom,
};
const selectionAbsolute = {
left: startPos.x,
top: startPos.y,
right: endPos.x,
bottom: endPos.y,
};
// Normalize selection rectangle (handle dragging in any direction)
const normalizedSelection = {
left: Math.min(selectionAbsolute.left, selectionAbsolute.right),
top: Math.min(selectionAbsolute.top, selectionAbsolute.bottom),
right: Math.max(selectionAbsolute.left, selectionAbsolute.right),
bottom: Math.max(selectionAbsolute.top, selectionAbsolute.bottom),
};
const elementId = element.getAttribute("data-element-id");
const trackId = element.getAttribute("data-track-id");
// Check if element intersects with selection rectangle (any overlap)
// Using absolute coordinates for more precise detection
const intersects = !(
elementAbsolute.right < normalizedSelection.left ||
elementAbsolute.left > normalizedSelection.right ||
elementAbsolute.bottom < normalizedSelection.top ||
elementAbsolute.top > normalizedSelection.bottom
);
if (intersects && elementId && trackId) {
selectedElements.push({ trackId, elementId });
}
});
// Always call the callback - with elements or empty array to clear selection
console.log(
JSON.stringify({ selectElementsInBox: selectedElements.length })
);
onSelectionComplete(selectedElements);
},
[containerRef, onSelectionComplete]
);
// Effect to track selection box movement
useEffect(() => {
if (!selectionBox) return;
const handleMouseMove = (e: MouseEvent) => {
const deltaX = Math.abs(e.clientX - selectionBox.startPos.x);
const deltaY = Math.abs(e.clientY - selectionBox.startPos.y);
// Start selection if mouse moved more than 5px
const shouldActivate = deltaX > 5 || deltaY > 5;
const newSelectionBox = {
...selectionBox,
currentPos: { x: e.clientX, y: e.clientY },
isActive: shouldActivate || selectionBox.isActive,
};
setSelectionBox(newSelectionBox);
// Real-time visual feedback: update selection as we drag
if (newSelectionBox.isActive) {
selectElementsInBox(
newSelectionBox.startPos,
newSelectionBox.currentPos
);
}
};
const handleMouseUp = () => {
console.log(
JSON.stringify({ mouseUp: { wasActive: selectionBox?.isActive } })
);
// If we had an active selection, mark that we just finished selecting
if (selectionBox?.isActive) {
console.log(JSON.stringify({ settingJustFinishedSelecting: true }));
setJustFinishedSelecting(true);
// Clear the flag after a short delay to allow click events to check it
setTimeout(() => {
console.log(JSON.stringify({ clearingJustFinishedSelecting: true }));
setJustFinishedSelecting(false);
}, 50);
}
// Don't call selectElementsInBox again - real-time selection already handled it
// Just clean up the selection box visual
setSelectionBox(null);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [selectionBox, selectElementsInBox]);
return {
selectionBox,
handleMouseDown,
isSelecting: selectionBox?.isActive || false,
justFinishedSelecting,
};
}

View File

@ -1,157 +1,157 @@
import { snapTimeToFrame } from "@/constants/timeline-constants";
import { useProjectStore } from "@/stores/project-store";
import { useState, useEffect, useCallback } from "react";
interface UseTimelinePlayheadProps {
currentTime: number;
duration: number;
zoomLevel: number;
seek: (time: number) => void;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
}
export function useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
}: UseTimelinePlayheadProps) {
// Playhead scrubbing state
const [isScrubbing, setIsScrubbing] = useState(false);
const [scrubTime, setScrubTime] = useState<number | null>(null);
// Ruler drag detection state
const [isDraggingRuler, setIsDraggingRuler] = useState(false);
const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
const playheadPosition =
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
// --- Playhead Scrubbing Handlers ---
const handlePlayheadMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation(); // Prevent ruler drag from triggering
setIsScrubbing(true);
handleScrub(e);
},
[duration, zoomLevel]
);
// Ruler mouse down handler
const handleRulerMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only handle left mouse button
if (e.button !== 0) return;
// Don't interfere if clicking on the playhead itself
if (playheadRef?.current?.contains(e.target as Node)) return;
e.preventDefault();
setIsDraggingRuler(true);
setHasDraggedRuler(false);
// Start scrubbing immediately
setIsScrubbing(true);
handleScrub(e);
},
[duration, zoomLevel]
);
const handleScrub = useCallback(
(e: MouseEvent | React.MouseEvent) => {
const ruler = rulerRef.current;
if (!ruler) return;
const rect = ruler.getBoundingClientRect();
const x = e.clientX - rect.left;
const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
// Use frame snapping for playhead scrubbing
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
const time = snapTimeToFrame(rawTime, projectFps);
setScrubTime(time);
seek(time); // update video preview in real time
},
[duration, zoomLevel, seek, rulerRef]
);
// Mouse move/up event handlers
useEffect(() => {
if (!isScrubbing) return;
const onMouseMove = (e: MouseEvent) => {
handleScrub(e);
// Mark that we've dragged if ruler drag is active
if (isDraggingRuler) {
setHasDraggedRuler(true);
}
};
const onMouseUp = (e: MouseEvent) => {
setIsScrubbing(false);
if (scrubTime !== null) seek(scrubTime); // finalize seek
setScrubTime(null);
// Handle ruler click vs drag
if (isDraggingRuler) {
setIsDraggingRuler(false);
// If we didn't drag, treat it as a click-to-seek
if (!hasDraggedRuler) {
handleScrub(e);
}
setHasDraggedRuler(false);
}
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, [
isScrubbing,
scrubTime,
seek,
handleScrub,
isDraggingRuler,
hasDraggedRuler,
]);
// --- Playhead auto-scroll effect ---
useEffect(() => {
const rulerViewport = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
if (!rulerViewport || !tracksViewport) return;
const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
const viewportWidth = rulerViewport.clientWidth;
const scrollMin = 0;
const scrollMax = rulerViewport.scrollWidth - viewportWidth;
// Center the playhead if it's not visible (100px buffer)
const desiredScroll = Math.max(
scrollMin,
Math.min(scrollMax, playheadPx - viewportWidth / 2)
);
if (
playheadPx < rulerViewport.scrollLeft + 100 ||
playheadPx > rulerViewport.scrollLeft + viewportWidth - 100
) {
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
}
}, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]);
return {
playheadPosition,
handlePlayheadMouseDown,
handleRulerMouseDown,
isDraggingRuler,
};
}
import { snapTimeToFrame } from "@/constants/timeline-constants";
import { useProjectStore } from "@/stores/project-store";
import { useState, useEffect, useCallback } from "react";
interface UseTimelinePlayheadProps {
currentTime: number;
duration: number;
zoomLevel: number;
seek: (time: number) => void;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
}
export function useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
}: UseTimelinePlayheadProps) {
// Playhead scrubbing state
const [isScrubbing, setIsScrubbing] = useState(false);
const [scrubTime, setScrubTime] = useState<number | null>(null);
// Ruler drag detection state
const [isDraggingRuler, setIsDraggingRuler] = useState(false);
const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
const playheadPosition =
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
// --- Playhead Scrubbing Handlers ---
const handlePlayheadMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation(); // Prevent ruler drag from triggering
setIsScrubbing(true);
handleScrub(e);
},
[duration, zoomLevel]
);
// Ruler mouse down handler
const handleRulerMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only handle left mouse button
if (e.button !== 0) return;
// Don't interfere if clicking on the playhead itself
if (playheadRef?.current?.contains(e.target as Node)) return;
e.preventDefault();
setIsDraggingRuler(true);
setHasDraggedRuler(false);
// Start scrubbing immediately
setIsScrubbing(true);
handleScrub(e);
},
[duration, zoomLevel]
);
const handleScrub = useCallback(
(e: MouseEvent | React.MouseEvent) => {
const ruler = rulerRef.current;
if (!ruler) return;
const rect = ruler.getBoundingClientRect();
const x = e.clientX - rect.left;
const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
// Use frame snapping for playhead scrubbing
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
const time = snapTimeToFrame(rawTime, projectFps);
setScrubTime(time);
seek(time); // update video preview in real time
},
[duration, zoomLevel, seek, rulerRef]
);
// Mouse move/up event handlers
useEffect(() => {
if (!isScrubbing) return;
const onMouseMove = (e: MouseEvent) => {
handleScrub(e);
// Mark that we've dragged if ruler drag is active
if (isDraggingRuler) {
setHasDraggedRuler(true);
}
};
const onMouseUp = (e: MouseEvent) => {
setIsScrubbing(false);
if (scrubTime !== null) seek(scrubTime); // finalize seek
setScrubTime(null);
// Handle ruler click vs drag
if (isDraggingRuler) {
setIsDraggingRuler(false);
// If we didn't drag, treat it as a click-to-seek
if (!hasDraggedRuler) {
handleScrub(e);
}
setHasDraggedRuler(false);
}
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, [
isScrubbing,
scrubTime,
seek,
handleScrub,
isDraggingRuler,
hasDraggedRuler,
]);
// --- Playhead auto-scroll effect ---
useEffect(() => {
const rulerViewport = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
if (!rulerViewport || !tracksViewport) return;
const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
const viewportWidth = rulerViewport.clientWidth;
const scrollMin = 0;
const scrollMax = rulerViewport.scrollWidth - viewportWidth;
// Center the playhead if it's not visible (100px buffer)
const desiredScroll = Math.max(
scrollMin,
Math.min(scrollMax, playheadPx - viewportWidth / 2)
);
if (
playheadPx < rulerViewport.scrollLeft + 100 ||
playheadPx > rulerViewport.scrollLeft + viewportWidth - 100
) {
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
}
}, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]);
return {
playheadPosition,
handlePlayheadMouseDown,
handleRulerMouseDown,
isDraggingRuler,
};
}

View File

@ -1,60 +1,60 @@
import { useState, useCallback, useEffect, RefObject } from "react";
interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement>;
isInTimeline?: boolean;
}
interface UseTimelineZoomReturn {
zoomLevel: number;
setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
handleWheel: (e: React.WheelEvent) => void;
}
export function useTimelineZoom({
containerRef,
isInTimeline = false,
}: UseTimelineZoomProps): UseTimelineZoomReturn {
const [zoomLevel, setZoomLevel] = useState(1);
const handleWheel = useCallback((e: React.WheelEvent) => {
// Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.15 : 0.15;
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
}
// For horizontal scrolling (when shift is held or horizontal wheel movement),
// let the event bubble up to allow ScrollArea to handle it
else if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
// Don't prevent default - let ScrollArea handle horizontal scrolling
return;
}
// Otherwise, allow normal scrolling
}, []);
// Prevent browser zooming in/out when in timeline
useEffect(() => {
const preventZoom = (e: WheelEvent) => {
if (
isInTimeline &&
(e.ctrlKey || e.metaKey) &&
containerRef.current?.contains(e.target as Node)
) {
e.preventDefault();
}
};
document.addEventListener("wheel", preventZoom, { passive: false });
return () => {
document.removeEventListener("wheel", preventZoom);
};
}, [isInTimeline, containerRef]);
return {
zoomLevel,
setZoomLevel,
handleWheel,
};
}
import { useState, useCallback, useEffect, RefObject } from "react";
interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement>;
isInTimeline?: boolean;
}
interface UseTimelineZoomReturn {
zoomLevel: number;
setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
handleWheel: (e: React.WheelEvent) => void;
}
export function useTimelineZoom({
containerRef,
isInTimeline = false,
}: UseTimelineZoomProps): UseTimelineZoomReturn {
const [zoomLevel, setZoomLevel] = useState(1);
const handleWheel = useCallback((e: React.WheelEvent) => {
// Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.15 : 0.15;
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
}
// For horizontal scrolling (when shift is held or horizontal wheel movement),
// let the event bubble up to allow ScrollArea to handle it
else if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
// Don't prevent default - let ScrollArea handle horizontal scrolling
return;
}
// Otherwise, allow normal scrolling
}, []);
// Prevent browser zooming in/out when in timeline
useEffect(() => {
const preventZoom = (e: WheelEvent) => {
if (
isInTimeline &&
(e.ctrlKey || e.metaKey) &&
containerRef.current?.contains(e.target as Node)
) {
e.preventDefault();
}
};
document.addEventListener("wheel", preventZoom, { passive: false });
return () => {
document.removeEventListener("wheel", preventZoom);
};
}, [isInTimeline, containerRef]);
return {
zoomLevel,
setZoomLevel,
handleWheel,
};
}

View File

@ -6,7 +6,7 @@ import * as React from "react";
import type { ToastActionElement, ToastProps } from "../components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
const TOAST_REMOVE_DELAY = 1_000_000;
type ToasterToast = ToastProps & {
id: string;
@ -64,7 +64,7 @@ const addToRemoveQueue = (toastId: string) => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
toastId,
});
}, TOAST_REMOVE_DELAY);
@ -162,7 +162,7 @@ function toast({ ...props }: Toast) {
});
return {
id: id,
id,
dismiss,
update,
};

View File

@ -1,4 +1,10 @@
import type { MarbleAuthorList, MarbleCategoryList, MarblePost, MarblePostList, MarbleTagList } from '@/types/post';
import type {
MarbleAuthorList,
MarbleCategoryList,
MarblePost,
MarblePostList,
MarbleTagList,
} from "@/types/post";
import { unified } from "unified";
import rehypeParse from "rehype-parse";
import rehypeStringify from "rehype-stringify";
@ -6,50 +12,53 @@ import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeSanitize from "rehype-sanitize";
const url = process.env.NEXT_PUBLIC_MARBLE_API_URL ?? "https://api.marblecms.com";
const url =
process.env.NEXT_PUBLIC_MARBLE_API_URL ?? "https://api.marblecms.com";
const key = process.env.MARBLE_WORKSPACE_KEY ?? "cmd4iw9mm0006l804kwqv0k46";
async function fetchFromMarble<T>(endpoint: string): Promise<T> {
try {
const response = await fetch(`${url}/${key}/${endpoint}`);
if (!response.ok) {
throw new Error(`Failed to fetch ${endpoint}: ${response.status} ${response.statusText}`);
}
return await response.json() as T;
} catch (error) {
console.error(`Error fetching ${endpoint}:`, error);
throw error;
try {
const response = await fetch(`${url}/${key}/${endpoint}`);
if (!response.ok) {
throw new Error(
`Failed to fetch ${endpoint}: ${response.status} ${response.statusText}`
);
}
return (await response.json()) as T;
} catch (error) {
console.error(`Error fetching ${endpoint}:`, error);
throw error;
}
export async function getPosts() {
return fetchFromMarble<MarblePostList>('posts');
}
export async function getTags() {
return fetchFromMarble<MarbleTagList>('tags');
}
export async function getSinglePost(slug: string) {
return fetchFromMarble<MarblePost>(`posts/${slug}`);
}
export async function getCategories() {
return fetchFromMarble<MarbleCategoryList>('categories');
}
export async function getAuthors() {
return fetchFromMarble<MarbleAuthorList>('authors');
}
}
export async function getPosts() {
return fetchFromMarble<MarblePostList>("posts");
}
export async function getTags() {
return fetchFromMarble<MarbleTagList>("tags");
}
export async function getSinglePost(slug: string) {
return fetchFromMarble<MarblePost>(`posts/${slug}`);
}
export async function getCategories() {
return fetchFromMarble<MarbleCategoryList>("categories");
}
export async function getAuthors() {
return fetchFromMarble<MarbleAuthorList>("authors");
}
export async function processHtmlContent(html: string): Promise<string> {
const processor = unified()
.use(rehypeSanitize)
.use(rehypeParse, { fragment: true })
.use(rehypeSlug)
.use(rehypeAutolinkHeadings, { behavior: "append" })
.use(rehypeStringify);
const processor = unified()
.use(rehypeSanitize)
.use(rehypeParse, { fragment: true })
.use(rehypeSlug)
.use(rehypeAutolinkHeadings, { behavior: "append" })
.use(rehypeStringify);
const file = await processor.process(html);
return String(file);
const file = await processor.process(html);
return String(file);
}

View File

@ -19,8 +19,8 @@ export async function getStars(): Promise<string> {
if (count >= 1_000_000)
return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
if (count >= 1_000)
return (count / 1_000).toFixed(1).replace(/\.0$/, "") + "k";
if (count >= 1000)
return (count / 1000).toFixed(1).replace(/\.0$/, "") + "k";
return count.toString();
} catch (error) {
console.error("Failed to fetch GitHub stars:", error);

View File

@ -1,5 +1,5 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { toBlobURL } from '@ffmpeg/util';
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { toBlobURL } from "@ffmpeg/util";
let ffmpeg: FFmpeg | null = null;
@ -7,13 +7,13 @@ export const initFFmpeg = async (): Promise<FFmpeg> => {
if (ffmpeg) return ffmpeg;
ffmpeg = new FFmpeg();
// Use locally hosted files instead of CDN
const baseURL = '/ffmpeg';
const baseURL = "/ffmpeg";
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"),
});
return ffmpeg;
@ -21,34 +21,42 @@ export const initFFmpeg = async (): Promise<FFmpeg> => {
export const generateThumbnail = async (
videoFile: File,
timeInSeconds: number = 1
timeInSeconds = 1
): Promise<string> => {
const ffmpeg = await initFFmpeg();
const inputName = 'input.mp4';
const outputName = 'thumbnail.jpg';
const inputName = "input.mp4";
const outputName = "thumbnail.jpg";
// Write input file
await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer()));
await ffmpeg.writeFile(
inputName,
new Uint8Array(await videoFile.arrayBuffer())
);
// Generate thumbnail at specific time
await ffmpeg.exec([
'-i', inputName,
'-ss', timeInSeconds.toString(),
'-vframes', '1',
'-vf', 'scale=320:240',
'-q:v', '2',
outputName
"-i",
inputName,
"-ss",
timeInSeconds.toString(),
"-vframes",
"1",
"-vf",
"scale=320:240",
"-q:v",
"2",
outputName,
]);
// Read output file
const data = await ffmpeg.readFile(outputName);
const blob = new Blob([data], { type: 'image/jpeg' });
const blob = new Blob([data], { type: "image/jpeg" });
// Cleanup
await ffmpeg.deleteFile(inputName);
await ffmpeg.deleteFile(outputName);
return URL.createObjectURL(blob);
};
@ -59,43 +67,52 @@ export const trimVideo = async (
onProgress?: (progress: number) => void
): Promise<Blob> => {
const ffmpeg = await initFFmpeg();
const inputName = 'input.mp4';
const outputName = 'output.mp4';
const inputName = "input.mp4";
const outputName = "output.mp4";
// Set up progress callback
if (onProgress) {
ffmpeg.on('progress', ({ progress }) => {
ffmpeg.on("progress", ({ progress }) => {
onProgress(progress * 100);
});
}
// Write input file
await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer()));
await ffmpeg.writeFile(
inputName,
new Uint8Array(await videoFile.arrayBuffer())
);
const duration = endTime - startTime;
// Trim video
await ffmpeg.exec([
'-i', inputName,
'-ss', startTime.toString(),
'-t', duration.toString(),
'-c', 'copy', // Use stream copy for faster processing
outputName
"-i",
inputName,
"-ss",
startTime.toString(),
"-t",
duration.toString(),
"-c",
"copy", // Use stream copy for faster processing
outputName,
]);
// Read output file
const data = await ffmpeg.readFile(outputName);
const blob = new Blob([data], { type: 'video/mp4' });
const blob = new Blob([data], { type: "video/mp4" });
// Cleanup
await ffmpeg.deleteFile(inputName);
await ffmpeg.deleteFile(outputName);
return blob;
};
export const getVideoInfo = async (videoFile: File): Promise<{
export const getVideoInfo = async (
videoFile: File
): Promise<{
duration: number;
width: number;
height: number;
@ -103,27 +120,32 @@ export const getVideoInfo = async (videoFile: File): Promise<{
}> => {
const ffmpeg = await initFFmpeg();
const inputName = 'input.mp4';
const inputName = "input.mp4";
// Write input file
await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer()));
await ffmpeg.writeFile(
inputName,
new Uint8Array(await videoFile.arrayBuffer())
);
// Capture FFmpeg stderr output with a one-time listener pattern
let ffmpegOutput = '';
let ffmpegOutput = "";
let listening = true;
const listener = (data: string) => {
if (listening) ffmpegOutput += data;
};
ffmpeg.on('log', ({ message }) => listener(message));
ffmpeg.on("log", ({ message }) => listener(message));
// Run ffmpeg to get info (stderr will contain the info)
try {
await ffmpeg.exec(['-i', inputName, '-f', 'null', '-']);
await ffmpeg.exec(["-i", inputName, "-f", "null", "-"]);
} catch (error) {
listening = false;
await ffmpeg.deleteFile(inputName);
console.error('FFmpeg execution failed:', error);
throw new Error('Failed to extract video info. The file may be corrupted or in an unsupported format.');
console.error("FFmpeg execution failed:", error);
throw new Error(
"Failed to extract video info. The file may be corrupted or in an unsupported format."
);
}
// Disable listener after exec completes
@ -143,8 +165,12 @@ export const getVideoInfo = async (videoFile: File): Promise<{
duration = parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s);
}
const videoStreamMatch = ffmpegOutput.match(/Video:.* (\d+)x(\d+)[^,]*, ([\d.]+) fps/);
let width = 0, height = 0, fps = 0;
const videoStreamMatch = ffmpegOutput.match(
/Video:.* (\d+)x(\d+)[^,]*, ([\d.]+) fps/
);
let width = 0,
height = 0,
fps = 0;
if (videoStreamMatch) {
width = parseInt(videoStreamMatch[1]);
height = parseInt(videoStreamMatch[2]);
@ -155,7 +181,7 @@ export const getVideoInfo = async (videoFile: File): Promise<{
duration,
width,
height,
fps
fps,
};
};
@ -164,68 +190,81 @@ export const convertToWebM = async (
onProgress?: (progress: number) => void
): Promise<Blob> => {
const ffmpeg = await initFFmpeg();
const inputName = 'input.mp4';
const outputName = 'output.webm';
const inputName = "input.mp4";
const outputName = "output.webm";
// Set up progress callback
if (onProgress) {
ffmpeg.on('progress', ({ progress }) => {
ffmpeg.on("progress", ({ progress }) => {
onProgress(progress * 100);
});
}
// Write input file
await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer()));
await ffmpeg.writeFile(
inputName,
new Uint8Array(await videoFile.arrayBuffer())
);
// Convert to WebM
await ffmpeg.exec([
'-i', inputName,
'-c:v', 'libvpx-vp9',
'-crf', '30',
'-b:v', '0',
'-c:a', 'libopus',
outputName
"-i",
inputName,
"-c:v",
"libvpx-vp9",
"-crf",
"30",
"-b:v",
"0",
"-c:a",
"libopus",
outputName,
]);
// Read output file
const data = await ffmpeg.readFile(outputName);
const blob = new Blob([data], { type: 'video/webm' });
const blob = new Blob([data], { type: "video/webm" });
// Cleanup
await ffmpeg.deleteFile(inputName);
await ffmpeg.deleteFile(outputName);
return blob;
};
export const extractAudio = async (
videoFile: File,
format: 'mp3' | 'wav' = 'mp3'
format: "mp3" | "wav" = "mp3"
): Promise<Blob> => {
const ffmpeg = await initFFmpeg();
const inputName = 'input.mp4';
const inputName = "input.mp4";
const outputName = `output.${format}`;
// Write input file
await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer()));
await ffmpeg.writeFile(
inputName,
new Uint8Array(await videoFile.arrayBuffer())
);
// Extract audio
await ffmpeg.exec([
'-i', inputName,
'-vn', // Disable video
'-acodec', format === 'mp3' ? 'libmp3lame' : 'pcm_s16le',
outputName
"-i",
inputName,
"-vn", // Disable video
"-acodec",
format === "mp3" ? "libmp3lame" : "pcm_s16le",
outputName,
]);
// Read output file
const data = await ffmpeg.readFile(outputName);
const blob = new Blob([data], { type: `audio/${format}` });
// Cleanup
await ffmpeg.deleteFile(inputName);
await ffmpeg.deleteFile(outputName);
return blob;
};
};

View File

@ -1,39 +1,39 @@
import {
Inter,
Roboto,
Open_Sans,
Playfair_Display,
Comic_Neue,
} from "next/font/google";
// Configure all fonts
const inter = Inter({ subsets: ["latin"] });
const roboto = Roboto({ subsets: ["latin"], weight: ["400", "700"] });
const openSans = Open_Sans({ subsets: ["latin"] });
const playfairDisplay = Playfair_Display({ subsets: ["latin"] });
const comicNeue = Comic_Neue({ subsets: ["latin"], weight: ["400", "700"] });
// Export font class mapping for use in components
export const FONT_CLASS_MAP = {
Inter: inter.className,
Roboto: roboto.className,
"Open Sans": openSans.className,
"Playfair Display": playfairDisplay.className,
"Comic Neue": comicNeue.className,
Arial: "",
Helvetica: "",
"Times New Roman": "",
Georgia: "",
} as const;
// Export individual fonts for use in layout
export const fonts = {
inter,
roboto,
openSans,
playfairDisplay,
comicNeue,
};
// Default font for the body
export const defaultFont = inter;
import {
Inter,
Roboto,
Open_Sans,
Playfair_Display,
Comic_Neue,
} from "next/font/google";
// Configure all fonts
const inter = Inter({ subsets: ["latin"] });
const roboto = Roboto({ subsets: ["latin"], weight: ["400", "700"] });
const openSans = Open_Sans({ subsets: ["latin"] });
const playfairDisplay = Playfair_Display({ subsets: ["latin"] });
const comicNeue = Comic_Neue({ subsets: ["latin"], weight: ["400", "700"] });
// Export font class mapping for use in components
export const FONT_CLASS_MAP = {
Inter: inter.className,
Roboto: roboto.className,
"Open Sans": openSans.className,
"Playfair Display": playfairDisplay.className,
"Comic Neue": comicNeue.className,
Arial: "",
Helvetica: "",
"Times New Roman": "",
Georgia: "",
} as const;
// Export individual fonts for use in layout
export const fonts = {
inter,
roboto,
openSans,
playfairDisplay,
comicNeue,
};
// Default font for the body
export const defaultFont = inter;

View File

@ -1,16 +1,16 @@
// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { env } from "@/env";
const redis = new Redis({
url: env.UPSTASH_REDIS_REST_URL,
token: env.UPSTASH_REDIS_REST_TOKEN,
});
export const waitlistRateLimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 requests per minute
analytics: true,
prefix: "waitlist-rate-limit",
});
// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { env } from "@/env";
const redis = new Redis({
url: env.UPSTASH_REDIS_REST_URL,
token: env.UPSTASH_REDIS_REST_TOKEN,
});
export const waitlistRateLimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 requests per minute
analytics: true,
prefix: "waitlist-rate-limit",
});

View File

@ -1,89 +1,89 @@
import { StorageAdapter } from "./types";
export class IndexedDBAdapter<T> implements StorageAdapter<T> {
private dbName: string;
private storeName: string;
private version: number;
constructor(dbName: string, storeName: string, version: number = 1) {
this.dbName = dbName;
this.storeName = storeName;
this.version = version;
}
private async getDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: "id" });
}
};
});
}
async get(key: string): Promise<T | null> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.get(key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || null);
});
}
async set(key: string, value: T): Promise<void> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.put({ id: key, ...value });
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
async remove(key: string): Promise<void> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.delete(key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
async list(): Promise<string[]> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.getAllKeys();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result as string[]);
});
}
async clear(): Promise<void> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.clear();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
}
import { StorageAdapter } from "./types";
export class IndexedDBAdapter<T> implements StorageAdapter<T> {
private dbName: string;
private storeName: string;
private version: number;
constructor(dbName: string, storeName: string, version = 1) {
this.dbName = dbName;
this.storeName = storeName;
this.version = version;
}
private async getDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: "id" });
}
};
});
}
async get(key: string): Promise<T | null> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.get(key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || null);
});
}
async set(key: string, value: T): Promise<void> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.put({ id: key, ...value });
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
async remove(key: string): Promise<void> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.delete(key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
async list(): Promise<string[]> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.getAllKeys();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result as string[]);
});
}
async clear(): Promise<void> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.clear();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
}

View File

@ -3,7 +3,7 @@ import { StorageAdapter } from "./types";
export class OPFSAdapter implements StorageAdapter<File> {
private directoryName: string;
constructor(directoryName: string = "media") {
constructor(directoryName = "media") {
this.directoryName = directoryName;
}

View File

@ -1,25 +1,25 @@
// Time-related utility functions
// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS, HH:MM:SS:FF)
export const formatTimeCode = (
timeInSeconds: number,
format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF" = "HH:MM:SS:CS",
fps: number = 30
): string => {
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60);
const centiseconds = Math.floor((timeInSeconds % 1) * 100);
const frames = Math.floor((timeInSeconds % 1) * fps);
switch (format) {
case "MM:SS":
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
case "HH:MM:SS":
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
case "HH:MM:SS:CS":
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
case "HH:MM:SS:FF":
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
}
};
// Time-related utility functions
// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS, HH:MM:SS:FF)
export const formatTimeCode = (
timeInSeconds: number,
format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF" = "HH:MM:SS:CS",
fps = 30
): string => {
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60);
const centiseconds = Math.floor((timeInSeconds % 1) * 100);
const frames = Math.floor((timeInSeconds % 1) * fps);
switch (format) {
case "MM:SS":
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
case "HH:MM:SS":
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
case "HH:MM:SS:CS":
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
case "HH:MM:SS:FF":
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
}
};

View File

@ -1,54 +1,54 @@
import { TimelineElement } from "@/types/timeline";
// Helper function to check for element overlaps and prevent invalid timeline states
export const checkElementOverlaps = (elements: TimelineElement[]): boolean => {
// Sort elements by start time
const sortedElements = [...elements].sort(
(a, b) => a.startTime - b.startTime
);
for (let i = 0; i < sortedElements.length - 1; i++) {
const current = sortedElements[i];
const next = sortedElements[i + 1];
const currentEnd =
current.startTime +
(current.duration - current.trimStart - current.trimEnd);
// Check if current element overlaps with next element
if (currentEnd > next.startTime) return true; // Overlap detected
}
return false; // No overlaps
};
// Helper function to resolve overlaps by adjusting element positions
export const resolveElementOverlaps = (
elements: TimelineElement[]
): TimelineElement[] => {
// Sort elements by start time
const sortedElements = [...elements].sort(
(a, b) => a.startTime - b.startTime
);
const resolvedElements: TimelineElement[] = [];
for (let i = 0; i < sortedElements.length; i++) {
const current = { ...sortedElements[i] };
if (resolvedElements.length > 0) {
const previous = resolvedElements[resolvedElements.length - 1];
const previousEnd =
previous.startTime +
(previous.duration - previous.trimStart - previous.trimEnd);
// If current element would overlap with previous, push it after previous ends
if (current.startTime < previousEnd) {
current.startTime = previousEnd;
}
}
resolvedElements.push(current);
}
return resolvedElements;
};
import { TimelineElement } from "@/types/timeline";
// Helper function to check for element overlaps and prevent invalid timeline states
export const checkElementOverlaps = (elements: TimelineElement[]): boolean => {
// Sort elements by start time
const sortedElements = [...elements].sort(
(a, b) => a.startTime - b.startTime
);
for (let i = 0; i < sortedElements.length - 1; i++) {
const current = sortedElements[i];
const next = sortedElements[i + 1];
const currentEnd =
current.startTime +
(current.duration - current.trimStart - current.trimEnd);
// Check if current element overlaps with next element
if (currentEnd > next.startTime) return true; // Overlap detected
}
return false; // No overlaps
};
// Helper function to resolve overlaps by adjusting element positions
export const resolveElementOverlaps = (
elements: TimelineElement[]
): TimelineElement[] => {
// Sort elements by start time
const sortedElements = [...elements].sort(
(a, b) => a.startTime - b.startTime
);
const resolvedElements: TimelineElement[] = [];
for (let i = 0; i < sortedElements.length; i++) {
const current = { ...sortedElements[i] };
if (resolvedElements.length > 0) {
const previous = resolvedElements[resolvedElements.length - 1];
const previousEnd =
previous.startTime +
(previous.duration - previous.trimStart - previous.trimEnd);
// If current element would overlap with previous, push it after previous ends
if (current.startTime < previousEnd) {
current.startTime = previousEnd;
}
}
resolvedElements.push(current);
}
return resolvedElements;
};

View File

@ -1,13 +1,13 @@
import { db, sql, waitlist } from "@opencut/db";
export async function getWaitlistCount() {
try {
const result = await db
.select({ count: sql<number>`count(*)` })
.from(waitlist);
return result[0]?.count || 0;
} catch (error) {
console.error("Failed to fetch waitlist count:", error);
return 0;
}
}
import { db, sql, waitlist } from "@opencut/db";
export async function getWaitlistCount() {
try {
const result = await db
.select({ count: sql<number>`count(*)` })
.from(waitlist);
return result[0]?.count || 0;
} catch (error) {
console.error("Failed to fetch waitlist count:", error);
return 0;
}
}

View File

@ -1,24 +1,24 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
// Handle fuckcapcut.com domain redirect
if (request.headers.get("host") === "fuckcapcut.com") {
return NextResponse.redirect("https://opencut.app/why-not-capcut", 301);
}
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
// Handle fuckcapcut.com domain redirect
if (request.headers.get("host") === "fuckcapcut.com") {
return NextResponse.redirect("https://opencut.app/why-not-capcut", 301);
}
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};

View File

@ -1,105 +1,104 @@
import { create } from "zustand";
import { CanvasSize, CanvasPreset } from "@/types/editor";
type CanvasMode = "preset" | "original" | "custom";
interface EditorState {
// Loading states
isInitializing: boolean;
isPanelsReady: boolean;
// Canvas/Project settings
canvasSize: CanvasSize;
canvasMode: CanvasMode;
canvasPresets: CanvasPreset[];
// Actions
setInitializing: (loading: boolean) => void;
setPanelsReady: (ready: boolean) => void;
initializeApp: () => Promise<void>;
setCanvasSize: (size: CanvasSize) => void;
setCanvasSizeToOriginal: (aspectRatio: number) => void;
setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
}
const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
{ name: "16:9", width: 1920, height: 1080 },
{ name: "9:16", width: 1080, height: 1920 },
{ name: "1:1", width: 1080, height: 1080 },
{ name: "4:3", width: 1440, height: 1080 },
];
// Helper function to find the best matching canvas preset for an aspect ratio
const findBestCanvasPreset = (aspectRatio: number): CanvasSize => {
// Calculate aspect ratio for each preset and find the closest match
let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
let smallestDifference = Math.abs(
aspectRatio - bestMatch.width / bestMatch.height
);
for (const preset of DEFAULT_CANVAS_PRESETS) {
const presetAspectRatio = preset.width / preset.height;
const difference = Math.abs(aspectRatio - presetAspectRatio);
if (difference < smallestDifference) {
smallestDifference = difference;
bestMatch = preset;
}
}
// If the difference is still significant (> 0.1), create a custom size
// based on the media aspect ratio with a reasonable resolution
const bestAspectRatio = bestMatch.width / bestMatch.height;
if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
// Create custom dimensions based on the aspect ratio
if (aspectRatio > 1) {
// Landscape - use 1920 width
return { width: 1920, height: Math.round(1920 / aspectRatio) };
} else {
// Portrait or square - use 1080 height
return { width: Math.round(1080 * aspectRatio), height: 1080 };
}
}
return { width: bestMatch.width, height: bestMatch.height };
};
export const useEditorStore = create<EditorState>((set, get) => ({
// Initial states
isInitializing: true,
isPanelsReady: false,
canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
canvasMode: "preset" as CanvasMode,
canvasPresets: DEFAULT_CANVAS_PRESETS,
// Actions
setInitializing: (loading) => {
set({ isInitializing: loading });
},
setPanelsReady: (ready) => {
set({ isPanelsReady: ready });
},
initializeApp: async () => {
console.log("Initializing video editor...");
set({ isInitializing: true, isPanelsReady: false });
set({ isPanelsReady: true, isInitializing: false });
console.log("Video editor ready");
},
setCanvasSize: (size) => {
set({ canvasSize: size, canvasMode: "preset" });
},
setCanvasSizeToOriginal: (aspectRatio) => {
const newCanvasSize = findBestCanvasPreset(aspectRatio);
set({ canvasSize: newCanvasSize, canvasMode: "original" });
},
setCanvasSizeFromAspectRatio: (aspectRatio) => {
const newCanvasSize = findBestCanvasPreset(aspectRatio);
set({ canvasSize: newCanvasSize, canvasMode: "custom" });
},
}));
import { create } from "zustand";
import { CanvasSize, CanvasPreset } from "@/types/editor";
type CanvasMode = "preset" | "original" | "custom";
interface EditorState {
// Loading states
isInitializing: boolean;
isPanelsReady: boolean;
// Canvas/Project settings
canvasSize: CanvasSize;
canvasMode: CanvasMode;
canvasPresets: CanvasPreset[];
// Actions
setInitializing: (loading: boolean) => void;
setPanelsReady: (ready: boolean) => void;
initializeApp: () => Promise<void>;
setCanvasSize: (size: CanvasSize) => void;
setCanvasSizeToOriginal: (aspectRatio: number) => void;
setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
}
const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
{ name: "16:9", width: 1920, height: 1080 },
{ name: "9:16", width: 1080, height: 1920 },
{ name: "1:1", width: 1080, height: 1080 },
{ name: "4:3", width: 1440, height: 1080 },
];
// Helper function to find the best matching canvas preset for an aspect ratio
const findBestCanvasPreset = (aspectRatio: number): CanvasSize => {
// Calculate aspect ratio for each preset and find the closest match
let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
let smallestDifference = Math.abs(
aspectRatio - bestMatch.width / bestMatch.height
);
for (const preset of DEFAULT_CANVAS_PRESETS) {
const presetAspectRatio = preset.width / preset.height;
const difference = Math.abs(aspectRatio - presetAspectRatio);
if (difference < smallestDifference) {
smallestDifference = difference;
bestMatch = preset;
}
}
// If the difference is still significant (> 0.1), create a custom size
// based on the media aspect ratio with a reasonable resolution
const bestAspectRatio = bestMatch.width / bestMatch.height;
if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
// Create custom dimensions based on the aspect ratio
if (aspectRatio > 1) {
// Landscape - use 1920 width
return { width: 1920, height: Math.round(1920 / aspectRatio) };
}
// Portrait or square - use 1080 height
return { width: Math.round(1080 * aspectRatio), height: 1080 };
}
return { width: bestMatch.width, height: bestMatch.height };
};
export const useEditorStore = create<EditorState>((set, get) => ({
// Initial states
isInitializing: true,
isPanelsReady: false,
canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
canvasMode: "preset" as CanvasMode,
canvasPresets: DEFAULT_CANVAS_PRESETS,
// Actions
setInitializing: (loading) => {
set({ isInitializing: loading });
},
setPanelsReady: (ready) => {
set({ isPanelsReady: ready });
},
initializeApp: async () => {
console.log("Initializing video editor...");
set({ isInitializing: true, isPanelsReady: false });
set({ isPanelsReady: true, isInitializing: false });
console.log("Video editor ready");
},
setCanvasSize: (size) => {
set({ canvasSize: size, canvasMode: "preset" });
},
setCanvasSizeToOriginal: (aspectRatio) => {
const newCanvasSize = findBestCanvasPreset(aspectRatio);
set({ canvasSize: newCanvasSize, canvasMode: "original" });
},
setCanvasSizeFromAspectRatio: (aspectRatio) => {
const newCanvasSize = findBestCanvasPreset(aspectRatio);
set({ canvasSize: newCanvasSize, canvasMode: "custom" });
},
}));

View File

@ -219,15 +219,19 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
mediaItems.map(async (item) => {
if (item.type === "video" && item.file) {
try {
const { thumbnailUrl, width, height } = await generateVideoThumbnail(item.file);
const { thumbnailUrl, width, height } =
await generateVideoThumbnail(item.file);
return {
...item,
thumbnailUrl,
width: width || item.width,
height: height || item.height
height: height || item.height,
};
} catch (error) {
console.error(`Failed to regenerate thumbnail for video ${item.id}:`, error);
console.error(
`Failed to regenerate thumbnail for video ${item.id}:`,
error
);
return item;
}
}

View File

@ -1,45 +1,45 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
const DEFAULT_PANEL_SIZES = {
toolsPanel: 45,
previewPanel: 75,
propertiesPanel: 20,
mainContent: 70,
timeline: 30,
} as const;
interface PanelState {
// Panel sizes as percentages
toolsPanel: number;
previewPanel: number;
propertiesPanel: number;
mainContent: number;
timeline: number;
// Actions
setToolsPanel: (size: number) => void;
setPreviewPanel: (size: number) => void;
setPropertiesPanel: (size: number) => void;
setMainContent: (size: number) => void;
setTimeline: (size: number) => void;
}
export const usePanelStore = create<PanelState>()(
persist(
(set) => ({
// Default sizes - optimized for responsiveness
...DEFAULT_PANEL_SIZES,
// Actions
setToolsPanel: (size) => set({ toolsPanel: size }),
setPreviewPanel: (size) => set({ previewPanel: size }),
setPropertiesPanel: (size) => set({ propertiesPanel: size }),
setMainContent: (size) => set({ mainContent: size }),
setTimeline: (size) => set({ timeline: size }),
}),
{
name: "panel-sizes",
}
)
);
import { create } from "zustand";
import { persist } from "zustand/middleware";
const DEFAULT_PANEL_SIZES = {
toolsPanel: 45,
previewPanel: 75,
propertiesPanel: 20,
mainContent: 70,
timeline: 30,
} as const;
interface PanelState {
// Panel sizes as percentages
toolsPanel: number;
previewPanel: number;
propertiesPanel: number;
mainContent: number;
timeline: number;
// Actions
setToolsPanel: (size: number) => void;
setPreviewPanel: (size: number) => void;
setPropertiesPanel: (size: number) => void;
setMainContent: (size: number) => void;
setTimeline: (size: number) => void;
}
export const usePanelStore = create<PanelState>()(
persist(
(set) => ({
// Default sizes - optimized for responsiveness
...DEFAULT_PANEL_SIZES,
// Actions
setToolsPanel: (size) => set({ toolsPanel: size }),
setPreviewPanel: (size) => set({ previewPanel: size }),
setPropertiesPanel: (size) => set({ propertiesPanel: size }),
setMainContent: (size) => set({ mainContent: size }),
setTimeline: (size) => set({ timeline: size }),
}),
{
name: "panel-sizes",
}
)
);

View File

@ -1,361 +1,360 @@
import { TProject } from "@/types/project";
import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
import { useMediaStore } from "./media-store";
import { useTimelineStore } from "./timeline-store";
import { generateUUID } from "@/lib/utils";
interface ProjectStore {
activeProject: TProject | null;
savedProjects: TProject[];
isLoading: boolean;
isInitialized: boolean;
// Actions
createNewProject: (name: string) => Promise<string>;
loadProject: (id: string) => Promise<void>;
saveCurrentProject: () => Promise<void>;
loadAllProjects: () => Promise<void>;
deleteProject: (id: string) => Promise<void>;
closeProject: () => void;
renameProject: (projectId: string, name: string) => Promise<void>;
duplicateProject: (projectId: string) => Promise<string>;
updateProjectBackground: (backgroundColor: string) => Promise<void>;
updateBackgroundType: (
type: "color" | "blur",
options?: { backgroundColor?: string; blurIntensity?: number }
) => Promise<void>;
updateProjectFps: (fps: number) => Promise<void>;
getFilteredAndSortedProjects: (
searchQuery: string,
sortOption: string
) => TProject[];
}
export const useProjectStore = create<ProjectStore>((set, get) => ({
activeProject: null,
savedProjects: [],
isLoading: true,
isInitialized: false,
createNewProject: async (name: string) => {
const newProject: TProject = {
id: generateUUID(),
name,
thumbnail: "",
createdAt: new Date(),
updatedAt: new Date(),
backgroundColor: "#000000",
backgroundType: "color",
blurIntensity: 8,
};
set({ activeProject: newProject });
try {
await storageService.saveProject(newProject);
// Reload all projects to update the list
await get().loadAllProjects();
return newProject.id;
} catch (error) {
toast.error("Failed to save new project");
throw error;
}
},
loadProject: async (id: string) => {
if (!get().isInitialized) {
set({ isLoading: true });
}
// Clear media and timeline immediately to prevent flickering when switching projects
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
try {
const project = await storageService.loadProject(id);
if (project) {
set({ activeProject: project });
// Load project-specific data in parallel
await Promise.all([
mediaStore.loadProjectMedia(id),
timelineStore.loadProjectTimeline(id),
]);
} else {
throw new Error(`Project with id ${id} not found`);
}
} catch (error) {
console.error("Failed to load project:", error);
throw error; // Re-throw so the editor page can handle it
} finally {
set({ isLoading: false });
}
},
saveCurrentProject: async () => {
const { activeProject } = get();
if (!activeProject) return;
try {
// Save project metadata and timeline data in parallel
const timelineStore = useTimelineStore.getState();
await Promise.all([
storageService.saveProject(activeProject),
timelineStore.saveProjectTimeline(activeProject.id),
]);
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to save project:", error);
}
},
loadAllProjects: async () => {
if (!get().isInitialized) {
set({ isLoading: true });
}
try {
const projects = await storageService.loadAllProjects();
set({ savedProjects: projects });
} catch (error) {
console.error("Failed to load projects:", error);
} finally {
set({ isLoading: false, isInitialized: true });
}
},
deleteProject: async (id: string) => {
try {
// Delete project data in parallel
await Promise.all([
storageService.deleteProjectMedia(id),
storageService.deleteProjectTimeline(id),
storageService.deleteProject(id),
]);
await get().loadAllProjects(); // Refresh the list
// If we deleted the active project, close it and clear data
const { activeProject } = get();
if (activeProject?.id === id) {
set({ activeProject: null });
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
}
} catch (error) {
console.error("Failed to delete project:", error);
}
},
closeProject: () => {
set({ activeProject: null });
// Clear data from stores when closing project
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
},
renameProject: async (id: string, name: string) => {
const { savedProjects } = get();
// Find the project to rename
const projectToRename = savedProjects.find((p) => p.id === id);
if (!projectToRename) {
toast.error("Project not found", {
description: "Please try again",
});
return;
}
const updatedProject = {
...projectToRename,
name,
updatedAt: new Date(),
};
try {
// Save to storage
await storageService.saveProject(updatedProject);
await get().loadAllProjects();
// Update activeProject if it's the same project
const { activeProject } = get();
if (activeProject?.id === id) {
set({ activeProject: updatedProject });
}
} catch (error) {
console.error("Failed to rename project:", error);
toast.error("Failed to rename project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
},
duplicateProject: async (projectId: string) => {
try {
const project = await storageService.loadProject(projectId);
if (!project) {
toast.error("Project not found", {
description: "Please try again",
});
throw new Error("Project not found");
}
const { savedProjects } = get();
// Extract the base name (remove any existing numbering)
const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
const baseName = numberMatch ? numberMatch[2] : project.name;
const existingNumbers: number[] = [];
// Check for pattern "(number) baseName" in existing projects
savedProjects.forEach((p) => {
const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
if (match && match[2] === baseName) {
existingNumbers.push(parseInt(match[1], 10));
}
});
const nextNumber =
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
const newProject: TProject = {
id: generateUUID(),
name: `(${nextNumber}) ${baseName}`,
thumbnail: project.thumbnail,
createdAt: new Date(),
updatedAt: new Date(),
};
await storageService.saveProject(newProject);
await get().loadAllProjects();
return newProject.id;
} catch (error) {
console.error("Failed to duplicate project:", error);
toast.error("Failed to duplicate project", {
description:
error instanceof Error ? error.message : "Please try again",
});
throw error;
}
},
updateProjectBackground: async (backgroundColor: string) => {
const { activeProject } = get();
if (!activeProject) return;
const updatedProject = {
...activeProject,
backgroundColor,
updatedAt: new Date(),
};
try {
await storageService.saveProject(updatedProject);
set({ activeProject: updatedProject });
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to update project background:", error);
toast.error("Failed to update background", {
description: "Please try again",
});
}
},
updateBackgroundType: async (
type: "color" | "blur",
options?: { backgroundColor?: string; blurIntensity?: number }
) => {
const { activeProject } = get();
if (!activeProject) return;
const updatedProject = {
...activeProject,
backgroundType: type,
...(options?.backgroundColor && {
backgroundColor: options.backgroundColor,
}),
...(options?.blurIntensity && { blurIntensity: options.blurIntensity }),
updatedAt: new Date(),
};
try {
await storageService.saveProject(updatedProject);
set({ activeProject: updatedProject });
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to update background type:", error);
toast.error("Failed to update background", {
description: "Please try again",
});
}
},
updateProjectFps: async (fps: number) => {
const { activeProject } = get();
if (!activeProject) return;
const updatedProject = {
...activeProject,
fps,
updatedAt: new Date(),
};
try {
await storageService.saveProject(updatedProject);
set({ activeProject: updatedProject });
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to update project FPS:", error);
toast.error("Failed to update project FPS", {
description: "Please try again",
});
}
},
getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
const { savedProjects } = get();
// Filter projects by search query
const filteredProjects = savedProjects.filter((project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase())
);
// Sort filtered projects
const sortedProjects = [...filteredProjects].sort((a, b) => {
const [key, order] = sortOption.split("-");
if (key !== "createdAt" && key !== "name") {
console.warn(`Invalid sort key: ${key}`);
return 0;
}
const aValue = a[key];
const bValue = b[key];
if (aValue === undefined || bValue === undefined) return 0;
if (order === "asc") {
if (aValue < bValue) return -1;
if (aValue > bValue) return 1;
return 0;
} else {
if (aValue > bValue) return -1;
if (aValue < bValue) return 1;
return 0;
}
});
return sortedProjects;
},
}));
import { TProject } from "@/types/project";
import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
import { useMediaStore } from "./media-store";
import { useTimelineStore } from "./timeline-store";
import { generateUUID } from "@/lib/utils";
interface ProjectStore {
activeProject: TProject | null;
savedProjects: TProject[];
isLoading: boolean;
isInitialized: boolean;
// Actions
createNewProject: (name: string) => Promise<string>;
loadProject: (id: string) => Promise<void>;
saveCurrentProject: () => Promise<void>;
loadAllProjects: () => Promise<void>;
deleteProject: (id: string) => Promise<void>;
closeProject: () => void;
renameProject: (projectId: string, name: string) => Promise<void>;
duplicateProject: (projectId: string) => Promise<string>;
updateProjectBackground: (backgroundColor: string) => Promise<void>;
updateBackgroundType: (
type: "color" | "blur",
options?: { backgroundColor?: string; blurIntensity?: number }
) => Promise<void>;
updateProjectFps: (fps: number) => Promise<void>;
getFilteredAndSortedProjects: (
searchQuery: string,
sortOption: string
) => TProject[];
}
export const useProjectStore = create<ProjectStore>((set, get) => ({
activeProject: null,
savedProjects: [],
isLoading: true,
isInitialized: false,
createNewProject: async (name: string) => {
const newProject: TProject = {
id: generateUUID(),
name,
thumbnail: "",
createdAt: new Date(),
updatedAt: new Date(),
backgroundColor: "#000000",
backgroundType: "color",
blurIntensity: 8,
};
set({ activeProject: newProject });
try {
await storageService.saveProject(newProject);
// Reload all projects to update the list
await get().loadAllProjects();
return newProject.id;
} catch (error) {
toast.error("Failed to save new project");
throw error;
}
},
loadProject: async (id: string) => {
if (!get().isInitialized) {
set({ isLoading: true });
}
// Clear media and timeline immediately to prevent flickering when switching projects
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
try {
const project = await storageService.loadProject(id);
if (project) {
set({ activeProject: project });
// Load project-specific data in parallel
await Promise.all([
mediaStore.loadProjectMedia(id),
timelineStore.loadProjectTimeline(id),
]);
} else {
throw new Error(`Project with id ${id} not found`);
}
} catch (error) {
console.error("Failed to load project:", error);
throw error; // Re-throw so the editor page can handle it
} finally {
set({ isLoading: false });
}
},
saveCurrentProject: async () => {
const { activeProject } = get();
if (!activeProject) return;
try {
// Save project metadata and timeline data in parallel
const timelineStore = useTimelineStore.getState();
await Promise.all([
storageService.saveProject(activeProject),
timelineStore.saveProjectTimeline(activeProject.id),
]);
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to save project:", error);
}
},
loadAllProjects: async () => {
if (!get().isInitialized) {
set({ isLoading: true });
}
try {
const projects = await storageService.loadAllProjects();
set({ savedProjects: projects });
} catch (error) {
console.error("Failed to load projects:", error);
} finally {
set({ isLoading: false, isInitialized: true });
}
},
deleteProject: async (id: string) => {
try {
// Delete project data in parallel
await Promise.all([
storageService.deleteProjectMedia(id),
storageService.deleteProjectTimeline(id),
storageService.deleteProject(id),
]);
await get().loadAllProjects(); // Refresh the list
// If we deleted the active project, close it and clear data
const { activeProject } = get();
if (activeProject?.id === id) {
set({ activeProject: null });
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
}
} catch (error) {
console.error("Failed to delete project:", error);
}
},
closeProject: () => {
set({ activeProject: null });
// Clear data from stores when closing project
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
},
renameProject: async (id: string, name: string) => {
const { savedProjects } = get();
// Find the project to rename
const projectToRename = savedProjects.find((p) => p.id === id);
if (!projectToRename) {
toast.error("Project not found", {
description: "Please try again",
});
return;
}
const updatedProject = {
...projectToRename,
name,
updatedAt: new Date(),
};
try {
// Save to storage
await storageService.saveProject(updatedProject);
await get().loadAllProjects();
// Update activeProject if it's the same project
const { activeProject } = get();
if (activeProject?.id === id) {
set({ activeProject: updatedProject });
}
} catch (error) {
console.error("Failed to rename project:", error);
toast.error("Failed to rename project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
},
duplicateProject: async (projectId: string) => {
try {
const project = await storageService.loadProject(projectId);
if (!project) {
toast.error("Project not found", {
description: "Please try again",
});
throw new Error("Project not found");
}
const { savedProjects } = get();
// Extract the base name (remove any existing numbering)
const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
const baseName = numberMatch ? numberMatch[2] : project.name;
const existingNumbers: number[] = [];
// Check for pattern "(number) baseName" in existing projects
savedProjects.forEach((p) => {
const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
if (match && match[2] === baseName) {
existingNumbers.push(parseInt(match[1], 10));
}
});
const nextNumber =
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
const newProject: TProject = {
id: generateUUID(),
name: `(${nextNumber}) ${baseName}`,
thumbnail: project.thumbnail,
createdAt: new Date(),
updatedAt: new Date(),
};
await storageService.saveProject(newProject);
await get().loadAllProjects();
return newProject.id;
} catch (error) {
console.error("Failed to duplicate project:", error);
toast.error("Failed to duplicate project", {
description:
error instanceof Error ? error.message : "Please try again",
});
throw error;
}
},
updateProjectBackground: async (backgroundColor: string) => {
const { activeProject } = get();
if (!activeProject) return;
const updatedProject = {
...activeProject,
backgroundColor,
updatedAt: new Date(),
};
try {
await storageService.saveProject(updatedProject);
set({ activeProject: updatedProject });
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to update project background:", error);
toast.error("Failed to update background", {
description: "Please try again",
});
}
},
updateBackgroundType: async (
type: "color" | "blur",
options?: { backgroundColor?: string; blurIntensity?: number }
) => {
const { activeProject } = get();
if (!activeProject) return;
const updatedProject = {
...activeProject,
backgroundType: type,
...(options?.backgroundColor && {
backgroundColor: options.backgroundColor,
}),
...(options?.blurIntensity && { blurIntensity: options.blurIntensity }),
updatedAt: new Date(),
};
try {
await storageService.saveProject(updatedProject);
set({ activeProject: updatedProject });
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to update background type:", error);
toast.error("Failed to update background", {
description: "Please try again",
});
}
},
updateProjectFps: async (fps: number) => {
const { activeProject } = get();
if (!activeProject) return;
const updatedProject = {
...activeProject,
fps,
updatedAt: new Date(),
};
try {
await storageService.saveProject(updatedProject);
set({ activeProject: updatedProject });
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to update project FPS:", error);
toast.error("Failed to update project FPS", {
description: "Please try again",
});
}
},
getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
const { savedProjects } = get();
// Filter projects by search query
const filteredProjects = savedProjects.filter((project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase())
);
// Sort filtered projects
const sortedProjects = [...filteredProjects].sort((a, b) => {
const [key, order] = sortOption.split("-");
if (key !== "createdAt" && key !== "name") {
console.warn(`Invalid sort key: ${key}`);
return 0;
}
const aValue = a[key];
const bValue = b[key];
if (aValue === undefined || bValue === undefined) return 0;
if (order === "asc") {
if (aValue < bValue) return -1;
if (aValue > bValue) return 1;
return 0;
}
if (aValue > bValue) return -1;
if (aValue < bValue) return 1;
return 0;
});
return sortedProjects;
},
}));

View File

@ -295,9 +295,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
{ trackId, elementId },
],
};
} else {
return { selectedElements: [{ trackId, elementId }] };
}
return { selectedElements: [{ trackId, elementId }] };
});
},
@ -680,7 +679,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
(element) => element.id !== elementId
),
};
} else if (track.id === toTrackId) {
}
if (track.id === toTrackId) {
return {
...track,
elements: [...track.elements, elementToMove],
@ -1091,7 +1091,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
if (!fileType) return false;
// Process the new media file
let mediaData: any = {
const mediaData: any = {
name: newFile.name,
type: fileType,
file: newFile,
@ -1204,7 +1204,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
);
const { thumbnailUrl } = await generateVideoThumbnail(mediaItem.file);
return thumbnailUrl;
} else if (mediaItem.type === "image" && mediaItem.url) {
}
if (mediaItem.type === "image" && mediaItem.url) {
return mediaItem.url;
}

View File

@ -1,12 +1,12 @@
export type BackgroundType = "blur" | "mirror" | "color";
export interface CanvasSize {
width: number;
height: number;
}
export interface CanvasPreset {
name: string;
width: number;
height: number;
}
export type BackgroundType = "blur" | "mirror" | "color";
export interface CanvasSize {
width: number;
height: number;
}
export interface CanvasPreset {
name: string;
width: number;
height: number;
}

View File

@ -16,12 +16,58 @@ export type ModifierKeys =
/* eslint-disable prettier/prettier */
// prettier-ignore
export type Key =
| "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j"
| "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t"
| "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3"
| "4" | "5" | "6" | "7" | "8" | "9" | "up" | "down" | "left"
| "right" | "/" | "?" | "." | "enter" | "tab" | "space"
| "escape" | "esc" | "backspace" | "delete" | "home" | "end"
| "a"
| "b"
| "c"
| "d"
| "e"
| "f"
| "g"
| "h"
| "i"
| "j"
| "k"
| "l"
| "m"
| "n"
| "o"
| "p"
| "q"
| "r"
| "s"
| "t"
| "u"
| "v"
| "w"
| "x"
| "y"
| "z"
| "0"
| "1"
| "2"
| "3"
| "4"
| "5"
| "6"
| "7"
| "8"
| "9"
| "up"
| "down"
| "left"
| "right"
| "/"
| "?"
| "."
| "enter"
| "tab"
| "space"
| "escape"
| "esc"
| "backspace"
| "delete"
| "home"
| "end";
/* eslint-enable */
export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`;

View File

@ -1,93 +1,92 @@
export type Post = {
id: string;
slug: string;
title: string;
content: string;
description: string;
coverImage: string;
publishedAt: Date;
updatedAt: Date;
authors: {
id: string;
name: string;
image: string;
}[];
category: {
id: string;
slug: string;
name: string;
};
tags: {
id: string;
slug: string;
name: string;
}[];
attribution: {
author: string;
url: string;
} | null;
};
export type Pagination = {
limit: number;
currpage: number;
nextPage: number | null;
prevPage: number | null;
totalItems: number;
totalPages: number;
};
export type MarblePostList = {
posts: Post[];
pagination: Pagination;
};
export type MarblePost = {
post: Post;
};
export type Tag = {
id: string;
name: string;
slug: string;
};
export type MarbleTag = {
tag: Tag;
};
export type MarbleTagList = {
tags: Tag[];
pagination: Pagination;
};
export type Category = {
id: string;
name: string;
slug: string;
};
export type MarbleCategory = {
category: Category;
};
export type MarbleCategoryList = {
categories: Category[];
pagination: Pagination;
};
export type Author = {
id: string;
slug: string;
title: string;
content: string;
description: string;
coverImage: string;
publishedAt: Date;
updatedAt: Date;
authors: {
id: string;
name: string;
image: string;
}[];
category: {
id: string;
slug: string;
name: string;
};
export type MarbleAuthor = {
author: Author;
};
export type MarbleAuthorList = {
authors: Author[];
pagination: Pagination;
};
tags: {
id: string;
slug: string;
name: string;
}[];
attribution: {
author: string;
url: string;
} | null;
};
export type Pagination = {
limit: number;
currpage: number;
nextPage: number | null;
prevPage: number | null;
totalItems: number;
totalPages: number;
};
export type MarblePostList = {
posts: Post[];
pagination: Pagination;
};
export type MarblePost = {
post: Post;
};
export type Tag = {
id: string;
name: string;
slug: string;
};
export type MarbleTag = {
tag: Tag;
};
export type MarbleTagList = {
tags: Tag[];
pagination: Pagination;
};
export type Category = {
id: string;
name: string;
slug: string;
};
export type MarbleCategory = {
category: Category;
};
export type MarbleCategoryList = {
categories: Category[];
pagination: Pagination;
};
export type Author = {
id: string;
name: string;
image: string;
};
export type MarbleAuthor = {
author: Author;
};
export type MarbleAuthorList = {
authors: Author[];
pagination: Pagination;
};

View File

@ -138,7 +138,8 @@ export function canElementGoOnTrack(
): boolean {
if (elementType === "text") {
return trackType === "text";
} else if (elementType === "media") {
}
if (elementType === "media") {
return trackType === "media" || trackType === "audio";
}
return false;

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