Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65cc22bf7f | ||
|
|
bee7aba960 | ||
|
|
a346bdf497 | ||
|
|
f483cb98cf | ||
|
|
88304c8a90 | ||
|
|
60debe2ce5 | ||
|
|
167e2b83dc | ||
|
|
c5c32a1e34 | ||
|
|
f7ef8d7f38 | ||
|
|
42cf851d63 | ||
|
|
c5480d307c | ||
|
|
3d67b065f5 | ||
|
|
9c00da37b9 | ||
|
|
d8f446580c | ||
|
|
b84e699e9c | ||
|
|
af1964cf11 | ||
|
|
e2bd8d1e09 | ||
|
|
971f9d35d7 | ||
|
|
dd02e31380 | ||
|
|
350b55081c | ||
|
|
2b700b7474 | ||
|
|
e43d79e4c5 | ||
|
|
45d0caf979 | ||
|
|
6e32d4aed5 | ||
|
|
8eabbeae7c | ||
|
|
2c5d5dc234 | ||
|
|
46a74b30ba | ||
|
|
73a156c002 | ||
|
|
29fdd8568d | ||
|
|
3db7666467 | ||
|
|
ae50aee4b6 | ||
|
|
e7d75ce1f8 | ||
|
|
d747fb458d | ||
|
|
e222f15d1d | ||
|
|
21dac9f09e | ||
|
|
37d4014808 | ||
|
|
5abddb0a22 | ||
|
|
598afab08b | ||
|
|
2afd4ec6fb | ||
|
|
e98e40028e | ||
|
|
454a308644 | ||
|
|
886cf81bd0 | ||
|
|
2d834a49d7 | ||
|
|
a7d90f7eac | ||
|
|
70a9bfebb1 | ||
|
|
d4e08bb7e3 | ||
|
|
06e3d0ca2a | ||
|
|
f9170dcf09 | ||
|
|
ebe1b38bdf | ||
|
|
88c4d160e0 | ||
|
|
163b3599dc | ||
|
|
adc8cb37fc | ||
|
|
b459b46802 | ||
|
|
2f7cd11ca1 | ||
|
|
9497bef10a | ||
|
|
74d45a7913 | ||
|
|
7434f90949 | ||
|
|
03e68e77a9 | ||
|
|
a4f2273e25 | ||
|
|
7756ed0d80 | ||
|
|
721a28e9a7 | ||
|
|
fedde33cba | ||
|
|
0e3c4ef13b | ||
|
|
ffc1d7526e | ||
|
|
f750c45765 | ||
|
|
792fb6cdc0 | ||
|
|
756a7c7caa | ||
|
|
17c2ba2fc8 | ||
|
|
13dff382c4 | ||
|
|
f3ddf26334 | ||
|
|
0f1ed40aef | ||
|
|
bb461fa9fb | ||
|
|
a90a0f9740 | ||
|
|
11d2a66c95 | ||
|
|
77d8c6c774 | ||
|
|
c0709293da | ||
|
|
9da0dfc107 | ||
|
|
a0f8e2c1a7 | ||
|
|
96f3373fad | ||
|
|
354b6caedd | ||
|
|
e96682f89e | ||
|
|
1b1486ad94 | ||
|
|
f4e127f2d4 | ||
|
|
8c77095268 | ||
|
|
4621caee19 | ||
|
|
7e4cd76762 | ||
|
|
033a9aa1bc | ||
|
|
aa5cd1edca | ||
|
|
32d263fccc | ||
|
|
7f91c9ca00 | ||
|
|
5e8a489a14 | ||
|
|
c3f3345d7b | ||
|
|
defff2fc46 | ||
|
|
0274fb6626 | ||
|
|
0ae09c9df5 | ||
|
|
f7fddff1fc | ||
|
|
71b6718337 | ||
|
|
c72badb7e9 | ||
|
|
f9aab49080 | ||
|
|
a97eb26fa4 | ||
|
|
ae987e1d66 | ||
|
|
8d3ccf0d56 | ||
|
|
3807e146e4 | ||
|
|
a1e48613b4 | ||
|
|
665bb9fbd1 | ||
|
|
5b059ce6de | ||
|
|
38f8276788 | ||
|
|
fa698c276c | ||
|
|
e908bad167 | ||
|
|
8c205273b7 | ||
|
|
88cb750a3b | ||
|
|
54e990af26 | ||
|
|
e71788014d | ||
|
|
effb6a8641 | ||
|
|
ce58b5199a | ||
|
|
2c90ddd5d5 | ||
|
|
e5a0deb777 | ||
|
|
6aaa9a1f58 | ||
|
|
e724a08684 | ||
|
|
43c8b59c80 | ||
|
|
f8724851fd | ||
|
|
4f6d014286 | ||
|
|
b83e6bedb7 | ||
|
|
9c6556ea05 | ||
|
|
2a7cc13d3e | ||
|
|
eafea56e99 | ||
|
|
539cd45f64 | ||
|
|
efbbc48bc5 | ||
|
|
949228f0bb | ||
|
|
e454612000 | ||
|
|
dee434cd33 | ||
|
|
974e3636fe | ||
|
|
65b04f5759 | ||
|
|
8bbb41a3cb | ||
|
|
1bed89d55f | ||
|
|
8f6c487c26 | ||
|
|
b0329f1372 | ||
|
|
f0dd30ebfc | ||
|
|
1c4c690048 | ||
|
|
f7cd3ebac9 | ||
|
|
9ec5a02252 | ||
|
|
2fcc5e0643 | ||
|
|
0386624ff5 | ||
|
|
efa5be2ab8 | ||
|
|
b0cfedbc5a | ||
|
|
06d99cb6be | ||
|
|
81de82ffaa | ||
|
|
21b248718b | ||
|
|
bb36b2e439 | ||
|
|
3a6bc6ebf7 | ||
|
|
d022a78ae2 | ||
|
|
4cd8100b6b | ||
|
|
2fddbf071d | ||
|
|
d3ace6a946 | ||
|
|
acc2089d0d | ||
|
|
35ade4b25f | ||
|
|
48b694bbd8 | ||
|
|
b52a9ca3e2 | ||
|
|
c0651fec19 | ||
|
|
f50e250ec6 | ||
|
|
02a50d12de | ||
|
|
5e2b07e518 | ||
|
|
c8980de41a | ||
|
|
ae54aff1a9 | ||
|
|
2c0423667c | ||
|
|
e5a5b76ece | ||
|
|
14393c559d | ||
|
|
7891ed2cc1 | ||
|
|
dd09bf2949 | ||
|
|
cd593d1b98 | ||
|
|
68da487f30 | ||
|
|
09cb8c8d98 | ||
|
|
6412158003 | ||
|
|
b00d6d5fb6 | ||
|
|
f5d5a387d3 | ||
|
|
60d4b0c3d6 | ||
|
|
c5baa3fed1 | ||
|
|
5475cea0de | ||
|
|
c60ec04f8d | ||
|
|
732e9f54a8 | ||
|
|
1603dd0e58 | ||
|
|
3c64f9cfa2 | ||
|
|
6899450df9 | ||
|
|
10afbda9dd | ||
|
|
2bde71d4bc | ||
|
|
83b568402a | ||
|
|
b2cd730fb7 | ||
|
|
5f4f3cc6c4 | ||
|
|
f255ccb818 | ||
|
|
a1d2fbd96b | ||
|
|
b229d94a8d | ||
|
|
cd5642161b | ||
|
|
48282555e0 | ||
|
|
e3ecf4855a | ||
|
|
dbc1d2e1a3 | ||
|
|
df1e80b528 | ||
|
|
e6e08057f5 | ||
|
|
f15508df62 | ||
|
|
281ec63d7e | ||
|
|
1c20027d33 | ||
|
|
b9e5bdff95 | ||
|
|
17ab403489 | ||
|
|
91c95b12d1 | ||
|
|
397421159b | ||
|
|
004fa0c12b | ||
|
|
da0e768b32 | ||
|
|
deda0e366f | ||
|
|
118d9a9890 | ||
|
|
95c53e441b | ||
|
|
726d9cb90b | ||
|
|
fbff145036 | ||
|
|
073cb817d3 | ||
|
|
2cf02dd90a | ||
|
|
2e0c9f4b23 | ||
|
|
f6f26fc635 | ||
|
|
ef5d8c25eb | ||
|
|
df3b092010 | ||
|
|
609e115977 | ||
|
|
6ab9f25658 | ||
|
|
d154937ac2 | ||
|
|
906f95399d | ||
|
|
14475dc9a6 | ||
|
|
f149ab3b6c | ||
|
|
6dfc80cd70 | ||
|
|
043bf7d70a | ||
|
|
317ee9141e | ||
|
|
f312d758da | ||
|
|
2a1d9f3c34 | ||
|
|
309a2ecb61 | ||
|
|
4bcefb1a6f | ||
|
|
b0bcca18f8 | ||
|
|
76ee723c37 | ||
|
|
ae94056952 | ||
|
|
7d0113d366 | ||
|
|
a4f1a02cea | ||
|
|
c51e82db9b | ||
|
|
00bcce8eca | ||
|
|
70fe5cacd1 | ||
|
|
87a8ac90e5 | ||
|
|
4f60c0d354 | ||
|
|
c34f3994d9 | ||
|
|
8c72b75bb6 | ||
|
|
a369b606a5 | ||
|
|
4e0d2c20f6 | ||
|
|
642f51dac5 | ||
|
|
812bab4edf | ||
|
|
b288cc0eee | ||
|
|
c73df818d2 | ||
|
|
411fcaccf1 | ||
|
|
5946b84b4b | ||
|
|
d667c1095c | ||
|
|
9a18a7a4e9 | ||
|
|
e3504de1f1 | ||
|
|
7e4a4e2095 | ||
|
|
8dcbbfa252 | ||
|
|
8e737d87e0 | ||
|
|
1ccacf6bb5 | ||
|
|
65c3f9f7f7 | ||
|
|
262a7d53d6 | ||
|
|
5931ddb77a | ||
|
|
7b840e9b4e | ||
|
|
cf039e48a9 | ||
|
|
33acf2497d | ||
|
|
275a23f583 | ||
|
|
5a7745f806 | ||
|
|
e8dc0f62da | ||
|
|
c1b6400612 | ||
|
|
4b882d005f | ||
|
|
0b7a943de3 | ||
|
|
b8671eb761 | ||
|
|
1504b4f60c | ||
|
|
7bd952b080 | ||
|
|
b208f355e2 | ||
|
|
9e711c1e09 | ||
|
|
8be8f0a5a5 | ||
|
|
81ad5c1782 | ||
|
|
17d1336c80 | ||
|
|
45096b7b65 | ||
|
|
41a78b4e4c | ||
|
|
f39f46788f | ||
|
|
184dcbabef | ||
|
|
57243bc4b4 | ||
|
|
8208d931d0 | ||
|
|
b87f470dc4 | ||
|
|
e0fac61a91 | ||
|
|
92079777b9 | ||
|
|
f6073c47c6 | ||
|
|
f98651cd33 | ||
|
|
771591bdfa | ||
|
|
f3d5eb9d08 | ||
|
|
a434459a9c | ||
|
|
901ddb509d | ||
|
|
4fb6018cf4 | ||
|
|
ec87526842 | ||
|
|
cf6ef8f1c4 | ||
|
|
cabf860330 | ||
|
|
68492df302 | ||
|
|
4e808b6c46 | ||
|
|
4350f9898e | ||
|
|
6de16395b7 | ||
|
|
40aac17cfb | ||
|
|
87a29963c3 | ||
|
|
747630ef3f | ||
|
|
a7c0b92051 | ||
|
|
fe0175c15e | ||
|
|
2791992b6b | ||
|
|
097c574d9c | ||
|
|
e7b89a4cc0 | ||
|
|
fb5dca9f9d | ||
|
|
a5f7b477e6 | ||
|
|
853509dd2c | ||
|
|
205ad520f0 | ||
|
|
26f5966ebd | ||
|
|
b6a8ca79f6 | ||
|
|
c783062121 | ||
|
|
301edd0749 | ||
|
|
f2a403b678 | ||
|
|
0ba2893610 | ||
|
|
550f5363fd | ||
|
|
2bc7ae0ea3 | ||
|
|
2bc3df5383 | ||
|
|
c06ff9249d | ||
|
|
cf8c401a2a | ||
|
|
58b42b8dcd | ||
|
|
169e3d698b | ||
|
|
ab82db6346 | ||
|
|
6c41348295 | ||
|
|
53d00b7274 | ||
|
|
d364bffc1f | ||
|
|
d27f05be92 | ||
|
|
2517f77d42 | ||
|
|
6ba75a28f5 | ||
|
|
18296402cb | ||
|
|
3c76a3f241 | ||
|
|
768a1dbfbc | ||
|
|
d6724e2552 | ||
|
|
b555d65ea6 | ||
|
|
35abbefd2e | ||
|
|
5a3fa8fbe1 | ||
|
|
fdb13c2130 | ||
|
|
fbb8446283 | ||
|
|
2f5dd8e12c | ||
|
|
3cd968d423 | ||
|
|
3f3fad1a23 | ||
|
|
6f8c864016 | ||
|
|
5915989669 | ||
|
|
a2c21dc3c4 | ||
|
|
b03287df8d | ||
|
|
061624ab58 |
@@ -0,0 +1,106 @@
|
||||
---
|
||||
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
|
||||
5. Avoid code duplication
|
||||
|
||||
## Rules
|
||||
|
||||
### Accessibility (a11y)
|
||||
|
||||
- Always include a `title` element for icons unless there's text beside the icon.
|
||||
- Always include a `type` attribute for button elements.
|
||||
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
|
||||
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
|
||||
|
||||
### Code Complexity and Quality
|
||||
|
||||
- Don't use primitive type aliases or misleading types.
|
||||
- Don't use the comma operator.
|
||||
- Use for...of statements instead of Array.forEach.
|
||||
- Don't initialize variables to undefined.
|
||||
- Use .flatMap() instead of map().flat() when possible.
|
||||
|
||||
### React and JSX Best Practices
|
||||
|
||||
- Don't import `React` itself.
|
||||
- Don't define React components inside other components.
|
||||
- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.
|
||||
- Don't insert comments as text nodes.
|
||||
- Use `<>...</>` instead of `<Fragment>...</Fragment>`.
|
||||
|
||||
### Function Parameters and Props
|
||||
|
||||
- Always use destructured props objects instead of individual parameters in functions.
|
||||
- Example: `function helloWorld({ prop }: { prop: string })` instead of `function helloWorld(param: string)`.
|
||||
- This applies to all functions, not just React components.
|
||||
|
||||
### Correctness and Safety
|
||||
|
||||
- Don't assign a value to itself.
|
||||
- Avoid unused imports and variables.
|
||||
- Don't use await inside loops.
|
||||
- Don't hardcode sensitive data like API keys and tokens.
|
||||
- Don't use the TypeScript directive @ts-ignore.
|
||||
- Make sure the `preconnect` attribute is used when using Google Fonts.
|
||||
- Don't use the `delete` operator.
|
||||
- Don't use `require()` in TypeScript/ES modules - use proper `import` statements.
|
||||
|
||||
### TypeScript Best Practices
|
||||
|
||||
- Don't use TypeScript enums.
|
||||
- Use either `T[]` or `Array<T>` consistently.
|
||||
- Don't use the `any` type.
|
||||
|
||||
### Style and Consistency
|
||||
|
||||
- Don't use global `eval()`.
|
||||
- Use `String.slice()` instead of `String.substr()` and `String.substring()`.
|
||||
- Don't use `else` blocks when the `if` block breaks early.
|
||||
- Put default function parameters and optional function parameters last.
|
||||
- Use `new` when throwing an error.
|
||||
- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.
|
||||
|
||||
### Next.js Specific Rules
|
||||
|
||||
- Don't use `<img>` elements in Next.js projects.
|
||||
- Don't use `<head>` elements in Next.js projects.
|
||||
|
||||
## Example: Error Handling
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Comprehensive error handling
|
||||
try {
|
||||
const result = await fetchData();
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
console.error("API call failed:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
// ❌ Bad: Swallowing errors
|
||||
try {
|
||||
return await fetchData();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
```
|
||||
@@ -59,8 +59,7 @@ representative at an online or offline event.
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
[INSERT CONTACT METHOD].
|
||||
reported to the community leaders responsible for enforcement at our discord server.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
@@ -87,4 +86,4 @@ version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
```
|
||||
@@ -27,9 +27,8 @@ node_modules
|
||||
*.env
|
||||
|
||||
# cursor
|
||||
|
||||
.cursor/
|
||||
bun.lockb
|
||||
apps/transcription/__pycache__
|
||||
apps/transcription/env
|
||||
|
||||
# mac
|
||||
.DS_Store
|
||||
apps/bg-remover/env
|
||||
@@ -0,0 +1 @@
|
||||
npx ultracite format
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
OpenCut is a free, open-source video editor built with Next.js, focusing on privacy (no server processing), multi-track timeline editing, and real-time preview. The project is a monorepo using Turborepo with multiple apps including a web application, desktop app (Tauri), background remover tools, and transcription services.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
**Development:**
|
||||
```bash
|
||||
# Root level development
|
||||
bun dev # Start all apps in development mode
|
||||
bun build # Build all apps
|
||||
bun lint # Lint all code using Ultracite
|
||||
bun format # Format all code using Ultracite
|
||||
|
||||
# Web app specific (from apps/web/)
|
||||
cd apps/web
|
||||
bun run dev # Start Next.js development server with Turbopack
|
||||
bun run build # Build for production
|
||||
bun run lint # Run Biome linting
|
||||
bun run lint:fix # Fix linting issues automatically
|
||||
bun run format # Format code with Biome
|
||||
|
||||
# Database operations (from apps/web/)
|
||||
bun run db:generate # Generate Drizzle migrations
|
||||
bun run db:migrate # Run migrations
|
||||
bun run db:push:local # Push schema to local development database
|
||||
bun run db:push:prod # Push schema to production database
|
||||
```
|
||||
|
||||
**Testing:**
|
||||
- No unified test commands are currently configured
|
||||
- Individual apps may have their own test setups
|
||||
|
||||
## Architecture & Key Components
|
||||
|
||||
### State Management
|
||||
The application uses **Zustand** for state management with separate stores for different concerns:
|
||||
- **editor-store.ts**: Canvas presets, layout guides, app initialization
|
||||
- **timeline-store.ts**: Timeline tracks, elements, playback state
|
||||
- **media-store.ts**: Media files and asset management
|
||||
- **playback-store.ts**: Video playback controls and timing
|
||||
- **project-store.ts**: Project-level data and persistence
|
||||
- **panel-store.ts**: UI panel visibility and layout
|
||||
- **keybindings-store.ts**: Keyboard shortcut management
|
||||
- **sounds-store.ts**: Audio effects and sound management
|
||||
- **stickers-store.ts**: Sticker/graphics management
|
||||
|
||||
### Storage System
|
||||
**Multi-layer storage approach:**
|
||||
- **IndexedDB**: Projects, saved sounds, and structured data
|
||||
- **OPFS (Origin Private File System)**: Large media files for better performance
|
||||
- **Storage Service** (`lib/storage/`): Abstraction layer managing both storage types
|
||||
|
||||
### Editor Architecture
|
||||
**Core editor components:**
|
||||
- **Timeline Canvas**: Custom canvas-based timeline with tracks and elements
|
||||
- **Preview Panel**: Real-time video preview (currently DOM-based, planned binary refactor)
|
||||
- **Media Panel**: Asset management with drag-and-drop support
|
||||
- **Properties Panel**: Context-sensitive element properties
|
||||
|
||||
### Media Processing
|
||||
- **FFmpeg Integration**: Client-side video processing using @ffmpeg/ffmpeg
|
||||
- **Background Removal**: Python-based tools with multiple AI models (U2Net, SAM, Gemini)
|
||||
- **Transcription**: Separate service for audio-to-text conversion
|
||||
|
||||
## Development Focus Areas
|
||||
|
||||
**✅ Recommended contribution areas:**
|
||||
- Timeline functionality and UI improvements
|
||||
- Project management features
|
||||
- Performance optimizations
|
||||
- Bug fixes in existing functionality
|
||||
- UI/UX improvements outside preview panel
|
||||
- Documentation and testing
|
||||
|
||||
**⚠️ Areas to avoid (pending refactor):**
|
||||
- Preview panel enhancements (fonts, stickers, effects)
|
||||
- Export functionality improvements
|
||||
- Preview rendering optimizations
|
||||
|
||||
**Reason:** The preview system is planned for a major refactor from DOM-based rendering to binary rendering for consistency with export and better performance.
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
**Linting & Formatting:**
|
||||
- Uses **Biome** for JavaScript/TypeScript linting and formatting
|
||||
- Extends **Ultracite** configuration for strict type safety and AI-friendly code
|
||||
- Comprehensive accessibility (a11y) rules enforced
|
||||
- Zero configuration approach with subsecond performance
|
||||
|
||||
**Key coding standards from Ultracite:**
|
||||
- Strict TypeScript with no `any` types
|
||||
- No React imports (uses automatic JSX runtime)
|
||||
- Comprehensive accessibility requirements
|
||||
- Use `for...of` instead of `Array.forEach`
|
||||
- No TypeScript enums, use const objects
|
||||
- Always include error handling with try-catch
|
||||
|
||||
## Environment Setup
|
||||
|
||||
**Required environment variables (apps/web/.env.local):**
|
||||
```bash
|
||||
# Database
|
||||
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
|
||||
|
||||
# Authentication
|
||||
BETTER_AUTH_SECRET="your-generated-secret-here"
|
||||
BETTER_AUTH_URL="http://localhost:3000"
|
||||
|
||||
# Redis
|
||||
UPSTASH_REDIS_REST_URL="http://localhost:8079"
|
||||
UPSTASH_REDIS_REST_TOKEN="example_token"
|
||||
|
||||
# Content Management
|
||||
MARBLE_WORKSPACE_KEY="workspace-key"
|
||||
NEXT_PUBLIC_MARBLE_API_URL="https://api.marblecms.com"
|
||||
```
|
||||
|
||||
**Docker services:**
|
||||
```bash
|
||||
# Start local database and Redis
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
**Monorepo layout:**
|
||||
- `apps/web/` - Main Next.js application
|
||||
- `apps/desktop/` - Tauri desktop application
|
||||
- `apps/bg-remover/` - Python background removal tools
|
||||
- `apps/transcription/` - Audio transcription service
|
||||
- `packages/` - Shared packages (auth, database)
|
||||
|
||||
**Web app structure:**
|
||||
- `src/components/` - React components organized by feature
|
||||
- `src/stores/` - Zustand state management
|
||||
- `src/hooks/` - Custom React hooks
|
||||
- `src/lib/` - Utility functions and services
|
||||
- `src/types/` - TypeScript type definitions
|
||||
- `src/app/` - Next.js app router pages and API routes
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Error handling:**
|
||||
```typescript
|
||||
try {
|
||||
const result = await processData();
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
console.error('Operation failed:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
```
|
||||
|
||||
**Store usage:**
|
||||
```typescript
|
||||
const { tracks, addTrack, updateTrack } = useTimelineStore();
|
||||
```
|
||||
|
||||
**Media processing:**
|
||||
```typescript
|
||||
import { processVideo } from '@/lib/ffmpeg-utils';
|
||||
const processedVideo = await processVideo(inputFile, options);
|
||||
```
|
||||
@@ -4,7 +4,7 @@
|
||||
<img src="apps/web/public/logo.png" alt="OpenCut Logo" width="100" />
|
||||
</td>
|
||||
<td align="right">
|
||||
<h1>OpenCut <span style="font-size: 0.7em; font-weight: normal;">(prev AppCut)</span></h1>
|
||||
<h1>OpenCut</span></h1>
|
||||
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -160,12 +160,12 @@ See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instruc
|
||||
|
||||
## Sponsors
|
||||
|
||||
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
|
||||
|
||||
<a href="https://vercel.com/oss">
|
||||
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
|
||||
<a href="https://fal.ai">
|
||||
<img alt="Powered by fal.ai" src="https://img.shields.io/badge/Powered%20by-fal.ai-000000?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FOpenCut-app%2FOpenCut&project-name=opencut&repository-name=opencut)
|
||||
|
||||
## License
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
Before you follow anything in this guide, please make sure you've followed the steps in the [README](../../README.md) (under "Optional: Auto-captions (Transcription) Setup").
|
||||
|
||||
Open your terminal and make sure you're in the `apps/transcription` directory.
|
||||
|
||||
1. Create virtual environment
|
||||
|
||||
```bash
|
||||
python -m venv env
|
||||
```
|
||||
|
||||
2. Activate it
|
||||
|
||||
**Windows:**
|
||||
|
||||
```bash
|
||||
env\Scripts\activate
|
||||
```
|
||||
|
||||
**macOS/Linux:**
|
||||
|
||||
```bash
|
||||
source env/bin/activate
|
||||
```
|
||||
|
||||
> Note: if you're using VS Code/Cursor and you're seeing errors with the imports about the modules not being found,
|
||||
> You might have to press CTRL + Shift + P -> Python: Select Interpreter -> Enter interpreter path -> Find -> env -> scripts -> python.exe
|
||||
|
||||
3. Install libraries/packages/whatever you wanna call them
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. Make sure you have a Modal account. If you don't: [create one](https://modal.com/)
|
||||
|
||||
> If you don't know what Modal is: it allows us to process the actual audio and transcribe with Whisper by providing the infra to run Python code with a lot of RAM, generally affordable.
|
||||
|
||||
5. Once you've got a Modal accoumt, run this:
|
||||
|
||||
```bash
|
||||
python -m modal setup
|
||||
```
|
||||
|
||||
It's gonna open a browser so you can authenticate.
|
||||
|
||||
6. Test it if you want to make sure it actually works:
|
||||
|
||||
```bash
|
||||
modal run transcription.py
|
||||
```
|
||||
|
||||
6. Deploy the function!
|
||||
|
||||
```bash
|
||||
modal deploy transcription.py
|
||||
```
|
||||
|
||||
7. Set the required secrets in Modal
|
||||
|
||||
So the script we just deployed interacts with Cloudflare to do two things:
|
||||
|
||||
- Download the audio (so it can be transcribed with Whisper)
|
||||
- Delete the file after processing (privacy)
|
||||
|
||||
To do those things, the script needs access to these environment variables:
|
||||
```bash
|
||||
CLOUDFLARE_ACCOUNT_ID=your-account-id
|
||||
R2_ACCESS_KEY_ID=your-access-key-id
|
||||
R2_SECRET_ACCESS_KEY=your-secret-access-key
|
||||
R2_BUCKET_NAME=opencut-transcription
|
||||
```
|
||||
|
||||
Remember, we set these earlier in `.env.local`.
|
||||
|
||||
So let's do it:
|
||||
|
||||
- Go to [Modal Secrets](https://modal.com/secrets/mazewinther/main)
|
||||
- Click "Custom" and enter "opencut-r2-secrets" for the name.
|
||||
- Now you can just click "Import .env" and copy/paste the 4 variables from your `.env.local` file. Copy and paste these only:
|
||||
```bash
|
||||
CLOUDFLARE_ACCOUNT_ID=your-account-id
|
||||
R2_ACCESS_KEY_ID=your-access-key-id
|
||||
R2_SECRET_ACCESS_KEY=your-secret-access-key
|
||||
R2_BUCKET_NAME=opencut-transcription
|
||||
```
|
||||
- Click "Done" and you should see some cool particles!
|
||||
@@ -0,0 +1,5 @@
|
||||
modal
|
||||
openai-whisper
|
||||
boto3
|
||||
pydantic
|
||||
cryptography
|
||||
@@ -0,0 +1,143 @@
|
||||
import modal
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = modal.App("opencut-transcription")
|
||||
|
||||
class TranscribeRequest(BaseModel):
|
||||
filename: str
|
||||
language: str = "auto"
|
||||
decryptionKey: str = None
|
||||
iv: str = None
|
||||
|
||||
@app.function(
|
||||
image=modal.Image.debian_slim()
|
||||
.apt_install(["ffmpeg"])
|
||||
.pip_install(["openai-whisper", "boto3", "fastapi[standard]", "pydantic", "cryptography"]),
|
||||
gpu="A10G",
|
||||
timeout=300, # 5m
|
||||
secrets=[modal.Secret.from_name("opencut-r2-secrets")]
|
||||
)
|
||||
@modal.fastapi_endpoint(method="POST")
|
||||
def transcribe_audio(request: TranscribeRequest):
|
||||
import whisper
|
||||
import boto3
|
||||
import tempfile
|
||||
import os
|
||||
import json
|
||||
|
||||
try:
|
||||
filename = request.filename
|
||||
language = request.language
|
||||
decryption_key = request.decryptionKey
|
||||
iv = request.iv
|
||||
|
||||
if not filename:
|
||||
return {
|
||||
"error": "Missing filename parameter"
|
||||
}
|
||||
|
||||
# Initialize R2 client
|
||||
s3_client = boto3.client(
|
||||
's3',
|
||||
endpoint_url=f'https://{os.environ["CLOUDFLARE_ACCOUNT_ID"]}.r2.cloudflarestorage.com',
|
||||
aws_access_key_id=os.environ["R2_ACCESS_KEY_ID"],
|
||||
aws_secret_access_key=os.environ["R2_SECRET_ACCESS_KEY"],
|
||||
region_name='auto'
|
||||
)
|
||||
|
||||
# Create temporary file for audio
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.wav') as temp_file:
|
||||
temp_path = temp_file.name
|
||||
|
||||
try:
|
||||
# Download audio from R2
|
||||
s3_client.download_file(
|
||||
os.environ["R2_BUCKET_NAME"],
|
||||
filename,
|
||||
temp_path
|
||||
)
|
||||
|
||||
# If decryption key provided, decrypt the file directly (zero-knowledge)
|
||||
if decryption_key and iv:
|
||||
import base64
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
# Read the encrypted file
|
||||
with open(temp_path, 'rb') as f:
|
||||
encrypted_data = f.read()
|
||||
|
||||
# Decode the key and IV from base64
|
||||
key_bytes = base64.b64decode(decryption_key)
|
||||
iv_bytes = base64.b64decode(iv)
|
||||
|
||||
# Decrypt the data using AES-GCM
|
||||
# Extract the tag (last 16 bytes) and ciphertext
|
||||
tag = encrypted_data[-16:]
|
||||
ciphertext = encrypted_data[:-16]
|
||||
|
||||
cipher = Cipher(
|
||||
algorithms.AES(key_bytes),
|
||||
modes.GCM(iv_bytes, tag),
|
||||
backend=default_backend()
|
||||
)
|
||||
decryptor = cipher.decryptor()
|
||||
decrypted_data = decryptor.update(ciphertext) + decryptor.finalize()
|
||||
|
||||
# Write decrypted audio back to temp file
|
||||
with open(temp_path, 'wb') as f:
|
||||
f.write(decrypted_data)
|
||||
|
||||
# Load Whisper model
|
||||
model = whisper.load_model("base")
|
||||
|
||||
# Transcribe audio
|
||||
if language == "auto":
|
||||
result = model.transcribe(temp_path)
|
||||
else:
|
||||
result = model.transcribe(temp_path, language=language.lower())
|
||||
|
||||
# Delete audio file from R2 (cleanup)
|
||||
s3_client.delete_object(
|
||||
Bucket=os.environ["R2_BUCKET_NAME"],
|
||||
Key=filename
|
||||
)
|
||||
|
||||
# Adjust segment timing - Whisper is consistently late by ~500ms
|
||||
adjusted_segments = []
|
||||
for segment in result["segments"]:
|
||||
adjusted_segment = segment.copy()
|
||||
# Shift start/end times earlier by 500ms, don't go below 0
|
||||
adjusted_segment["start"] = max(0, segment["start"] - 0.5)
|
||||
adjusted_segment["end"] = max(0.5, segment["end"] - 0.5) # Ensure duration is at least 0.5s
|
||||
adjusted_segments.append(adjusted_segment)
|
||||
|
||||
return {
|
||||
"text": result["text"],
|
||||
"segments": adjusted_segments,
|
||||
"language": result["language"]
|
||||
}
|
||||
|
||||
finally:
|
||||
# Clean up temporary file
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"Transcription error: {str(e)}")
|
||||
print(f"Traceback: {traceback.format_exc()}")
|
||||
|
||||
# Return error response that matches expected format
|
||||
return {
|
||||
"error": str(e),
|
||||
"text": "",
|
||||
"segments": [],
|
||||
"language": "unknown"
|
||||
}
|
||||
|
||||
@app.local_entrypoint()
|
||||
def main():
|
||||
# Test function - you can call this with modal run transcription.py
|
||||
print("Transcription service is ready to deploy!")
|
||||
print("Deploy with: modal deploy transcription.py")
|
||||
@@ -16,4 +16,18 @@ UPSTASH_REDIS_REST_TOKEN=example_token
|
||||
|
||||
# Marble Blog
|
||||
MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key
|
||||
NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
|
||||
NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
|
||||
|
||||
# Freesound (generate at https://freesound.org/apiv2/apply/)
|
||||
FREESOUND_CLIENT_ID=...
|
||||
FREESOUND_API_KEY=...
|
||||
|
||||
# Cloudflare R2 (for auto-captions/transcription)
|
||||
# Get these from Cloudflare Dashboard > R2 > Manage R2 API tokens
|
||||
CLOUDFLARE_ACCOUNT_ID=your-account-id
|
||||
R2_ACCESS_KEY_ID=your-access-key-id
|
||||
R2_SECRET_ACCESS_KEY=your-secret-access-key
|
||||
R2_BUCKET_NAME=opencut-transcription
|
||||
|
||||
# Modal transcription endpoint (from modal deploy transcription.py)
|
||||
MODAL_TRANSCRIPTION_URL=https://your-username--opencut-transcription-transcribe-audio.modal.run
|
||||
@@ -5,6 +5,9 @@ FROM base AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG FREESOUND_CLIENT_ID
|
||||
ARG FREESOUND_API_KEY
|
||||
|
||||
COPY package.json package.json
|
||||
COPY bun.lock bun.lock
|
||||
COPY turbo.json turbo.json
|
||||
@@ -28,6 +31,9 @@ ENV UPSTASH_REDIS_REST_URL="http://localhost:8079"
|
||||
ENV UPSTASH_REDIS_REST_TOKEN="example_token"
|
||||
ENV NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000"
|
||||
|
||||
ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID
|
||||
ENV FREESOUND_API_KEY=$FREESOUND_API_KEY
|
||||
|
||||
WORKDIR /app/apps/web
|
||||
RUN bun run build
|
||||
|
||||
@@ -54,4 +60,6 @@ EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["bun", "apps/web/server.js"]
|
||||
|
||||
CMD ["bun", "apps/web/server.js"]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
@@ -18,4 +18,4 @@
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE "export_waitlist" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"created_at" timestamp NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
CONSTRAINT "export_waitlist_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "export_waitlist" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DROP TABLE "waitlist" CASCADE;
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
{
|
||||
"id": "71b84015-78f8-4d07-9115-8d56ea550459",
|
||||
"prevId": "1b9148ed-497b-4e53-8cf6-f556c8fe7f7f",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.accounts": {
|
||||
"name": "accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"accounts_user_id_users_id_fk": {
|
||||
"name": "accounts_user_id_users_id_fk",
|
||||
"tableFrom": "accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.export_waitlist": {
|
||||
"name": "export_waitlist",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"export_waitlist_email_unique": {
|
||||
"name": "export_waitlist_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"sessions_token_unique": {
|
||||
"name": "sessions_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.verifications": {
|
||||
"name": "verifications",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,13 @@
|
||||
"when": 1752569400809,
|
||||
"tag": "0002_cuddly_pretty_boy",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1755377469662,
|
||||
"tag": "0003_long_polaris",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -30,6 +30,18 @@ const nextConfig: NextConfig = {
|
||||
protocol: "https",
|
||||
hostname: "avatars.githubusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.iconify.design",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.simplesvg.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.unisvg.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
"private": true,
|
||||
"packageManager": "bun@1.2.18",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "biome check src/",
|
||||
"lint:fix": "biome check src/ --write",
|
||||
"format": "biome format src/ --write",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
|
||||
@@ -27,6 +29,7 @@
|
||||
"@upstash/ratelimit": "^2.0.5",
|
||||
"@upstash/redis": "^1.35.0",
|
||||
"@vercel/analytics": "^1.4.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"better-auth": "^1.2.7",
|
||||
"botid": "^1.4.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -36,11 +39,13 @@
|
||||
"dotenv": "^16.5.0",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
"feed": "^5.1.0",
|
||||
"framer-motion": "^11.13.1",
|
||||
"input-otp": "^1.4.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"motion": "^12.18.1",
|
||||
"next": "^15.4.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "^15.4.5",
|
||||
"next-themes": "^0.4.4",
|
||||
"pg": "^8.16.2",
|
||||
"radix-ui": "^1.4.2",
|
||||
@@ -67,16 +72,17 @@
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"cross-env": "^7.0.3",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 467 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 564 B After Width: | Height: | Size: 741 B |
|
Before Width: | Height: | Size: 222 B After Width: | Height: | Size: 768 B |
|
Before Width: | Height: | Size: 253 B After Width: | Height: | Size: 802 B |
|
Before Width: | Height: | Size: 274 B After Width: | Height: | Size: 826 B |
|
Before Width: | Height: | Size: 334 B After Width: | Height: | Size: 906 B |
|
Before Width: | Height: | Size: 399 B After Width: | Height: | Size: 985 B |
|
Before Width: | Height: | Size: 419 B After Width: | Height: | Size: 998 B |
|
Before Width: | Height: | Size: 467 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 546 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 724 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 257 B After Width: | Height: | Size: 809 B |
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 843 B |
|
Before Width: | Height: | Size: 274 B After Width: | Height: | Size: 826 B |
|
Before Width: | Height: | Size: 263 B After Width: | Height: | Size: 820 B |
|
Before Width: | Height: | Size: 131 B After Width: | Height: | Size: 670 B |
|
Before Width: | Height: | Size: 206 B After Width: | Height: | Size: 747 B |
|
Before Width: | Height: | Size: 334 B After Width: | Height: | Size: 906 B |
|
Before Width: | Height: | Size: 467 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 539 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 259 B After Width: | Height: | Size: 814 B |
|
Before Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 1021 B After Width: | Height: | Size: 1.7 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { InputWithBack } from "@/components/ui/input-with-back";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function AnimationPage() {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="h-dvh flex">
|
||||
<div className="bg-panel p-6 w-[20rem]">
|
||||
<InputWithBack isExpanded={isExpanded} setIsExpanded={setIsExpanded} />
|
||||
</div>
|
||||
<div className="p-6 flex items-end justify-end">
|
||||
<p
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="cursor-pointer hover:opacity-75 transition-opacity"
|
||||
>
|
||||
{isExpanded ? "Collapse" : "Expand"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { auth } from "@opencut/auth";
|
||||
import { 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);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { AwsClient } from "aws4fetch";
|
||||
import { nanoid } from "nanoid";
|
||||
import { env } from "@/env";
|
||||
import { baseRateLimit } from "@/lib/rate-limit";
|
||||
import { isTranscriptionConfigured } from "@/lib/transcription-utils";
|
||||
|
||||
const uploadRequestSchema = z.object({
|
||||
fileExtension: z.enum(["wav", "mp3", "m4a", "flac"], {
|
||||
errorMap: () => ({
|
||||
message: "File extension must be wav, mp3, m4a, or flac",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const apiResponseSchema = z.object({
|
||||
uploadUrl: z.string().url(),
|
||||
fileName: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Generates a presigned upload URL and a unique filename for uploading an audio file to Cloudflare R2.
|
||||
*
|
||||
* Accepts a JSON request body with a `fileExtension` (one of "wav", "mp3", "m4a", "flac"). Applies client rate limiting and verifies required transcription environment configuration before producing a signed PUT URL valid for 1 hour.
|
||||
*
|
||||
* @param request - Incoming Next.js request whose JSON body must include `fileExtension`
|
||||
* @returns On success, an object with `uploadUrl` (the presigned PUT URL) and `fileName` (the generated object path). On failure, a JSON error object with an `error` field and optional `message`/`details`; responses use appropriate HTTP status codes (400, 429, 503, 500).
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
|
||||
const { success } = await baseRateLimit.limit(ip);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
// Check transcription configuration
|
||||
const transcriptionCheck = isTranscriptionConfigured();
|
||||
if (!transcriptionCheck.configured) {
|
||||
console.error(
|
||||
"Missing environment variables:",
|
||||
JSON.stringify(transcriptionCheck.missingVars)
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Transcription not configured",
|
||||
message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`,
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate request body
|
||||
const rawBody = await request.json().catch(() => null);
|
||||
if (!rawBody) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON in request body" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const validationResult = uploadRequestSchema.safeParse(rawBody);
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid request parameters",
|
||||
details: validationResult.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { fileExtension } = validationResult.data;
|
||||
|
||||
// Initialize R2 client
|
||||
const client = new AwsClient({
|
||||
accessKeyId: env.R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
|
||||
});
|
||||
|
||||
// Generate unique filename with timestamp
|
||||
const timestamp = Date.now();
|
||||
const fileName = `audio/${timestamp}-${nanoid()}.${fileExtension}`;
|
||||
|
||||
// Create presigned URL
|
||||
const url = new URL(
|
||||
`https://${env.R2_BUCKET_NAME}.${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}`
|
||||
);
|
||||
|
||||
url.searchParams.set("X-Amz-Expires", "3600"); // 1 hour expiry
|
||||
|
||||
const signed = await client.sign(new Request(url, { method: "PUT" }), {
|
||||
aws: { signQuery: true },
|
||||
});
|
||||
|
||||
if (!signed.url) {
|
||||
throw new Error("Failed to generate presigned URL");
|
||||
}
|
||||
|
||||
// Prepare and validate response
|
||||
const responseData = {
|
||||
uploadUrl: signed.url,
|
||||
fileName,
|
||||
};
|
||||
|
||||
const responseValidation = apiResponseSchema.safeParse(responseData);
|
||||
if (!responseValidation.success) {
|
||||
console.error(
|
||||
"Invalid API response structure:",
|
||||
responseValidation.error
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal response formatting error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(responseValidation.data);
|
||||
} catch (error) {
|
||||
console.error("Error generating upload URL:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to generate upload URL",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { env } from "@/env";
|
||||
import { baseRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
q: z.string().max(500, "Query too long").optional(),
|
||||
type: z.enum(["songs", "effects"]).optional(),
|
||||
page: z.coerce.number().int().min(1).max(1000).default(1),
|
||||
page_size: z.coerce.number().int().min(1).max(150).default(20),
|
||||
sort: z
|
||||
.enum(["downloads", "rating", "created", "score"])
|
||||
.default("downloads"),
|
||||
min_rating: z.coerce.number().min(0).max(5).default(3),
|
||||
commercial_only: z.coerce.boolean().default(true),
|
||||
});
|
||||
|
||||
const freesoundResultSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string().url(),
|
||||
previews: z
|
||||
.object({
|
||||
"preview-hq-mp3": z.string().url(),
|
||||
"preview-lq-mp3": z.string().url(),
|
||||
"preview-hq-ogg": z.string().url(),
|
||||
"preview-lq-ogg": z.string().url(),
|
||||
})
|
||||
.optional(),
|
||||
download: z.string().url().optional(),
|
||||
duration: z.number(),
|
||||
filesize: z.number(),
|
||||
type: z.string(),
|
||||
channels: z.number(),
|
||||
bitrate: z.number(),
|
||||
bitdepth: z.number(),
|
||||
samplerate: z.number(),
|
||||
username: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
license: z.string(),
|
||||
created: z.string(),
|
||||
num_downloads: z.number().optional(),
|
||||
avg_rating: z.number().optional(),
|
||||
num_ratings: z.number().optional(),
|
||||
});
|
||||
|
||||
const freesoundResponseSchema = z.object({
|
||||
count: z.number(),
|
||||
next: z.string().url().nullable(),
|
||||
previous: z.string().url().nullable(),
|
||||
results: z.array(freesoundResultSchema),
|
||||
});
|
||||
|
||||
const transformedResultSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string(),
|
||||
previewUrl: z.string().optional(),
|
||||
downloadUrl: z.string().optional(),
|
||||
duration: z.number(),
|
||||
filesize: z.number(),
|
||||
type: z.string(),
|
||||
channels: z.number(),
|
||||
bitrate: z.number(),
|
||||
bitdepth: z.number(),
|
||||
samplerate: z.number(),
|
||||
username: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
license: z.string(),
|
||||
created: z.string(),
|
||||
downloads: z.number().optional(),
|
||||
rating: z.number().optional(),
|
||||
ratingCount: z.number().optional(),
|
||||
});
|
||||
|
||||
const apiResponseSchema = z.object({
|
||||
count: z.number(),
|
||||
next: z.string().nullable(),
|
||||
previous: z.string().nullable(),
|
||||
results: z.array(transformedResultSchema),
|
||||
query: z.string().optional(),
|
||||
type: z.string(),
|
||||
page: z.number(),
|
||||
pageSize: z.number(),
|
||||
sort: z.string(),
|
||||
minRating: z.number().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle GET requests to search Freesound and return a normalized search response.
|
||||
*
|
||||
* Validates query parameters, applies rate limiting and sound-effect filters, queries the Freesound API, transforms results into the API's response shape, and returns the formatted JSON payload or a JSON error object.
|
||||
*
|
||||
* @param request - The incoming NextRequest containing URL query parameters (q, type, page, page_size, sort, min_rating, commercial_only) and client headers used for rate limiting.
|
||||
* @returns A JSON object with search metadata and an array of transformed sound results when successful; otherwise a JSON error object with an `error` field and optional `details` or `message` fields describing the failure.
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
|
||||
const { success } = await baseRateLimit.limit(ip);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const validationResult = searchParamsSchema.safeParse({
|
||||
q: searchParams.get("q") || undefined,
|
||||
type: searchParams.get("type") || undefined,
|
||||
page: searchParams.get("page") || undefined,
|
||||
page_size: searchParams.get("page_size") || undefined,
|
||||
sort: searchParams.get("sort") || undefined,
|
||||
min_rating: searchParams.get("min_rating") || undefined,
|
||||
});
|
||||
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid parameters",
|
||||
details: validationResult.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
q: query,
|
||||
type,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
sort,
|
||||
min_rating,
|
||||
commercial_only,
|
||||
} = validationResult.data;
|
||||
|
||||
if (type === "songs") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Songs are not available yet",
|
||||
message:
|
||||
"Song search functionality is coming soon. Try searching for sound effects instead.",
|
||||
},
|
||||
{ status: 501 }
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = "https://freesound.org/apiv2/search/text/";
|
||||
|
||||
// Use score sorting for search queries, downloads for top sounds
|
||||
const sortParam = query
|
||||
? sort === "score"
|
||||
? "score"
|
||||
: `${sort}_desc`
|
||||
: `${sort}_desc`;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
query: query || "",
|
||||
token: env.FREESOUND_API_KEY,
|
||||
page: page.toString(),
|
||||
page_size: pageSize.toString(),
|
||||
sort: sortParam,
|
||||
fields:
|
||||
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
|
||||
});
|
||||
|
||||
// Always apply sound effect filters (since we're primarily a sound effects search)
|
||||
if (type === "effects" || !type) {
|
||||
params.append("filter", "duration:[* TO 30.0]");
|
||||
params.append("filter", `avg_rating:[${min_rating} TO *]`);
|
||||
|
||||
// Filter by license if commercial_only is true
|
||||
if (commercial_only) {
|
||||
params.append(
|
||||
"filter",
|
||||
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")'
|
||||
);
|
||||
}
|
||||
|
||||
params.append(
|
||||
"filter",
|
||||
"tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion"
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("Freesound API error:", response.status, errorText);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to search sounds" },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
|
||||
const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
|
||||
if (!freesoundValidation.success) {
|
||||
console.error(
|
||||
"Invalid Freesound API response:",
|
||||
freesoundValidation.error
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid response from Freesound API" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = freesoundValidation.data;
|
||||
|
||||
const transformedResults = data.results.map((result) => ({
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
description: result.description,
|
||||
url: result.url,
|
||||
previewUrl:
|
||||
result.previews?.["preview-hq-mp3"] ||
|
||||
result.previews?.["preview-lq-mp3"],
|
||||
downloadUrl: result.download,
|
||||
duration: result.duration,
|
||||
filesize: result.filesize,
|
||||
type: result.type,
|
||||
channels: result.channels,
|
||||
bitrate: result.bitrate,
|
||||
bitdepth: result.bitdepth,
|
||||
samplerate: result.samplerate,
|
||||
username: result.username,
|
||||
tags: result.tags,
|
||||
license: result.license,
|
||||
created: result.created,
|
||||
downloads: result.num_downloads || 0,
|
||||
rating: result.avg_rating || 0,
|
||||
ratingCount: result.num_ratings || 0,
|
||||
}));
|
||||
|
||||
const responseData = {
|
||||
count: data.count,
|
||||
next: data.next,
|
||||
previous: data.previous,
|
||||
results: transformedResults,
|
||||
query: query || "",
|
||||
type: type || "effects",
|
||||
page,
|
||||
pageSize,
|
||||
sort,
|
||||
minRating: min_rating,
|
||||
};
|
||||
|
||||
const responseValidation = apiResponseSchema.safeParse(responseData);
|
||||
if (!responseValidation.success) {
|
||||
console.error(
|
||||
"Invalid API response structure:",
|
||||
responseValidation.error
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal response formatting error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(responseValidation.data);
|
||||
} catch (error) {
|
||||
console.error("Error searching sounds:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { env } from "@/env";
|
||||
import { baseRateLimit } from "@/lib/rate-limit";
|
||||
import { isTranscriptionConfigured } from "@/lib/transcription-utils";
|
||||
|
||||
const transcribeRequestSchema = z.object({
|
||||
filename: z.string().min(1, "Filename is required"),
|
||||
language: z.string().optional().default("auto"),
|
||||
decryptionKey: z.string().min(1, "Decryption key is required").optional(),
|
||||
iv: z.string().min(1, "IV is required").optional(),
|
||||
});
|
||||
|
||||
const modalResponseSchema = z.object({
|
||||
text: z.string(),
|
||||
segments: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
seek: z.number(),
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
text: z.string(),
|
||||
tokens: z.array(z.number()),
|
||||
temperature: z.number(),
|
||||
avg_logprob: z.number(),
|
||||
compression_ratio: z.number(),
|
||||
no_speech_prob: z.number(),
|
||||
})
|
||||
),
|
||||
language: z.string(),
|
||||
});
|
||||
|
||||
const apiResponseSchema = z.object({
|
||||
text: z.string(),
|
||||
segments: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
seek: z.number(),
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
text: z.string(),
|
||||
tokens: z.array(z.number()),
|
||||
temperature: z.number(),
|
||||
avg_logprob: z.number(),
|
||||
compression_ratio: z.number(),
|
||||
no_speech_prob: z.number(),
|
||||
})
|
||||
),
|
||||
language: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle POST requests to transcribe a file and return structured transcription results.
|
||||
*
|
||||
* Validates rate limits and request body, forwards the transcription request to the configured
|
||||
* transcription service, validates the service response, and returns a JSON payload with
|
||||
* `text`, `segments`, and `language` on success.
|
||||
*
|
||||
* On error the response is a JSON object containing an `error` string and often a `message`
|
||||
* with an appropriate HTTP status code (examples: 400 for invalid input, 429 for rate limit,
|
||||
* 502 for upstream transcription errors, 503 for missing configuration, 500 for internal errors).
|
||||
*
|
||||
* @returns A JSON response with the transcription `{ text, segments, language }` on success; otherwise a JSON error object with `error` and optional `message`.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
|
||||
const { success } = await baseRateLimit.limit(ip);
|
||||
const origin = request.headers.get("origin");
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
// Check transcription configuration
|
||||
const transcriptionCheck = isTranscriptionConfigured();
|
||||
if (!transcriptionCheck.configured) {
|
||||
console.error(
|
||||
"Missing environment variables:",
|
||||
JSON.stringify(transcriptionCheck.missingVars)
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Transcription not configured",
|
||||
message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`,
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate request body
|
||||
const rawBody = await request.json().catch(() => null);
|
||||
if (!rawBody) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON in request body" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const validationResult = transcribeRequestSchema.safeParse(rawBody);
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid request parameters",
|
||||
details: validationResult.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { filename, language, decryptionKey, iv } = validationResult.data;
|
||||
|
||||
// Prepare request body for Modal
|
||||
const modalRequestBody: any = {
|
||||
filename,
|
||||
language,
|
||||
};
|
||||
|
||||
// Add encryption parameters if provided (zero-knowledge)
|
||||
if (decryptionKey && iv) {
|
||||
modalRequestBody.decryptionKey = decryptionKey;
|
||||
modalRequestBody.iv = iv;
|
||||
}
|
||||
|
||||
// Call Modal transcription service
|
||||
const response = await fetch(env.MODAL_TRANSCRIPTION_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(modalRequestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("Modal API error:", response.status, errorText);
|
||||
|
||||
let errorMessage = "Transcription service unavailable";
|
||||
try {
|
||||
const errorData = JSON.parse(errorText);
|
||||
errorMessage = errorData.error || errorMessage;
|
||||
} catch {
|
||||
// Use default message if parsing fails
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: errorMessage,
|
||||
message: "Failed to process transcription request",
|
||||
},
|
||||
{ status: response.status >= 500 ? 502 : response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const rawResult = await response.json();
|
||||
console.log("Raw Modal response:", JSON.stringify(rawResult, null, 2));
|
||||
|
||||
// Validate Modal response
|
||||
const modalValidation = modalResponseSchema.safeParse(rawResult);
|
||||
if (!modalValidation.success) {
|
||||
console.error("Invalid Modal API response:", modalValidation.error);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid response from transcription service" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = modalValidation.data;
|
||||
|
||||
// Prepare and validate API response
|
||||
const responseData = {
|
||||
text: result.text,
|
||||
segments: result.segments,
|
||||
language: result.language,
|
||||
};
|
||||
|
||||
const responseValidation = apiResponseSchema.safeParse(responseData);
|
||||
if (!responseValidation.success) {
|
||||
console.error(
|
||||
"Invalid API response structure:",
|
||||
responseValidation.error
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal response formatting error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(responseValidation.data);
|
||||
} catch (error) {
|
||||
console.error("Transcription API error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Internal server error",
|
||||
message: "An unexpected error occurred during transcription",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { baseRateLimit } from "@/lib/rate-limit";
|
||||
import { db, exportWaitlist, eq } from "@opencut/db";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
exportWaitlistSchema,
|
||||
exportWaitlistResponseSchema,
|
||||
} from "@/lib/schemas/waitlist";
|
||||
|
||||
const requestSchema = exportWaitlistSchema;
|
||||
const responseSchema = exportWaitlistResponseSchema;
|
||||
|
||||
/**
|
||||
* Handle POST requests to add an email to the export waitlist while enforcing per-IP rate limits.
|
||||
*
|
||||
* Validates JSON input, checks for an existing waitlist entry, inserts a new entry when needed,
|
||||
* and returns a JSON response indicating success or an error condition.
|
||||
*
|
||||
* @returns A JSON response object:
|
||||
* - `{ success: true, alreadySubscribed: true }` when the email is already on the waitlist
|
||||
* - `{ success: true }` when a new waitlist entry is created
|
||||
* - `{ error: string, ... }` with details and an appropriate HTTP status for invalid input, rate limit exceeded, or internal errors
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
|
||||
const { success } = await baseRateLimit.limit(ip);
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON in request body" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = requestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid request parameters",
|
||||
details: parsed.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { email } = parsed.data;
|
||||
|
||||
const existing = await db
|
||||
.select({ id: exportWaitlist.id })
|
||||
.from(exportWaitlist)
|
||||
.where(eq(exportWaitlist.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
const responseData = { success: true, alreadySubscribed: true } as const;
|
||||
const validated = responseSchema.safeParse(responseData);
|
||||
if (!validated.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Internal response formatting error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(validated.data);
|
||||
}
|
||||
|
||||
await db.insert(exportWaitlist).values({
|
||||
id: randomUUID(),
|
||||
email,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const responseData = { success: true } as const;
|
||||
const validated = responseSchema.safeParse(responseData);
|
||||
if (!validated.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Internal response formatting error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(validated.data);
|
||||
} catch (error) {
|
||||
console.error("Waitlist API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import crypto from "crypto";
|
||||
import { env } from "@/env";
|
||||
|
||||
const CSRF_TOKEN_NAME = "waitlist-csrf";
|
||||
const TOKEN_EXPIRY = 60 * 60 * 1000;
|
||||
const allowedHosts = env.NODE_ENV === "development" ? ["localhost:3000", "127.0.0.1:3000"] : ["opencut.app", "www.opencut.app"];
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const referer = request.headers.get("referer");
|
||||
const host = request.headers.get("host");
|
||||
|
||||
if (referer) {
|
||||
const refererUrl = new URL(referer);
|
||||
|
||||
if (!allowedHosts.some((allowed) => refererUrl.host === allowed || refererUrl.host.endsWith(allowed))) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
} else if (host) {
|
||||
if (!allowedHosts.some((allowed) => host === allowed || host.endsWith(allowed))) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!env.BETTER_AUTH_SECRET) {
|
||||
throw new Error("BETTER_AUTH_SECRET must be configured");
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const timestamp = Date.now();
|
||||
const signature = crypto.createHmac("sha256", env.BETTER_AUTH_SECRET).update(`${token}:${timestamp}`).digest("hex");
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(CSRF_TOKEN_NAME, `${token}:${timestamp}:${signature}`, {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === "production",
|
||||
sameSite: "strict",
|
||||
maxAge: TOKEN_EXPIRY / 1000,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return NextResponse.json({ token });
|
||||
}
|
||||
@@ -87,8 +87,8 @@ async function Page({ params }: PageProps) {
|
||||
|
||||
<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 className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative container max-w-3xl mx-auto px-4 py-16">
|
||||
|
||||
@@ -28,8 +28,8 @@ export default async function BlogPage() {
|
||||
|
||||
<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 className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative container max-w-3xl mx-auto px-4 py-16">
|
||||
|
||||
@@ -1,277 +1,330 @@
|
||||
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,
|
||||
MarbleIcon,
|
||||
VercelIcon,
|
||||
DataBuddyIcon,
|
||||
} from "@/components/icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EXTERNAL_TOOLS } from "@/constants/site";
|
||||
|
||||
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
|
||||
} as RequestInit
|
||||
);
|
||||
|
||||
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-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative container mx-auto px-4 py-16">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-20">
|
||||
<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-linear-to-r from-muted/50 to-muted/30 rounded-2xl blur-sm group-hover:blur-md transition-all duration-300" />
|
||||
<Card className="relative bg-background/80 backdrop-blur-xs border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
|
||||
<CardContent className="p-8 text-center">
|
||||
<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-6 mb-20">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-2xl font-semibold mb-2">External Tools</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Tools we use to build OpenCut
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
|
||||
{EXTERNAL_TOOLS.map((tool, index) => {
|
||||
const IconComponent = {
|
||||
MarbleIcon,
|
||||
VercelIcon,
|
||||
DataBuddyIcon,
|
||||
}[tool.icon];
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tool.name}
|
||||
href={tool.url}
|
||||
target="_blank"
|
||||
className="group block"
|
||||
style={{
|
||||
animationDelay: `${index * 100}ms`,
|
||||
}}
|
||||
>
|
||||
<Card className="h-full bg-background/80 backdrop-blur-xs border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
|
||||
<CardContent className="p-6 text-center h-full flex flex-col">
|
||||
<div className="mb-4">
|
||||
<div className="w-12 h-12 mx-auto rounded-full bg-muted/50 flex items-center justify-center group-hover:bg-muted/70 transition-colors">
|
||||
<IconComponent className="h-6 w-6" size={24} />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-2 group-hover:text-foreground/80 transition-colors">
|
||||
{tool.name}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground flex-1">
|
||||
{tool.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-32 text-center">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h2 className="text-3xl font-bold mb-4">Join the community</h2>
|
||||
<p className="text-lg text-muted-foreground mb-10 leading-relaxed">
|
||||
OpenCut is built by developers like you. Every contribution,
|
||||
no matter how small, helps make video editing more accessible
|
||||
for everyone.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="https://github.com/OpenCut-app/OpenCut/blob/main/.github/CONTRIBUTING.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button size="lg" className="gap-2 group">
|
||||
<GithubIcon className="h-4 w-4 group-hover:scale-110 transition-transform" />
|
||||
Start Contributing
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/OpenCut-app/OpenCut/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="outline" size="lg" className="gap-2 group">
|
||||
Browse Issues
|
||||
<ExternalLink className="h-4 w-4 group-hover:scale-110 transition-transform" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
export default function EditorLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
@@ -6,17 +6,17 @@ import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "../../../components/ui/resizable";
|
||||
import { MediaPanel } from "../../../components/editor/media-panel";
|
||||
import { PropertiesPanel } from "../../../components/editor/properties-panel";
|
||||
import { Timeline } from "../../../components/editor/timeline";
|
||||
import { PreviewPanel } from "../../../components/editor/preview-panel";
|
||||
import { EditorHeader } from "@/components/editor-header";
|
||||
} from "@/components/ui/resizable";
|
||||
import { MediaPanel } from "@/components/editor/media-panel";
|
||||
import { PropertiesPanel } from "@/components/editor/properties-panel";
|
||||
import { Timeline } from "@/components/editor/timeline";
|
||||
import { PreviewPanel } from "@/components/editor/preview-panel";
|
||||
import { EditorHeader } from "@/components/editor/editor-header";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { EditorProvider } from "@/components/editor-provider";
|
||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||
import { usePlaybackControls } from "@/hooks/use-playback-controls";
|
||||
import { Onboarding } from "@/components/onboarding";
|
||||
import { Onboarding } from "@/components/editor/onboarding";
|
||||
|
||||
export default function Editor() {
|
||||
const {
|
||||
@@ -30,113 +30,427 @@ export default function Editor() {
|
||||
setTimeline,
|
||||
propertiesPanel,
|
||||
setPropertiesPanel,
|
||||
activePreset,
|
||||
resetCounter,
|
||||
} = usePanelStore();
|
||||
|
||||
const { activeProject, loadProject, createNewProject } = useProjectStore();
|
||||
const {
|
||||
activeProject,
|
||||
loadProject,
|
||||
createNewProject,
|
||||
isInvalidProjectId,
|
||||
markProjectIdAsInvalid,
|
||||
} = useProjectStore();
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const projectId = params.project_id as string;
|
||||
const handledProjectIds = useRef<Set<string>>(new Set());
|
||||
const isInitializingRef = useRef<boolean>(false);
|
||||
|
||||
usePlaybackControls();
|
||||
|
||||
useEffect(() => {
|
||||
const initProject = async () => {
|
||||
if (!projectId) return;
|
||||
let isCancelled = false;
|
||||
|
||||
const initProject = async () => {
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent duplicate initialization
|
||||
if (isInitializingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if project is already loaded
|
||||
if (activeProject?.id === projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check global invalid tracking first (most important for preventing duplicates)
|
||||
if (isInvalidProjectId(projectId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've already handled this project ID locally
|
||||
if (handledProjectIds.current.has(projectId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as initializing to prevent race conditions
|
||||
isInitializingRef.current = true;
|
||||
handledProjectIds.current.add(projectId);
|
||||
|
||||
try {
|
||||
await loadProject(projectId);
|
||||
} catch (error) {
|
||||
handledProjectIds.current.add(projectId);
|
||||
|
||||
const newProjectId = await createNewProject("Untitled Project");
|
||||
router.replace(`/editor/${newProjectId}`);
|
||||
return;
|
||||
// Check if component was unmounted during async operation
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Project loaded successfully
|
||||
isInitializingRef.current = false;
|
||||
} catch (error) {
|
||||
// Check if component was unmounted during async operation
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// More specific error handling - only create new project for actual "not found" errors
|
||||
const isProjectNotFound =
|
||||
error instanceof Error &&
|
||||
(error.message.includes("not found") ||
|
||||
error.message.includes("does not exist") ||
|
||||
error.message.includes("Project not found"));
|
||||
|
||||
if (isProjectNotFound) {
|
||||
// Mark this project ID as invalid globally BEFORE creating project
|
||||
markProjectIdAsInvalid(projectId);
|
||||
|
||||
try {
|
||||
const newProjectId = await createNewProject("Untitled Project");
|
||||
|
||||
// Check again if component was unmounted
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(`/editor/${newProjectId}`);
|
||||
} catch (createError) {
|
||||
console.error("Failed to create new project:", createError);
|
||||
}
|
||||
} else {
|
||||
// For other errors (storage issues, corruption, etc.), don't create new project
|
||||
console.error(
|
||||
"Project loading failed with recoverable error:",
|
||||
error
|
||||
);
|
||||
// Remove from handled set so user can retry
|
||||
handledProjectIds.current.delete(projectId);
|
||||
}
|
||||
|
||||
isInitializingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
initProject();
|
||||
}, [projectId, activeProject?.id, loadProject, createNewProject, router]);
|
||||
|
||||
// Cleanup function to cancel async operations
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
isInitializingRef.current = false;
|
||||
};
|
||||
}, [
|
||||
projectId,
|
||||
loadProject,
|
||||
createNewProject,
|
||||
router,
|
||||
isInvalidProjectId,
|
||||
markProjectIdAsInvalid,
|
||||
]);
|
||||
|
||||
return (
|
||||
<EditorProvider>
|
||||
<div className="h-screen w-screen flex flex-col bg-background overflow-hidden">
|
||||
<EditorHeader />
|
||||
<div className="flex-1 min-h-0 min-w-0">
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
onResize={setMainContent}
|
||||
className="min-h-0"
|
||||
{activePreset === "media" ? (
|
||||
<ResizablePanelGroup
|
||||
key={`media-${activePreset}-${resetCounter}`}
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.18rem] px-3 pb-3"
|
||||
>
|
||||
{/* Main content area */}
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem] px-2"
|
||||
<ResizablePanel
|
||||
defaultSize={toolsPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
{/* Tools Panel */}
|
||||
<ResizablePanel
|
||||
defaultSize={toolsPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0"
|
||||
<MediaPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={100 - toolsPanel}
|
||||
minSize={60}
|
||||
className="min-w-0 min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<MediaPanel />
|
||||
</ResizablePanel>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
onResize={setMainContent}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={previewPanel}
|
||||
minSize={30}
|
||||
onResize={setPreviewPanel}
|
||||
className="min-w-0 min-h-0 flex-1"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Preview Area */}
|
||||
<ResizablePanel
|
||||
defaultSize={previewPanel}
|
||||
minSize={30}
|
||||
onResize={setPreviewPanel}
|
||||
className="min-w-0 min-h-0 flex-1"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
<ResizablePanel
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setPropertiesPanel}
|
||||
className="min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setPropertiesPanel}
|
||||
className="min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Timeline */}
|
||||
<ResizablePanel
|
||||
defaultSize={timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0 px-2 pb-2"
|
||||
<ResizablePanel
|
||||
defaultSize={timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : activePreset === "inspector" ? (
|
||||
<ResizablePanelGroup
|
||||
key={`inspector-${activePreset}-${resetCounter}`}
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.18rem] px-3 pb-3"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<ResizablePanel
|
||||
defaultSize={100 - propertiesPanel}
|
||||
minSize={30}
|
||||
onResize={(size) => setPropertiesPanel(100 - size)}
|
||||
className="min-w-0 min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
onResize={setMainContent}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={toolsPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<MediaPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={previewPanel}
|
||||
minSize={30}
|
||||
onResize={setPreviewPanel}
|
||||
className="min-w-0 min-h-0 flex-1"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setPropertiesPanel}
|
||||
className="min-w-0 min-h-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : activePreset === "vertical-preview" ? (
|
||||
<ResizablePanelGroup
|
||||
key={`vertical-preview-${activePreset}-${resetCounter}`}
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.18rem] px-3 pb-3"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={100 - previewPanel}
|
||||
minSize={30}
|
||||
onResize={(size) => setPreviewPanel(100 - size)}
|
||||
className="min-w-0 min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
onResize={setMainContent}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={toolsPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<MediaPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setPropertiesPanel}
|
||||
className="min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={previewPanel}
|
||||
minSize={30}
|
||||
onResize={setPreviewPanel}
|
||||
className="min-w-0 min-h-0"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : (
|
||||
<ResizablePanelGroup
|
||||
key={`default-${activePreset}-${resetCounter}`}
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
onResize={setMainContent}
|
||||
className="min-h-0"
|
||||
>
|
||||
{/* Main content area */}
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem] px-3"
|
||||
>
|
||||
{/* Tools Panel */}
|
||||
<ResizablePanel
|
||||
defaultSize={toolsPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<MediaPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Preview Area */}
|
||||
<ResizablePanel
|
||||
defaultSize={previewPanel}
|
||||
minSize={30}
|
||||
onResize={setPreviewPanel}
|
||||
className="min-w-0 min-h-0 flex-1"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setPropertiesPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Timeline */}
|
||||
<ResizablePanel
|
||||
defaultSize={timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0 px-3 pb-3"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</div>
|
||||
<Onboarding />
|
||||
</div>
|
||||
|
||||
@@ -1,82 +1,107 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 1rem;
|
||||
--sidebar-background: 0 0% 100%;
|
||||
--sidebar-foreground: 0 0% 3.9%;
|
||||
--sidebar-primary: 0 0% 9%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 0 0% 96.1%;
|
||||
--sidebar-accent-foreground: 0 0% 9%;
|
||||
--sidebar-border: 0 0% 89.8%;
|
||||
--sidebar-ring: 0 0% 3.9%;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 4%;
|
||||
--foreground: 0 0% 89%;
|
||||
--card: 0 0% 14.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 14.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 180 95% 40%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 100% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 17%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--sidebar-background: 0 0% 3.9%;
|
||||
--sidebar-foreground: 0 0% 98%;
|
||||
--sidebar-primary: 0 0% 98%;
|
||||
--sidebar-primary-foreground: 0 0% 9%;
|
||||
--sidebar-accent: 0 0% 14.9%;
|
||||
--sidebar-accent-foreground: 0 0% 98%;
|
||||
--sidebar-border: 0 0% 14.9%;
|
||||
--sidebar-ring: 0 0% 83.1%;
|
||||
--panel-background: 0 0% 11%;
|
||||
--panel-accent: 0 0% 15%;
|
||||
}
|
||||
/* Custom variant for dark mode */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Plugins */
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
:root {
|
||||
/* Custom colors - light mode (default) */
|
||||
--background: hsl(0, 0%, 100%);
|
||||
--foreground: hsl(0 0% 11%);
|
||||
--card: hsl(216, 8%, 86%);
|
||||
--card-foreground: hsl(0 0% 2%);
|
||||
--popover: hsl(0, 0%, 100%);
|
||||
--popover-foreground: hsl(0 0% 2%);
|
||||
--primary: hsl(205, 84%, 47%);
|
||||
--primary-foreground: hsl(0 0% 91%);
|
||||
--secondary: hsl(216, 13%, 92%);
|
||||
--secondary-foreground: hsl(0 0% 2%);
|
||||
--muted: hsl(0 0% 85.1%);
|
||||
--muted-foreground: hsl(0 0% 50%);
|
||||
--accent: hsl(216, 13%, 92%);
|
||||
--accent-foreground: hsl(0 0% 2%);
|
||||
--destructive: hsl(0, 83%, 50%);
|
||||
--destructive-foreground: hsl(0, 0%, 100%);
|
||||
--border: hsl(0 0% 83%);
|
||||
--input: hsl(0 0% 85.1%);
|
||||
--ring: hsl(0, 0%, 55%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 96.1%);
|
||||
--sidebar-foreground: hsl(0 0% 2%);
|
||||
--sidebar-primary: hsl(0 0% 2%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 91%);
|
||||
--sidebar-accent: hsl(0 0% 85.1%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 2%);
|
||||
--sidebar-border: hsl(0 0% 85.1%);
|
||||
--sidebar-ring: hsl(0 0% 16.9%);
|
||||
--panel-background: hsl(216 13% 92%);
|
||||
--panel-accent: hsl(216, 8%, 86%);
|
||||
|
||||
/* Radius base */
|
||||
--radius: 1rem;
|
||||
}
|
||||
.dark {
|
||||
/* Custom colors - dark mode */
|
||||
--background: hsl(0 0% 4%);
|
||||
--foreground: hsl(0 0% 89%);
|
||||
--card: hsl(0 0% 14.9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--popover: hsl(0 0% 14.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(205, 84%, 53%);
|
||||
--primary-foreground: hsl(0 0% 9%);
|
||||
--secondary: hsl(0 0% 14.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(0 0% 14.9%);
|
||||
--muted-foreground: hsl(0 0% 63.9%);
|
||||
--accent: hsl(0 0% 14.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 100% 60%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(0 0% 17%);
|
||||
--input: hsl(0 0% 14.9%);
|
||||
--ring: hsl(0 0% 83.1%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 3.9%);
|
||||
--sidebar-foreground: hsl(0 0% 98%);
|
||||
--sidebar-primary: hsl(0 0% 98%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 9%);
|
||||
--sidebar-accent: hsl(0 0% 14.9%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 98%);
|
||||
--sidebar-border: hsl(0 0% 14.9%);
|
||||
--sidebar-ring: hsl(0 0% 83.1%);
|
||||
--panel-background: hsl(0 0% 11%);
|
||||
--panel-accent: hsl(0 0% 15%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
/* Other default base styles */
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
@@ -86,3 +111,135 @@
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Responsive breakpoints */
|
||||
--breakpoint-xs: 30rem;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: var(--font-inter), sans-serif;
|
||||
|
||||
/* Font sizes */
|
||||
--text-base: 0.95rem;
|
||||
--text-base--line-height: calc(1.5 / 0.95);
|
||||
--text-xs: 0.8rem;
|
||||
--text-xs--line-height: calc(1 / 0.8);
|
||||
|
||||
/* Border radius */
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 8px);
|
||||
|
||||
/* Palette mapped to root design tokens */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Chart colors */
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
/* Sidebar */
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
/* Panel */
|
||||
--color-panel: var(--panel-background);
|
||||
--color-panel-accent: var(--panel-accent);
|
||||
|
||||
/* Animations */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-x-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:horizontal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-y-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:vertical {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-thin {
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 8px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import "./globals.css";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { TooltipProvider } from "../components/ui/tooltip";
|
||||
import { StorageProvider } from "../components/storage-provider";
|
||||
import { ScenesMigrator } from "../components/providers/migrators/scenes-migrator";
|
||||
import { baseMetaData } from "./metadata";
|
||||
import { defaultFont } from "../lib/font-config";
|
||||
import { BotIdClient } from "botid/client";
|
||||
@@ -14,7 +15,11 @@ export const metadata = baseMetaData;
|
||||
|
||||
const protectedRoutes = [
|
||||
{
|
||||
path: "/api/waitlist",
|
||||
path: "/none",
|
||||
method: "GET",
|
||||
},
|
||||
{
|
||||
path: "/api/waitlist/export",
|
||||
method: "POST",
|
||||
},
|
||||
];
|
||||
@@ -30,9 +35,11 @@ export default function RootLayout({
|
||||
<BotIdClient protect={protectedRoutes} />
|
||||
</head>
|
||||
<body className={`${defaultFont.className} font-sans antialiased`}>
|
||||
<ThemeProvider attribute="class" forcedTheme="dark">
|
||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||
<TooltipProvider>
|
||||
<StorageProvider>{children}</StorageProvider>
|
||||
<StorageProvider>
|
||||
<ScenesMigrator>{children}</ScenesMigrator>
|
||||
</StorageProvider>
|
||||
<Analytics />
|
||||
<Toaster />
|
||||
<Script
|
||||
|
||||
@@ -1,37 +1,32 @@
|
||||
import { Metadata } from "next";
|
||||
|
||||
const title = "OpenCut";
|
||||
const description =
|
||||
"A simple but powerful video editor that gets the job done. In your browser.";
|
||||
const openGraphImageUrl = "https://opencut.app/open-graph/default.jpg";
|
||||
const twitterImageUrl = "/open-graph/default.jpg";
|
||||
import type { Metadata } from "next";
|
||||
import { SITE_INFO, SITE_URL } from "@/constants/site";
|
||||
|
||||
export const baseMetaData: Metadata = {
|
||||
metadataBase: new URL("https://opencut.app"),
|
||||
title: title,
|
||||
description: description,
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: SITE_INFO.title,
|
||||
description: SITE_INFO.description,
|
||||
openGraph: {
|
||||
title: title,
|
||||
description: description,
|
||||
url: "https://opencut.app",
|
||||
siteName: "OpenCut",
|
||||
title: SITE_INFO.title,
|
||||
description: SITE_INFO.description,
|
||||
url: SITE_URL,
|
||||
siteName: SITE_INFO.title,
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: openGraphImageUrl,
|
||||
url: SITE_INFO.openGraphImage,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "OpenCut",
|
||||
alt: "OpenCut Wordmark",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: title,
|
||||
description: description,
|
||||
title: SITE_INFO.title,
|
||||
description: SITE_INFO.description,
|
||||
creator: "@opencutapp",
|
||||
images: [twitterImageUrl],
|
||||
images: [SITE_INFO.twitterImage],
|
||||
},
|
||||
pinterest: {
|
||||
richPin: false,
|
||||
@@ -82,7 +77,7 @@ export const baseMetaData: Metadata = {
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: title,
|
||||
title: SITE_INFO.title,
|
||||
},
|
||||
manifest: "/manifest.json",
|
||||
other: {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Hero } from "@/components/landing/hero";
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import type { Metadata } from "next";
|
||||
import { SITE_URL } from "@/constants/site";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
alternates: {
|
||||
canonical: SITE_URL,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function Home() {
|
||||
return (
|
||||
|
||||
@@ -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-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
<div className="relative container mx-auto px-4 py-16">
|
||||
<div className="max-w-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-xs 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Calendar,
|
||||
ChevronLeft,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
ArrowDown01,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Video,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { DeleteProjectDialog } from "@/components/delete-project-dialog";
|
||||
import { RenameProjectDialog } from "@/components/rename-project-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
ChevronLeft,
|
||||
Plus,
|
||||
Calendar,
|
||||
MoreHorizontal,
|
||||
Video,
|
||||
Loader2,
|
||||
X,
|
||||
Trash2,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { TProject } from "@/types/project";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DeleteProjectDialog } from "@/components/delete-project-dialog";
|
||||
import { RenameProjectDialog } from "@/components/rename-project-dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import type { TProject } from "@/types/project";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const {
|
||||
createNewProject,
|
||||
savedProjects,
|
||||
isLoading,
|
||||
isInitialized,
|
||||
deleteProject,
|
||||
createNewProject,
|
||||
getFilteredAndSortedProjects,
|
||||
} = useProjectStore();
|
||||
const router = useRouter();
|
||||
const [thumbnailCache, setThumbnailCache] = useState<
|
||||
Record<string, string | null>
|
||||
>({});
|
||||
const [_loadingThumbnails, setLoadingThumbnails] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
|
||||
new Set()
|
||||
@@ -55,6 +62,32 @@ export default function ProjectsPage() {
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortOption, setSortOption] = useState("createdAt-desc");
|
||||
const router = useRouter();
|
||||
|
||||
const getProjectThumbnail = useCallback(
|
||||
async (projectId: string): Promise<string | null> => {
|
||||
if (thumbnailCache[projectId] !== undefined) {
|
||||
return thumbnailCache[projectId];
|
||||
}
|
||||
|
||||
setLoadingThumbnails((prev) => new Set(prev).add(projectId));
|
||||
|
||||
try {
|
||||
const thumbnail = await useTimelineStore
|
||||
.getState()
|
||||
.getProjectThumbnail(projectId);
|
||||
setThumbnailCache((prev) => ({ ...prev, [projectId]: thumbnail }));
|
||||
return thumbnail;
|
||||
} finally {
|
||||
setLoadingThumbnails((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(projectId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
const projectId = await createNewProject("New Project");
|
||||
@@ -109,7 +142,7 @@ export default function ProjectsPage() {
|
||||
href="/"
|
||||
className="flex items-center gap-1 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<ChevronLeft className="!size-5 shrink-0" />
|
||||
<ChevronLeft className="size-5! shrink-0" />
|
||||
<span className="text-sm font-medium">Back</span>
|
||||
</Link>
|
||||
<div className="block md:hidden">
|
||||
@@ -120,7 +153,7 @@ export default function ProjectsPage() {
|
||||
size="sm"
|
||||
onClick={handleCancelSelection}
|
||||
>
|
||||
<X className="!size-4" />
|
||||
<X className="size-4!" />
|
||||
Cancel
|
||||
</Button>
|
||||
{selectedProjects.size > 0 && (
|
||||
@@ -129,7 +162,7 @@ export default function ProjectsPage() {
|
||||
size="sm"
|
||||
onClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="!size-4" />
|
||||
<Trash2 className="size-4!" />
|
||||
Delete ({selectedProjects.size})
|
||||
</Button>
|
||||
)}
|
||||
@@ -159,7 +192,7 @@ export default function ProjectsPage() {
|
||||
{isSelectionMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancelSelection}>
|
||||
<X className="!size-4" />
|
||||
<X className="size-4!" />
|
||||
Cancel
|
||||
</Button>
|
||||
{selectedProjects.size > 0 && (
|
||||
@@ -167,7 +200,7 @@ export default function ProjectsPage() {
|
||||
variant="destructive"
|
||||
onClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="!size-4" />
|
||||
<Trash2 className="size-4!" />
|
||||
Delete Selected ({selectedProjects.size})
|
||||
</Button>
|
||||
)}
|
||||
@@ -195,39 +228,113 @@ export default function ProjectsPage() {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={sortOption} onValueChange={setSortOption}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="createdAt-desc">Newest to Oldest</SelectItem>
|
||||
<SelectItem value="createdAt-asc">Oldest to Newest</SelectItem>
|
||||
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
|
||||
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-0">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<DropdownMenu>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="justify-center items-center w-9 h-9"
|
||||
>
|
||||
<ArrowDown01
|
||||
strokeWidth={1.5}
|
||||
className="!size-[1.05rem]"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortOption.startsWith("createdAt")) {
|
||||
setSortOption(
|
||||
sortOption.endsWith("asc")
|
||||
? "createdAt-desc"
|
||||
: "createdAt-asc"
|
||||
);
|
||||
} else {
|
||||
setSortOption("createdAt-asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Created{" "}
|
||||
{sortOption.startsWith("createdAt") &&
|
||||
(sortOption.endsWith("asc") ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortOption.startsWith("name")) {
|
||||
setSortOption(
|
||||
sortOption.endsWith("asc")
|
||||
? "name-desc"
|
||||
: "name-asc"
|
||||
);
|
||||
} else {
|
||||
setSortOption("name-asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Name{" "}
|
||||
{sortOption.startsWith("name") &&
|
||||
(sortOption.endsWith("asc") ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Sort by{" "}
|
||||
{sortOption.startsWith("createdAt") ? "date" : "name"} (
|
||||
{sortOption.endsWith("asc") ? "ascending" : "descending"})
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelectionMode && sortedProjects.length > 0 && (
|
||||
<div className="mb-6 p-4 bg-muted/30 rounded-lg border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={someSelected ? "indeterminate" : allSelected}
|
||||
onCheckedChange={(value) => handleSelectAll(!!value)}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{allSelected ? "Deselect All" : "Select All"}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({selectedProjects.size} of {sortedProjects.length} selected)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectAll(!allSelected)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelectAll(!allSelected);
|
||||
}
|
||||
}}
|
||||
className="w-full hover:cursor-pointer gap-2 mb-6 p-4 bg-muted/30 rounded-lg border items-center flex"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Checkbox checked={someSelected ? "indeterminate" : allSelected} />
|
||||
<span className="text-sm font-medium">
|
||||
{allSelected ? "Deselect All" : "Select All"}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({selectedProjects.size} of {sortedProjects.length} selected)
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isLoading || !isInitialized ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{Array.from({ length: 8 }, (_, index) => (
|
||||
<div
|
||||
key={`skeleton-${index}-${Date.now()}`}
|
||||
className="overflow-hidden bg-background border-none p-0"
|
||||
>
|
||||
<Skeleton className="aspect-square w-full bg-muted/50" />
|
||||
<div className="px-0 pt-5 flex flex-col gap-1">
|
||||
<Skeleton className="h-4 w-3/4 bg-muted/50" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="h-4 w-4 bg-muted/50" />
|
||||
<Skeleton className="h-4 w-24 bg-muted/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : savedProjects.length === 0 ? (
|
||||
<NoProjects onCreateProject={handleCreateProject} />
|
||||
@@ -245,6 +352,7 @@ export default function ProjectsPage() {
|
||||
isSelectionMode={isSelectionMode}
|
||||
isSelected={selectedProjects.has(project.id)}
|
||||
onSelect={handleSelectProject}
|
||||
getProjectThumbnail={getProjectThumbnail}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -265,6 +373,7 @@ interface ProjectCardProps {
|
||||
isSelectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onSelect?: (projectId: string, checked: boolean) => void;
|
||||
getProjectThumbnail: (projectId: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
function ProjectCard({
|
||||
@@ -272,12 +381,28 @@ function ProjectCard({
|
||||
isSelectionMode = false,
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
getProjectThumbnail,
|
||||
}: ProjectCardProps) {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [dynamicThumbnail, setDynamicThumbnail] = useState<string | null>(null);
|
||||
const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(true);
|
||||
const { deleteProject, renameProject, duplicateProject } = useProjectStore();
|
||||
|
||||
useEffect(() => {
|
||||
const loadThumbnail = async () => {
|
||||
setIsLoadingThumbnail(true);
|
||||
try {
|
||||
const thumbnail = await getProjectThumbnail(project.id);
|
||||
setDynamicThumbnail(thumbnail);
|
||||
} finally {
|
||||
setIsLoadingThumbnail(false);
|
||||
}
|
||||
};
|
||||
loadThumbnail();
|
||||
}, [project.id, getProjectThumbnail]);
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
@@ -308,242 +433,156 @@ function ProjectCard({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (isSelectionMode && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
onSelect?.(project.id, !isSelected);
|
||||
}
|
||||
};
|
||||
|
||||
const cardContent = (
|
||||
<Card
|
||||
className={`overflow-hidden bg-background border-none p-0 transition-all ${
|
||||
isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`relative aspect-square bg-muted transition-opacity ${
|
||||
isDropdownOpen ? "opacity-65" : "opacity-100 group-hover:opacity-65"
|
||||
}`}
|
||||
>
|
||||
{isSelectionMode && (
|
||||
<div className="absolute top-3 left-3 z-10">
|
||||
<div className="w-5 h-5 rounded-full bg-background/80 backdrop-blur-xs border flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) =>
|
||||
onSelect?.(project.id, checked as boolean)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0">
|
||||
{isLoadingThumbnail ? (
|
||||
<div className="w-full h-full bg-muted/50 flex items-center justify-center">
|
||||
<Loader2 className="h-12 w-12 text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
) : dynamicThumbnail ? (
|
||||
<Image
|
||||
src={dynamicThumbnail}
|
||||
alt="Project thumbnail"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted/50 flex items-center justify-center">
|
||||
<Video className="h-12 w-12 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="px-0 pt-5 flex flex-col gap-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
|
||||
{project.name}
|
||||
</h3>
|
||||
{!isSelectionMode && (
|
||||
<DropdownMenu
|
||||
open={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className={`size-6 p-0 transition-all shrink-0 ml-2 ${
|
||||
isDropdownOpen
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropdownOpen(false);
|
||||
setIsRenameDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDuplicateProject();
|
||||
}}
|
||||
>
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropdownOpen(false);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar className="size-4!" />
|
||||
<span>Created {formatDate(project.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSelectionMode ? (
|
||||
<div onClick={handleCardClick} className="block group cursor-pointer">
|
||||
<Card
|
||||
className={`overflow-hidden bg-background border-none p-0 transition-all ${
|
||||
isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`relative aspect-square bg-muted transition-opacity ${
|
||||
isDropdownOpen
|
||||
? "opacity-65"
|
||||
: "opacity-100 group-hover:opacity-65"
|
||||
}`}
|
||||
>
|
||||
{/* Selection checkbox */}
|
||||
{isSelectionMode && (
|
||||
<div className="absolute top-3 left-3 z-10">
|
||||
<div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm border flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) =>
|
||||
onSelect?.(project.id, checked as boolean)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail preview or placeholder */}
|
||||
<div className="absolute inset-0">
|
||||
{project.thumbnail ? (
|
||||
<Image
|
||||
src={project.thumbnail}
|
||||
alt="Project thumbnail"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted/50 flex items-center justify-center">
|
||||
<Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="px-0 pt-5 flex flex-col gap-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
|
||||
{project.name}
|
||||
</h3>
|
||||
{!isSelectionMode && (
|
||||
<DropdownMenu
|
||||
open={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className={`size-6 p-0 transition-all shrink-0 ml-2 ${
|
||||
isDropdownOpen
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropdownOpen(false);
|
||||
setIsRenameDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDuplicateProject();
|
||||
}}
|
||||
>
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropdownOpen(false);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar className="!size-4" />
|
||||
<span>Created {formatDate(project.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={handleCardKeyDown}
|
||||
className="block group cursor-pointer w-full text-left"
|
||||
>
|
||||
{cardContent}
|
||||
</button>
|
||||
) : (
|
||||
<Link href={`/editor/${project.id}`} className="block group">
|
||||
<Card
|
||||
className={`overflow-hidden bg-background border-none p-0 transition-all ${
|
||||
isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`relative aspect-square bg-muted transition-opacity ${
|
||||
isDropdownOpen
|
||||
? "opacity-65"
|
||||
: "opacity-100 group-hover:opacity-65"
|
||||
}`}
|
||||
>
|
||||
{/* Thumbnail preview or placeholder */}
|
||||
<div className="absolute inset-0">
|
||||
{project.thumbnail ? (
|
||||
<Image
|
||||
src={project.thumbnail}
|
||||
alt="Project thumbnail"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted/50 flex items-center justify-center">
|
||||
<Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="px-0 pt-5 flex flex-col gap-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
|
||||
{project.name}
|
||||
</h3>
|
||||
<DropdownMenu
|
||||
open={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className={`size-6 p-0 transition-all shrink-0 ml-2 ${
|
||||
isDropdownOpen
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropdownOpen(false);
|
||||
setIsRenameDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDuplicateProject();
|
||||
}}
|
||||
>
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropdownOpen(false);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar className="!size-4" />
|
||||
<span>Created {formatDate(project.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{cardContent}
|
||||
</Link>
|
||||
)}
|
||||
<DeleteProjectDialog
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
onConfirm={handleDeleteProject}
|
||||
projectName={project.name}
|
||||
/>
|
||||
<RenameProjectDialog
|
||||
isOpen={isRenameDialogOpen}
|
||||
@@ -558,7 +597,7 @@ function ProjectCard({
|
||||
function CreateButton({ onClick }: { onClick?: () => void }) {
|
||||
return (
|
||||
<Button className="flex" onClick={onClick}>
|
||||
<Plus className="!size-4" />
|
||||
<Plus className="size-4!" />
|
||||
<span className="text-sm font-medium">New project</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -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: "Completed",
|
||||
type: "complete",
|
||||
},
|
||||
},
|
||||
{
|
||||
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-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
<div className="relative container mx-auto px-4 py-16">
|
||||
<div className="max-w-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-normal">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/constants/site";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/_next/", "/projects/", "/editor/"],
|
||||
},
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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",
|
||||
image: `${SITE_INFO.openGraphImage}`,
|
||||
favicon: `${SITE_INFO.favicon}`,
|
||||
copyright: `All rights reserved ${new Date().getFullYear()}, ${
|
||||
SITE_INFO.title
|
||||
}`,
|
||||
});
|
||||
|
||||
for (const post of posts) {
|
||||
feed.addItem({
|
||||
title: post.title,
|
||||
id: `${SITE_URL}/blog/${post.slug}`,
|
||||
link: `${SITE_URL}/blog/${post.slug}`,
|
||||
description: post.description,
|
||||
author: post.authors.map((author) => ({
|
||||
name: author.name,
|
||||
})),
|
||||
date: new Date(post.publishedAt),
|
||||
image: post.coverImage || SITE_INFO.openGraphImage,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(feed.rss2(), {
|
||||
headers: {
|
||||
"Content-Type": "text/xml",
|
||||
"Cache-Control": "public, max-age=86400, stale-while-revalidate",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating RSS feed", error);
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { SITE_URL } from "@/constants/site";
|
||||
import { getPosts } from "@/lib/blog-query";
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const data = await getPosts();
|
||||
|
||||
const postPages: MetadataRoute.Sitemap =
|
||||
data?.posts?.map((post) => ({
|
||||
url: `${SITE_URL}/blog/${post.slug}`,
|
||||
lastModified: new Date(post.publishedAt),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.8,
|
||||
})) ?? [];
|
||||
|
||||
return [
|
||||
{
|
||||
url: SITE_URL,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/contributors`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/roadmap`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/privacy`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/terms`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/why-not-capcut`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "yearly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/blog`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
...postPages,
|
||||
];
|
||||
}
|
||||
@@ -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-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
<div className="relative container mx-auto px-4 py-16">
|
||||
<div className="max-w-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-xs 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative container mx-auto px-4 py-16">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-20">
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
||||
Fuck CapCut
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
|
||||
Roasting time, so get ready motherfucker.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto space-y-12">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
Seriously, what the fuck else do you want?
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
You probably use CapCut and think your video editing is
|
||||
special. You think your fucking TikTok with 47 transitions and
|
||||
12 different fonts is going to get you some viral fame. You
|
||||
think loading up every goddamn effect in their library makes
|
||||
your content better. Wrong, motherfucker. Let me describe what
|
||||
CapCut actually gives you:
|
||||
</p>
|
||||
<ul className="text-lg space-y-2 mb-6 list-disc list-inside">
|
||||
<li>A paywall every time you breathe</li>
|
||||
<li>Terms of service that steal your shit</li>
|
||||
<li>
|
||||
More "Get Pro" dialogs than a Windows 95 error message
|
||||
</li>
|
||||
<li>
|
||||
Features that disappear behind paywalls while you're fucking
|
||||
using them
|
||||
</li>
|
||||
<li>Bugs disguised as "premium features"</li>
|
||||
</ul>
|
||||
<p className="text-lg mb-6">
|
||||
<strong>Well guess what, motherfucker:</strong>
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
You. Are. Getting. Scammed. Look at this shit. It's a fucking
|
||||
video editor. Why the fuck do you need to pay $20/month just
|
||||
to remove a goddamn watermark? You spent hours editing your
|
||||
video and they slap their logo on it like they fucking made
|
||||
it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
The "Get Pro" dialog is everywhere
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
This motherfucking dialog pops up more than ads on a pirated
|
||||
movie site. Want to add a transition? Get Pro. Want to export
|
||||
without their watermark? Get Pro. Want to use more than 2
|
||||
fonts? Get fucking Pro, peasant.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
Did you seriously think you could edit a video without seeing
|
||||
this dialog 47 times? You click one button and BAM - there it
|
||||
is again, asking for your credit card like a desperate ex
|
||||
asking for money.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
Everything costs money now
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
You dumbass. You thought CapCut was free, but no. Free means
|
||||
they let you open the app. Everything else costs money. Basic
|
||||
shake effect? That'll be $20/month. A decent transition that
|
||||
isn't "fade"? Pay up, motherfucker.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
Here's my favorite piece of bullshit: You import an MP3 file -
|
||||
you know, AUDIO - and try to export. "Sorry, can't export
|
||||
because you're using our premium extract audio feature!"
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
<strong>
|
||||
My MP3 was already fucking audio, you absolute morons.
|
||||
</strong>
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
But wait, there's more! If you drag that same MP3 to their
|
||||
media panel first, then to the timeline, it magically works.
|
||||
This isn't a bug, it's a fucking scam disguised as software
|
||||
engineering.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
Their Terms of Service are insane
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
Look at this shit. You upload your content and they basically
|
||||
say "thanks for the free content, we own it now, but if Disney
|
||||
sues anyone, that's your problem."
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
<strong>CapCut's Terms of Service:</strong> We get full rights
|
||||
to use, modify, distribute, and monetize everything you upload
|
||||
- permanently and without paying you shit. But you're still
|
||||
responsible if anything goes wrong.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
Translation: "We'll make money off your viral video, you
|
||||
handle the lawsuits." Brilliant legal strategy, you fucks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
The editor is actually good
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
Here's the thing that makes me want to punch my monitor: the
|
||||
actual video editor is fucking good. It's intuitive, powerful,
|
||||
and anyone can figure it out. When it's not begging for money
|
||||
every 30 seconds, it actually works well.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
Which makes everything else so much worse. They built
|
||||
something people want to use, then turned it into a digital
|
||||
slot machine. Every click might trigger a payment request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
This is a video editor. Look at it. You've never seen one
|
||||
before.
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
Like the person who's never used software that doesn't
|
||||
constantly beg for money, you have no fucking idea what a
|
||||
video editor should be. All you've ever seen are predatory
|
||||
apps disguised as creative tools.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
A real video editor lets you edit videos. It doesn't steal
|
||||
your content. It doesn't pop up payment dialogs every 5
|
||||
seconds. It doesn't charge you separately for basic features
|
||||
that should be free.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
Yes, this is fucking satire, you fuck
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
I'm not actually saying all video editors should be basic as
|
||||
shit. What I'm saying is that all the problems we have with
|
||||
video editing apps are{" "}
|
||||
<strong>ones they create themselves</strong>. Video editors
|
||||
aren't broken by default - they edit videos, export them, and
|
||||
let you use basic features without constantly begging for
|
||||
money. CapCut breaks them. They turn them into payment
|
||||
processors with video editing as a side feature.
|
||||
</p>
|
||||
<p className="text-lg">
|
||||
<em>"Good software gets out of your way."</em>
|
||||
<br />- Some smart motherfucker who definitely wasn't working
|
||||
at CapCut
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export function DeleteProjectDialog({
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="text"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"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";
|
||||
|
||||
export function EditorHeader() {
|
||||
const { getTotalDuration } = useTimelineStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
|
||||
const handleExport = () => {
|
||||
// TODO: Implement export functionality
|
||||
console.log("Export project");
|
||||
};
|
||||
|
||||
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" />
|
||||
<span className="text-sm">{activeProject?.name}</span>
|
||||
</Link>
|
||||
</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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -19,78 +19,125 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
let ws = wavesurfer.current;
|
||||
|
||||
const initWaveSurfer = async () => {
|
||||
if (!waveformRef.current || !audioUrl) return;
|
||||
|
||||
try {
|
||||
// Clean up any existing instance
|
||||
if (wavesurfer.current) {
|
||||
try {
|
||||
wavesurfer.current.destroy();
|
||||
} catch (e) {
|
||||
// Silently ignore destroy errors
|
||||
}
|
||||
// Clear any existing instance safely
|
||||
if (ws) {
|
||||
// Instead of immediately destroying, just set to null
|
||||
// We'll destroy it outside this function
|
||||
wavesurfer.current = null;
|
||||
}
|
||||
|
||||
wavesurfer.current = WaveSurfer.create({
|
||||
// Create a fresh instance
|
||||
const newWaveSurfer = WaveSurfer.create({
|
||||
container: waveformRef.current,
|
||||
waveColor: 'rgba(255, 255, 255, 0.6)',
|
||||
progressColor: 'rgba(255, 255, 255, 0.9)',
|
||||
cursorColor: 'transparent',
|
||||
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,
|
||||
});
|
||||
|
||||
// Assign to ref only if component is still mounted
|
||||
if (mounted) {
|
||||
wavesurfer.current = newWaveSurfer;
|
||||
} else {
|
||||
// Component unmounted during initialization, clean up
|
||||
try {
|
||||
newWaveSurfer.destroy();
|
||||
} catch (e) {
|
||||
// Ignore destroy errors
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
wavesurfer.current.on('ready', () => {
|
||||
newWaveSurfer.on("ready", () => {
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
setError(false);
|
||||
}
|
||||
});
|
||||
|
||||
wavesurfer.current.on('error', (err) => {
|
||||
console.error('WaveSurfer error:', err);
|
||||
newWaveSurfer.on("error", (err) => {
|
||||
if (mounted) {
|
||||
console.error("WaveSurfer error:", err);
|
||||
setError(true);
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
await wavesurfer.current.load(audioUrl);
|
||||
|
||||
await newWaveSurfer.load(audioUrl);
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize WaveSurfer:', err);
|
||||
if (mounted) {
|
||||
console.error("Failed to initialize WaveSurfer:", err);
|
||||
setError(true);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initWaveSurfer();
|
||||
// First safely destroy previous instance if it exists
|
||||
if (ws) {
|
||||
// Use this pattern to safely destroy the previous instance
|
||||
const wsToDestroy = ws;
|
||||
// Detach from ref immediately
|
||||
wavesurfer.current = null;
|
||||
|
||||
// Wait a tick to destroy so any pending operations can complete
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
wsToDestroy.destroy();
|
||||
} catch (e) {
|
||||
// Ignore errors during destroy
|
||||
}
|
||||
// Only initialize new instance after destroying the old one
|
||||
if (mounted) {
|
||||
initWaveSurfer();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// No previous instance to clean up, initialize directly
|
||||
initWaveSurfer();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Mark component as unmounted
|
||||
mounted = false;
|
||||
if (wavesurfer.current) {
|
||||
try {
|
||||
wavesurfer.current.destroy();
|
||||
} catch (e) {
|
||||
// Silently ignore destroy errors
|
||||
}
|
||||
wavesurfer.current = null;
|
||||
|
||||
// Store reference to current wavesurfer instance
|
||||
const wsToDestroy = wavesurfer.current;
|
||||
|
||||
// Immediately clear the ref to prevent accessing it after unmount
|
||||
wavesurfer.current = null;
|
||||
|
||||
// If we have an instance to clean up, do it safely
|
||||
if (wsToDestroy) {
|
||||
// Delay destruction to avoid race conditions
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
wsToDestroy.destroy();
|
||||
} catch (e) {
|
||||
// Ignore destroy errors - they're expected
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [audioUrl, height]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${className}`} style={{ height }}>
|
||||
<div
|
||||
className={`flex items-center justify-center ${className}`}
|
||||
style={{ height }}
|
||||
>
|
||||
<span className="text-xs text-foreground/60">Audio unavailable</span>
|
||||
</div>
|
||||
);
|
||||
@@ -103,13 +150,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;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import { ChevronDown, ArrowLeft, SquarePen, Trash } from "lucide-react";
|
||||
import { HeaderBase } from "../header-base";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { RenameProjectDialog } from "../rename-project-dialog";
|
||||
import { DeleteProjectDialog } from "../delete-project-dialog";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FaDiscord } from "react-icons/fa6";
|
||||
import { PanelPresetSelector } from "./panel-preset-selector";
|
||||
import { ExportButton } from "./export-button";
|
||||
import { ThemeToggle } from "../theme-toggle";
|
||||
|
||||
export function EditorHeader() {
|
||||
const { activeProject, renameProject, deleteProject } = useProjectStore();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleNameSave = async (newName: string) => {
|
||||
console.log("handleNameSave", newName);
|
||||
if (activeProject && newName.trim() && newName !== activeProject.name) {
|
||||
try {
|
||||
await renameProject(activeProject.id, newName.trim());
|
||||
setIsRenameDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to rename project:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (activeProject) {
|
||||
deleteProject(activeProject.id);
|
||||
setIsDeleteDialogOpen(false);
|
||||
router.push("/projects");
|
||||
}
|
||||
};
|
||||
|
||||
const leftContent = (
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-auto py-1.5 px-2.5 flex items-center justify-center"
|
||||
>
|
||||
<ChevronDown className="text-muted-foreground" />
|
||||
<span className="text-[0.85rem] mr-2">{activeProject?.name}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-40 z-100">
|
||||
<Link href="/projects">
|
||||
<DropdownMenuItem className="flex items-center gap-1.5">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Projects
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={() => setIsRenameDialogOpen(true)}
|
||||
>
|
||||
<SquarePen className="h-4 w-4" />
|
||||
Rename project
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
Delete Project
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="https://discord.gg/zmR9N35cjK"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<FaDiscord className="h-4 w-4" />
|
||||
Discord
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<RenameProjectDialog
|
||||
isOpen={isRenameDialogOpen}
|
||||
onOpenChange={setIsRenameDialogOpen}
|
||||
onConfirm={handleNameSave}
|
||||
projectName={activeProject?.name || ""}
|
||||
/>
|
||||
<DeleteProjectDialog
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
onConfirm={handleDelete}
|
||||
projectName={activeProject?.name || ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const rightContent = (
|
||||
<nav className="flex items-center gap-2">
|
||||
<PanelPresetSelector />
|
||||
<KeyboardShortcutsHelp />
|
||||
<ExportButton />
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
);
|
||||
|
||||
return (
|
||||
<HeaderBase
|
||||
leftContent={leftContent}
|
||||
rightContent={rightContent}
|
||||
className="bg-background h-[3.2rem] px-3 items-center mt-0.5"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TransitionUpIcon } from "../icons";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { Button } from "../ui/button";
|
||||
import { Label } from "../ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
|
||||
import { Progress } from "../ui/progress";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
exportProject,
|
||||
getExportMimeType,
|
||||
getExportFileExtension,
|
||||
DEFAULT_EXPORT_OPTIONS,
|
||||
} from "@/lib/export";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
|
||||
import { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
|
||||
import { PropertyGroup } from "./properties-panel/property-item";
|
||||
|
||||
/**
|
||||
* Renders the Export button and its associated popover, enabling export actions when a project is active.
|
||||
*
|
||||
* @returns A JSX element containing the Export button trigger and, when an active project exists, the ExportPopover; the button is disabled and non-interactive when no project is available.
|
||||
*/
|
||||
export function ExportButton() {
|
||||
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
|
||||
const { activeProject } = useProjectStore();
|
||||
|
||||
const handleExport = () => {
|
||||
setIsExportPopoverOpen(true);
|
||||
};
|
||||
|
||||
const hasProject = !!activeProject;
|
||||
|
||||
return (
|
||||
<Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 bg-[#38BDF8] text-white rounded-md px-[0.12rem] py-[0.12rem] transition-all duration-200",
|
||||
hasProject
|
||||
? "cursor-pointer hover:brightness-95"
|
||||
: "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={hasProject ? handleExport : undefined}
|
||||
disabled={!hasProject}
|
||||
onKeyDown={(event) => {
|
||||
if (hasProject && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
handleExport();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 bg-linear-270 from-[#2567EC] to-[#37B6F7] rounded-[0.8rem] px-4 py-1 relative shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
|
||||
<TransitionUpIcon className="z-50" />
|
||||
<span className="text-[0.875rem] z-50">Export</span>
|
||||
<div className="absolute w-full h-full left-0 top-0 bg-linear-to-t from-white/0 to-white/50 z-10 rounded-[0.8rem] flex items-center justify-center">
|
||||
<div className="absolute w-[calc(100%-2px)] h-[calc(100%-2px)] top-[0.08rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] z-50 rounded-[0.8rem]" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
{hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportPopover({
|
||||
onOpenChange,
|
||||
}: {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { activeProject } = useProjectStore();
|
||||
const [format, setFormat] = useState<ExportFormat>(
|
||||
DEFAULT_EXPORT_OPTIONS.format
|
||||
);
|
||||
const [quality, setQuality] = useState<ExportQuality>(
|
||||
DEFAULT_EXPORT_OPTIONS.quality
|
||||
);
|
||||
const [includeAudio, setIncludeAudio] = useState<boolean>(
|
||||
DEFAULT_EXPORT_OPTIONS.includeAudio || true
|
||||
);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [exportResult, setExportResult] = useState<ExportResult | null>(null);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!activeProject) return;
|
||||
|
||||
setIsExporting(true);
|
||||
setProgress(0);
|
||||
setExportResult(null);
|
||||
|
||||
const result = await exportProject({
|
||||
format,
|
||||
quality,
|
||||
fps: activeProject.fps,
|
||||
includeAudio,
|
||||
onProgress: setProgress,
|
||||
onCancel: () => false, // TODO: Add cancel functionality
|
||||
});
|
||||
|
||||
setIsExporting(false);
|
||||
setExportResult(result);
|
||||
|
||||
if (result.success && result.buffer) {
|
||||
// Download the file
|
||||
const mimeType = getExportMimeType(format);
|
||||
const extension = getExportFileExtension(format);
|
||||
const blob = new Blob([result.buffer], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${activeProject.name}${extension}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
onOpenChange(false);
|
||||
setExportResult(null);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isExporting) {
|
||||
onOpenChange(false);
|
||||
setExportResult(null);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PopoverContent className="w-80 mr-4 flex flex-col gap-3 bg-background">
|
||||
<>
|
||||
{exportResult && !exportResult.success ? (
|
||||
<ExportError
|
||||
error={exportResult.error || "Unknown error occurred"}
|
||||
onRetry={handleExport}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className=" font-medium">
|
||||
{isExporting ? "Exporting project" : "Export project"}
|
||||
</h3>
|
||||
<Button variant="text" size="icon" onClick={handleClose}>
|
||||
<X className="!size-5 text-foreground/85" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{!isExporting && (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<PropertyGroup
|
||||
title="Format"
|
||||
titleClassName="text-sm"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<RadioGroup
|
||||
value={format}
|
||||
onValueChange={(value) =>
|
||||
setFormat(value as ExportFormat)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="mp4" id="mp4" />
|
||||
<Label htmlFor="mp4">
|
||||
MP4 (H.264) - Better compatibility
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="webm" id="webm" />
|
||||
<Label htmlFor="webm">
|
||||
WebM (VP9) - Smaller file size
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup
|
||||
title="Quality"
|
||||
titleClassName="text-sm"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<RadioGroup
|
||||
value={quality}
|
||||
onValueChange={(value) =>
|
||||
setQuality(value as ExportQuality)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="low" id="low" />
|
||||
<Label htmlFor="low">Low - Smallest file size</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="medium" id="medium" />
|
||||
<Label htmlFor="medium">Medium - Balanced</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="high" id="high" />
|
||||
<Label htmlFor="high">High - Recommended</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="very_high" id="very_high" />
|
||||
<Label htmlFor="very_high">
|
||||
Very High - Largest file size
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup
|
||||
title="Audio"
|
||||
titleClassName="text-sm"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="include-audio"
|
||||
checked={includeAudio}
|
||||
onCheckedChange={(checked) =>
|
||||
setIncludeAudio(!!checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="include-audio">
|
||||
Include audio in export
|
||||
</Label>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleExport} className="w-full gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Export
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isExporting && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-center flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{Math.round(progress * 100)}%
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-2">100%</p>
|
||||
</div>
|
||||
<Progress value={progress * 100} className="w-full" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-md w-full"
|
||||
onClick={() => {}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</PopoverContent>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an error view for a failed export with actions to copy the error and retry.
|
||||
*
|
||||
* @param error - The error message to display and to copy to the clipboard when the user activates Copy
|
||||
* @param onRetry - Callback invoked when the user activates Retry
|
||||
* @returns A JSX element showing the failure message and action buttons for copying the error and retrying the export
|
||||
*/
|
||||
function ExportError({
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
error: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(error);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium text-red-400">Export failed</p>
|
||||
<p className="text-xs text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs h-8"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check className="text-green-500" /> : <Copy />}
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs h-8"
|
||||
onClick={onRetry}
|
||||
>
|
||||
<RotateCcw />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import Image from "next/image";
|
||||
|
||||
/**
|
||||
* Renders a non-interactive, full-size overlay showing the TikTok layout guide.
|
||||
*
|
||||
* @returns A JSX element containing an absolutely positioned, pointer-events-none container with the TikTok layout guide image filling its bounds.
|
||||
*/
|
||||
function TikTokGuide() {
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<Image
|
||||
src="/platform-guides/tiktok-blueprint.png"
|
||||
alt="TikTok layout guide"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
draggable={false}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a layout guide overlay based on the editor store's selected platform.
|
||||
*
|
||||
* Renders the TikTokGuide component when `layoutGuide.platform` is `"tiktok"`, otherwise renders `null`.
|
||||
*
|
||||
* @returns The overlay JSX element for the selected platform, or `null` when no guide applies.
|
||||
*/
|
||||
export function LayoutGuideOverlay() {
|
||||
const { layoutGuide } = useEditorStore();
|
||||
|
||||
if (layoutGuide.platform === null) return null;
|
||||
if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,52 @@
|
||||
"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 { SoundsView } from "./views/sounds";
|
||||
import { StickersView } from "./views/stickers";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SettingsView } from "./views/settings";
|
||||
import { Captions } from "./views/captions";
|
||||
|
||||
export function MediaPanel() {
|
||||
const { activeTab } = useMediaPanelStore();
|
||||
|
||||
const viewMap: Record<Tab, React.ReactNode> = {
|
||||
media: <MediaView />,
|
||||
sounds: <SoundsView />,
|
||||
text: <TextView />,
|
||||
stickers: <StickersView />,
|
||||
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: <Captions />,
|
||||
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>
|
||||
),
|
||||
settings: <SettingsView />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex bg-panel">
|
||||
<TabBar />
|
||||
<Separator orientation="vertical" />
|
||||
<div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,73 +1,86 @@
|
||||
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,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { create } from "zustand";
|
||||
|
||||
export type Tab =
|
||||
| "media"
|
||||
| "sounds"
|
||||
| "text"
|
||||
| "stickers"
|
||||
| "effects"
|
||||
| "transitions"
|
||||
| "captions"
|
||||
| "filters"
|
||||
| "adjustment"
|
||||
| "settings";
|
||||
|
||||
export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
|
||||
media: {
|
||||
icon: VideoIcon,
|
||||
label: "Media",
|
||||
},
|
||||
sounds: {
|
||||
icon: MusicIcon,
|
||||
label: "Sounds",
|
||||
},
|
||||
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",
|
||||
},
|
||||
settings: {
|
||||
icon: SettingsIcon,
|
||||
label: "Settings",
|
||||
},
|
||||
};
|
||||
|
||||
interface MediaPanelStore {
|
||||
activeTab: Tab;
|
||||
setActiveTab: (tab: Tab) => void;
|
||||
highlightMediaId: string | null;
|
||||
requestRevealMedia: (mediaId: string) => void;
|
||||
clearHighlight: () => void;
|
||||
}
|
||||
|
||||
export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
|
||||
activeTab: "media",
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
highlightMediaId: null,
|
||||
requestRevealMedia: (mediaId) =>
|
||||
set({ activeTab: "media", highlightMediaId: mediaId }),
|
||||
clearHighlight: () => set({ highlightMediaId: null }),
|
||||
}));
|
||||
|
||||
@@ -1,124 +1,120 @@
|
||||
"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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Renders a vertical, scrollable sidebar of icon tabs with tooltips and edge fades.
|
||||
*
|
||||
* Highlights the active tab, updates the active tab when a tab is clicked, and shows
|
||||
* top/bottom gradient overlays when the scroll position is not at the respective edge.
|
||||
*
|
||||
* @returns The rendered tab bar element.
|
||||
*/
|
||||
export function TabBar() {
|
||||
const { activeTab, setActiveTab } = useMediaPanelStore();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showTopFade, setShowTopFade] = useState(false);
|
||||
const [showBottomFade, setShowBottomFade] = useState(false);
|
||||
|
||||
const checkScrollPosition = () => {
|
||||
const element = scrollRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||
setShowTopFade(scrollTop > 0);
|
||||
setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const element = scrollRef.current;
|
||||
if (!element) return;
|
||||
|
||||
checkScrollPosition();
|
||||
element.addEventListener("scroll", checkScrollPosition);
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkScrollPosition);
|
||||
resizeObserver.observe(element);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("scroll", checkScrollPosition);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex relative">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-y-auto scrollbar-hidden relative w-full py-4"
|
||||
>
|
||||
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
|
||||
const tab = tabs[tabKey];
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex z-[100] flex-col gap-0.5 items-center cursor-pointer",
|
||||
activeTab === tabKey
|
||||
? "text-primary !opacity-100"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab(tabKey)}
|
||||
key={tabKey}
|
||||
>
|
||||
<Tooltip delayDuration={10}>
|
||||
<TooltipTrigger asChild>
|
||||
<tab.icon className="size-[1.1rem]! opacity-100 hover:opacity-75" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
variant="sidebar"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="dark:text-base-gray-950 text-black text-sm font-medium leading-none dark:text-white">
|
||||
{tab.label}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<FadeOverlay direction="top" show={showTopFade} />
|
||||
<FadeOverlay direction="bottom" show={showBottomFade} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a directional gradient overlay that visually fades content at an edge.
|
||||
*
|
||||
* @param direction - Which edge to place the gradient on; `"top"` positions it at the top, `"bottom"` positions it at the bottom.
|
||||
* @param show - Whether the overlay is visible.
|
||||
* @returns A div element that displays a top or bottom gradient overlay when `show` is `true`.
|
||||
*/
|
||||
function FadeOverlay({
|
||||
direction,
|
||||
show,
|
||||
}: {
|
||||
direction: "top" | "bottom";
|
||||
show: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 right-0 h-6 pointer-events-none z-[101] transition-opacity duration-200",
|
||||
direction === "top" && show
|
||||
? "top-0 bg-gradient-to-b from-panel to-transparent"
|
||||
: "bottom-0 bg-gradient-to-t from-panel to-transparent"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PropertyGroup } from "../../properties-panel/property-item";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
|
||||
import { Language, LanguageSelect } from "@/components/language-select";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { extractTimelineAudio } from "@/lib/mediabunny-utils";
|
||||
import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
import { Loader2, Shield, Trash2, Upload } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { TextElement } from "@/types/timeline";
|
||||
|
||||
export const languages: Language[] = [
|
||||
{ code: "US", name: "English" },
|
||||
{ code: "ES", name: "Spanish" },
|
||||
{ code: "IT", name: "Italian" },
|
||||
{ code: "FR", name: "French" },
|
||||
{ code: "DE", name: "German" },
|
||||
{ code: "PT", name: "Portuguese" },
|
||||
{ code: "RU", name: "Russian" },
|
||||
{ code: "JP", name: "Japanese" },
|
||||
{ code: "CN", name: "Chinese" },
|
||||
];
|
||||
|
||||
const PRIVACY_DIALOG_KEY = "opencut-transcription-privacy-accepted";
|
||||
|
||||
/**
|
||||
* UI panel that generates caption text elements from the project's timeline audio while preserving user privacy.
|
||||
*
|
||||
* Renders language selection, progress and error UI, and a privacy consent dialog. When triggered, it extracts audio from the timeline, performs client-side zero-knowledge encryption, uploads the encrypted audio for transcription, and inserts the resulting caption text elements into a new text track on the timeline.
|
||||
*
|
||||
* @returns The component's JSX element.
|
||||
*/
|
||||
export function Captions() {
|
||||
const [selectedCountry, setSelectedCountry] = useState("auto");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [processingStep, setProcessingStep] = useState<string>("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPrivacyDialog, setShowPrivacyDialog] = useState(false);
|
||||
const [hasAcceptedPrivacy, setHasAcceptedPrivacy] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { insertTrackAt, addElementToTrack } = useTimelineStore();
|
||||
|
||||
// Check if user has already accepted privacy on mount
|
||||
useEffect(() => {
|
||||
const hasAccepted = localStorage.getItem(PRIVACY_DIALOG_KEY) === "true";
|
||||
setHasAcceptedPrivacy(hasAccepted);
|
||||
}, []);
|
||||
|
||||
const handleGenerateTranscript = async () => {
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
setProcessingStep("Extracting audio...");
|
||||
|
||||
const audioBlob = await extractTimelineAudio();
|
||||
|
||||
setProcessingStep("Encrypting audio...");
|
||||
|
||||
// Encrypt the audio with a random key (zero-knowledge)
|
||||
const audioBuffer = await audioBlob.arrayBuffer();
|
||||
const encryptionResult = await encryptWithRandomKey(audioBuffer);
|
||||
|
||||
// Convert encrypted data to blob for upload
|
||||
const encryptedBlob = new Blob([encryptionResult.encryptedData]);
|
||||
|
||||
setProcessingStep("Uploading...");
|
||||
const uploadResponse = await fetch("/api/get-upload-url", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fileExtension: "wav" }),
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
const error = await uploadResponse.json();
|
||||
throw new Error(error.message || "Failed to get upload URL");
|
||||
}
|
||||
|
||||
const { uploadUrl, fileName } = await uploadResponse.json();
|
||||
|
||||
// Upload to R2
|
||||
await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
body: encryptedBlob,
|
||||
});
|
||||
|
||||
setProcessingStep("Transcribing...");
|
||||
|
||||
// Call Modal transcription API with encryption parameters
|
||||
const transcriptionResponse = await fetch("/api/transcribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
filename: fileName,
|
||||
language:
|
||||
selectedCountry === "auto" ? "auto" : selectedCountry.toLowerCase(),
|
||||
// Send the raw encryption key and IV (zero-knowledge)
|
||||
decryptionKey: arrayBufferToBase64(encryptionResult.key),
|
||||
iv: arrayBufferToBase64(encryptionResult.iv),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!transcriptionResponse.ok) {
|
||||
const error = await transcriptionResponse.json();
|
||||
throw new Error(error.message || "Transcription failed");
|
||||
}
|
||||
|
||||
const { text, segments } = await transcriptionResponse.json();
|
||||
|
||||
console.log("Transcription completed:", { text, segments });
|
||||
|
||||
const shortCaptions: Array<{
|
||||
text: string;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
}> = [];
|
||||
|
||||
let globalEndTime = 0; // Track the end time of the last caption globally
|
||||
|
||||
segments.forEach((segment: any) => {
|
||||
const words = segment.text.trim().split(/\s+/);
|
||||
const segmentDuration = segment.end - segment.start;
|
||||
const wordsPerSecond = words.length / segmentDuration;
|
||||
|
||||
// Split into chunks of 2-4 words
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < words.length; i += 3) {
|
||||
chunks.push(words.slice(i, i + 3).join(" "));
|
||||
}
|
||||
|
||||
// Calculate timing for each chunk to place them sequentially
|
||||
let chunkStartTime = segment.start;
|
||||
chunks.forEach((chunk) => {
|
||||
const chunkWords = chunk.split(/\s+/).length;
|
||||
const chunkDuration = Math.max(0.8, chunkWords / wordsPerSecond); // Minimum 0.8s per chunk
|
||||
|
||||
let adjustedStartTime = chunkStartTime;
|
||||
|
||||
// Prevent overlapping: if this caption would start before the last one ends,
|
||||
// start it right after the last one ends
|
||||
if (adjustedStartTime < globalEndTime) {
|
||||
adjustedStartTime = globalEndTime;
|
||||
}
|
||||
|
||||
shortCaptions.push({
|
||||
text: chunk,
|
||||
startTime: adjustedStartTime,
|
||||
duration: chunkDuration,
|
||||
});
|
||||
|
||||
// Update global end time
|
||||
globalEndTime = adjustedStartTime + chunkDuration;
|
||||
|
||||
// Next chunk starts when this one ends (for within-segment timing)
|
||||
chunkStartTime += chunkDuration;
|
||||
});
|
||||
});
|
||||
|
||||
// Create a single track for all captions
|
||||
const captionTrackId = insertTrackAt("text", 0);
|
||||
|
||||
// Add all caption elements to the same track
|
||||
shortCaptions.forEach((caption, index) => {
|
||||
addElementToTrack(captionTrackId, {
|
||||
...DEFAULT_TEXT_ELEMENT,
|
||||
name: `Caption ${index + 1}`,
|
||||
content: caption.text,
|
||||
duration: caption.duration,
|
||||
startTime: caption.startTime,
|
||||
fontSize: 65, // Larger for captions
|
||||
fontWeight: "bold", // Bold for captions
|
||||
} as TextElement);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`✅ ${shortCaptions.length} short-form caption chunks added to timeline!`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Transcription failed:", error);
|
||||
setError(
|
||||
error instanceof Error ? error.message : "An unexpected error occurred"
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setProcessingStep("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseView
|
||||
ref={containerRef}
|
||||
className="flex flex-col justify-between h-full"
|
||||
>
|
||||
<PropertyGroup title="Language">
|
||||
<LanguageSelect
|
||||
selectedCountry={selectedCountry}
|
||||
onSelect={setSelectedCountry}
|
||||
containerRef={containerRef}
|
||||
languages={languages}
|
||||
/>
|
||||
</PropertyGroup>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
if (hasAcceptedPrivacy) {
|
||||
handleGenerateTranscript();
|
||||
} else {
|
||||
setShowPrivacyDialog(true);
|
||||
}
|
||||
}}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
|
||||
{isProcessing ? processingStep : "Generate transcript"}
|
||||
</Button>
|
||||
|
||||
<Dialog open={showPrivacyDialog} onOpenChange={setShowPrivacyDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Audio Processing Notice
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-3">
|
||||
<p>
|
||||
To generate captions, we need to process your timeline audio
|
||||
using speech-to-text technology.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Zero-knowledge encryption - we cannot decrypt your files
|
||||
even if we wanted to
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Encryption keys generated randomly in your browser, never
|
||||
stored anywhere
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Upload className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Audio encrypted before upload - raw audio never leaves
|
||||
your device
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Trash2 className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Everything permanently deleted within seconds after
|
||||
transcription
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>True zero-knowledge privacy:</strong> Encryption keys
|
||||
are generated randomly in your browser and never stored
|
||||
anywhere. It's cryptographically impossible for us, our cloud
|
||||
providers, or anyone else to decrypt your audio files.
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPrivacyDialog(false)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
localStorage.setItem(PRIVACY_DIALOG_KEY, "true");
|
||||
setHasAcceptedPrivacy(true);
|
||||
setShowPrivacyDialog(false);
|
||||
handleGenerateTranscript();
|
||||
}}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Continue & Generate Captions
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</BaseView>
|
||||
);
|
||||
}
|
||||
@@ -1,298 +1,552 @@
|
||||
"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 } from "@/stores/media-store";
|
||||
import { MediaFile } from "@/types/media";
|
||||
import {
|
||||
ArrowDown01,
|
||||
CloudUpload,
|
||||
Grid2X2,
|
||||
Image,
|
||||
List,
|
||||
Loader2,
|
||||
Music,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import { useRef, useState, useMemo } from "react";
|
||||
import { useHighlightScroll } from "@/hooks/use-highlight-scroll";
|
||||
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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useMediaPanelStore } from "../store";
|
||||
|
||||
function MediaItemWithContextMenu({
|
||||
item,
|
||||
children,
|
||||
onRemove,
|
||||
}: {
|
||||
item: MediaFile;
|
||||
children: React.ReactNode;
|
||||
onRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Export clips</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => onRemove(e, item.id)}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the media gallery and editor panel with upload, drag-and-drop, view modes, sorting, previews, and item operations.
|
||||
*
|
||||
* Displays upload controls and view/sort toggles, provides a drag-and-drop and file-picker upload surface, and shows media items as either a grid or list with previews and per-item actions (delete, export placeholder).
|
||||
*
|
||||
* @returns The React element representing the media view panel.
|
||||
*/
|
||||
export function MediaView() {
|
||||
const { mediaFiles, addMediaFile, removeMediaFile } = useMediaStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const { mediaViewMode, setMediaViewMode } = usePanelStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
|
||||
"name"
|
||||
);
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const { highlightMediaId, clearHighlight } = useMediaPanelStore();
|
||||
const { highlightedId, registerElement } = useHighlightScroll(
|
||||
highlightMediaId,
|
||||
clearHighlight
|
||||
);
|
||||
|
||||
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 {
|
||||
const processedItems = await processMediaFiles(files, (p) =>
|
||||
setProgress(p)
|
||||
);
|
||||
for (const item of processedItems) {
|
||||
await addMediaFile(activeProject.id, item);
|
||||
}
|
||||
} catch (error) {
|
||||
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>) => {
|
||||
if (e.target.files) processFiles(e.target.files);
|
||||
e.target.value = ""; // Reset input
|
||||
};
|
||||
|
||||
const handleRemove = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
await removeMediaFile(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 = useMemo(() => {
|
||||
const filtered = mediaFiles.filter((item) => {
|
||||
if (item.ephemeral) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
let valueA: string | number;
|
||||
let valueB: string | number;
|
||||
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
valueA = a.name.toLowerCase();
|
||||
valueB = b.name.toLowerCase();
|
||||
break;
|
||||
case "type":
|
||||
valueA = a.type;
|
||||
valueB = b.type;
|
||||
break;
|
||||
case "duration":
|
||||
valueA = a.duration || 0;
|
||||
valueB = b.duration || 0;
|
||||
break;
|
||||
case "size":
|
||||
valueA = a.file.size;
|
||||
valueB = b.file.size;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (valueA < valueB) return sortOrder === "asc" ? -1 : 1;
|
||||
if (valueA > valueB) return sortOrder === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [mediaFiles, sortBy, sortOrder]);
|
||||
|
||||
const previewComponents = useMemo(() => {
|
||||
const previews = new Map<string, React.ReactNode>();
|
||||
|
||||
filteredMediaItems.forEach((item) => {
|
||||
let preview: React.ReactNode;
|
||||
|
||||
if (item.type === "image") {
|
||||
preview = (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.name}
|
||||
className="w-full max-h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (item.type === "video") {
|
||||
if (item.thumbnailUrl) {
|
||||
preview = (
|
||||
<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>
|
||||
);
|
||||
} else {
|
||||
preview = (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
} else if (item.type === "audio") {
|
||||
preview = (
|
||||
<div className="w-full h-full bg-linear-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>
|
||||
);
|
||||
} else {
|
||||
preview = (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
previews.set(item.id, preview);
|
||||
});
|
||||
|
||||
return previews;
|
||||
}, [filteredMediaItems]);
|
||||
|
||||
const renderPreview = (item: MediaFile) => previewComponents.get(item.id);
|
||||
|
||||
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 bg-panel">
|
||||
{/* Search and filter controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleFileSelect}
|
||||
disabled={isProcessing}
|
||||
className="!bg-background px-4 flex-1 justify-center items-center h-9 opacity-100 hover:opacity-75 transition-opacity"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CloudUpload className="h-4 w-4" />
|
||||
)}
|
||||
<span>Upload</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-0">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="text"
|
||||
onClick={() =>
|
||||
setMediaViewMode(
|
||||
mediaViewMode === "grid" ? "list" : "grid"
|
||||
)
|
||||
}
|
||||
disabled={isProcessing}
|
||||
className="justify-center items-center"
|
||||
>
|
||||
{mediaViewMode === "grid" ? (
|
||||
<List strokeWidth={1.5} className="!size-[1.05rem]" />
|
||||
) : (
|
||||
<Grid2X2
|
||||
strokeWidth={1.5}
|
||||
className="!size-[1.05rem]"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{mediaViewMode === "grid"
|
||||
? "Switch to list view"
|
||||
: "Switch to grid view"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
<Tooltip>
|
||||
<DropdownMenu>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="text"
|
||||
disabled={isProcessing}
|
||||
className="justify-center items-center"
|
||||
>
|
||||
<ArrowDown01
|
||||
strokeWidth={1.5}
|
||||
className="!size-[1.05rem]"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortBy === "name") {
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc"
|
||||
);
|
||||
} else {
|
||||
setSortBy("name");
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Name{" "}
|
||||
{sortBy === "name" &&
|
||||
(sortOrder === "asc" ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortBy === "type") {
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc"
|
||||
);
|
||||
} else {
|
||||
setSortBy("type");
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Type{" "}
|
||||
{sortBy === "type" &&
|
||||
(sortOrder === "asc" ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortBy === "duration") {
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc"
|
||||
);
|
||||
} else {
|
||||
setSortBy("duration");
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Duration{" "}
|
||||
{sortBy === "duration" &&
|
||||
(sortOrder === "asc" ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortBy === "size") {
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc"
|
||||
);
|
||||
} else {
|
||||
setSortBy("size");
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
File Size{" "}
|
||||
{sortBy === "size" &&
|
||||
(sortOrder === "asc" ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Sort by {sortBy} (
|
||||
{sortOrder === "asc" ? "ascending" : "descending"})
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-full w-full overflow-y-auto scrollbar-thin pt-1">
|
||||
<div className="flex-1 p-3 pt-0 w-full">
|
||||
{isDragOver || filteredMediaItems.length === 0 ? (
|
||||
<MediaDragOverlay
|
||||
isVisible={true}
|
||||
isProcessing={isProcessing}
|
||||
progress={progress}
|
||||
onClick={handleFileSelect}
|
||||
isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
|
||||
/>
|
||||
) : mediaViewMode === "grid" ? (
|
||||
<GridView
|
||||
filteredMediaItems={filteredMediaItems}
|
||||
renderPreview={renderPreview}
|
||||
handleRemove={handleRemove}
|
||||
highlightedId={highlightedId}
|
||||
registerElement={registerElement}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
filteredMediaItems={filteredMediaItems}
|
||||
renderPreview={renderPreview}
|
||||
handleRemove={handleRemove}
|
||||
highlightedId={highlightedId}
|
||||
registerElement={registerElement}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GridView({
|
||||
filteredMediaItems,
|
||||
renderPreview,
|
||||
handleRemove,
|
||||
highlightedId,
|
||||
registerElement,
|
||||
}: {
|
||||
filteredMediaItems: MediaFile[];
|
||||
renderPreview: (item: MediaFile) => React.ReactNode;
|
||||
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
||||
highlightedId: string | null;
|
||||
registerElement: (id: string, element: HTMLElement | null) => void;
|
||||
}) {
|
||||
const { addElementAtTime } = useTimelineStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, 160px)",
|
||||
}}
|
||||
>
|
||||
{filteredMediaItems.map((item) => (
|
||||
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
|
||||
<MediaItemWithContextMenu item={item} onRemove={handleRemove}>
|
||||
<DraggableMediaItem
|
||||
name={item.name}
|
||||
preview={renderPreview(item)}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
}}
|
||||
showPlusOnDrag={false}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
addElementAtTime(item, currentTime)
|
||||
}
|
||||
rounded={false}
|
||||
variant="card"
|
||||
isHighlighted={highlightedId === item.id}
|
||||
/>
|
||||
</MediaItemWithContextMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListView({
|
||||
filteredMediaItems,
|
||||
renderPreview,
|
||||
handleRemove,
|
||||
highlightedId,
|
||||
registerElement,
|
||||
}: {
|
||||
filteredMediaItems: MediaFile[];
|
||||
renderPreview: (item: MediaFile) => React.ReactNode;
|
||||
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
||||
highlightedId: string | null;
|
||||
registerElement: (id: string, element: HTMLElement | null) => void;
|
||||
}) {
|
||||
const { addElementAtTime } = useTimelineStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{filteredMediaItems.map((item) => (
|
||||
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
|
||||
<MediaItemWithContextMenu item={item} onRemove={handleRemove}>
|
||||
<DraggableMediaItem
|
||||
name={item.name}
|
||||
preview={renderPreview(item)}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
}}
|
||||
showPlusOnDrag={false}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
addElementAtTime(item, currentTime)
|
||||
}
|
||||
variant="compact"
|
||||
isHighlighted={highlightedId === item.id}
|
||||
/>
|
||||
</MediaItemWithContextMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
|
||||
import {
|
||||
PropertyItem,
|
||||
PropertyItemLabel,
|
||||
PropertyItemValue,
|
||||
PropertyGroup,
|
||||
} from "../../properties-panel/property-item";
|
||||
import { FPS_PRESETS } from "@/constants/timeline-constants";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import type { BlurIntensity } from "@/types/project";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { colors } from "@/data/colors/solid";
|
||||
import { patternCraftGradients } from "@/data/colors/pattern-craft";
|
||||
import { PipetteIcon, PlusIcon } from "lucide-react";
|
||||
import { useMemo, memo, useCallback } from "react";
|
||||
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export function SettingsView() {
|
||||
return <ProjectSettingsTabs />;
|
||||
}
|
||||
|
||||
function ProjectSettingsTabs() {
|
||||
return (
|
||||
<BaseView
|
||||
defaultTab="project-info"
|
||||
tabs={[
|
||||
{
|
||||
value: "project-info",
|
||||
label: "Project info",
|
||||
content: (
|
||||
<div className="p-5">
|
||||
<ProjectInfoView />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "background",
|
||||
label: "Background",
|
||||
content: (
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
<div className="flex-1 p-5">
|
||||
<BackgroundView />
|
||||
</div>
|
||||
<div className="flex flex-col sticky -bottom-0 bg-panel/85 backdrop-blur-lg">
|
||||
<Separator />
|
||||
<Button className="w-fit h-auto p-5 py-4 !bg-transparent shadow-none text-muted-foreground hover:text-foreground/85 text-xs">
|
||||
Custom background
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Another UI, looks so beautiful i don't wanna remove it */}
|
||||
{/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0">
|
||||
<Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full">
|
||||
<span className="text-sm">Custom</span>
|
||||
<PlusIcon className="" />
|
||||
</Button>
|
||||
</div> */}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
className="flex flex-col justify-between h-full p-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectInfoView() {
|
||||
const { activeProject, updateProjectFps, updateCanvasSize } =
|
||||
useProjectStore();
|
||||
const { canvasPresets } = useEditorStore();
|
||||
const { getDisplayName } = useAspectRatio();
|
||||
|
||||
const handleAspectRatioChange = (value: string) => {
|
||||
const preset = canvasPresets.find((p) => p.name === value);
|
||||
if (preset) {
|
||||
updateCanvasSize(
|
||||
{ width: preset.width, height: preset.height },
|
||||
"preset"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFpsChange = (value: string) => {
|
||||
const fps = parseFloat(value);
|
||||
updateProjectFps(fps);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Name</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
{activeProject?.name || "Untitled project"}
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Aspect ratio</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<Select
|
||||
value={getDisplayName()}
|
||||
onValueChange={handleAspectRatioChange}
|
||||
>
|
||||
<SelectTrigger className="bg-panel-accent">
|
||||
<SelectValue placeholder="Select an aspect ratio" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{canvasPresets.map((preset) => (
|
||||
<SelectItem key={preset.name} value={preset.name}>
|
||||
{preset.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Frame rate</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<Select
|
||||
value={(activeProject?.fps || 30).toString()}
|
||||
onValueChange={handleFpsChange}
|
||||
>
|
||||
<SelectTrigger className="bg-panel-accent">
|
||||
<SelectValue placeholder="Select a frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FPS_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BlurPreview = memo(
|
||||
({
|
||||
blur,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
blur: { label: string; value: number };
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) => (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary relative overflow-hidden",
|
||||
isSelected && "border-2 border-primary"
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<Image
|
||||
src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
|
||||
alt={`Blur preview ${blur.label}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
style={{ filter: `blur(${blur.value}px)` }}
|
||||
loading="eager"
|
||||
/>
|
||||
<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>
|
||||
)
|
||||
);
|
||||
|
||||
BlurPreview.displayName = "BlurPreview";
|
||||
|
||||
const BackgroundPreviews = memo(
|
||||
({
|
||||
backgrounds,
|
||||
currentBackgroundColor,
|
||||
isColorBackground,
|
||||
handleColorSelect,
|
||||
useBackgroundColor = false,
|
||||
}: {
|
||||
backgrounds: string[];
|
||||
currentBackgroundColor: string;
|
||||
isColorBackground: boolean;
|
||||
handleColorSelect: (bg: string) => void;
|
||||
useBackgroundColor?: boolean;
|
||||
}) => {
|
||||
return useMemo(
|
||||
() =>
|
||||
backgrounds.map((bg, index) => (
|
||||
<div
|
||||
key={`${index}-${bg}`}
|
||||
className={cn(
|
||||
"w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary",
|
||||
isColorBackground &&
|
||||
bg === currentBackgroundColor &&
|
||||
"border-2 border-primary"
|
||||
)}
|
||||
style={
|
||||
useBackgroundColor
|
||||
? { backgroundColor: bg }
|
||||
: {
|
||||
background: bg,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}
|
||||
}
|
||||
onClick={() => handleColorSelect(bg)}
|
||||
/>
|
||||
)),
|
||||
[
|
||||
backgrounds,
|
||||
isColorBackground,
|
||||
currentBackgroundColor,
|
||||
handleColorSelect,
|
||||
useBackgroundColor,
|
||||
]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
BackgroundPreviews.displayName = "BackgroundPreviews";
|
||||
|
||||
function BackgroundView() {
|
||||
const { activeProject, updateBackgroundType } = useProjectStore();
|
||||
|
||||
const blurLevels = useMemo<Array<{ label: string; value: BlurIntensity }>>(
|
||||
() => [
|
||||
{ label: "Light", value: 4 },
|
||||
{ label: "Medium", value: 8 },
|
||||
{ label: "Heavy", value: 18 },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const handleBlurSelect = useCallback(
|
||||
async (blurIntensity: BlurIntensity) => {
|
||||
await updateBackgroundType("blur", { blurIntensity });
|
||||
},
|
||||
[updateBackgroundType]
|
||||
);
|
||||
|
||||
const handleColorSelect = useCallback(
|
||||
async (color: string) => {
|
||||
await updateBackgroundType("color", { backgroundColor: color });
|
||||
},
|
||||
[updateBackgroundType]
|
||||
);
|
||||
|
||||
const currentBlurIntensity = activeProject?.blurIntensity || 8;
|
||||
const isBlurBackground = activeProject?.backgroundType === "blur";
|
||||
const currentBackgroundColor = activeProject?.backgroundColor || "#000000";
|
||||
const isColorBackground = activeProject?.backgroundType === "color";
|
||||
|
||||
const blurPreviews = useMemo(
|
||||
() =>
|
||||
blurLevels.map((blur) => (
|
||||
<BlurPreview
|
||||
key={blur.value}
|
||||
blur={blur}
|
||||
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
|
||||
onSelect={() => handleBlurSelect(blur.value)}
|
||||
/>
|
||||
)),
|
||||
[blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 h-full">
|
||||
<PropertyGroup title="Blur" defaultExpanded={false}>
|
||||
<div className="grid grid-cols-4 gap-2 w-full">{blurPreviews}</div>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup title="Colors" defaultExpanded={false}>
|
||||
<div className="grid grid-cols-4 gap-2 w-full">
|
||||
<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>
|
||||
<BackgroundPreviews
|
||||
backgrounds={colors}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={handleColorSelect}
|
||||
useBackgroundColor={true}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup title="Pattern craft" defaultExpanded={false}>
|
||||
<div className="grid grid-cols-4 gap-2 w-full">
|
||||
<BackgroundPreviews
|
||||
backgrounds={patternCraftGradients}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={handleColorSelect}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup title="Syntax UI" defaultExpanded={false}>
|
||||
<div className="grid grid-cols-4 gap-2 w-full">
|
||||
<BackgroundPreviews
|
||||
backgrounds={syntaxUIGradients}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={handleColorSelect}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
PlayIcon,
|
||||
PauseIcon,
|
||||
HeartIcon,
|
||||
PlusIcon,
|
||||
ListFilter,
|
||||
} from "lucide-react";
|
||||
import { useSoundsStore } from "@/stores/sounds-store";
|
||||
import { useSoundSearch } from "@/hooks/use-sound-search";
|
||||
import type { SoundEffect, SavedSound } from "@/types/sounds";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
|
||||
export function SoundsView() {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Tabs defaultValue="sound-effects" className="flex flex-col h-full">
|
||||
<div className="px-3 pt-4 pb-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="sound-effects">Sound effects</TabsTrigger>
|
||||
<TabsTrigger value="songs">Songs</TabsTrigger>
|
||||
<TabsTrigger value="saved">Saved</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<TabsContent
|
||||
value="sound-effects"
|
||||
className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<SoundEffectsView />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="saved"
|
||||
className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<SavedSoundsView />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="songs"
|
||||
className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<SongsView />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SoundEffectsView() {
|
||||
const {
|
||||
topSoundEffects,
|
||||
isLoading,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
scrollPosition,
|
||||
setScrollPosition,
|
||||
loadSavedSounds,
|
||||
isSoundSaved,
|
||||
toggleSavedSound,
|
||||
showCommercialOnly,
|
||||
toggleCommercialFilter,
|
||||
hasLoaded,
|
||||
setTopSoundEffects,
|
||||
setLoading,
|
||||
setError,
|
||||
setHasLoaded,
|
||||
setCurrentPage,
|
||||
setHasNextPage,
|
||||
setTotalCount,
|
||||
} = useSoundsStore();
|
||||
const {
|
||||
results: searchResults,
|
||||
isLoading: isSearching,
|
||||
loadMore,
|
||||
hasNextPage,
|
||||
isLoadingMore,
|
||||
} = useSoundSearch(searchQuery, showCommercialOnly);
|
||||
|
||||
// Audio playback state
|
||||
const [playingId, setPlayingId] = useState<number | null>(null);
|
||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const { scrollAreaRef, handleScroll } = useInfiniteScroll({
|
||||
onLoadMore: loadMore,
|
||||
hasMore: hasNextPage,
|
||||
isLoading: isLoadingMore || isSearching,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadSavedSounds();
|
||||
|
||||
if (!hasLoaded) {
|
||||
let ignore = false;
|
||||
|
||||
const fetchTopSounds = async () => {
|
||||
try {
|
||||
if (!ignore) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
"/api/sounds/search?page_size=50&sort=downloads"
|
||||
);
|
||||
|
||||
if (!ignore) {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTopSoundEffects(data.results);
|
||||
setHasLoaded(true);
|
||||
|
||||
setCurrentPage(1);
|
||||
setHasNextPage(!!data.next);
|
||||
setTotalCount(data.count);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!ignore) {
|
||||
console.error("Failed to fetch top sounds:", error);
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Failed to load sounds"
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!ignore) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(fetchTopSounds, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
ignore = true;
|
||||
};
|
||||
}
|
||||
|
||||
if (scrollAreaRef.current && scrollPosition > 0) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
scrollAreaRef.current?.scrollTo({ top: scrollPosition });
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [
|
||||
hasLoaded,
|
||||
setTopSoundEffects,
|
||||
setLoading,
|
||||
setError,
|
||||
setHasLoaded,
|
||||
setCurrentPage,
|
||||
setHasNextPage,
|
||||
setTotalCount,
|
||||
]);
|
||||
|
||||
const handleScrollWithPosition = (event: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop } = event.currentTarget;
|
||||
setScrollPosition(scrollTop);
|
||||
handleScroll(event);
|
||||
};
|
||||
|
||||
const displayedSounds = useMemo(() => {
|
||||
const sounds = searchQuery ? searchResults : topSoundEffects;
|
||||
return sounds;
|
||||
}, [searchQuery, searchResults, topSoundEffects]);
|
||||
|
||||
const playSound = (sound: SoundEffect) => {
|
||||
if (playingId === sound.id) {
|
||||
audioElement?.pause();
|
||||
setPlayingId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop previous sound
|
||||
audioElement?.pause();
|
||||
|
||||
if (sound.previewUrl) {
|
||||
const audio = new Audio(sound.previewUrl);
|
||||
audio.addEventListener("ended", () => {
|
||||
setPlayingId(null);
|
||||
});
|
||||
audio.addEventListener("error", (e) => {
|
||||
setPlayingId(null);
|
||||
});
|
||||
audio.play().catch((error) => {
|
||||
setPlayingId(null);
|
||||
});
|
||||
|
||||
setAudioElement(audio);
|
||||
setPlayingId(sound.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 mt-1 h-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
placeholder="Search sound effects"
|
||||
className="bg-panel-accent w-full"
|
||||
containerClassName="w-full"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
showClearIcon
|
||||
onClear={() => setSearchQuery("")}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className={cn(showCommercialOnly && "text-primary")}
|
||||
>
|
||||
<ListFilter className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showCommercialOnly}
|
||||
onCheckedChange={toggleCommercialFilter}
|
||||
>
|
||||
Show only commercially licensed
|
||||
</DropdownMenuCheckboxItem>
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{showCommercialOnly
|
||||
? "Only showing sounds licensed for commercial use"
|
||||
: "Showing all sounds regardless of license"}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full overflow-hidden">
|
||||
<ScrollArea
|
||||
className="flex-1 h-full"
|
||||
ref={scrollAreaRef}
|
||||
onScrollCapture={handleScrollWithPosition}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoading && !searchQuery && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Loading sounds...
|
||||
</div>
|
||||
)}
|
||||
{isSearching && searchQuery && (
|
||||
<div className="text-muted-foreground text-sm">Searching...</div>
|
||||
)}
|
||||
{displayedSounds.map((sound) => (
|
||||
<AudioItem
|
||||
key={sound.id}
|
||||
sound={sound}
|
||||
isPlaying={playingId === sound.id}
|
||||
onPlay={() => playSound(sound)}
|
||||
isSaved={isSoundSaved(sound.id)}
|
||||
onToggleSaved={() => toggleSavedSound(sound)}
|
||||
/>
|
||||
))}
|
||||
{!isLoading && !isSearching && displayedSounds.length === 0 && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{searchQuery ? "No sounds found" : "No sounds available"}
|
||||
</div>
|
||||
)}
|
||||
{isLoadingMore && (
|
||||
<div className="text-muted-foreground text-sm text-center py-4">
|
||||
Loading more sounds...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedSoundsView() {
|
||||
const {
|
||||
savedSounds,
|
||||
isLoadingSavedSounds,
|
||||
savedSoundsError,
|
||||
loadSavedSounds,
|
||||
isSoundSaved,
|
||||
toggleSavedSound,
|
||||
clearSavedSounds,
|
||||
} = useSoundsStore();
|
||||
|
||||
// Audio playback state
|
||||
const [playingId, setPlayingId] = useState<number | null>(null);
|
||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Clear confirmation dialog state
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
|
||||
// Load saved sounds when tab becomes active
|
||||
useEffect(() => {
|
||||
loadSavedSounds();
|
||||
}, [loadSavedSounds]);
|
||||
|
||||
const playSound = (sound: SavedSound) => {
|
||||
if (playingId === sound.id) {
|
||||
audioElement?.pause();
|
||||
setPlayingId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop previous sound
|
||||
audioElement?.pause();
|
||||
|
||||
if (sound.previewUrl) {
|
||||
const audio = new Audio(sound.previewUrl);
|
||||
audio.addEventListener("ended", () => {
|
||||
setPlayingId(null);
|
||||
});
|
||||
audio.addEventListener("error", (e) => {
|
||||
setPlayingId(null);
|
||||
});
|
||||
audio.play().catch((error) => {
|
||||
setPlayingId(null);
|
||||
});
|
||||
|
||||
setAudioElement(audio);
|
||||
setPlayingId(sound.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Convert SavedSound to SoundEffect for compatibility with AudioItem
|
||||
const convertToSoundEffect = (savedSound: SavedSound): SoundEffect => ({
|
||||
id: savedSound.id,
|
||||
name: savedSound.name,
|
||||
description: "",
|
||||
url: "",
|
||||
previewUrl: savedSound.previewUrl,
|
||||
downloadUrl: savedSound.downloadUrl,
|
||||
duration: savedSound.duration,
|
||||
filesize: 0,
|
||||
type: "audio",
|
||||
channels: 0,
|
||||
bitrate: 0,
|
||||
bitdepth: 0,
|
||||
samplerate: 0,
|
||||
username: savedSound.username,
|
||||
tags: savedSound.tags,
|
||||
license: savedSound.license,
|
||||
created: savedSound.savedAt,
|
||||
downloads: 0,
|
||||
rating: 0,
|
||||
ratingCount: 0,
|
||||
});
|
||||
|
||||
if (isLoadingSavedSounds) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Loading saved sounds...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (savedSoundsError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-destructive text-sm">
|
||||
Error: {savedSoundsError}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (savedSounds.length === 0) {
|
||||
return (
|
||||
<div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3">
|
||||
<HeartIcon
|
||||
className="w-10 h-10 text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<p className="text-lg font-medium">No saved sounds</p>
|
||||
<p className="text-sm text-muted-foreground text-balance">
|
||||
Click the heart icon on any sound to save it here
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 mt-1 h-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{savedSounds.length} saved{" "}
|
||||
{savedSounds.length === 1 ? "sound" : "sounds"}
|
||||
</p>
|
||||
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="h-auto text-muted-foreground hover:text-destructive !opacity-100"
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clear all saved sounds?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently remove all {savedSounds.length} saved
|
||||
sounds from your collection. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="text" onClick={() => setShowClearDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
await clearSavedSounds();
|
||||
setShowClearDialog(false);
|
||||
}}
|
||||
>
|
||||
Clear all sounds
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full overflow-hidden">
|
||||
<ScrollArea className="flex-1 h-full">
|
||||
<div className="flex flex-col gap-4">
|
||||
{savedSounds.map((sound) => (
|
||||
<AudioItem
|
||||
key={sound.id}
|
||||
sound={convertToSoundEffect(sound)}
|
||||
isPlaying={playingId === sound.id}
|
||||
onPlay={() => playSound(sound)}
|
||||
isSaved={isSoundSaved(sound.id)}
|
||||
onToggleSaved={() =>
|
||||
toggleSavedSound(convertToSoundEffect(sound))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SongsView() {
|
||||
return <div>Songs</div>;
|
||||
}
|
||||
|
||||
interface AudioItemProps {
|
||||
sound: SoundEffect;
|
||||
isPlaying: boolean;
|
||||
onPlay: () => void;
|
||||
isSaved: boolean;
|
||||
onToggleSaved: () => void;
|
||||
}
|
||||
|
||||
function AudioItem({
|
||||
sound,
|
||||
isPlaying,
|
||||
onPlay,
|
||||
isSaved,
|
||||
onToggleSaved,
|
||||
}: AudioItemProps) {
|
||||
const { addSoundToTimeline } = useSoundsStore();
|
||||
|
||||
const handleClick = () => {
|
||||
onPlay();
|
||||
};
|
||||
|
||||
const handleSaveClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onToggleSaved();
|
||||
};
|
||||
|
||||
const handleAddToTimeline = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await addSoundToTimeline(sound);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex items-center gap-3 opacity-100 hover:opacity-75 transition-opacity cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="relative w-12 h-12 bg-accent rounded-md flex items-center justify-center overflow-hidden shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-transparent" />
|
||||
{isPlaying ? (
|
||||
<PauseIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<PlayIcon className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<p className="font-medium truncate text-sm">{sound.name}</p>
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{sound.username}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pr-2">
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground !opacity-100 w-auto"
|
||||
onClick={handleAddToTimeline}
|
||||
title="Add to timeline"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className={`hover:text-foreground !opacity-100 w-auto ${
|
||||
isSaved
|
||||
? "text-red-500 hover:text-red-600"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
onClick={handleSaveClick}
|
||||
title={isSaved ? "Remove from saved" : "Save sound"}
|
||||
>
|
||||
<HeartIcon className={`w-4 h-4 ${isSaved ? "fill-current" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useStickersStore } from "@/stores/stickers-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import {
|
||||
Loader2,
|
||||
Grid3X3,
|
||||
Hash,
|
||||
Smile,
|
||||
Clock,
|
||||
X,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
StickerIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
import {
|
||||
getIconSvgUrl,
|
||||
buildIconSvgUrl,
|
||||
ICONIFY_HOSTS,
|
||||
POPULAR_COLLECTIONS,
|
||||
} from "@/lib/iconify-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import Image from "next/image";
|
||||
import type { MediaFile } from "@/types/media";
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { InputWithBack } from "@/components/ui/input-with-back";
|
||||
import { StickerCategory } from "@/stores/stickers-store";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
|
||||
export function StickersView() {
|
||||
const { selectedCategory, setSelectedCategory } = useStickersStore();
|
||||
|
||||
return (
|
||||
<BaseView
|
||||
value={selectedCategory}
|
||||
onValueChange={(v) => {
|
||||
if (["all", "general", "brands", "emoji"].includes(v)) {
|
||||
setSelectedCategory(v as StickerCategory);
|
||||
}
|
||||
}}
|
||||
tabs={[
|
||||
{
|
||||
value: "all",
|
||||
label: "All",
|
||||
icon: <Grid3X3 className="h-3 w-3" />,
|
||||
content: <StickersContentView category="all" />,
|
||||
},
|
||||
{
|
||||
value: "general",
|
||||
label: "Icons",
|
||||
icon: <Sparkles className="h-3 w-3" />,
|
||||
content: <StickersContentView category="general" />,
|
||||
},
|
||||
{
|
||||
value: "brands",
|
||||
label: "Brands",
|
||||
icon: <Hash className="h-3 w-3" />,
|
||||
content: <StickersContentView category="brands" />,
|
||||
},
|
||||
{
|
||||
value: "emoji",
|
||||
label: "Emoji",
|
||||
icon: <Smile className="h-3 w-3" />,
|
||||
content: <StickersContentView category="emoji" />,
|
||||
},
|
||||
]}
|
||||
className="flex flex-col h-full p-0 overflow-hidden"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a responsive grid of stickers for the provided icon names.
|
||||
*
|
||||
* Renders one StickerItem per entry in `icons`, laying out items responsively and optionally capping their maximum size.
|
||||
*
|
||||
* @param icons - Array of sticker icon identifiers (e.g., "prefix:name")
|
||||
* @param onAdd - Callback invoked with an icon name when that sticker is requested to be added
|
||||
* @param addingSticker - Icon identifier that is currently in an "adding"/loading state, or `null`
|
||||
* @param capSize - When true, constrains sticker items to a capped max size for a denser grid
|
||||
* @returns A React element containing the sticker grid
|
||||
*/
|
||||
function StickerGrid({
|
||||
icons,
|
||||
onAdd,
|
||||
addingSticker,
|
||||
capSize = false,
|
||||
}: {
|
||||
icons: string[];
|
||||
onAdd: (iconName: string) => void;
|
||||
addingSticker: string | null;
|
||||
capSize?: boolean;
|
||||
}) {
|
||||
const gridStyle: CSSProperties & Record<string, string> = {
|
||||
gridTemplateColumns: capSize
|
||||
? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))"
|
||||
: "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))",
|
||||
"--sticker-min": "96px",
|
||||
};
|
||||
|
||||
if (capSize) {
|
||||
gridStyle["--sticker-max"] = "160px";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2" style={gridStyle}>
|
||||
{icons.map((iconName) => (
|
||||
<StickerItem
|
||||
key={iconName}
|
||||
iconName={iconName}
|
||||
onAdd={onAdd}
|
||||
isAdding={addingSticker === iconName}
|
||||
capSize={capSize}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionGrid({
|
||||
collections,
|
||||
onSelectCollection,
|
||||
}: {
|
||||
collections: Array<{
|
||||
prefix: string;
|
||||
name: string;
|
||||
total: number;
|
||||
category?: string;
|
||||
}>;
|
||||
onSelectCollection: (prefix: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{collections.map((collection) => (
|
||||
<CollectionItem
|
||||
key={collection.prefix}
|
||||
title={collection.name}
|
||||
subtitle={`${collection.total.toLocaleString()} icons${collection.category ? ` • ${collection.category}` : ""}`}
|
||||
onClick={() => onSelectCollection(collection.prefix)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyView({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3">
|
||||
<StickerIcon
|
||||
className="w-10 h-10 text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<p className="text-lg font-medium">No stickers found</p>
|
||||
<p className="text-sm text-muted-foreground text-balance">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the stickers panel for a given category, handling browsing, searching, collection views, and adding stickers to the timeline.
|
||||
*
|
||||
* This component coordinates sticker-related UI state and interactions (search input with debounce, collection listing and pagination, recent stickers, loading states, and adding a sticker to the timeline) and renders the appropriate subviews (recent, collection, search results, or browse collections) based on the current view mode.
|
||||
*
|
||||
* @param category - The sticker category to display (`"all" | "general" | "brands" | "emoji"`).
|
||||
* @returns The React element for the stickers content view.
|
||||
*/
|
||||
function StickersContentView({ category }: { category: StickerCategory }) {
|
||||
const { activeProject } = useProjectStore();
|
||||
const { addElementAtTime } = useTimelineStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const { addMediaFile } = useMediaStore();
|
||||
const {
|
||||
searchQuery,
|
||||
selectedCollection,
|
||||
viewMode,
|
||||
collections,
|
||||
currentCollection,
|
||||
searchResults,
|
||||
recentStickers,
|
||||
isLoadingCollections,
|
||||
isLoadingCollection,
|
||||
isSearching,
|
||||
setSearchQuery,
|
||||
setSelectedCollection,
|
||||
loadCollections,
|
||||
searchStickers,
|
||||
addStickerToTimeline,
|
||||
clearRecentStickers,
|
||||
setSelectedCategory,
|
||||
addingSticker,
|
||||
} = useStickersStore();
|
||||
|
||||
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
|
||||
const [collectionsToShow, setCollectionsToShow] = useState(20);
|
||||
const [showCollectionItems, setShowCollectionItems] = useState(false);
|
||||
|
||||
const filteredCollections = useMemo(() => {
|
||||
if (category === "all") {
|
||||
return Object.entries(collections).map(([prefix, collection]) => ({
|
||||
prefix,
|
||||
name: collection.name,
|
||||
total: collection.total,
|
||||
category: collection.category,
|
||||
}));
|
||||
}
|
||||
|
||||
const collectionList =
|
||||
POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS];
|
||||
if (!collectionList) return [];
|
||||
|
||||
return collectionList
|
||||
.map((c) => {
|
||||
const collection = collections[c.prefix];
|
||||
return collection
|
||||
? {
|
||||
prefix: c.prefix,
|
||||
name: c.name,
|
||||
total: collection.total,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
prefix: string;
|
||||
name: string;
|
||||
total: number;
|
||||
}>;
|
||||
}, [collections, category]);
|
||||
|
||||
const { scrollAreaRef, handleScroll } = useInfiniteScroll({
|
||||
onLoadMore: () => setCollectionsToShow((prev) => prev + 20),
|
||||
hasMore: filteredCollections.length > collectionsToShow,
|
||||
isLoading: isLoadingCollections,
|
||||
enabled: viewMode === "browse" && !selectedCollection && category === "all",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(collections).length === 0) {
|
||||
loadCollections();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (localSearchQuery !== searchQuery) {
|
||||
setSearchQuery(localSearchQuery);
|
||||
if (localSearchQuery.trim()) {
|
||||
searchStickers(localSearchQuery);
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [localSearchQuery]);
|
||||
|
||||
const handleAddSticker = async (iconName: string) => {
|
||||
try {
|
||||
await addStickerToTimeline(iconName);
|
||||
} catch (error) {
|
||||
console.error("Failed to add sticker:", error);
|
||||
toast.error("Failed to add sticker to timeline");
|
||||
}
|
||||
};
|
||||
|
||||
const iconsToDisplay = useMemo(() => {
|
||||
if (viewMode === "search" && searchResults) {
|
||||
return searchResults.icons;
|
||||
}
|
||||
|
||||
if (viewMode === "collection" && currentCollection) {
|
||||
const icons: string[] = [];
|
||||
|
||||
if (currentCollection.uncategorized) {
|
||||
icons.push(
|
||||
...currentCollection.uncategorized.map(
|
||||
(name) => `${currentCollection.prefix}:${name}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (currentCollection.categories) {
|
||||
Object.values(currentCollection.categories).forEach((categoryIcons) => {
|
||||
icons.push(
|
||||
...categoryIcons.map(
|
||||
(name) => `${currentCollection.prefix}:${name}`
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return icons.slice(0, 100);
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [viewMode, searchResults, currentCollection]);
|
||||
|
||||
const isInCollection = viewMode === "collection" && !!selectedCollection;
|
||||
|
||||
useEffect(() => {
|
||||
if (isInCollection) {
|
||||
setShowCollectionItems(false);
|
||||
const timer = setTimeout(() => setShowCollectionItems(true), 350);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setShowCollectionItems(false);
|
||||
}, [isInCollection]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 mt-1 h-full p-4">
|
||||
<div className="space-y-3">
|
||||
<InputWithBack
|
||||
isExpanded={isInCollection}
|
||||
setIsExpanded={(expanded) => {
|
||||
if (!expanded && isInCollection) {
|
||||
setSelectedCollection(null);
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
category === "all"
|
||||
? "Search all stickers"
|
||||
: category === "general"
|
||||
? "Search icons"
|
||||
: category === "brands"
|
||||
? "Search brands"
|
||||
: "Search Emojis"
|
||||
}
|
||||
value={localSearchQuery}
|
||||
onChange={setLocalSearchQuery}
|
||||
disableAnimation={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full overflow-hidden">
|
||||
<ScrollArea
|
||||
className="flex-1 h-full"
|
||||
ref={scrollAreaRef}
|
||||
onScrollCapture={handleScroll}
|
||||
>
|
||||
<div className="flex flex-col gap-4 h-full">
|
||||
{recentStickers.length > 0 && viewMode === "browse" && (
|
||||
<div className="h-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Recent</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearRecentStickers}
|
||||
className="ml-auto h-5 w-5 p-0 rounded hover:bg-accent flex items-center justify-center"
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Clear recent stickers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<StickerGrid
|
||||
icons={recentStickers.slice(0, 12)}
|
||||
onAdd={handleAddSticker}
|
||||
addingSticker={addingSticker}
|
||||
capSize
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "collection" && selectedCollection && (
|
||||
<div className="h-full">
|
||||
{isLoadingCollection ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : showCollectionItems ? (
|
||||
<StickerGrid
|
||||
icons={iconsToDisplay}
|
||||
onAdd={handleAddSticker}
|
||||
addingSticker={addingSticker}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "search" && (
|
||||
<div className="h-full">
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : searchResults?.icons.length ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{searchResults.total} results
|
||||
</span>
|
||||
</div>
|
||||
<StickerGrid
|
||||
icons={iconsToDisplay}
|
||||
onAdd={handleAddSticker}
|
||||
addingSticker={addingSticker}
|
||||
capSize
|
||||
/>
|
||||
</>
|
||||
) : searchQuery ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-3">
|
||||
<EmptyView
|
||||
message={`No stickers found for "${searchQuery}"`}
|
||||
/>
|
||||
{category !== "all" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const q = localSearchQuery || searchQuery;
|
||||
if (q) {
|
||||
setSearchQuery(q);
|
||||
}
|
||||
setSelectedCategory("all");
|
||||
if (q) {
|
||||
searchStickers(q);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Search in all icons
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "browse" && !selectedCollection && (
|
||||
<div className="space-y-4 h-full">
|
||||
{isLoadingCollections ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{category !== "all" && (
|
||||
<div className="h-full">
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
Popular{" "}
|
||||
{category === "general"
|
||||
? "Icon Sets"
|
||||
: category === "brands"
|
||||
? "Brand Icons"
|
||||
: "Emoji Sets"}
|
||||
</h3>
|
||||
<CollectionGrid
|
||||
collections={filteredCollections}
|
||||
onSelectCollection={setSelectedCollection}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{category === "all" && filteredCollections.length > 0 && (
|
||||
<div className="h-full">
|
||||
<CollectionGrid
|
||||
collections={filteredCollections.slice(
|
||||
0,
|
||||
collectionsToShow
|
||||
)}
|
||||
onSelectCollection={setSelectedCollection}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CollectionItemProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-between h-auto py-2 "
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="font-medium">{title}</p>
|
||||
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface StickerItemProps {
|
||||
iconName: string;
|
||||
onAdd: (iconName: string) => void;
|
||||
isAdding?: boolean;
|
||||
capSize?: boolean;
|
||||
}
|
||||
|
||||
function StickerItem({
|
||||
iconName,
|
||||
onAdd,
|
||||
isAdding,
|
||||
capSize = false,
|
||||
}: StickerItemProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [hostIndex, setHostIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setImageError(false);
|
||||
setHostIndex(0);
|
||||
}, [iconName]);
|
||||
|
||||
const displayName = iconName.split(":")[1] || iconName;
|
||||
const collectionPrefix = iconName.split(":")[0];
|
||||
|
||||
const preview = imageError ? (
|
||||
<div className="w-full h-full flex items-center justify-center p-2">
|
||||
<span className="text-xs text-muted-foreground text-center break-all">
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full p-4 flex items-center justify-center">
|
||||
<Image
|
||||
src={
|
||||
hostIndex === 0
|
||||
? getIconSvgUrl(iconName, { width: 64, height: 64 })
|
||||
: buildIconSvgUrl(
|
||||
ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)],
|
||||
iconName,
|
||||
{ width: 64, height: 64 }
|
||||
)
|
||||
}
|
||||
alt={displayName}
|
||||
width={64}
|
||||
height={64}
|
||||
className="w-full h-full object-contain"
|
||||
style={
|
||||
capSize
|
||||
? {
|
||||
maxWidth: "var(--sticker-max, 160px)",
|
||||
maxHeight: "var(--sticker-max, 160px)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onError={() => {
|
||||
const next = hostIndex + 1;
|
||||
if (next < ICONIFY_HOSTS.length) {
|
||||
setHostIndex(next);
|
||||
} else {
|
||||
setImageError(true);
|
||||
}
|
||||
}}
|
||||
loading="lazy"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
isAdding && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<DraggableMediaItem
|
||||
name={displayName}
|
||||
preview={preview}
|
||||
dragData={{
|
||||
id: "sticker-placeholder",
|
||||
type: "image",
|
||||
name: displayName,
|
||||
}}
|
||||
onAddToTimeline={() => onAdd(iconName)}
|
||||
aspectRatio={1}
|
||||
showLabel={false}
|
||||
rounded={true}
|
||||
variant="card"
|
||||
className=""
|
||||
containerClassName="w-full"
|
||||
isDraggable={false}
|
||||
/>
|
||||
{isAdding && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-md z-10">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{displayName}</p>
|
||||
<p className="text-xs text-muted-foreground">{collectionPrefix}</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +1,36 @@
|
||||
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 { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
|
||||
export function TextView() {
|
||||
return (
|
||||
<BaseView>
|
||||
<DraggableMediaItem
|
||||
name="Default text"
|
||||
preview={
|
||||
<div className="flex items-center justify-center w-full h-full bg-panel-accent rounded">
|
||||
<span className="text-xs select-none">Default text</span>
|
||||
</div>
|
||||
}
|
||||
dragData={{
|
||||
id: "temp-text-id",
|
||||
type: DEFAULT_TEXT_ELEMENT.type,
|
||||
name: DEFAULT_TEXT_ELEMENT.name,
|
||||
content: DEFAULT_TEXT_ELEMENT.content,
|
||||
}}
|
||||
aspectRatio={1}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
useTimelineStore.getState().addElementAtTime(
|
||||
{
|
||||
...DEFAULT_TEXT_ELEMENT,
|
||||
id: "temp-text-id",
|
||||
},
|
||||
currentTime
|
||||
)
|
||||
}
|
||||
showLabel={false}
|
||||
/>
|
||||
</BaseView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,121 +1,138 @@
|
||||
"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 alpha! 🎉" />
|
||||
<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 alpha!" />
|
||||
<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, DialogTitle } 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 getStepTitle = () => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return "Welcome to OpenCut Beta! 🎉";
|
||||
case 1:
|
||||
return "⚠️ This is a super early beta!";
|
||||
case 2:
|
||||
return "🦋 Have fun testing!";
|
||||
default:
|
||||
return "OpenCut Onboarding";
|
||||
}
|
||||
};
|
||||
|
||||
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={getStepTitle()} />
|
||||
<Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." />
|
||||
<Description description="A lot of features are still missing, like the export functionality. We're working hard to build them out!" />
|
||||
<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={getStepTitle()} />
|
||||
<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 pt-2">
|
||||
<DialogTitle>
|
||||
<span className="sr-only">{getStepTitle()}</span>
|
||||
</DialogTitle>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PanelBaseViewProps {
|
||||
children?: React.ReactNode;
|
||||
defaultTab?: string;
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
tabs?: {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
}[];
|
||||
className?: string;
|
||||
ref?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a vertically scrollable content area with standard padding.
|
||||
*
|
||||
* @param children - Content to render inside the scrollable area.
|
||||
* @param className - Additional CSS classes applied to the inner content container.
|
||||
* @returns The scrollable content element containing the provided `children`.
|
||||
*/
|
||||
function ViewContent({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className={cn("p-5", className)}>{children}</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a full-height panel that displays either a simple scrollable content area or a tabs-based layout.
|
||||
*
|
||||
* @param children - Content to render inside the panel or inside the active tab's content area
|
||||
* @param defaultTab - The tab value selected initially when uncontrolled
|
||||
* @param value - Controlled current tab value; when provided, panel uses controlled tab state
|
||||
* @param onValueChange - Callback invoked with the new tab value when the active tab changes
|
||||
* @param tabs - Array of tab descriptors (value, label, optional icon, and content) to render a tabbed interface; if omitted or empty, `children` is rendered instead
|
||||
* @param className - Additional CSS class names applied to the outer container and passed to inner content wrappers
|
||||
* @param ref - Ref forwarded to the outer container div
|
||||
* @returns A React element containing either the scrollable content area or the tabbed layout with each tab's content
|
||||
*/
|
||||
export function PanelBaseView({
|
||||
children,
|
||||
defaultTab,
|
||||
value,
|
||||
onValueChange,
|
||||
tabs,
|
||||
className = "",
|
||||
ref,
|
||||
}: PanelBaseViewProps) {
|
||||
return (
|
||||
<div className={cn("h-full flex flex-col", className)} ref={ref}>
|
||||
{!tabs || tabs.length === 0 ? (
|
||||
<ViewContent className={className}>{children}</ViewContent>
|
||||
) : (
|
||||
<Tabs
|
||||
defaultValue={defaultTab}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
<div className="sticky top-0 z-10 bg-panel">
|
||||
<div className="px-3 pt-3.5 pb-0">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.icon ? (
|
||||
<span className="inline-flex items-center mr-1">
|
||||
{tab.icon}
|
||||
</span>
|
||||
) : null}
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<Separator className="mt-3.5" />
|
||||
</div>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="mt-0 flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<ViewContent className={className}>{tab.content}</ViewContent>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react";
|
||||
import { usePanelStore, type PanelPreset } from "@/stores/panel-store";
|
||||
|
||||
const PRESET_LABELS: Record<PanelPreset, string> = {
|
||||
default: "Default",
|
||||
media: "Media",
|
||||
inspector: "Inspector",
|
||||
"vertical-preview": "Vertical Preview",
|
||||
};
|
||||
|
||||
const PRESET_DESCRIPTIONS: Record<PanelPreset, string> = {
|
||||
default: "Media, preview, and inspector on top row, timeline on bottom",
|
||||
media: "Full height media on left, preview and inspector on top row",
|
||||
inspector: "Full height inspector on right, media and preview on top row",
|
||||
"vertical-preview": "Full height preview on right for vertical videos",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a dropdown for selecting and resetting panel presets.
|
||||
*
|
||||
* Displays each preset with a label and description, highlights the currently active preset, and provides a per-preset reset button.
|
||||
*
|
||||
* @returns The React element for the panel preset selector dropdown.
|
||||
*/
|
||||
export function PanelPresetSelector() {
|
||||
const { activePreset, setActivePreset, resetPreset } = usePanelStore();
|
||||
|
||||
const handlePresetChange = (preset: PanelPreset) => {
|
||||
setActivePreset(preset);
|
||||
};
|
||||
|
||||
const handleResetPreset = (preset: PanelPreset, event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
resetPreset(preset);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 px-2 flex items-center gap-1 text-xs"
|
||||
title="Panel Presets"
|
||||
>
|
||||
<LayoutPanelTop className="h-4 w-4" />
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<div className="px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
Panel Presets
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset}
|
||||
onClick={() => handlePresetChange(preset)}
|
||||
className="flex items-start justify-between gap-2 py-2 px-3 cursor-pointer"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">
|
||||
{PRESET_LABELS[preset]}
|
||||
</span>
|
||||
{activePreset === preset && (
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-tight">
|
||||
{PRESET_DESCRIPTIONS[preset]}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 opacity-60 hover:opacity-100"
|
||||
onClick={(e) => handleResetPreset(preset, e)}
|
||||
title={`Reset ${PRESET_LABELS[preset]} preset`}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||