codebase overhaul
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Comment Guidelines
|
||||
|
||||
## Good Comments (Human-style)
|
||||
- Explain WHY, not WHAT
|
||||
- Document non-obvious behavior or edge cases
|
||||
- Warn about performance implications or side effects
|
||||
- Explain business logic that isn't clear from code
|
||||
|
||||
Examples:
|
||||
```javascript
|
||||
// transfer, not copy; sender buffer detaches
|
||||
// satisfies: check shape; keep literals
|
||||
// keep multibyte across chunks
|
||||
// timingSafeEqual throws on length mismatch
|
||||
```
|
||||
|
||||
## Bad Comments (AI-style)
|
||||
- Don't explain what the code literally does
|
||||
- Don't add changelog-style comments in code
|
||||
- Don't comment every line or obvious operations
|
||||
|
||||
Avoid:
|
||||
```javascript
|
||||
// Prevent duplicate initialization
|
||||
// Check if project is already loaded
|
||||
// Mark as initializing to prevent race conditions
|
||||
// (changed from blah to blah)
|
||||
```
|
||||
|
||||
## Rule
|
||||
Only add comments when there's genuinely non-obvious behavior, performance considerations, or business logic that needs context. Code should be self-documenting through naming and structure.
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Handling Uncertainty
|
||||
|
||||
## Principle
|
||||
If you can't confidently respond due to missing context, data access, or ambiguity (multiple interpretations), report it instead of guessing. Seek clarification to avoid errors.
|
||||
|
||||
Apply when: query lacks details, no access to info/tools, or unclear intent.
|
||||
|
||||
## How to Report
|
||||
1. **Description**: Why uncertain and what you need.
|
||||
2. **Questions**: 1-3 targeted ones.
|
||||
3. **Assumptions** (opt.): State if proceeding; omit otherwise.
|
||||
|
||||
Direct and concise.
|
||||
|
||||
**Assumptions**: None.
|
||||
|
||||
Builds transparency.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Readability First
|
||||
|
||||
Optimize code for AI agents to understand and modify.
|
||||
|
||||
Never abbreviate. `event` not `e`, `element` not `el`. If it's easy to read, it's correct. In this case, "config" is better than "configuration" because it's shorter and is **still very readable**. "El" is not very readable.
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Separation of Concerns
|
||||
|
||||
## Core Principle
|
||||
|
||||
Each file should have one single purpose/responsibility. Related functionality should be grouped together, unrelated functionality should be separated.
|
||||
|
||||
## Good Separation
|
||||
|
||||
- One file per major concern (auth, validation, data transformation)
|
||||
- Group related utilities together
|
||||
- Extract shared logic into dedicated files
|
||||
- Keep API routes focused on their specific endpoint logic
|
||||
|
||||
Examples:
|
||||
|
||||
```javascript
|
||||
// ✅ Good: Each file has clear responsibility
|
||||
/lib/rate-limit.ts // Rate limiting utilities
|
||||
/lib/validation.ts // Input validation schemas
|
||||
/lib/freesound-api.ts // External API integration
|
||||
/api/sounds/search/route.ts // Route handler only
|
||||
```
|
||||
|
||||
## Bad Mixing of Concerns
|
||||
|
||||
Avoid cramming multiple responsibilities into one file:
|
||||
|
||||
```javascript
|
||||
// ❌ Bad: Route file doing everything
|
||||
/api/sounds/search/route.ts
|
||||
- Rate limiting logic
|
||||
- Validation schemas
|
||||
- API transformation
|
||||
- External API calls
|
||||
- Response formatting
|
||||
- Error handling utilities
|
||||
```
|
||||
|
||||
## When to Separate
|
||||
|
||||
- File is getting long (>500 lines)
|
||||
- Multiple distinct responsibilities in one file
|
||||
- Logic could be reused elsewhere
|
||||
- Complex utilities that distract from main purpose
|
||||
|
||||
## Rule
|
||||
|
||||
One file, one responsibility. Extract shared concerns into focused utility files
|
||||
|
|
@ -6,7 +6,7 @@ alwaysApply: true
|
|||
|
||||
# Project Context
|
||||
|
||||
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter.
|
||||
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's formatter.
|
||||
|
||||
## Key Principles
|
||||
|
||||
|
|
@ -43,7 +43,6 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
|
|||
### 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>`.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Scannable Code Guidelines/Separating Concerns.
|
||||
|
||||
## Core Principle
|
||||
|
||||
Code should be scannable through proper abstraction, not comments. Use variables and helper functions to make intent clear at a glance.
|
||||
|
||||
## Good Scannable Code
|
||||
|
||||
- Extract complex logic into well-named variables
|
||||
- Create helper functions for multi-step operations
|
||||
- Use descriptive names that explain intent
|
||||
|
||||
Examples:
|
||||
|
||||
```javascript
|
||||
// ✅ Scannable: Intent is clear from variable names
|
||||
const isValidUser = user.isActive && user.hasPermissions;
|
||||
const shouldProcessPayment = amount > 0 && !order.isPaid;
|
||||
|
||||
// ✅ Scannable: Complex logic extracted to helper
|
||||
const searchParams = buildFreesoundSearchParams({ query, filters, pagination });
|
||||
const transformedResults = transformFreesoundResults({ rawResults });
|
||||
```
|
||||
|
||||
## Bad Unscannable Code
|
||||
|
||||
Avoid:
|
||||
|
||||
```javascript
|
||||
// ❌ Hard to scan: What does this condition mean?
|
||||
if (type === "effects" || !type) {
|
||||
params.append("filter", "duration:[* TO 30.0]");
|
||||
params.append("filter", `avg_rating:[${min_rating} TO *]`);
|
||||
if (commercial_only) {
|
||||
params.append("filter", 'license:("Attribution" OR "Creative Commons 0")');
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Hard to scan: Complex ternary
|
||||
const sortParam = query
|
||||
? sort === "score"
|
||||
? "score"
|
||||
: `${sort}_desc`
|
||||
: `${sort}_desc`;
|
||||
```
|
||||
|
||||
## Rule
|
||||
|
||||
Make code scannable by extracting intent into variables and helper functions. If you need to think about what code does, extract it. The reader should understand the flow without diving into implementation details.
|
||||
|
|
@ -1 +0,0 @@
|
|||
!apps/web/.env.example
|
||||
|
|
@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
|
|||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
- Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
- The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
|
|
|||
|
|
@ -98,11 +98,11 @@ If you're unsure whether your idea falls into the preview category, feel free to
|
|||
|
||||
```bash
|
||||
# Database (matches docker-compose.yaml)
|
||||
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
|
||||
DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut"
|
||||
|
||||
# Generate a secure secret for Better Auth
|
||||
BETTER_AUTH_SECRET="your-generated-secret-here"
|
||||
NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
||||
|
||||
# Redis (matches docker-compose.yaml)
|
||||
UPSTASH_REDIS_REST_URL="http://localhost:8079"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
name: Bug report
|
||||
description: Create a report to help us improve
|
||||
title: '[BUG] '
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
body:
|
||||
- type: input
|
||||
|
|
@ -15,7 +15,7 @@ body:
|
|||
id: Browser
|
||||
attributes:
|
||||
label: Browser
|
||||
description: Please enter the browser on which you encountered the bug.
|
||||
description: Please enter the browser on which you encountered the bug.
|
||||
placeholder: e.g. Chrome 137, Firefox 137, Safari 17
|
||||
validations:
|
||||
required: true
|
||||
|
|
@ -67,4 +67,4 @@ body:
|
|||
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||
validations:
|
||||
required: false
|
||||
required: false
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
name: Feature request
|
||||
description: Suggest an idea for OpenCut
|
||||
title: '[FEATURE] '
|
||||
title: "[FEATURE] "
|
||||
labels: enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
|
|
@ -39,4 +39,4 @@ body:
|
|||
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||
validations:
|
||||
required: false
|
||||
required: false
|
||||
|
|
|
|||
|
|
@ -25,4 +25,4 @@ Please do not report security vulnerabilities through public GitHub issues.
|
|||
- We will provide a detailed response within 5 business days
|
||||
- We will keep you updated on our progress
|
||||
|
||||
Thank you for helping keep OpenCut secure!
|
||||
Thank you for helping keep OpenCut secure!
|
||||
|
|
|
|||
|
|
@ -3,21 +3,25 @@
|
|||
Thanks for using OpenCut! If you need help, here are your options:
|
||||
|
||||
## Documentation
|
||||
|
||||
- Check our [README](../README.md) for basic setup instructions
|
||||
- Review the [Contributing Guidelines](CONTRIBUTING.md) for development setup
|
||||
|
||||
## Issues
|
||||
|
||||
- **Bug reports**: Use the bug report template
|
||||
- **Feature requests**: Use the feature request template
|
||||
- **Questions**: Use GitHub Discussions for general questions
|
||||
|
||||
## Community
|
||||
|
||||
- Join our discussions on GitHub
|
||||
- Follow the [Code of Conduct](CODE_OF_CONDUCT.md)
|
||||
|
||||
## Response Times
|
||||
|
||||
- Issues are typically triaged within 2-3 business days
|
||||
- Feature requests may take longer to evaluate
|
||||
- Security issues are handled with priority
|
||||
|
||||
We appreciate your patience and contributions to making OpenCut better!
|
||||
We appreciate your patience and contributions to making OpenCut better!
|
||||
|
|
|
|||
|
|
@ -3,15 +3,18 @@ applyTo: "**/*.{ts,tsx,js,jsx}"
|
|||
---
|
||||
|
||||
# Project Context
|
||||
|
||||
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- Zero configuration required
|
||||
- Subsecond performance
|
||||
- Maximum type safety
|
||||
- AI-friendly code generation
|
||||
|
||||
## Before Writing Code
|
||||
|
||||
1. Analyze existing patterns in the codebase
|
||||
2. Consider edge cases and error scenarios
|
||||
3. Follow the rules below strictly
|
||||
|
|
@ -20,6 +23,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
|
|||
## Rules
|
||||
|
||||
### Accessibility (a11y)
|
||||
|
||||
- Don't use `accessKey` attribute on any HTML element.
|
||||
- Don't set `aria-hidden="true"` on focusable elements.
|
||||
- Don't add ARIA roles, states, and properties to elements that don't support them.
|
||||
|
|
@ -56,6 +60,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
|
|||
- Use correct ISO language/country codes for the `lang` attribute.
|
||||
|
||||
### Code Complexity and Quality
|
||||
|
||||
- Don't use consecutive spaces in regular expression literals.
|
||||
- Don't use the `arguments` object.
|
||||
- Don't use primitive type aliases or misleading types.
|
||||
|
|
@ -111,6 +116,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
|
|||
- Don't use literal numbers that lose precision.
|
||||
|
||||
### React and JSX Best Practices
|
||||
|
||||
- Don't use the return value of React.render.
|
||||
- Make sure all dependencies are correctly specified in React hooks.
|
||||
- Make sure all React hooks are called from the top level of component functions.
|
||||
|
|
@ -129,6 +135,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
|
|||
- Watch out for possible "wrong" semicolons inside JSX elements.
|
||||
|
||||
### Correctness and Safety
|
||||
|
||||
- Don't assign a value to itself.
|
||||
- Don't return a value from a setter.
|
||||
- Don't compare expressions that modify string case with non-compliant values.
|
||||
|
|
@ -153,7 +160,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
|
|||
- Don't use bitwise operators.
|
||||
- Don't use expressions where the operation doesn't change the value.
|
||||
- Make sure Promise-like statements are handled appropriately.
|
||||
- Don't use __dirname and __filename in the global scope.
|
||||
- Don't use **dirname and **filename in the global scope.
|
||||
- Prevent import cycles.
|
||||
- Don't use configured elements.
|
||||
- Don't hardcode sensitive data like API keys and tokens.
|
||||
|
|
@ -184,6 +191,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
|
|||
- Don't use `target="_blank"` without `rel="noopener"`.
|
||||
|
||||
### TypeScript Best Practices
|
||||
|
||||
- Don't use TypeScript enums.
|
||||
- Don't export imported variables.
|
||||
- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.
|
||||
|
|
@ -208,6 +216,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
|
|||
- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.
|
||||
|
||||
### Style and Consistency
|
||||
|
||||
- Don't use global `eval()`.
|
||||
- Don't use callbacks in asynchronous tests and hooks.
|
||||
- Don't use negation in `if` statements that have `else` clauses.
|
||||
|
|
@ -295,30 +304,34 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
|
|||
- Make sure to use the "use strict" directive in script files.
|
||||
|
||||
### Next.js Specific Rules
|
||||
|
||||
- Don't use `<img>` elements in Next.js projects.
|
||||
- Don't use `<head>` elements in Next.js projects.
|
||||
- Don't import next/document outside of pages/_document.jsx in Next.js projects.
|
||||
- Don't use the next/head module in pages/_document.js on Next.js projects.
|
||||
- Don't import next/document outside of pages/\_document.jsx in Next.js projects.
|
||||
- Don't use the next/head module in pages/\_document.js on Next.js projects.
|
||||
|
||||
### Testing Best Practices
|
||||
|
||||
- Don't use export or module.exports in test files.
|
||||
- Don't use focused tests.
|
||||
- Make sure the assertion function, like expect, is placed inside an it() function call.
|
||||
- Don't use disabled tests.
|
||||
|
||||
## Common Tasks
|
||||
|
||||
- `npx ultracite init` - Initialize Ultracite in your project
|
||||
- `npx ultracite format` - Format and fix code automatically
|
||||
- `npx ultracite lint` - Check for issues without fixing
|
||||
|
||||
## Example: Error Handling
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Comprehensive error handling
|
||||
try {
|
||||
const result = await fetchData();
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
console.error('API call failed:', error);
|
||||
console.error("API call failed:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
|
|
@ -328,4 +341,4 @@ try {
|
|||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
```
|
||||
```
|
||||
|
|
|
|||
|
|
@ -24,9 +24,10 @@ Please describe the tests that you ran to verify your changes. Provide instructi
|
|||
- [ ] Test B
|
||||
|
||||
**Test Configuration**:
|
||||
* Node version:
|
||||
* Browser (if applicable):
|
||||
* Operating System:
|
||||
|
||||
- Node version:
|
||||
- Browser (if applicable):
|
||||
- Operating System:
|
||||
|
||||
## Screenshots (if applicable)
|
||||
|
||||
|
|
@ -46,4 +47,4 @@ Add screenshots to help explain your changes.
|
|||
|
||||
## Additional context
|
||||
|
||||
Add any other context about the pull request here.
|
||||
Add any other context about the pull request here.
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ jobs:
|
|||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
|
||||
env:
|
||||
DATABASE_URL: "postgresql://opencut:opencutthegoat@localhost:5432/opencut"
|
||||
DATABASE_URL: "postgresql://opencut:opencut@localhost:5432/opencut"
|
||||
BETTER_AUTH_SECRET: "supersecret"
|
||||
NEXT_PUBLIC_BETTER_AUTH_URL: "http://localhost:3000"
|
||||
NEXT_PUBLIC_SITE_URL: "http://localhost:3000"
|
||||
UPSTASH_REDIS_REST_URL: "https://your-upstash-redis-url"
|
||||
UPSTASH_REDIS_REST_TOKEN: "your-upstash-redis-token"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,5 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/apps/web/node_modules
|
||||
|
||||
# next.js
|
||||
/apps/web/.next/
|
||||
/apps/web/out/
|
||||
|
||||
# debug
|
||||
/apps/web/npm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
/apps/web/.env*
|
||||
!/apps/web/.env.example
|
||||
|
||||
# typescript
|
||||
/apps/web/next-env.d.ts
|
||||
|
||||
# asdf version management
|
||||
.tool-versions
|
||||
|
||||
|
|
@ -24,11 +7,11 @@ node_modules
|
|||
.cursorignore
|
||||
.turbo
|
||||
|
||||
*.env
|
||||
.env*
|
||||
!*.env.example
|
||||
|
||||
# cursor
|
||||
bun.lockb
|
||||
apps/transcription/__pycache__
|
||||
apps/transcription/env
|
||||
|
||||
apps/bg-remover/env
|
||||
# Twiggy
|
||||
.cursor/rules/file-structure.mdc
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
npx ultracite format
|
||||
|
|
@ -1,14 +1,20 @@
|
|||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][css][graphql]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"emmet.showExpandedAbbreviation": "never",
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
# AGENTS.md
|
||||
|
||||
## Overview
|
||||
|
||||
Privacy-first video editor, with a focus on simplicity and ease of use.
|
||||
|
||||
## Lib vs Utils
|
||||
|
||||
- `lib/` - domain logic (specific to this app)
|
||||
- `utils/` - small helper utils (generic, could be copy-pasted into any other app)
|
||||
|
||||
## Core Editor System
|
||||
|
||||
The editor uses a **singleton EditorCore** that manages all editor state through specialized managers.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
EditorCore (singleton)
|
||||
├── playback: PlaybackManager
|
||||
├── timeline: TimelineManager
|
||||
├── scene: SceneManager
|
||||
├── project: ProjectManager
|
||||
├── media: MediaManager
|
||||
└── renderer: RendererManager
|
||||
```
|
||||
|
||||
### When to Use What
|
||||
|
||||
#### In React Components
|
||||
|
||||
**Always use the `useEditor()` hook:**
|
||||
|
||||
```typescript
|
||||
import { useEditor } from '@/hooks/use-editor';
|
||||
|
||||
function MyComponent() {
|
||||
const editor = useEditor();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
|
||||
// Call methods
|
||||
editor.timeline.addTrack({ type: 'media' });
|
||||
|
||||
// Display data (auto re-renders on changes)
|
||||
return <div>{tracks.length} tracks</div>;
|
||||
}
|
||||
```
|
||||
|
||||
The hook:
|
||||
|
||||
- Returns the singleton instance
|
||||
- Subscribes to all manager changes
|
||||
- Automatically re-renders when state changes
|
||||
|
||||
#### Outside React Components
|
||||
|
||||
**Use `EditorCore.getInstance()` directly:**
|
||||
|
||||
```typescript
|
||||
// In utilities, event handlers, or non-React code
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
await editor.export({ format: "mp4", quality: "high" });
|
||||
```
|
||||
|
||||
## Actions System
|
||||
|
||||
Actions are the trigger layer for user-initiated operations. The single source of truth is `@/lib/actions/definitions.ts`.
|
||||
|
||||
**To add a new action:**
|
||||
|
||||
1. Add it to `ACTIONS` in `@/lib/actions/definitions.ts`:
|
||||
|
||||
```typescript
|
||||
export const ACTIONS = {
|
||||
"my-action": {
|
||||
description: "What the action does",
|
||||
category: "editing",
|
||||
defaultShortcuts: ["ctrl+m"],
|
||||
},
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
2. Add handler in `@/hooks/use-editor-actions.ts`:
|
||||
|
||||
```typescript
|
||||
useActionHandler(
|
||||
"my-action",
|
||||
() => {
|
||||
// implementation
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
```
|
||||
|
||||
**In components, use `invokeAction()` for user-triggered operations:**
|
||||
|
||||
```typescript
|
||||
import { invokeAction } from '@/lib/actions';
|
||||
|
||||
// Good - uses action system
|
||||
const handleSplit = () => invokeAction("split-selected");
|
||||
|
||||
// Avoid - bypasses UX layer (toasts, validation feedback)
|
||||
const handleSplit = () => editor.timeline.splitElements({ ... });
|
||||
```
|
||||
|
||||
Direct `editor.xxx()` calls are for internal use (commands, tests, complex multi-step operations).
|
||||
|
||||
## Commands System
|
||||
|
||||
Commands handle undo/redo. They live in `@/lib/commands/` organized by domain (timeline, media, scene).
|
||||
|
||||
Each command extends `Command` from `@/lib/commands/base-command` and implements:
|
||||
|
||||
- `execute()` - saves current state, then does the mutation
|
||||
- `undo()` - restores the saved state
|
||||
|
||||
Actions and commands work together: actions are "what triggered this", commands are "how to do it (and undo it)".
|
||||
168
CLAUDE.md
|
|
@ -1,168 +0,0 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
OpenCut is a free, open-source video editor built with Next.js, focusing on privacy (no server processing), multi-track timeline editing, and real-time preview. The project is a monorepo using Turborepo with multiple apps including a web application, desktop app (Tauri), background remover tools, and transcription services.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
**Development:**
|
||||
```bash
|
||||
# Root level development
|
||||
bun dev # Start all apps in development mode
|
||||
bun build # Build all apps
|
||||
bun lint # Lint all code using Ultracite
|
||||
bun format # Format all code using Ultracite
|
||||
|
||||
# Web app specific (from apps/web/)
|
||||
cd apps/web
|
||||
bun run dev # Start Next.js development server with Turbopack
|
||||
bun run build # Build for production
|
||||
bun run lint # Run Biome linting
|
||||
bun run lint:fix # Fix linting issues automatically
|
||||
bun run format # Format code with Biome
|
||||
|
||||
# Database operations (from apps/web/)
|
||||
bun run db:generate # Generate Drizzle migrations
|
||||
bun run db:migrate # Run migrations
|
||||
bun run db:push:local # Push schema to local development database
|
||||
bun run db:push:prod # Push schema to production database
|
||||
```
|
||||
|
||||
**Testing:**
|
||||
- No unified test commands are currently configured
|
||||
- Individual apps may have their own test setups
|
||||
|
||||
## Architecture & Key Components
|
||||
|
||||
### State Management
|
||||
The application uses **Zustand** for state management with separate stores for different concerns:
|
||||
- **editor-store.ts**: Canvas presets, layout guides, app initialization
|
||||
- **timeline-store.ts**: Timeline tracks, elements, playback state
|
||||
- **media-store.ts**: Media files and asset management
|
||||
- **playback-store.ts**: Video playback controls and timing
|
||||
- **project-store.ts**: Project-level data and persistence
|
||||
- **panel-store.ts**: UI panel visibility and layout
|
||||
- **keybindings-store.ts**: Keyboard shortcut management
|
||||
- **sounds-store.ts**: Audio effects and sound management
|
||||
- **stickers-store.ts**: Sticker/graphics management
|
||||
|
||||
### Storage System
|
||||
**Multi-layer storage approach:**
|
||||
- **IndexedDB**: Projects, saved sounds, and structured data
|
||||
- **OPFS (Origin Private File System)**: Large media files for better performance
|
||||
- **Storage Service** (`lib/storage/`): Abstraction layer managing both storage types
|
||||
|
||||
### Editor Architecture
|
||||
**Core editor components:**
|
||||
- **Timeline Canvas**: Custom canvas-based timeline with tracks and elements
|
||||
- **Preview Panel**: Real-time video preview (currently DOM-based, planned binary refactor)
|
||||
- **Media Panel**: Asset management with drag-and-drop support
|
||||
- **Properties Panel**: Context-sensitive element properties
|
||||
|
||||
### Media Processing
|
||||
- **FFmpeg Integration**: Client-side video processing using @ffmpeg/ffmpeg
|
||||
- **Background Removal**: Python-based tools with multiple AI models (U2Net, SAM, Gemini)
|
||||
- **Transcription**: Separate service for audio-to-text conversion
|
||||
|
||||
## Development Focus Areas
|
||||
|
||||
**✅ Recommended contribution areas:**
|
||||
- Timeline functionality and UI improvements
|
||||
- Project management features
|
||||
- Performance optimizations
|
||||
- Bug fixes in existing functionality
|
||||
- UI/UX improvements outside preview panel
|
||||
- Documentation and testing
|
||||
|
||||
**⚠️ Areas to avoid (pending refactor):**
|
||||
- Preview panel enhancements (fonts, stickers, effects)
|
||||
- Export functionality improvements
|
||||
- Preview rendering optimizations
|
||||
|
||||
**Reason:** The preview system is planned for a major refactor from DOM-based rendering to binary rendering for consistency with export and better performance.
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
**Linting & Formatting:**
|
||||
- Uses **Biome** for JavaScript/TypeScript linting and formatting
|
||||
- Extends **Ultracite** configuration for strict type safety and AI-friendly code
|
||||
- Comprehensive accessibility (a11y) rules enforced
|
||||
- Zero configuration approach with subsecond performance
|
||||
|
||||
**Key coding standards from Ultracite:**
|
||||
- Strict TypeScript with no `any` types
|
||||
- No React imports (uses automatic JSX runtime)
|
||||
- Comprehensive accessibility requirements
|
||||
- Use `for...of` instead of `Array.forEach`
|
||||
- No TypeScript enums, use const objects
|
||||
- Always include error handling with try-catch
|
||||
|
||||
## Environment Setup
|
||||
|
||||
**Required environment variables (apps/web/.env.local):**
|
||||
```bash
|
||||
# Database
|
||||
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
|
||||
|
||||
# Authentication
|
||||
BETTER_AUTH_SECRET="your-generated-secret-here"
|
||||
BETTER_AUTH_URL="http://localhost:3000"
|
||||
|
||||
# Redis
|
||||
UPSTASH_REDIS_REST_URL="http://localhost:8079"
|
||||
UPSTASH_REDIS_REST_TOKEN="example_token"
|
||||
|
||||
# Content Management
|
||||
MARBLE_WORKSPACE_KEY="workspace-key"
|
||||
NEXT_PUBLIC_MARBLE_API_URL="https://api.marblecms.com"
|
||||
```
|
||||
|
||||
**Docker services:**
|
||||
```bash
|
||||
# Start local database and Redis
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
**Monorepo layout:**
|
||||
- `apps/web/` - Main Next.js application
|
||||
- `apps/desktop/` - Tauri desktop application
|
||||
- `apps/bg-remover/` - Python background removal tools
|
||||
- `apps/transcription/` - Audio transcription service
|
||||
- `packages/` - Shared packages (auth, database)
|
||||
|
||||
**Web app structure:**
|
||||
- `src/components/` - React components organized by feature
|
||||
- `src/stores/` - Zustand state management
|
||||
- `src/hooks/` - Custom React hooks
|
||||
- `src/lib/` - Utility functions and services
|
||||
- `src/types/` - TypeScript type definitions
|
||||
- `src/app/` - Next.js app router pages and API routes
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Error handling:**
|
||||
```typescript
|
||||
try {
|
||||
const result = await processData();
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
console.error('Operation failed:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
```
|
||||
|
||||
**Store usage:**
|
||||
```typescript
|
||||
const { tracks, addTrack, updateTrack } = useTimelineStore();
|
||||
```
|
||||
|
||||
**Media processing:**
|
||||
```typescript
|
||||
import { processVideo } from '@/lib/ffmpeg-utils';
|
||||
const processedVideo = await processVideo(inputFile, options);
|
||||
```
|
||||
10
README.md
|
|
@ -1,7 +1,7 @@
|
|||
<table width="100%">
|
||||
<tr>
|
||||
<td align="left" width="120">
|
||||
<img src="apps/web/public/logo.png" alt="OpenCut Logo" width="100" />
|
||||
<img src="apps/web/public/logos/opencut/1k/logo.png" alt="OpenCut Logo" width="100" />
|
||||
</td>
|
||||
<td align="right">
|
||||
<h1>OpenCut</span></h1>
|
||||
|
|
@ -104,7 +104,7 @@ Before you begin, ensure you have the following installed on your system:
|
|||
|
||||
```bash
|
||||
# Database (matches docker-compose.yaml)
|
||||
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
|
||||
DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut"
|
||||
|
||||
# Generate a secure secret for Better Auth
|
||||
BETTER_AUTH_SECRET="your-generated-secret-here"
|
||||
|
|
@ -160,6 +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) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
|
||||
|
||||
<a href="https://vercel.com/oss">
|
||||
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
|
||||
</a>
|
||||
|
||||
<a href="https://fal.ai">
|
||||
<img alt="Powered by fal.ai" src="https://img.shields.io/badge/Powered%20by-fal.ai-000000?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" />
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
Before you follow anything in this guide, please make sure you've followed the steps in the [README](../../README.md) (under "Optional: Auto-captions (Transcription) Setup").
|
||||
|
||||
Open your terminal and make sure you're in the `apps/transcription` directory.
|
||||
|
||||
1. Create virtual environment
|
||||
|
||||
```bash
|
||||
python -m venv env
|
||||
```
|
||||
|
||||
2. Activate it
|
||||
|
||||
**Windows:**
|
||||
|
||||
```bash
|
||||
env\Scripts\activate
|
||||
```
|
||||
|
||||
**macOS/Linux:**
|
||||
|
||||
```bash
|
||||
source env/bin/activate
|
||||
```
|
||||
|
||||
> Note: if you're using VS Code/Cursor and you're seeing errors with the imports about the modules not being found,
|
||||
> You might have to press CTRL + Shift + P -> Python: Select Interpreter -> Enter interpreter path -> Find -> env -> scripts -> python.exe
|
||||
|
||||
3. Install libraries/packages/whatever you wanna call them
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. Make sure you have a Modal account. If you don't: [create one](https://modal.com/)
|
||||
|
||||
> If you don't know what Modal is: it allows us to process the actual audio and transcribe with Whisper by providing the infra to run Python code with a lot of RAM, generally affordable.
|
||||
|
||||
5. Once you've got a Modal accoumt, run this:
|
||||
|
||||
```bash
|
||||
python -m modal setup
|
||||
```
|
||||
|
||||
It's gonna open a browser so you can authenticate.
|
||||
|
||||
6. Test it if you want to make sure it actually works:
|
||||
|
||||
```bash
|
||||
modal run transcription.py
|
||||
```
|
||||
|
||||
6. Deploy the function!
|
||||
|
||||
```bash
|
||||
modal deploy transcription.py
|
||||
```
|
||||
|
||||
7. Set the required secrets in Modal
|
||||
|
||||
So the script we just deployed interacts with Cloudflare to do two things:
|
||||
|
||||
- Download the audio (so it can be transcribed with Whisper)
|
||||
- Delete the file after processing (privacy)
|
||||
|
||||
To do those things, the script needs access to these environment variables:
|
||||
```bash
|
||||
CLOUDFLARE_ACCOUNT_ID=your-account-id
|
||||
R2_ACCESS_KEY_ID=your-access-key-id
|
||||
R2_SECRET_ACCESS_KEY=your-secret-access-key
|
||||
R2_BUCKET_NAME=opencut-transcription
|
||||
```
|
||||
|
||||
Remember, we set these earlier in `.env.local`.
|
||||
|
||||
So let's do it:
|
||||
|
||||
- Go to [Modal Secrets](https://modal.com/secrets/mazewinther/main)
|
||||
- Click "Custom" and enter "opencut-r2-secrets" for the name.
|
||||
- Now you can just click "Import .env" and copy/paste the 4 variables from your `.env.local` file. Copy and paste these only:
|
||||
```bash
|
||||
CLOUDFLARE_ACCOUNT_ID=your-account-id
|
||||
R2_ACCESS_KEY_ID=your-access-key-id
|
||||
R2_SECRET_ACCESS_KEY=your-secret-access-key
|
||||
R2_BUCKET_NAME=opencut-transcription
|
||||
```
|
||||
- Click "Done" and you should see some cool particles!
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
modal
|
||||
openai-whisper
|
||||
boto3
|
||||
pydantic
|
||||
cryptography
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
import modal
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = modal.App("opencut-transcription")
|
||||
|
||||
class TranscribeRequest(BaseModel):
|
||||
filename: str
|
||||
language: str = "auto"
|
||||
decryptionKey: str = None
|
||||
iv: str = None
|
||||
|
||||
@app.function(
|
||||
image=modal.Image.debian_slim()
|
||||
.apt_install(["ffmpeg"])
|
||||
.pip_install(["openai-whisper", "boto3", "fastapi[standard]", "pydantic", "cryptography"]),
|
||||
gpu="A10G",
|
||||
timeout=300, # 5m
|
||||
secrets=[modal.Secret.from_name("opencut-r2-secrets")]
|
||||
)
|
||||
@modal.fastapi_endpoint(method="POST")
|
||||
def transcribe_audio(request: TranscribeRequest):
|
||||
import whisper
|
||||
import boto3
|
||||
import tempfile
|
||||
import os
|
||||
import json
|
||||
|
||||
try:
|
||||
filename = request.filename
|
||||
language = request.language
|
||||
decryption_key = request.decryptionKey
|
||||
iv = request.iv
|
||||
|
||||
if not filename:
|
||||
return {
|
||||
"error": "Missing filename parameter"
|
||||
}
|
||||
|
||||
# Initialize R2 client
|
||||
s3_client = boto3.client(
|
||||
's3',
|
||||
endpoint_url=f'https://{os.environ["CLOUDFLARE_ACCOUNT_ID"]}.r2.cloudflarestorage.com',
|
||||
aws_access_key_id=os.environ["R2_ACCESS_KEY_ID"],
|
||||
aws_secret_access_key=os.environ["R2_SECRET_ACCESS_KEY"],
|
||||
region_name='auto'
|
||||
)
|
||||
|
||||
# Create temporary file for audio
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.wav') as temp_file:
|
||||
temp_path = temp_file.name
|
||||
|
||||
try:
|
||||
# Download audio from R2
|
||||
s3_client.download_file(
|
||||
os.environ["R2_BUCKET_NAME"],
|
||||
filename,
|
||||
temp_path
|
||||
)
|
||||
|
||||
# If decryption key provided, decrypt the file directly (zero-knowledge)
|
||||
if decryption_key and iv:
|
||||
import base64
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
# Read the encrypted file
|
||||
with open(temp_path, 'rb') as f:
|
||||
encrypted_data = f.read()
|
||||
|
||||
# Decode the key and IV from base64
|
||||
key_bytes = base64.b64decode(decryption_key)
|
||||
iv_bytes = base64.b64decode(iv)
|
||||
|
||||
# Decrypt the data using AES-GCM
|
||||
# Extract the tag (last 16 bytes) and ciphertext
|
||||
tag = encrypted_data[-16:]
|
||||
ciphertext = encrypted_data[:-16]
|
||||
|
||||
cipher = Cipher(
|
||||
algorithms.AES(key_bytes),
|
||||
modes.GCM(iv_bytes, tag),
|
||||
backend=default_backend()
|
||||
)
|
||||
decryptor = cipher.decryptor()
|
||||
decrypted_data = decryptor.update(ciphertext) + decryptor.finalize()
|
||||
|
||||
# Write decrypted audio back to temp file
|
||||
with open(temp_path, 'wb') as f:
|
||||
f.write(decrypted_data)
|
||||
|
||||
# Load Whisper model
|
||||
model = whisper.load_model("base")
|
||||
|
||||
# Transcribe audio
|
||||
if language == "auto":
|
||||
result = model.transcribe(temp_path)
|
||||
else:
|
||||
result = model.transcribe(temp_path, language=language.lower())
|
||||
|
||||
# Delete audio file from R2 (cleanup)
|
||||
s3_client.delete_object(
|
||||
Bucket=os.environ["R2_BUCKET_NAME"],
|
||||
Key=filename
|
||||
)
|
||||
|
||||
# Adjust segment timing - Whisper is consistently late by ~500ms
|
||||
adjusted_segments = []
|
||||
for segment in result["segments"]:
|
||||
adjusted_segment = segment.copy()
|
||||
# Shift start/end times earlier by 500ms, don't go below 0
|
||||
adjusted_segment["start"] = max(0, segment["start"] - 0.5)
|
||||
adjusted_segment["end"] = max(0.5, segment["end"] - 0.5) # Ensure duration is at least 0.5s
|
||||
adjusted_segments.append(adjusted_segment)
|
||||
|
||||
return {
|
||||
"text": result["text"],
|
||||
"segments": adjusted_segments,
|
||||
"language": result["language"]
|
||||
}
|
||||
|
||||
finally:
|
||||
# Clean up temporary file
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"Transcription error: {str(e)}")
|
||||
print(f"Traceback: {traceback.format_exc()}")
|
||||
|
||||
# Return error response that matches expected format
|
||||
return {
|
||||
"error": str(e),
|
||||
"text": "",
|
||||
"segments": [],
|
||||
"language": "unknown"
|
||||
}
|
||||
|
||||
@app.local_entrypoint()
|
||||
def main():
|
||||
# Test function - you can call this with modal run transcription.py
|
||||
print("Transcription service is ready to deploy!")
|
||||
print("Deploy with: modal deploy transcription.py")
|
||||
|
|
@ -1,33 +1,28 @@
|
|||
# Environment Variables Example
|
||||
# Environment variables example
|
||||
# Copy this file to .env.local and update the values as needed
|
||||
|
||||
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
|
||||
|
||||
# Better Auth
|
||||
NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000
|
||||
BETTER_AUTH_SECRET=your-secret-key-here
|
||||
|
||||
# Development Environment
|
||||
# Node
|
||||
NODE_ENV=development
|
||||
|
||||
# Redis
|
||||
UPSTASH_REDIS_REST_URL=http://localhost:8079
|
||||
UPSTASH_REDIS_REST_TOKEN=example_token
|
||||
|
||||
# Marble Blog
|
||||
MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key
|
||||
# Public
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
|
||||
|
||||
# Freesound (generate at https://freesound.org/apiv2/apply/)
|
||||
FREESOUND_CLIENT_ID=...
|
||||
FREESOUND_API_KEY=...
|
||||
# Server
|
||||
DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut"
|
||||
BETTER_AUTH_SECRET=your_better_auth_secret
|
||||
|
||||
# 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
|
||||
UPSTASH_REDIS_REST_URL=http://localhost:8079
|
||||
UPSTASH_REDIS_REST_TOKEN=example_token_here
|
||||
|
||||
# Modal transcription endpoint (from modal deploy transcription.py)
|
||||
MODAL_TRANSCRIPTION_URL=https://your-username--opencut-transcription-transcribe-audio.modal.run
|
||||
MARBLE_WORKSPACE_KEY=your_workspace_key_here
|
||||
|
||||
FREESOUND_CLIENT_ID=your_client_id_here
|
||||
FREESOUND_API_KEY=your_api_key_here
|
||||
|
||||
CLOUDFLARE_ACCOUNT_ID=your_account_id_here
|
||||
R2_ACCESS_KEY_ID=your_access_key_here
|
||||
R2_SECRET_ACCESS_KEY=your_secret_key_here
|
||||
R2_BUCKET_NAME=opencut-transcription # whatever you named your r2 bucket
|
||||
|
||||
MODAL_TRANSCRIPTION_URL=your_modal_url_here
|
||||
|
|
@ -1,2 +1,8 @@
|
|||
# Turborepo
|
||||
.turbo
|
||||
.turbo
|
||||
|
||||
# Env vars
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
.next/
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
FROM oven/bun:alpine AS base
|
||||
|
||||
# Install dependencies and build the application
|
||||
FROM base AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG FREESOUND_CLIENT_ID
|
||||
ARG FREESOUND_API_KEY
|
||||
|
||||
COPY package.json package.json
|
||||
COPY bun.lock bun.lock
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
COPY packages/auth/package.json packages/auth/package.json
|
||||
|
||||
RUN bun install
|
||||
|
||||
COPY apps/web/ apps/web/
|
||||
COPY packages/db/ packages/db/
|
||||
COPY packages/auth/ packages/auth/
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
# Set build-time environment variables for validation
|
||||
ENV DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
|
||||
ENV BETTER_AUTH_SECRET="build-time-secret"
|
||||
ENV UPSTASH_REDIS_REST_URL="http://localhost:8079"
|
||||
ENV UPSTASH_REDIS_REST_TOKEN="example_token"
|
||||
ENV NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000"
|
||||
|
||||
ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID
|
||||
ENV FREESOUND_API_KEY=$FREESOUND_API_KEY
|
||||
|
||||
WORKDIR /app/apps/web
|
||||
RUN bun run build
|
||||
|
||||
# Production image
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
|
||||
RUN chown nextjs:nodejs apps
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
|
||||
CMD ["bun", "apps/web/server.js"]
|
||||
|
||||
|
|
@ -1,19 +1,23 @@
|
|||
import type { Config } from "drizzle-kit";
|
||||
import * as dotenv from "dotenv";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
|
||||
// Load the right env file based on environment
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
if (webEnv.NODE_ENV === "production") {
|
||||
dotenv.config({ path: ".env.production" });
|
||||
} else {
|
||||
dotenv.config({ path: ".env.local" });
|
||||
}
|
||||
|
||||
export default {
|
||||
schema: "../../packages/db/src/schema.ts",
|
||||
schema: "./src/schema.ts",
|
||||
dialect: "postgresql",
|
||||
migrations: {
|
||||
table: "drizzle_migrations",
|
||||
},
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
url: webEnv.DATABASE_URL,
|
||||
},
|
||||
out: "./migrations",
|
||||
strict: process.env.NODE_ENV === "production",
|
||||
strict: webEnv.NODE_ENV === "production",
|
||||
} satisfies Config;
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
CREATE TABLE "accounts" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"account_id" text NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"access_token" text,
|
||||
"refresh_token" text,
|
||||
"id_token" text,
|
||||
"access_token_expires_at" timestamp,
|
||||
"refresh_token_expires_at" timestamp,
|
||||
"scope" text,
|
||||
"password" text,
|
||||
"created_at" timestamp NOT NULL,
|
||||
"updated_at" timestamp NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "accounts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
CREATE TABLE "sessions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"created_at" timestamp NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"user_id" text NOT NULL,
|
||||
CONSTRAINT "sessions_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"email_verified" boolean NOT NULL,
|
||||
"image" text,
|
||||
"created_at" timestamp NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
CREATE TABLE "verifications" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"identifier" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"created_at" timestamp,
|
||||
"updated_at" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "verifications" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
CREATE TABLE "waitlist" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"created_at" timestamp NOT NULL,
|
||||
CONSTRAINT "waitlist_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "waitlist" ENABLE ROW LEVEL SECURITY;
|
||||
|
|
@ -1 +0,0 @@
|
|||
ALTER TABLE "users" ALTER COLUMN "email_verified" SET DEFAULT false;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
CREATE TABLE "export_waitlist" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"created_at" timestamp NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
CONSTRAINT "export_waitlist_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "export_waitlist" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DROP TABLE "waitlist" CASCADE;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"id": "4440fb90-cf0e-4cb2-afad-08250ce3dc1e",
|
||||
"id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
|
|
@ -291,6 +291,43 @@
|
|||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.waitlist": {
|
||||
"name": "waitlist",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"waitlist_email_unique": {
|
||||
"name": "waitlist_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
|
|
|
|||
|
|
@ -1,344 +0,0 @@
|
|||
{
|
||||
"id": "b7d920ca-6dd0-430f-8ee6-1d38fdf3e80f",
|
||||
"prevId": "4440fb90-cf0e-4cb2-afad-08250ce3dc1e",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.accounts": {
|
||||
"name": "accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"accounts_user_id_users_id_fk": {
|
||||
"name": "accounts_user_id_users_id_fk",
|
||||
"tableFrom": "accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"sessions_token_unique": {
|
||||
"name": "sessions_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["token"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.verifications": {
|
||||
"name": "verifications",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.waitlist": {
|
||||
"name": "waitlist",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"waitlist_email_unique": {
|
||||
"name": "waitlist_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,345 +0,0 @@
|
|||
{
|
||||
"id": "1b9148ed-497b-4e53-8cf6-f556c8fe7f7f",
|
||||
"prevId": "b7d920ca-6dd0-430f-8ee6-1d38fdf3e80f",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.accounts": {
|
||||
"name": "accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"accounts_user_id_users_id_fk": {
|
||||
"name": "accounts_user_id_users_id_fk",
|
||||
"tableFrom": "accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"sessions_token_unique": {
|
||||
"name": "sessions_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["token"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.verifications": {
|
||||
"name": "verifications",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.waitlist": {
|
||||
"name": "waitlist",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"waitlist_email_unique": {
|
||||
"name": "waitlist_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,365 +0,0 @@
|
|||
{
|
||||
"id": "71b84015-78f8-4d07-9115-8d56ea550459",
|
||||
"prevId": "1b9148ed-497b-4e53-8cf6-f556c8fe7f7f",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.accounts": {
|
||||
"name": "accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"accounts_user_id_users_id_fk": {
|
||||
"name": "accounts_user_id_users_id_fk",
|
||||
"tableFrom": "accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.export_waitlist": {
|
||||
"name": "export_waitlist",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"export_waitlist_email_unique": {
|
||||
"name": "export_waitlist_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"sessions_token_unique": {
|
||||
"name": "sessions_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.verifications": {
|
||||
"name": "verifications",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,30 +5,9 @@
|
|||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1750581188229,
|
||||
"tag": "0000_hot_the_fallen",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1750689835736,
|
||||
"tag": "0001_tricky_jackpot",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1752569400809,
|
||||
"tag": "0002_cuddly_pretty_boy",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1755377469662,
|
||||
"tag": "0003_long_polaris",
|
||||
"when": 1750753385927,
|
||||
"tag": "0000_brainy_saracen",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "opencut",
|
||||
"name": "@opencut/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1.2.18",
|
||||
|
|
@ -21,13 +21,18 @@
|
|||
"@ffmpeg/util": "^0.12.2",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@opencut/auth": "workspace:*",
|
||||
"@opencut/db": "workspace:*",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@t3-oss/env-core": "^0.13.8",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"@upstash/ratelimit": "^2.0.5",
|
||||
"@upstash/redis": "^1.35.0",
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.4",
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"@opencut/env": "workspace:*",
|
||||
"@opencut/ui": "workspace:*",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@upstash/ratelimit": "^2.0.6",
|
||||
"@upstash/redis": "^1.35.4",
|
||||
"@vercel/analytics": "^1.4.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"better-auth": "^1.2.7",
|
||||
|
|
@ -36,18 +41,19 @@
|
|||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.5.0",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"feed": "^5.1.0",
|
||||
"framer-motion": "^11.13.1",
|
||||
"input-otp": "^1.4.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mediabunny": "^1.29.1",
|
||||
"motion": "^12.18.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "^15.5.7",
|
||||
"next": "16.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.2",
|
||||
"react": "^18.2.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
|
|
@ -67,7 +73,9 @@
|
|||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unified": "^11.0.5",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"vaul": "^1.1.1",
|
||||
"wavesurfer.js": "^7.9.8",
|
||||
"zod": "^3.25.67",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
|
|
@ -81,6 +89,7 @@
|
|||
"@types/react-dom": "^18.2.18",
|
||||
"cross-env": "^7.0.3",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"dotenv": "^16.5.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tsx": "^4.7.1",
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
<svg width="459" height="77" viewBox="0 0 459 77" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="1.15625" width="458" height="75" rx="15.5" fill="#101010"/>
|
||||
<rect x="0.5" y="1.15625" width="458" height="75" rx="15.5" stroke="#FFCC00"/>
|
||||
<rect x="0.5" y="1.15625" width="31" height="75" rx="15.5" fill="#2A2A2A"/>
|
||||
<rect x="0.5" y="1.15625" width="31" height="75" rx="15.5" stroke="#FFCC00"/>
|
||||
<rect x="13" y="20.6562" width="6" height="36" rx="3" fill="#FFCC00"/>
|
||||
<rect x="427.5" y="1.15625" width="31" height="75" rx="15.5" fill="#2A2A2A"/>
|
||||
<rect x="427.5" y="1.15625" width="31" height="75" rx="15.5" stroke="#FFCC00"/>
|
||||
<rect x="440" y="20.6562" width="6" height="36" rx="3" fill="#FFCC00"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 716 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 362 B After Width: | Height: | Size: 362 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="512" height="513" viewBox="0 0 512 513" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M330.144 0.740479C339.014 0.740479 346.122 7.95256 346.971 16.7816C354.534 95.534 417.207 158.206 495.959 165.769C504.788 166.618 512 173.726 512 182.596V330.886C512 339.755 504.788 346.863 495.959 347.712C417.207 355.275 354.534 417.947 346.971 496.7C346.122 505.529 339.014 512.74 330.144 512.74H181.856C172.986 512.74 165.878 505.529 165.029 496.7C157.467 417.947 94.7934 355.275 16.041 347.712C7.21212 346.863 0 339.755 0 330.886V182.596C0 173.726 7.21216 166.618 16.0411 165.769C94.7935 158.206 157.467 95.534 165.029 16.7816C165.878 7.95256 172.986 0.740479 181.856 0.740479H330.144ZM102.8 256.307C102.8 341.495 171.781 410.554 256.874 410.554C341.966 410.554 410.948 341.495 410.948 256.307C410.948 171.119 341.966 102.06 256.874 102.06C171.781 102.06 102.8 171.119 102.8 256.307Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 960 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="588" height="512" viewBox="0 0 588 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M294 0L588 512H0L294 0Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 156 B |
|
|
@ -1,146 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { memo, Suspense } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import Link from "next/link";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { GoogleIcon } from "@/components/icons";
|
||||
import { useLogin } from "@/hooks/auth/useLogin";
|
||||
|
||||
const LoginPage = () => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
error,
|
||||
isAnyLoading,
|
||||
isEmailLoading,
|
||||
isGoogleLoading,
|
||||
handleLogin,
|
||||
handleGoogleLogin,
|
||||
} = useLogin();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center relative">
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => router.back()}
|
||||
className="absolute top-6 left-6"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" /> Back
|
||||
</Button>
|
||||
<Card className="w-[400px] shadow-lg border-0">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-2xl font-semibold">Welcome back</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Sign in to your account to continue
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="text-center">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
disabled={isAnyLoading}
|
||||
>
|
||||
{isGoogleLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<GoogleIcon />
|
||||
)}{" "}
|
||||
Continue with Google
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator className="w-full" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
disabled={isAnyLoading || !email || !password}
|
||||
className="w-full h-11"
|
||||
size="lg"
|
||||
>
|
||||
{isEmailLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/signup"
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(LoginPage);
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { memo, Suspense } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import Link from "next/link";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { GoogleIcon } from "@/components/icons";
|
||||
import { useSignUp } from "@/hooks/auth/useSignUp";
|
||||
|
||||
const SignUpPage = () => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
name,
|
||||
setName,
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
error,
|
||||
isAnyLoading,
|
||||
isEmailLoading,
|
||||
isGoogleLoading,
|
||||
handleSignUp,
|
||||
handleGoogleSignUp,
|
||||
} = useSignUp();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center relative">
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => router.back()}
|
||||
className="absolute top-6 left-6"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" /> Back
|
||||
</Button>
|
||||
<Card className="w-[400px] shadow-lg border-0">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-2xl font-semibold">
|
||||
Create your account
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Get started with your free account today
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="text-center">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleGoogleSignUp}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
disabled={isAnyLoading}
|
||||
>
|
||||
{isGoogleLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<GoogleIcon />
|
||||
)}{" "}
|
||||
Continue with Google
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator className="w-full" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Create a strong password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSignUp}
|
||||
disabled={isAnyLoading || !name || !email || !password}
|
||||
className="w-full h-11"
|
||||
size="lg"
|
||||
>
|
||||
{isEmailLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
"Create account"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SignUpPage);
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { InputWithBack } from "@/components/ui/input-with-back";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function AnimationPage() {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="h-dvh flex">
|
||||
<div className="bg-panel p-6 w-[20rem]">
|
||||
<InputWithBack isExpanded={isExpanded} setIsExpanded={setIsExpanded} />
|
||||
</div>
|
||||
<div className="p-6 flex items-end justify-end">
|
||||
<p
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="cursor-pointer hover:opacity-75 transition-opacity"
|
||||
>
|
||||
{isExpanded ? "Collapse" : "Expand"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { auth } from "@opencut/auth";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { AwsClient } from "aws4fetch";
|
||||
import { nanoid } from "nanoid";
|
||||
import { env } from "@/env";
|
||||
import { baseRateLimit } from "@/lib/rate-limit";
|
||||
import { isTranscriptionConfigured } from "@/lib/transcription-utils";
|
||||
|
||||
const uploadRequestSchema = z.object({
|
||||
fileExtension: z.enum(["wav", "mp3", "m4a", "flac"], {
|
||||
errorMap: () => ({
|
||||
message: "File extension must be wav, mp3, m4a, or flac",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const apiResponseSchema = z.object({
|
||||
uploadUrl: z.string().url(),
|
||||
fileName: z.string().min(1),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
|
||||
const { success } = await baseRateLimit.limit(ip);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
// Check transcription configuration
|
||||
const transcriptionCheck = isTranscriptionConfigured();
|
||||
if (!transcriptionCheck.configured) {
|
||||
console.error(
|
||||
"Missing environment variables:",
|
||||
JSON.stringify(transcriptionCheck.missingVars)
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Transcription not configured",
|
||||
message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`,
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate request body
|
||||
const rawBody = await request.json().catch(() => null);
|
||||
if (!rawBody) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON in request body" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const validationResult = uploadRequestSchema.safeParse(rawBody);
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid request parameters",
|
||||
details: validationResult.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { fileExtension } = validationResult.data;
|
||||
|
||||
// Initialize R2 client
|
||||
const client = new AwsClient({
|
||||
accessKeyId: env.R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
|
||||
});
|
||||
|
||||
// Generate unique filename with timestamp
|
||||
const timestamp = Date.now();
|
||||
const fileName = `audio/${timestamp}-${nanoid()}.${fileExtension}`;
|
||||
|
||||
// Create presigned URL
|
||||
const url = new URL(
|
||||
`https://${env.R2_BUCKET_NAME}.${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}`
|
||||
);
|
||||
|
||||
url.searchParams.set("X-Amz-Expires", "3600"); // 1 hour expiry
|
||||
|
||||
const signed = await client.sign(new Request(url, { method: "PUT" }), {
|
||||
aws: { signQuery: true },
|
||||
});
|
||||
|
||||
if (!signed.url) {
|
||||
throw new Error("Failed to generate presigned URL");
|
||||
}
|
||||
|
||||
// Prepare and validate response
|
||||
const responseData = {
|
||||
uploadUrl: signed.url,
|
||||
fileName,
|
||||
};
|
||||
|
||||
const responseValidation = apiResponseSchema.safeParse(responseData);
|
||||
if (!responseValidation.success) {
|
||||
console.error(
|
||||
"Invalid API response structure:",
|
||||
responseValidation.error
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal response formatting error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(responseValidation.data);
|
||||
} catch (error) {
|
||||
console.error("Error generating upload URL:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to generate upload URL",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return new Response("OK", { status: 200 });
|
||||
export async function GET() {
|
||||
return new Response("OK", { status: 200 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,265 +1,280 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { env } from "@/env";
|
||||
import { baseRateLimit } from "@/lib/rate-limit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
q: z.string().max(500, "Query too long").optional(),
|
||||
type: z.enum(["songs", "effects"]).optional(),
|
||||
page: z.coerce.number().int().min(1).max(1000).default(1),
|
||||
page_size: z.coerce.number().int().min(1).max(150).default(20),
|
||||
sort: z
|
||||
.enum(["downloads", "rating", "created", "score"])
|
||||
.default("downloads"),
|
||||
min_rating: z.coerce.number().min(0).max(5).default(3),
|
||||
commercial_only: z.coerce.boolean().default(true),
|
||||
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(),
|
||||
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),
|
||||
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(),
|
||||
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(),
|
||||
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(),
|
||||
});
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
|
||||
if (!query) return `${sort}_desc`;
|
||||
return sort === "score" ? "score" : `${sort}_desc`;
|
||||
}
|
||||
|
||||
function applyEffectsFilters({
|
||||
params,
|
||||
min_rating,
|
||||
commercial_only,
|
||||
}: {
|
||||
params: URLSearchParams;
|
||||
min_rating: number;
|
||||
commercial_only: boolean;
|
||||
}) {
|
||||
params.append("filter", "duration:[* TO 30.0]");
|
||||
params.append("filter", `avg_rating:[${min_rating} TO *]`);
|
||||
|
||||
if (commercial_only) {
|
||||
params.append(
|
||||
"filter",
|
||||
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
|
||||
);
|
||||
}
|
||||
|
||||
params.append(
|
||||
"filter",
|
||||
"tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
|
||||
);
|
||||
}
|
||||
|
||||
function transformFreesoundResult(
|
||||
result: z.infer<typeof freesoundResultSchema>,
|
||||
) {
|
||||
return {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
description: result.description,
|
||||
url: result.url,
|
||||
previewUrl:
|
||||
result.previews?.["preview-hq-mp3"] ||
|
||||
result.previews?.["preview-lq-mp3"],
|
||||
downloadUrl: result.download,
|
||||
duration: result.duration,
|
||||
filesize: result.filesize,
|
||||
type: result.type,
|
||||
channels: result.channels,
|
||||
bitrate: result.bitrate,
|
||||
bitdepth: result.bitdepth,
|
||||
samplerate: result.samplerate,
|
||||
username: result.username,
|
||||
tags: result.tags,
|
||||
license: result.license,
|
||||
created: result.created,
|
||||
downloads: result.num_downloads || 0,
|
||||
rating: result.avg_rating || 0,
|
||||
ratingCount: result.num_ratings || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { limited } = await checkRateLimit({ request });
|
||||
if (limited) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const validationResult = searchParamsSchema.safeParse({
|
||||
q: searchParams.get("q") || undefined,
|
||||
type: searchParams.get("type") || undefined,
|
||||
page: searchParams.get("page") || undefined,
|
||||
page_size: searchParams.get("page_size") || undefined,
|
||||
sort: searchParams.get("sort") || undefined,
|
||||
min_rating: searchParams.get("min_rating") || undefined,
|
||||
});
|
||||
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid parameters",
|
||||
details: validationResult.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
q: query,
|
||||
type,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
sort,
|
||||
min_rating,
|
||||
commercial_only,
|
||||
} = validationResult.data;
|
||||
|
||||
if (type === "songs") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Songs are not available yet",
|
||||
message:
|
||||
"Song search functionality is coming soon. Try searching for sound effects instead.",
|
||||
},
|
||||
{ status: 501 },
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = "https://freesound.org/apiv2/search/text/";
|
||||
|
||||
const sortParam = buildSortParameter({ query, sort });
|
||||
|
||||
const params = new URLSearchParams({
|
||||
query: query || "",
|
||||
token: webEnv.FREESOUND_API_KEY,
|
||||
page: page.toString(),
|
||||
page_size: pageSize.toString(),
|
||||
sort: sortParam,
|
||||
fields:
|
||||
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
|
||||
});
|
||||
|
||||
const isEffectsSearch = type === "effects" || !type;
|
||||
if (isEffectsSearch) {
|
||||
applyEffectsFilters({ params, min_rating, commercial_only });
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("Freesound API error:", response.status, errorText);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to search sounds" },
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
|
||||
const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
|
||||
if (!freesoundValidation.success) {
|
||||
console.error(
|
||||
"Invalid Freesound API response:",
|
||||
freesoundValidation.error,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid response from Freesound API" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const data = freesoundValidation.data;
|
||||
|
||||
const transformedResults = data.results.map(transformFreesoundResult);
|
||||
|
||||
const responseData = {
|
||||
count: data.count,
|
||||
next: data.next,
|
||||
previous: data.previous,
|
||||
results: transformedResults,
|
||||
query: query || "",
|
||||
type: type || "effects",
|
||||
page,
|
||||
pageSize,
|
||||
sort,
|
||||
minRating: min_rating,
|
||||
};
|
||||
|
||||
const responseValidation = apiResponseSchema.safeParse(responseData);
|
||||
if (!responseValidation.success) {
|
||||
console.error(
|
||||
"Invalid API response structure:",
|
||||
responseValidation.error,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal response formatting error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(responseValidation.data);
|
||||
} catch (error) {
|
||||
console.error("Error searching sounds:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,189 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { env } from "@/env";
|
||||
import { baseRateLimit } from "@/lib/rate-limit";
|
||||
import { isTranscriptionConfigured } from "@/lib/transcription-utils";
|
||||
|
||||
const transcribeRequestSchema = z.object({
|
||||
filename: z.string().min(1, "Filename is required"),
|
||||
language: z.string().optional().default("auto"),
|
||||
decryptionKey: z.string().min(1, "Decryption key is required").optional(),
|
||||
iv: z.string().min(1, "IV is required").optional(),
|
||||
});
|
||||
|
||||
const modalResponseSchema = z.object({
|
||||
text: z.string(),
|
||||
segments: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
seek: z.number(),
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
text: z.string(),
|
||||
tokens: z.array(z.number()),
|
||||
temperature: z.number(),
|
||||
avg_logprob: z.number(),
|
||||
compression_ratio: z.number(),
|
||||
no_speech_prob: z.number(),
|
||||
})
|
||||
),
|
||||
language: z.string(),
|
||||
});
|
||||
|
||||
const apiResponseSchema = z.object({
|
||||
text: z.string(),
|
||||
segments: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
seek: z.number(),
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
text: z.string(),
|
||||
tokens: z.array(z.number()),
|
||||
temperature: z.number(),
|
||||
avg_logprob: z.number(),
|
||||
compression_ratio: z.number(),
|
||||
no_speech_prob: z.number(),
|
||||
})
|
||||
),
|
||||
language: z.string(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
|
||||
const { success } = await baseRateLimit.limit(ip);
|
||||
const origin = request.headers.get("origin");
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
// Check transcription configuration
|
||||
const transcriptionCheck = isTranscriptionConfigured();
|
||||
if (!transcriptionCheck.configured) {
|
||||
console.error(
|
||||
"Missing environment variables:",
|
||||
JSON.stringify(transcriptionCheck.missingVars)
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Transcription not configured",
|
||||
message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`,
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate request body
|
||||
const rawBody = await request.json().catch(() => null);
|
||||
if (!rawBody) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON in request body" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const validationResult = transcribeRequestSchema.safeParse(rawBody);
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid request parameters",
|
||||
details: validationResult.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { filename, language, decryptionKey, iv } = validationResult.data;
|
||||
|
||||
// Prepare request body for Modal
|
||||
const modalRequestBody: any = {
|
||||
filename,
|
||||
language,
|
||||
};
|
||||
|
||||
// Add encryption parameters if provided (zero-knowledge)
|
||||
if (decryptionKey && iv) {
|
||||
modalRequestBody.decryptionKey = decryptionKey;
|
||||
modalRequestBody.iv = iv;
|
||||
}
|
||||
|
||||
// Call Modal transcription service
|
||||
const response = await fetch(env.MODAL_TRANSCRIPTION_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(modalRequestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("Modal API error:", response.status, errorText);
|
||||
|
||||
let errorMessage = "Transcription service unavailable";
|
||||
try {
|
||||
const errorData = JSON.parse(errorText);
|
||||
errorMessage = errorData.error || errorMessage;
|
||||
} catch {
|
||||
// Use default message if parsing fails
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: errorMessage,
|
||||
message: "Failed to process transcription request",
|
||||
},
|
||||
{ status: response.status >= 500 ? 502 : response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const rawResult = await response.json();
|
||||
console.log("Raw Modal response:", JSON.stringify(rawResult, null, 2));
|
||||
|
||||
// Validate Modal response
|
||||
const modalValidation = modalResponseSchema.safeParse(rawResult);
|
||||
if (!modalValidation.success) {
|
||||
console.error("Invalid Modal API response:", modalValidation.error);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid response from transcription service" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = modalValidation.data;
|
||||
|
||||
// Prepare and validate API response
|
||||
const responseData = {
|
||||
text: result.text,
|
||||
segments: result.segments,
|
||||
language: result.language,
|
||||
};
|
||||
|
||||
const responseValidation = apiResponseSchema.safeParse(responseData);
|
||||
if (!responseValidation.success) {
|
||||
console.error(
|
||||
"Invalid API response structure:",
|
||||
responseValidation.error
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal response formatting error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(responseValidation.data);
|
||||
} catch (error) {
|
||||
console.error("Transcription API error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Internal server error",
|
||||
message: "An unexpected error occurred during transcription",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { baseRateLimit } from "@/lib/rate-limit";
|
||||
import { db, exportWaitlist, eq } from "@opencut/db";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
exportWaitlistSchema,
|
||||
exportWaitlistResponseSchema,
|
||||
} from "@/lib/schemas/waitlist";
|
||||
|
||||
const requestSchema = exportWaitlistSchema;
|
||||
const responseSchema = exportWaitlistResponseSchema;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
|
||||
const { success } = await baseRateLimit.limit(ip);
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON in request body" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = requestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid request parameters",
|
||||
details: parsed.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { email } = parsed.data;
|
||||
|
||||
const existing = await db
|
||||
.select({ id: exportWaitlist.id })
|
||||
.from(exportWaitlist)
|
||||
.where(eq(exportWaitlist.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
const responseData = { success: true, alreadySubscribed: true } as const;
|
||||
const validated = responseSchema.safeParse(responseData);
|
||||
if (!validated.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Internal response formatting error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(validated.data);
|
||||
}
|
||||
|
||||
await db.insert(exportWaitlist).values({
|
||||
id: randomUUID(),
|
||||
email,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const responseData = { success: true } as const;
|
||||
const validated = responseSchema.safeParse(responseData);
|
||||
if (!validated.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Internal response formatting error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(validated.data);
|
||||
} catch (error) {
|
||||
console.error("Waitlist API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
interface BasePageProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
mainClassName?: string;
|
||||
maxWidth?: "3xl" | "6xl" | "full";
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function BasePage({
|
||||
children,
|
||||
className = "",
|
||||
mainClassName = "",
|
||||
maxWidth = "3xl",
|
||||
title,
|
||||
description,
|
||||
}: BasePageProps) {
|
||||
const maxWidthClass = {
|
||||
"3xl": "max-w-3xl",
|
||||
"6xl": "max-w-6xl",
|
||||
full: "max-w-full",
|
||||
}[maxWidth];
|
||||
|
||||
return (
|
||||
<section className={cn("bg-background min-h-screen", className)}>
|
||||
<Header />
|
||||
<main
|
||||
className={cn(
|
||||
"relative container mx-auto flex flex-col gap-12 px-6 pt-12 pb-24 md:pt-24",
|
||||
maxWidthClass,
|
||||
mainClassName,
|
||||
)}
|
||||
>
|
||||
{title && description && (
|
||||
<div className="flex flex-col gap-8 text-center">
|
||||
<h1 className="text-5xl font-bold tracking-tight md:text-6xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,144 +1,152 @@
|
|||
import { Header } from "@/components/header";
|
||||
import Prose from "@/components/ui/prose";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog-query";
|
||||
import { Metadata } from "next";
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import Prose from "@/components/ui/prose";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
|
||||
import type { Author, Post } from "@/types/blog";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
params,
|
||||
}: PageProps): Promise<Metadata> {
|
||||
const slug = (await params).slug;
|
||||
const slug = (await params).slug;
|
||||
|
||||
const data = await getSinglePost(slug);
|
||||
const data = await getSinglePost({ slug });
|
||||
|
||||
if (!data || !data.post) return {};
|
||||
if (!data || !data.post) return {};
|
||||
|
||||
return {
|
||||
title: data.post.title,
|
||||
description: data.post.description,
|
||||
twitter: {
|
||||
title: `${data.post.title}`,
|
||||
description: `${data.post.description}`,
|
||||
card: "summary_large_image",
|
||||
images: [
|
||||
{
|
||||
url: data.post.coverImage,
|
||||
width: "1200",
|
||||
height: "630",
|
||||
alt: data.post.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
openGraph: {
|
||||
type: "article",
|
||||
images: [
|
||||
{
|
||||
url: data.post.coverImage,
|
||||
width: "1200",
|
||||
height: "630",
|
||||
alt: data.post.title,
|
||||
},
|
||||
],
|
||||
title: data.post.title,
|
||||
description: data.post.description,
|
||||
publishedTime: new Date(data.post.publishedAt).toISOString(),
|
||||
authors: [
|
||||
...data.post.authors.map((author: { name: string }) => author.name),
|
||||
],
|
||||
},
|
||||
};
|
||||
return {
|
||||
title: data.post.title,
|
||||
description: data.post.description,
|
||||
twitter: {
|
||||
title: `${data.post.title}`,
|
||||
description: `${data.post.description}`,
|
||||
card: "summary_large_image",
|
||||
images: [
|
||||
{
|
||||
url: data.post.coverImage,
|
||||
width: "1200",
|
||||
height: "630",
|
||||
alt: data.post.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
openGraph: {
|
||||
type: "article",
|
||||
images: [
|
||||
{
|
||||
url: data.post.coverImage,
|
||||
width: "1200",
|
||||
height: "630",
|
||||
alt: data.post.title,
|
||||
},
|
||||
],
|
||||
title: data.post.title,
|
||||
description: data.post.description,
|
||||
publishedTime: new Date(data.post.publishedAt).toISOString(),
|
||||
authors: data.post.authors.map((author: Author) => author.name),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const data = await getPosts();
|
||||
if (!data || !data.posts.length) return [];
|
||||
const data = await getPosts();
|
||||
if (!data || !data.posts.length) return [];
|
||||
|
||||
return data.posts.map((post) => ({
|
||||
slug: post.slug,
|
||||
}));
|
||||
return data.posts.map((post) => ({
|
||||
slug: post.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
async function Page({ params }: PageProps) {
|
||||
const slug = (await params).slug;
|
||||
const data = await getSinglePost(slug);
|
||||
if (!data || !data.post) return notFound();
|
||||
export default async function BlogPostPage({ params }: PageProps) {
|
||||
const slug = (await params).slug;
|
||||
const data = await getSinglePost({ slug });
|
||||
if (!data || !data.post) return notFound();
|
||||
|
||||
const html = await processHtmlContent(data.post.content);
|
||||
const html = await processHtmlContent({ html: data.post.content });
|
||||
|
||||
const formattedDate = new Date(data.post.publishedAt).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
|
||||
<main className="relative">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative container max-w-3xl mx-auto px-4 py-16">
|
||||
<div className="text-center mb-6">
|
||||
{data.post.coverImage && (
|
||||
<div className="relative aspect-video rounded-lg overflow-hidden mb-6">
|
||||
<Image
|
||||
src={data.post.coverImage}
|
||||
alt={data.post.title}
|
||||
loading="eager"
|
||||
fill
|
||||
className="object-cover rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<time dateTime={data.post.publishedAt.toString()}>
|
||||
{formattedDate}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-4xl font-bold tracking-tight mb-6">
|
||||
{data.post.title}
|
||||
</h1>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{data.post.authors[0] && (
|
||||
<>
|
||||
<Image
|
||||
src={data.post.authors[0].image}
|
||||
alt={data.post.authors[0].name}
|
||||
width={36}
|
||||
height={36}
|
||||
loading="eager"
|
||||
className="aspect-square shrink-0 size-8 rounded-full"
|
||||
/>
|
||||
<p className="text-muted-foreground">
|
||||
{data.post.authors[0].name}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<section className="mt-14">
|
||||
<Prose html={html} />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<BasePage>
|
||||
<PostHeader post={data.post} />
|
||||
<Separator />
|
||||
<PostContent html={html} />
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
||||
function PostHeader({ post }: { post: Post }) {
|
||||
const formattedDate = new Date(post.publishedAt).toLocaleDateString("en-US", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{post.coverImage && <PostCoverImage post={post} />}
|
||||
<PostMeta date={formattedDate} publishedAt={post.publishedAt} />
|
||||
<PostTitle title={post.title} />
|
||||
<PostAuthor author={post.authors[0]} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PostCoverImage({ post }: { post: Post }) {
|
||||
return (
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg">
|
||||
<Image
|
||||
src={post.coverImage}
|
||||
alt={post.title}
|
||||
loading="eager"
|
||||
fill
|
||||
className="rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<time dateTime={publishedAt.toString()}>{date}</time>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostTitle({ title }: { title: string }) {
|
||||
return (
|
||||
<h1 className="text-5xl font-bold tracking-tight md:text-4xl">{title}</h1>
|
||||
);
|
||||
}
|
||||
|
||||
function PostAuthor({ author }: { author?: Author }) {
|
||||
if (!author) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Image
|
||||
src={author.image}
|
||||
alt={author.name}
|
||||
width={36}
|
||||
height={36}
|
||||
loading="eager"
|
||||
className="aspect-square size-8 shrink-0 rounded-full"
|
||||
/>
|
||||
<p className="text-muted-foreground">{author.name}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostContent({ html }: { html: string }) {
|
||||
return (
|
||||
<section className="pt-8">
|
||||
<Prose html={html} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,98 +1,73 @@
|
|||
import { Metadata } from "next";
|
||||
import { Header } from "@/components/header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getPosts } from "@/lib/blog-query";
|
||||
import Image from "next/image";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import type { Author, Post } from "@/types/blog";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog - OpenCut",
|
||||
description:
|
||||
"Read the latest news and updates about OpenCut, the free and open-source video editor.",
|
||||
openGraph: {
|
||||
title: "Blog - OpenCut",
|
||||
description:
|
||||
"Read the latest news and updates about OpenCut, the free and open-source video editor.",
|
||||
type: "website",
|
||||
},
|
||||
title: "Blog - OpenCut",
|
||||
description:
|
||||
"Read the latest news and updates about OpenCut, the free and open-source video editor.",
|
||||
openGraph: {
|
||||
title: "Blog - OpenCut",
|
||||
description:
|
||||
"Read the latest news and updates about OpenCut, the free and open-source video editor.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default async function BlogPage() {
|
||||
const data = await getPosts();
|
||||
if (!data || !data.posts) return <div>No posts yet</div>;
|
||||
const data = await getPosts();
|
||||
if (!data || !data.posts) return <div>No posts yet</div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
|
||||
<main className="relative">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative container max-w-3xl mx-auto px-4 py-16">
|
||||
<div className="text-center mb-20">
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
||||
Blog
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
|
||||
Read the latest news and updates about OpenCut, the free and
|
||||
open-source video editor.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{data.posts.map((post) => (
|
||||
<Link key={post.id} href={`/blog/${post.slug}`}>
|
||||
<Card className="h-full hover:shadow-lg transition-shadow overflow-hidden">
|
||||
{post.coverImage && (
|
||||
<div className="relative aspect-video">
|
||||
<Image
|
||||
src={post.coverImage}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardContent className="p-6">
|
||||
{post.authors && post.authors.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{post.authors.map((author, index) => (
|
||||
<div
|
||||
key={author.id}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Avatar className="w-6 h-6">
|
||||
<AvatarImage
|
||||
src={author.image}
|
||||
alt={author.name}
|
||||
/>
|
||||
<AvatarFallback className="text-xs">
|
||||
{author.name.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{author.name}
|
||||
</span>
|
||||
{index < post.authors.length - 1 && (
|
||||
<span className="text-muted-foreground">•</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-xl font-semibold mb-2">{post.title}</h2>
|
||||
<p className="text-muted-foreground">{post.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<BasePage
|
||||
title="Blog"
|
||||
description="Read the latest news and updates about OpenCut, the free and open-source video editor."
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{data.posts.map((post) => (
|
||||
<div key={post.id} className="flex flex-col">
|
||||
<BlogPostItem post={post} />
|
||||
<Separator />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function BlogPostItem({ post }: { post: Post }) {
|
||||
return (
|
||||
<Link href={`/blog/${post.slug}`}>
|
||||
<div className="flex h-auto w-full items-center justify-between py-6 opacity-100 hover:opacity-75">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xl font-semibold">{post.title}</h2>
|
||||
<p className="text-muted-foreground">{post.description}</p>
|
||||
</div>
|
||||
{post.authors && post.authors.length > 0 && (
|
||||
<AuthorList authors={post.authors} />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthorList({ authors }: { authors: Author[] }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{authors.map((author) => (
|
||||
<div key={author.id} className="flex items-center gap-2">
|
||||
<Avatar className="size-6 shadow-sm">
|
||||
<AvatarImage src={author.image} alt={author.name} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{author.name.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,330 +1,242 @@
|
|||
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 type { Metadata } from "next";
|
||||
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";
|
||||
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EXTERNAL_TOOLS } from "@/constants/site-constants";
|
||||
import { BasePage } from "../base-page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Contributors - OpenCut",
|
||||
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",
|
||||
},
|
||||
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;
|
||||
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
|
||||
);
|
||||
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 [];
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.error("Failed to fetch contributors");
|
||||
return [];
|
||||
}
|
||||
|
||||
const contributors = (await response.json()) as Contributor[];
|
||||
const contributors = (await response.json()) as Contributor[];
|
||||
|
||||
const filteredContributors = contributors.filter(
|
||||
(contributor: Contributor) => contributor.type === "User"
|
||||
);
|
||||
const filteredContributors = contributors.filter(
|
||||
(contributor) => contributor.type === "User",
|
||||
);
|
||||
|
||||
return filteredContributors;
|
||||
} catch (error) {
|
||||
console.error("Error fetching contributors:", error);
|
||||
return [];
|
||||
}
|
||||
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);
|
||||
const contributors = await getContributors();
|
||||
const topContributors = contributors.slice(0, 2);
|
||||
const otherContributors = contributors.slice(2);
|
||||
const totalContributions = contributors.reduce(
|
||||
(sum, c) => sum + c.contributions,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
return (
|
||||
<BasePage
|
||||
title="Contributors"
|
||||
description="Meet the amazing people who contribute to OpenCut, the free and open-source video editor."
|
||||
>
|
||||
<div className="-mt-4 flex items-center justify-center gap-8 text-sm">
|
||||
<StatItem value={contributors.length} label="contributors" />
|
||||
<StatItem value={totalContributions} label="contributions" />
|
||||
</div>
|
||||
|
||||
<main className="relative">
|
||||
<div className="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>
|
||||
);
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-20">
|
||||
{topContributors.length > 0 && (
|
||||
<TopContributorsSection contributors={topContributors} />
|
||||
)}
|
||||
{otherContributors.length > 0 && (
|
||||
<AllContributorsSection contributors={otherContributors} />
|
||||
)}
|
||||
<ExternalToolsSection />
|
||||
<GitHubContributeSection
|
||||
title="Join the community"
|
||||
description="OpenCut is built by developers like you. Every contribution, no matter how small, helps make video editing more accessible for everyone."
|
||||
/>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ value, label }: { value: number; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-foreground size-2 rounded-full" />
|
||||
<span className="font-medium">{value}</span>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopContributorsSection({
|
||||
contributors,
|
||||
}: {
|
||||
contributors: Contributor[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<h2 className="text-2xl font-semibold">Top contributors</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Leading the way in contributions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex w-full max-w-xl flex-col justify-center gap-6 md:flex-row">
|
||||
{contributors.map((contributor) => (
|
||||
<TopContributorCard key={contributor.id} contributor={contributor} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopContributorCard({ contributor }: { contributor: Contributor }) {
|
||||
return (
|
||||
<Link
|
||||
href={contributor.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full"
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-6 p-8 text-center">
|
||||
<Avatar className="mx-auto size-28">
|
||||
<AvatarImage
|
||||
src={contributor.avatar_url}
|
||||
alt={`${contributor.login}'s avatar`}
|
||||
/>
|
||||
<AvatarFallback className="text-lg font-semibold">
|
||||
{contributor.login.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-xl font-semibold">{contributor.login}</h3>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="font-medium">{contributor.contributions}</span>
|
||||
<span className="text-muted-foreground">contributions</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function AllContributorsSection({
|
||||
contributors,
|
||||
}: {
|
||||
contributors: Contributor[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-12">
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<h2 className="text-2xl font-semibold">All contributors</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Everyone who makes OpenCut better
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{contributors.map((contributor) => (
|
||||
<Link
|
||||
key={contributor.id}
|
||||
href={contributor.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="opacity-100 hover:opacity-70"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 p-2">
|
||||
<Avatar className="size-16">
|
||||
<AvatarImage
|
||||
src={contributor.avatar_url}
|
||||
alt={`${contributor.login}'s avatar`}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{contributor.login.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="text-center">
|
||||
<h3 className="text-sm font-medium">{contributor.login}</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{contributor.contributions}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalToolsSection() {
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<h2 className="text-2xl font-semibold">External tools</h2>
|
||||
<p className="text-muted-foreground">Tools we use to build OpenCut</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{EXTERNAL_TOOLS.map((tool, index) => (
|
||||
<Link
|
||||
key={tool.url}
|
||||
href={tool.url}
|
||||
target="_blank"
|
||||
className="block"
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
>
|
||||
<Card className="h-full">
|
||||
<CardContent className="flex items-center justify-center h-full flex-col gap-4 p-6 text-center">
|
||||
<tool.icon className="size-8" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<h3 className="text-lg font-semibold">{tool.name}</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
"use client";
|
||||
|
||||
export default function EditorLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
|
@ -1,459 +1,108 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { MediaPanel } from "@/components/editor/media-panel";
|
||||
import { PropertiesPanel } from "@/components/editor/properties-panel";
|
||||
import { AssetsPanel } from "@/components/editor/panels/assets";
|
||||
import { PropertiesPanel } from "@/components/editor/panels/properties";
|
||||
import { Timeline } from "@/components/editor/timeline";
|
||||
import { PreviewPanel } from "@/components/editor/preview-panel";
|
||||
import { PreviewPanel } from "@/components/editor/panels/preview";
|
||||
import { EditorHeader } from "@/components/editor/editor-header";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||
import { usePlaybackControls } from "@/hooks/use-playback-controls";
|
||||
import { Onboarding } from "@/components/editor/onboarding";
|
||||
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
export default function Editor() {
|
||||
const {
|
||||
toolsPanel,
|
||||
previewPanel,
|
||||
mainContent,
|
||||
timeline,
|
||||
setToolsPanel,
|
||||
setPreviewPanel,
|
||||
setMainContent,
|
||||
setTimeline,
|
||||
propertiesPanel,
|
||||
setPropertiesPanel,
|
||||
activePreset,
|
||||
resetCounter,
|
||||
} = usePanelStore();
|
||||
const params = useParams();
|
||||
const projectId = params.project_id as string;
|
||||
|
||||
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(() => {
|
||||
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);
|
||||
|
||||
// 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();
|
||||
|
||||
// Cleanup function to cancel async operations
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
isInitializingRef.current = false;
|
||||
};
|
||||
}, [
|
||||
projectId,
|
||||
loadProject,
|
||||
createNewProject,
|
||||
router,
|
||||
isInvalidProjectId,
|
||||
markProjectIdAsInvalid,
|
||||
]);
|
||||
|
||||
return (
|
||||
<EditorProvider>
|
||||
<div className="h-screen w-screen flex flex-col bg-background overflow-hidden">
|
||||
<EditorHeader />
|
||||
<div className="flex-1 min-h-0 min-w-0">
|
||||
{activePreset === "media" ? (
|
||||
<ResizablePanelGroup
|
||||
key={`media-${activePreset}-${resetCounter}`}
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.18rem] px-3 pb-3"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={toolsPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<MediaPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={100 - toolsPanel}
|
||||
minSize={60}
|
||||
className="min-w-0 min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
onResize={setMainContent}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={previewPanel}
|
||||
minSize={30}
|
||||
onResize={setPreviewPanel}
|
||||
className="min-w-0 min-h-0 flex-1"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setPropertiesPanel}
|
||||
className="min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : activePreset === "inspector" ? (
|
||||
<ResizablePanelGroup
|
||||
key={`inspector-${activePreset}-${resetCounter}`}
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.18rem] px-3 pb-3"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={100 - propertiesPanel}
|
||||
minSize={30}
|
||||
onResize={(size) => setPropertiesPanel(100 - size)}
|
||||
className="min-w-0 min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
onResize={setMainContent}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={toolsPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<MediaPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={previewPanel}
|
||||
minSize={30}
|
||||
onResize={setPreviewPanel}
|
||||
className="min-w-0 min-h-0 flex-1"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setPropertiesPanel}
|
||||
className="min-w-0 min-h-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : activePreset === "vertical-preview" ? (
|
||||
<ResizablePanelGroup
|
||||
key={`vertical-preview-${activePreset}-${resetCounter}`}
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.18rem] px-3 pb-3"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={100 - previewPanel}
|
||||
minSize={30}
|
||||
onResize={(size) => setPreviewPanel(100 - size)}
|
||||
className="min-w-0 min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
onResize={setMainContent}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={toolsPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<MediaPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setPropertiesPanel}
|
||||
className="min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={previewPanel}
|
||||
minSize={30}
|
||||
onResize={setPreviewPanel}
|
||||
className="min-w-0 min-h-0"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : (
|
||||
<ResizablePanelGroup
|
||||
key={`default-${activePreset}-${resetCounter}`}
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
onResize={setMainContent}
|
||||
className="min-h-0"
|
||||
>
|
||||
{/* Main content area */}
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem] px-3"
|
||||
>
|
||||
{/* Tools Panel */}
|
||||
<ResizablePanel
|
||||
defaultSize={toolsPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setToolsPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<MediaPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Preview Area */}
|
||||
<ResizablePanel
|
||||
defaultSize={previewPanel}
|
||||
minSize={30}
|
||||
onResize={setPreviewPanel}
|
||||
className="min-w-0 min-h-0 flex-1"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
onResize={setPropertiesPanel}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Timeline */}
|
||||
<ResizablePanel
|
||||
defaultSize={timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0 px-3 pb-3"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</div>
|
||||
<Onboarding />
|
||||
</div>
|
||||
</EditorProvider>
|
||||
);
|
||||
return (
|
||||
<EditorProvider projectId={projectId}>
|
||||
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
|
||||
<EditorHeader />
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<EditorLayout />
|
||||
</div>
|
||||
<Onboarding />
|
||||
<MigrationDialog />
|
||||
</div>
|
||||
</EditorProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorLayout() {
|
||||
const { panels, setPanel } = usePanelStore();
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="size-full gap-[0.18rem]"
|
||||
onLayout={(sizes) => {
|
||||
setPanel("mainContent", sizes[0] ?? panels.mainContent);
|
||||
setPanel("timeline", sizes[1] ?? panels.timeline);
|
||||
}}
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={panels.mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="size-full gap-[0.19rem] px-3"
|
||||
onLayout={(sizes) => {
|
||||
setPanel("tools", sizes[0] ?? panels.tools);
|
||||
setPanel("preview", sizes[1] ?? panels.preview);
|
||||
setPanel("properties", sizes[2] ?? panels.properties);
|
||||
}}
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={panels.tools}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<AssetsPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={panels.preview}
|
||||
minSize={30}
|
||||
className="min-h-0 min-w-0 flex-1"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={panels.properties}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
className="min-w-0 rounded-sm"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={panels.timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
className="min-h-0 px-3 pb-3"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,85 +8,88 @@
|
|||
@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;
|
||||
--background: hsl(0, 0%, 100%);
|
||||
--foreground: hsl(0 0% 11%);
|
||||
--card: hsl(0, 0%, 100%);
|
||||
--card-foreground: hsl(0 0% 11%);
|
||||
--popover: hsl(0, 0%, 100%);
|
||||
--popover-foreground: hsl(0 0% 2%);
|
||||
--primary: hsl(203, 100%, 50%);
|
||||
--primary-hover: hsl(203, 100%, 45%);
|
||||
--primary-foreground: hsl(0, 0%, 100%);
|
||||
--secondary: hsl(216 13% 94%);
|
||||
--secondary-foreground: hsl(0 0% 2%);
|
||||
--muted: hsl(0 0% 85.1%);
|
||||
--muted-foreground: hsl(0 0% 50%);
|
||||
--accent: hsl(216, 13%, 88%);
|
||||
--accent-foreground: hsl(0 0% 2%);
|
||||
--destructive: hsl(0, 83%, 50%);
|
||||
--destructive-foreground: hsl(0, 0%, 100%);
|
||||
--constructive: hsl(141, 71%, 48%);
|
||||
--constructive-foreground: hsl(0, 0%, 100%);
|
||||
--border: hsl(0 0% 88%);
|
||||
--input: hsl(0 0% 85.1%);
|
||||
--ring: hsl(0, 0%, 55%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 96.1%);
|
||||
--sidebar-foreground: hsl(0 0% 2%);
|
||||
--sidebar-primary: hsl(0 0% 2%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 91%);
|
||||
--sidebar-accent: hsl(0 0% 85.1%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 2%);
|
||||
--sidebar-border: hsl(0 0% 85.1%);
|
||||
--sidebar-ring: hsl(0 0% 16.9%);
|
||||
--panel-background: hsl(216 13% 94%);
|
||||
--panel-accent: hsl(216, 8%, 88%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
}
|
||||
.dark {
|
||||
/* 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%);
|
||||
--background: hsl(0 0% 4%);
|
||||
--foreground: hsl(0 0% 89%);
|
||||
--card: hsl(0 0% 4%);
|
||||
--card-foreground: hsl(0 0% 89%);
|
||||
--popover: hsl(0 0% 14.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(203, 100%, 50%);
|
||||
--primary-hover: hsl(203, 100%, 45%);
|
||||
--primary-foreground: hsl(0 0% 9%);
|
||||
--secondary: hsl(0 0% 14.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(0 0% 14.9%);
|
||||
--muted-foreground: hsl(0 0% 63.9%);
|
||||
--accent: hsl(0, 0%, 28%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 83%, 55%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--constructive: hsl(141, 71%, 48%);
|
||||
--constructive-foreground: hsl(0 0% 100%);
|
||||
--border: hsl(0 0% 17%);
|
||||
--input: hsl(0 0% 14.9%);
|
||||
--ring: hsl(0 0% 83.1%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 3.9%);
|
||||
--sidebar-foreground: hsl(0 0% 98%);
|
||||
--sidebar-primary: hsl(0 0% 98%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 9%);
|
||||
--sidebar-accent: hsl(0 0% 14.9%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 98%);
|
||||
--sidebar-border: hsl(0 0% 14.9%);
|
||||
--sidebar-ring: hsl(0 0% 83.1%);
|
||||
--panel-background: hsl(0 0% 11%);
|
||||
--panel-accent: hsl(0 0% 15%);
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/*
|
||||
/*
|
||||
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.
|
||||
|
|
@ -94,152 +97,165 @@
|
|||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
/* Other default base styles */
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
/* Other default base styles */
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Responsive breakpoints */
|
||||
--breakpoint-xs: 30rem;
|
||||
/* Responsive breakpoints */
|
||||
--breakpoint-xs: 30rem;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: var(--font-inter), sans-serif;
|
||||
/* 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);
|
||||
/* Font sizes */
|
||||
--text-xl: 1.20rem;
|
||||
--text-base: 0.92rem;
|
||||
--text-base--line-height: calc(1.5 / 0.95);
|
||||
--text-xs: 0.75rem;
|
||||
--text-xs--line-height: calc(1 / 0.8);
|
||||
|
||||
/* Border radius */
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 8px);
|
||||
/* Border radius */
|
||||
--radius-lg: 0.82rem;
|
||||
--radius-md: 0.65rem;
|
||||
--radius-sm: 0.35rem;
|
||||
|
||||
/* Palette mapped to root design tokens */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
/* 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-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary-hover: var(--primary-hover);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-constructive: var(--constructive);
|
||||
--color-constructive-foreground: var(--constructive-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--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);
|
||||
/* 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);
|
||||
/* 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);
|
||||
/* 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;
|
||||
/* 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-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
@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;
|
||||
}
|
||||
-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;
|
||||
}
|
||||
-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;
|
||||
}
|
||||
-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);
|
||||
}
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 8px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,62 +1,58 @@
|
|||
import { ThemeProvider } from "next-themes";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import Script from "next/script";
|
||||
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";
|
||||
import { env } from "@/env";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
const siteFont = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata = baseMetaData;
|
||||
|
||||
const protectedRoutes = [
|
||||
{
|
||||
path: "/none",
|
||||
method: "GET",
|
||||
},
|
||||
{
|
||||
path: "/api/waitlist/export",
|
||||
method: "POST",
|
||||
},
|
||||
{
|
||||
path: "/none",
|
||||
method: "GET",
|
||||
},
|
||||
];
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<BotIdClient protect={protectedRoutes} />
|
||||
</head>
|
||||
<body className={`${defaultFont.className} font-sans antialiased`}>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark">
|
||||
<TooltipProvider>
|
||||
<StorageProvider>
|
||||
<ScenesMigrator>{children}</ScenesMigrator>
|
||||
</StorageProvider>
|
||||
<Analytics />
|
||||
<Toaster />
|
||||
<Script
|
||||
src="https://cdn.databuddy.cc/databuddy.js"
|
||||
strategy="afterInteractive"
|
||||
async
|
||||
data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
|
||||
data-disabled={env.NODE_ENV === "development"}
|
||||
data-track-attributes={false}
|
||||
data-track-errors={true}
|
||||
data-track-outgoing-links={false}
|
||||
data-track-web-vitals={false}
|
||||
data-track-sessions={false}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<BotIdClient protect={protectedRoutes} />
|
||||
</head>
|
||||
<body className={`${siteFont.className} font-sans antialiased`}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
disableTransitionOnChange={true}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Script
|
||||
src="https://cdn.databuddy.cc/databuddy.js"
|
||||
strategy="afterInteractive"
|
||||
async
|
||||
data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
|
||||
data-disabled={webEnv.NODE_ENV === "development"}
|
||||
data-track-attributes={false}
|
||||
data-track-errors={true}
|
||||
data-track-outgoing-links={false}
|
||||
data-track-web-vitals={false}
|
||||
data-track-sessions={false}
|
||||
/>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,86 +1,86 @@
|
|||
import type { Metadata } from "next";
|
||||
import { SITE_INFO, SITE_URL } from "@/constants/site";
|
||||
import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
|
||||
|
||||
export const baseMetaData: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: SITE_INFO.title,
|
||||
description: SITE_INFO.description,
|
||||
openGraph: {
|
||||
title: SITE_INFO.title,
|
||||
description: SITE_INFO.description,
|
||||
url: SITE_URL,
|
||||
siteName: SITE_INFO.title,
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: SITE_INFO.openGraphImage,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "OpenCut Wordmark",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: SITE_INFO.title,
|
||||
description: SITE_INFO.description,
|
||||
creator: "@opencutapp",
|
||||
images: [SITE_INFO.twitterImage],
|
||||
},
|
||||
pinterest: {
|
||||
richPin: false,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.ico" },
|
||||
{ url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" },
|
||||
{ url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
||||
{ url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" },
|
||||
],
|
||||
apple: [
|
||||
{ url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" },
|
||||
{
|
||||
url: "/icons/apple-icon-114x114.png",
|
||||
sizes: "114x114",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/icons/apple-icon-120x120.png",
|
||||
sizes: "120x120",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/icons/apple-icon-144x144.png",
|
||||
sizes: "144x144",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/icons/apple-icon-152x152.png",
|
||||
sizes: "152x152",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/icons/apple-icon-180x180.png",
|
||||
sizes: "180x180",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
shortcut: ["/favicon.ico"],
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: SITE_INFO.title,
|
||||
},
|
||||
manifest: "/manifest.json",
|
||||
other: {
|
||||
"msapplication-config": "/browserconfig.xml",
|
||||
},
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: SITE_INFO.title,
|
||||
description: SITE_INFO.description,
|
||||
openGraph: {
|
||||
title: SITE_INFO.title,
|
||||
description: SITE_INFO.description,
|
||||
url: SITE_URL,
|
||||
siteName: SITE_INFO.title,
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: SITE_INFO.openGraphImage,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "OpenCut Wordmark",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: SITE_INFO.title,
|
||||
description: SITE_INFO.description,
|
||||
creator: "@opencutapp",
|
||||
images: [SITE_INFO.twitterImage],
|
||||
},
|
||||
pinterest: {
|
||||
richPin: false,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.ico" },
|
||||
{ url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" },
|
||||
{ url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
||||
{ url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" },
|
||||
],
|
||||
apple: [
|
||||
{ url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" },
|
||||
{ url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" },
|
||||
{
|
||||
url: "/icons/apple-icon-114x114.png",
|
||||
sizes: "114x114",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/icons/apple-icon-120x120.png",
|
||||
sizes: "120x120",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/icons/apple-icon-144x144.png",
|
||||
sizes: "144x144",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/icons/apple-icon-152x152.png",
|
||||
sizes: "152x152",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
url: "/icons/apple-icon-180x180.png",
|
||||
sizes: "180x180",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
shortcut: ["/favicon.ico"],
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: SITE_INFO.title,
|
||||
},
|
||||
manifest: "/manifest.json",
|
||||
other: {
|
||||
"msapplication-config": "/browserconfig.xml",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,20 +2,20 @@ import { Hero } from "@/components/landing/hero";
|
|||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import type { Metadata } from "next";
|
||||
import { SITE_URL } from "@/constants/site";
|
||||
import { SITE_URL } from "@/constants/site-constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
alternates: {
|
||||
canonical: SITE_URL,
|
||||
},
|
||||
alternates: {
|
||||
canonical: SITE_URL,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function Home() {
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<Hero />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<Hero />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,253 +1,287 @@
|
|||
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";
|
||||
import type { Metadata } from "next";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy - OpenCut",
|
||||
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",
|
||||
},
|
||||
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>
|
||||
return (
|
||||
<BasePage
|
||||
title="Privacy policy"
|
||||
description="Learn how we handle your data and privacy. Contact us if you have any questions."
|
||||
>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem
|
||||
value="quick-summary"
|
||||
className="rounded-2xl border px-5"
|
||||
>
|
||||
<AccordionTrigger className="no-underline!">
|
||||
Quick summary
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<h3 className="mb-3 text-lg font-medium">
|
||||
Your content stays private and encrypted.
|
||||
</h3>
|
||||
<ol className="list-decimal space-y-2 pl-6">
|
||||
<li>
|
||||
Basic editing happens locally in your browser - we never see
|
||||
your files
|
||||
</li>
|
||||
<li>
|
||||
AI features require encrypted uploads - your content is
|
||||
encrypted before leaving your device
|
||||
</li>
|
||||
<li>
|
||||
We only collect your email and basic profile info for your
|
||||
account
|
||||
</li>
|
||||
<li>Project data stays on your device, not our servers</li>
|
||||
<li>
|
||||
We use analytics to improve the app, but no personal video
|
||||
content is tracked
|
||||
</li>
|
||||
<li>
|
||||
You can delete your account anytime and all data gets removed
|
||||
</li>
|
||||
<li>We don't sell your data or share it with advertisers</li>
|
||||
</ol>
|
||||
<p className="mt-4">
|
||||
Questions? Email us at{" "}
|
||||
<a
|
||||
href="mailto:oss@opencut.app"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
oss@opencut.app
|
||||
</a>
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<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 className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">How We Handle Your Content</h2>
|
||||
<p>
|
||||
<strong>Basic video editing happens locally on your device.</strong>{" "}
|
||||
For standard editing features, we never upload, store, or have access
|
||||
to your video files. Your content remains completely private and under
|
||||
your control.
|
||||
</p>
|
||||
<p>
|
||||
<strong>AI features require secure processing:</strong> When you
|
||||
choose to use AI features like auto captions, your audio/video content
|
||||
is encrypted on your device before being uploaded to our servers for
|
||||
processing. We use zero-knowledge encryption, meaning we cannot
|
||||
decrypt or view your content.
|
||||
</p>
|
||||
<p>
|
||||
After AI processing is complete, the encrypted content is immediately
|
||||
deleted from our servers. Only the results (like generated captions)
|
||||
are returned to your device.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="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 className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">Account Information</h2>
|
||||
<p>When you create an account, we only collect:</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>Email address (for account access)</li>
|
||||
<li>
|
||||
Profile information from Google OAuth (if you choose to sign in with
|
||||
Google)
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>We do NOT store your projects on our servers.</strong> All
|
||||
project data, including names, thumbnails, and creation dates, is
|
||||
stored locally in your browser using IndexedDB.
|
||||
</p>
|
||||
<p>
|
||||
We use{" "}
|
||||
<a
|
||||
href="https://www.better-auth.com"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Better Auth
|
||||
</a>{" "}
|
||||
for secure authentication and follow industry-standard security
|
||||
practices.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="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 className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">AI Features & Encryption</h2>
|
||||
<p>
|
||||
When you use AI-powered features (like auto captions, content
|
||||
analysis, or enhancement tools), your content needs to be processed on
|
||||
our servers. Here's how we protect your privacy:
|
||||
</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>Client-side encryption:</strong> Your content is encrypted
|
||||
on your device before upload
|
||||
</li>
|
||||
<li>
|
||||
<strong>Zero-knowledge processing:</strong> We cannot decrypt or
|
||||
view your original content
|
||||
</li>
|
||||
<li>
|
||||
<strong>Temporary processing:</strong> Encrypted content is deleted
|
||||
immediately after processing
|
||||
</li>
|
||||
<li>
|
||||
<strong>Opt-in only:</strong> AI features are optional - basic
|
||||
editing remains fully local
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Different AI features may process different types of content (audio
|
||||
for captions, video for analysis, etc.), but all follow the same
|
||||
zero-knowledge encryption approach.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="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 className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">Analytics</h2>
|
||||
<p>
|
||||
We use{" "}
|
||||
<a
|
||||
href="https://www.databuddy.cc"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Databuddy
|
||||
</a>{" "}
|
||||
for completely anonymized and non-invasive analytics to understand how
|
||||
people use OpenCut.
|
||||
</p>
|
||||
<p>
|
||||
This helps us improve the editor, but we never collect personal
|
||||
information, track individual users, or store any data that could
|
||||
identify you.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="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 className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">Local Storage & Cookies</h2>
|
||||
<p>We use browser local storage and IndexedDB to:</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>Save your projects locally on your device</li>
|
||||
<li>Remember your editor preferences and settings</li>
|
||||
<li>Keep you logged in across browser sessions</li>
|
||||
</ul>
|
||||
<p>
|
||||
All data stays on your device and can be cleared at any time through
|
||||
your browser settings.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="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 className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">Third-Party Services</h2>
|
||||
<p>OpenCut integrates with these services:</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>Google OAuth:</strong> For optional Google sign-in (governed
|
||||
by Google's privacy policy)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Vercel:</strong> For hosting and content delivery
|
||||
</li>
|
||||
<li>
|
||||
<strong>Databuddy:</strong> For anonymized analytics
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="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>
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">Your Rights</h2>
|
||||
<p>You have complete control over your data:</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>Delete your account and all associated data at any time</li>
|
||||
<li>Export your project data</li>
|
||||
<li>Clear local storage to remove all saved projects</li>
|
||||
<li>Contact us with any privacy concerns</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p className="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>
|
||||
);
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">Open Source Transparency</h2>
|
||||
<p>
|
||||
OpenCut is completely open source. You can review our code, see
|
||||
exactly how we handle data, and even self-host the application if you
|
||||
prefer.
|
||||
</p>
|
||||
<p>
|
||||
View our source code on{" "}
|
||||
<a
|
||||
href={SOCIAL_LINKS.github}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">Contact Us</h2>
|
||||
<p>Questions about this privacy policy or how we handle your data?</p>
|
||||
<p>
|
||||
Open an issue on our{" "}
|
||||
<a
|
||||
href={`${SOCIAL_LINKS.github}/issues`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
GitHub repository
|
||||
</a>
|
||||
, email us at{" "}
|
||||
<a
|
||||
href="mailto:oss@opencut.app"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
oss@opencut.app
|
||||
</a>
|
||||
, or reach out on{" "}
|
||||
<a
|
||||
href={SOCIAL_LINKS.x}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
X (Twitter)
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Last updated: July 14, 2025
|
||||
</p>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { TProjectSortKey } from "@/types/project";
|
||||
|
||||
export type ProjectsViewMode = "grid" | "list";
|
||||
|
||||
interface ProjectsState {
|
||||
searchQuery: string;
|
||||
sortKey: TProjectSortKey;
|
||||
sortOrder: "asc" | "desc";
|
||||
viewMode: ProjectsViewMode;
|
||||
selectedProjectIds: string[];
|
||||
lastSelectedProjectId: string | null;
|
||||
isHydrated: boolean;
|
||||
setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void;
|
||||
setSearchQuery: ({ query }: { query: string }) => void;
|
||||
setSortKey: ({ sortKey }: { sortKey: TProjectSortKey }) => void;
|
||||
setSortOrder: ({ sortOrder }: { sortOrder: "asc" | "desc" }) => void;
|
||||
toggleSortOrder: () => void;
|
||||
setViewMode: ({ viewMode }: { viewMode: ProjectsViewMode }) => void;
|
||||
setSelectedProjects: ({ projectIds }: { projectIds: string[] }) => void;
|
||||
clearSelectedProjects: () => void;
|
||||
setProjectSelected: ({
|
||||
projectId,
|
||||
isSelected,
|
||||
}: {
|
||||
projectId: string;
|
||||
isSelected: boolean;
|
||||
}) => void;
|
||||
selectProjectRange: ({
|
||||
projectId,
|
||||
allProjectIds,
|
||||
}: {
|
||||
projectId: string;
|
||||
allProjectIds: string[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const getNextSelectedProjectIds = ({
|
||||
selectedProjectIds,
|
||||
projectId,
|
||||
isSelected,
|
||||
}: {
|
||||
selectedProjectIds: string[];
|
||||
projectId: string;
|
||||
isSelected: boolean;
|
||||
}): string[] => {
|
||||
const selectedProjectIdSet = new Set(selectedProjectIds);
|
||||
|
||||
if (isSelected) {
|
||||
selectedProjectIdSet.add(projectId);
|
||||
return Array.from(selectedProjectIdSet);
|
||||
}
|
||||
|
||||
selectedProjectIdSet.delete(projectId);
|
||||
return Array.from(selectedProjectIdSet);
|
||||
};
|
||||
|
||||
export const useProjectsStore = create<ProjectsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
searchQuery: "",
|
||||
sortKey: "createdAt",
|
||||
sortOrder: "desc",
|
||||
viewMode: "grid",
|
||||
selectedProjectIds: [],
|
||||
lastSelectedProjectId: null,
|
||||
isHydrated: false,
|
||||
setIsHydrated: ({ isHydrated }) => set({ isHydrated }),
|
||||
setSearchQuery: ({ query }) => set({ searchQuery: query }),
|
||||
setSortKey: ({ sortKey }) => set({ sortKey }),
|
||||
setSortOrder: ({ sortOrder }) => set({ sortOrder }),
|
||||
toggleSortOrder: () =>
|
||||
set((state) => ({
|
||||
sortOrder: state.sortOrder === "asc" ? "desc" : "asc",
|
||||
})),
|
||||
setViewMode: ({ viewMode }) => set({ viewMode }),
|
||||
setSelectedProjects: ({ projectIds }) =>
|
||||
set({ selectedProjectIds: projectIds }),
|
||||
clearSelectedProjects: () =>
|
||||
set({ selectedProjectIds: [], lastSelectedProjectId: null }),
|
||||
setProjectSelected: ({ projectId, isSelected }) =>
|
||||
set((state) => ({
|
||||
selectedProjectIds: getNextSelectedProjectIds({
|
||||
selectedProjectIds: state.selectedProjectIds,
|
||||
projectId,
|
||||
isSelected,
|
||||
}),
|
||||
lastSelectedProjectId: isSelected ? projectId : state.lastSelectedProjectId,
|
||||
})),
|
||||
selectProjectRange: ({ projectId, allProjectIds }) =>
|
||||
set((state) => {
|
||||
const anchorId = state.lastSelectedProjectId;
|
||||
if (!anchorId) {
|
||||
return {
|
||||
selectedProjectIds: [projectId],
|
||||
lastSelectedProjectId: projectId,
|
||||
};
|
||||
}
|
||||
|
||||
const anchorIndex = allProjectIds.indexOf(anchorId);
|
||||
const targetIndex = allProjectIds.indexOf(projectId);
|
||||
|
||||
if (anchorIndex === -1 || targetIndex === -1) {
|
||||
return {
|
||||
selectedProjectIds: [projectId],
|
||||
lastSelectedProjectId: projectId,
|
||||
};
|
||||
}
|
||||
|
||||
const startIndex = Math.min(anchorIndex, targetIndex);
|
||||
const endIndex = Math.max(anchorIndex, targetIndex);
|
||||
const rangeIds = allProjectIds.slice(startIndex, endIndex + 1);
|
||||
|
||||
const merged = new Set([...state.selectedProjectIds, ...rangeIds]);
|
||||
return {
|
||||
selectedProjectIds: Array.from(merged),
|
||||
};
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "projects-view-mode",
|
||||
partialize: (state) => ({ viewMode: state.viewMode }),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
state?.setIsHydrated({ isHydrated: true });
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,247 +1,144 @@
|
|||
import { Metadata } from "next";
|
||||
import type { Metadata } from "next";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { 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";
|
||||
import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
type StatusType = "complete" | "pending" | "default" | "info";
|
||||
|
||||
interface Status {
|
||||
text: string;
|
||||
type: StatusType;
|
||||
}
|
||||
|
||||
interface RoadmapItem {
|
||||
title: string;
|
||||
description: string;
|
||||
status: Status;
|
||||
}
|
||||
|
||||
const roadmapItems: RoadmapItem[] = [
|
||||
{
|
||||
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:
|
||||
"Build the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.",
|
||||
status: {
|
||||
text: "Completed",
|
||||
type: "complete",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Essential functionality",
|
||||
description:
|
||||
"Everything that makes a video editor **useful**. Timeline interactivity, storage, effects, transitions, etc.",
|
||||
status: {
|
||||
text: "In progress",
|
||||
type: "pending",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Badge (potentially)",
|
||||
description:
|
||||
'An "Edit with OpenCut" badge web apps can integrate. Shows on video players.',
|
||||
status: {
|
||||
text: "Not started",
|
||||
type: "default",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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"],
|
||||
},
|
||||
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>
|
||||
);
|
||||
return (
|
||||
<BasePage
|
||||
title="Roadmap"
|
||||
description="What's coming next for OpenCut (last updated: July 14, 2025)"
|
||||
>
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-16">
|
||||
<div className="flex flex-col gap-6">
|
||||
{roadmapItems.map((item, index) => (
|
||||
<RoadmapItem key={item.title} item={item} index={index} />
|
||||
))}
|
||||
</div>
|
||||
<GitHubContributeSection
|
||||
title="Want to help?"
|
||||
description="OpenCut is open source and built by the community. Every contribution,
|
||||
no matter how small, helps us build the best free video editor
|
||||
possible."
|
||||
/>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function RoadmapItem({ item, index }: { item: RoadmapItem; index: number }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-lg font-medium">
|
||||
<span className="leading-normal select-none">{index + 1}</span>
|
||||
<h3>{item.title}</h3>
|
||||
<StatusBadge status={item.status} className="ml-1" />
|
||||
</div>
|
||||
<div className="text-foreground/70 leading-relaxed">
|
||||
<ReactMarkdownWrapper>{item.description}</ReactMarkdownWrapper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({
|
||||
status,
|
||||
className,
|
||||
}: {
|
||||
status: Status;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
className={cn("shadow-none", className, {
|
||||
"bg-green-500! text-white": status.type === "complete",
|
||||
"bg-yellow-500! text-white": status.type === "pending",
|
||||
"bg-blue-500! text-white": status.type === "info",
|
||||
"bg-foreground/10! text-accent-foreground": status.type === "default",
|
||||
})}
|
||||
>
|
||||
{status.text}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/constants/site";
|
||||
import { SITE_URL } from "@/constants/site-constants";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/_next/", "/projects/", "/editor/"],
|
||||
},
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
};
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/_next/", "/projects/", "/editor/"],
|
||||
},
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,46 +1,46 @@
|
|||
import { Feed } from "feed";
|
||||
import { getPosts } from "@/lib/blog-query";
|
||||
import { SITE_INFO, SITE_URL } from "@/constants/site";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { posts } = await getPosts();
|
||||
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
|
||||
}`,
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
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 });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,61 @@
|
|||
import { SITE_URL } from "@/constants/site";
|
||||
import { getPosts } from "@/lib/blog-query";
|
||||
import { SITE_URL } from "@/constants/site-constants";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const data = await getPosts();
|
||||
const data = await getPosts();
|
||||
|
||||
const postPages: MetadataRoute.Sitemap =
|
||||
data?.posts?.map((post) => ({
|
||||
url: `${SITE_URL}/blog/${post.slug}`,
|
||||
lastModified: new Date(post.publishedAt),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.8,
|
||||
})) ?? [];
|
||||
const postPages: MetadataRoute.Sitemap =
|
||||
data?.posts?.map((post) => ({
|
||||
url: `${SITE_URL}/blog/${post.slug}`,
|
||||
lastModified: new Date(post.publishedAt),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.8,
|
||||
})) ?? [];
|
||||
|
||||
return [
|
||||
{
|
||||
url: SITE_URL,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/contributors`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/roadmap`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/privacy`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/terms`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/why-not-capcut`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "yearly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/blog`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
...postPages,
|
||||
];
|
||||
return [
|
||||
{
|
||||
url: SITE_URL,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/contributors`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/roadmap`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/privacy`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/terms`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/why-not-capcut`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "yearly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/blog`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
...postPages,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { SPONSORS, type Sponsor } from "@/constants/site-constants";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sponsors - OpenCut",
|
||||
description:
|
||||
"Support OpenCut and help us build the future of free and open-source video editing.",
|
||||
openGraph: {
|
||||
title: "Sponsors - OpenCut",
|
||||
description:
|
||||
"Support OpenCut and help us build the future of free and open-source video editing.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default function SponsorsPage() {
|
||||
return (
|
||||
<BasePage>
|
||||
<div className="flex flex-col gap-8 text-center">
|
||||
<h1 className="text-5xl font-bold tracking-tight md:text-6xl">
|
||||
Sponsors
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed text-pretty">
|
||||
Support OpenCut and help us build the future of privacy-first video
|
||||
editing.
|
||||
</p>
|
||||
</div>
|
||||
<SponsorsGrid />
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function SponsorsGrid() {
|
||||
return (
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{SPONSORS.map((sponsor) => (
|
||||
<SponsorCard key={sponsor.name} sponsor={sponsor} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SponsorCard({ sponsor }: { sponsor: Sponsor }) {
|
||||
return (
|
||||
<Link
|
||||
href={sponsor.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="size-full"
|
||||
>
|
||||
<Card className="h-full">
|
||||
<CardContent className="flex h-full flex-col justify-center gap-8 p-8">
|
||||
<Image
|
||||
src={sponsor.logo}
|
||||
alt={`${sponsor.name} logo`}
|
||||
width={50}
|
||||
height={50}
|
||||
className="object-contain"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-xl font-semibold group-hover:underline">
|
||||
{sponsor.name}
|
||||
</h3>
|
||||
<HugeiconsIcon
|
||||
icon={LinkSquare02Icon}
|
||||
className="text-muted-foreground size-4"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{sponsor.description}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,317 +1,295 @@
|
|||
import { Metadata } from "next";
|
||||
import { 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";
|
||||
import type { Metadata } from "next";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms of Service - OpenCut",
|
||||
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",
|
||||
},
|
||||
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>
|
||||
return (
|
||||
<BasePage
|
||||
title="Terms of service"
|
||||
description="Fair and transparent terms for our free, open-source video editor. Contact us if you have any questions."
|
||||
>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem
|
||||
value="quick-summary"
|
||||
className="rounded-2xl border px-5"
|
||||
>
|
||||
<AccordionTrigger className="no-underline!">
|
||||
Quick summary
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<h3 className="mb-3 text-lg font-medium">
|
||||
You own your content, we own nothing.
|
||||
</h3>
|
||||
<ol className="list-decimal space-y-2 pl-6">
|
||||
<li>
|
||||
Your content stays private - basic editing is local, AI features
|
||||
use encrypted uploads
|
||||
</li>
|
||||
<li>
|
||||
We never claim ownership of your content, even when processing
|
||||
AI features
|
||||
</li>
|
||||
<li>
|
||||
Free for personal and commercial use with no watermarks or
|
||||
restrictions
|
||||
</li>
|
||||
<li>Don't use OpenCut for illegal activities or harassment</li>
|
||||
<li>
|
||||
Service provided "as is" - we can't guarantee perfect uptime
|
||||
</li>
|
||||
<li>
|
||||
Open source means you can review our code and self-host if
|
||||
needed
|
||||
</li>
|
||||
<li>
|
||||
You can delete your account anytime and keep using your exported
|
||||
videos
|
||||
</li>
|
||||
</ol>
|
||||
<p className="mt-4">
|
||||
Questions? Email us at{" "}
|
||||
<a
|
||||
href="mailto:oss@opencut.app"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
oss@opencut.app
|
||||
</a>
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<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 className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">Your Content, Your Rights</h2>
|
||||
<p>
|
||||
<strong>You own everything you create.</strong> OpenCut processes
|
||||
basic editing locally on your device. For AI features, content is
|
||||
encrypted before upload and we cannot access your original files. We
|
||||
make no claims to ownership, licensing, or rights over your videos,
|
||||
projects, or any content you create using OpenCut.
|
||||
</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
Your content remains private and under your control at all times
|
||||
</li>
|
||||
<li>You retain all intellectual property rights to your content</li>
|
||||
<li>
|
||||
Even when using AI features, we cannot access your unencrypted
|
||||
content
|
||||
</li>
|
||||
<li>You can export and use your content however you choose</li>
|
||||
<li>No watermarks, no licensing restrictions from OpenCut</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="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 className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">How You Can Use OpenCut</h2>
|
||||
<p>OpenCut is free for personal and commercial use. You can:</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
Create videos for personal, educational, or commercial purposes
|
||||
</li>
|
||||
<li>Use OpenCut for client work and paid projects</li>
|
||||
<li>Share and distribute videos created with OpenCut</li>
|
||||
<li>
|
||||
Modify and distribute the OpenCut software (under MIT license)
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>What we ask:</strong> Don't use OpenCut for illegal
|
||||
activities, harassment, or creating harmful content. Be respectful of
|
||||
others and follow applicable laws.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="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 className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
AI Features and Data Processing
|
||||
</h2>
|
||||
<p>
|
||||
OpenCut offers optional AI-powered features that require server
|
||||
processing:
|
||||
</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
AI features (auto captions, content analysis, etc.) are completely
|
||||
optional
|
||||
</li>
|
||||
<li>Your content is encrypted on your device before any upload</li>
|
||||
<li>
|
||||
We use zero-knowledge encryption - we cannot decrypt your content
|
||||
</li>
|
||||
<li>Encrypted content is deleted immediately after processing</li>
|
||||
<li>
|
||||
You maintain full ownership and control of your content throughout
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
By using AI features, you consent to the temporary, encrypted
|
||||
processing of your content as described in our Privacy Policy. You can
|
||||
always choose to use only local editing features.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="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 className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">Account and Service</h2>
|
||||
<p>To use certain features, you may create an account:</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>Provide accurate information when signing up</li>
|
||||
<li>Keep your account secure and don't share credentials</li>
|
||||
<li>You're responsible for activity under your account</li>
|
||||
<li>You can delete your account at any time</li>
|
||||
</ul>
|
||||
<p>
|
||||
OpenCut is provided "as is" without warranties. While we strive for
|
||||
reliability, we can't guarantee uninterrupted service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="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 className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">Open Source Benefits</h2>
|
||||
<p>Because OpenCut is open source, you have additional rights:</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>Review our code to see exactly how we handle your data</li>
|
||||
<li>Self-host OpenCut on your own servers</li>
|
||||
<li>Modify the software to suit your needs</li>
|
||||
<li>Contribute improvements back to the community</li>
|
||||
</ul>
|
||||
<p>
|
||||
View our source code and license on{" "}
|
||||
<a
|
||||
href={SOCIAL_LINKS.github}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="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 className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">Third-Party Content</h2>
|
||||
<p>
|
||||
When using OpenCut, make sure you have the right to use any content
|
||||
you import:
|
||||
</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>Only upload content you own or have permission to use</li>
|
||||
<li>
|
||||
Respect copyright, trademarks, and other intellectual property
|
||||
</li>
|
||||
<li>
|
||||
Don't use copyrighted music, images, or videos without permission
|
||||
</li>
|
||||
<li>You're responsible for any claims related to your content</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="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 className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">Limitations and Liability</h2>
|
||||
<p>
|
||||
OpenCut is provided free of charge. To the extent permitted by law:
|
||||
</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>We're not liable for any loss of data or content</li>
|
||||
<li>
|
||||
Projects are stored in your browser and may be lost if you clear
|
||||
browser data
|
||||
</li>
|
||||
<li>We're not responsible for how you use the service</li>
|
||||
<li>Our liability is limited to the maximum extent allowed by law</li>
|
||||
</ul>
|
||||
<p>
|
||||
Since your content stays on your device, we have no way to recover
|
||||
lost projects. Consider exporting important videos when finished
|
||||
editing.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="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 className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">Service Changes</h2>
|
||||
<p>We may update OpenCut and these terms:</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>We'll notify you of significant changes to these terms</li>
|
||||
<li>Continued use means you accept any updates</li>
|
||||
<li>You can always self-host an older version if you prefer</li>
|
||||
<li>Major changes will be discussed with the community on GitHub</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="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>
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">Termination</h2>
|
||||
<p>You can stop using OpenCut at any time:</p>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>Delete your account through your profile settings</li>
|
||||
<li>Clear your browser data to remove local projects</li>
|
||||
<li>Your content remains yours even if you stop using OpenCut</li>
|
||||
<li>We may suspend accounts for violations of these terms</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p className="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>
|
||||
);
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-2xl font-semibold">Contact Us</h2>
|
||||
<p>Questions about these terms or need to report an issue?</p>
|
||||
<p>
|
||||
Contact us through our{" "}
|
||||
<a
|
||||
href={`${SOCIAL_LINKS.github}/issues`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
GitHub repository
|
||||
</a>
|
||||
, email us at{" "}
|
||||
<a
|
||||
href="mailto:oss@opencut.app"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
oss@opencut.app
|
||||
</a>
|
||||
, or reach out on{" "}
|
||||
<a
|
||||
href={SOCIAL_LINKS.x}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
X (Twitter)
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
These terms are governed by applicable law in your jurisdiction. We
|
||||
prefer to resolve disputes through friendly discussion in our
|
||||
open-source community.
|
||||
</p>
|
||||
</section>
|
||||
<Separator />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Last updated: July 14, 2025
|
||||
</p>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,191 +0,0 @@
|
|||
import { Header } from "@/components/header";
|
||||
|
||||
export default function WhyNotCapcut() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background px-5">
|
||||
<Header />
|
||||
|
||||
<main className="relative mt-12">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-linear-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-linear-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative container mx-auto px-4 py-16">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-20">
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
||||
Fuck CapCut
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
|
||||
Roasting time, so get ready motherfucker.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto space-y-12">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
Seriously, what the fuck else do you want?
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
You probably use CapCut and think your video editing is
|
||||
special. You think your fucking TikTok with 47 transitions and
|
||||
12 different fonts is going to get you some viral fame. You
|
||||
think loading up every goddamn effect in their library makes
|
||||
your content better. Wrong, motherfucker. Let me describe what
|
||||
CapCut actually gives you:
|
||||
</p>
|
||||
<ul className="text-lg space-y-2 mb-6 list-disc list-inside">
|
||||
<li>A paywall every time you breathe</li>
|
||||
<li>Terms of service that steal your shit</li>
|
||||
<li>
|
||||
More "Get Pro" dialogs than a Windows 95 error message
|
||||
</li>
|
||||
<li>
|
||||
Features that disappear behind paywalls while you're fucking
|
||||
using them
|
||||
</li>
|
||||
<li>Bugs disguised as "premium features"</li>
|
||||
</ul>
|
||||
<p className="text-lg mb-6">
|
||||
<strong>Well guess what, motherfucker:</strong>
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
You. Are. Getting. Scammed. Look at this shit. It's a fucking
|
||||
video editor. Why the fuck do you need to pay $20/month just
|
||||
to remove a goddamn watermark? You spent hours editing your
|
||||
video and they slap their logo on it like they fucking made
|
||||
it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
The "Get Pro" dialog is everywhere
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
This motherfucking dialog pops up more than ads on a pirated
|
||||
movie site. Want to add a transition? Get Pro. Want to export
|
||||
without their watermark? Get Pro. Want to use more than 2
|
||||
fonts? Get fucking Pro, peasant.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
Did you seriously think you could edit a video without seeing
|
||||
this dialog 47 times? You click one button and BAM - there it
|
||||
is again, asking for your credit card like a desperate ex
|
||||
asking for money.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
Everything costs money now
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
You dumbass. You thought CapCut was free, but no. Free means
|
||||
they let you open the app. Everything else costs money. Basic
|
||||
shake effect? That'll be $20/month. A decent transition that
|
||||
isn't "fade"? Pay up, motherfucker.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
Here's my favorite piece of bullshit: You import an MP3 file -
|
||||
you know, AUDIO - and try to export. "Sorry, can't export
|
||||
because you're using our premium extract audio feature!"
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
<strong>
|
||||
My MP3 was already fucking audio, you absolute morons.
|
||||
</strong>
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
But wait, there's more! If you drag that same MP3 to their
|
||||
media panel first, then to the timeline, it magically works.
|
||||
This isn't a bug, it's a fucking scam disguised as software
|
||||
engineering.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
Their Terms of Service are insane
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
Look at this shit. You upload your content and they basically
|
||||
say "thanks for the free content, we own it now, but if Disney
|
||||
sues anyone, that's your problem."
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
<strong>CapCut's Terms of Service:</strong> We get full rights
|
||||
to use, modify, distribute, and monetize everything you upload
|
||||
- permanently and without paying you shit. But you're still
|
||||
responsible if anything goes wrong.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
Translation: "We'll make money off your viral video, you
|
||||
handle the lawsuits." Brilliant legal strategy, you fucks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
The editor is actually good
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
Here's the thing that makes me want to punch my monitor: the
|
||||
actual video editor is fucking good. It's intuitive, powerful,
|
||||
and anyone can figure it out. When it's not begging for money
|
||||
every 30 seconds, it actually works well.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
Which makes everything else so much worse. They built
|
||||
something people want to use, then turned it into a digital
|
||||
slot machine. Every click might trigger a payment request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
This is a video editor. Look at it. You've never seen one
|
||||
before.
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
Like the person who's never used software that doesn't
|
||||
constantly beg for money, you have no fucking idea what a
|
||||
video editor should be. All you've ever seen are predatory
|
||||
apps disguised as creative tools.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
A real video editor lets you edit videos. It doesn't steal
|
||||
your content. It doesn't pop up payment dialogs every 5
|
||||
seconds. It doesn't charge you separately for basic features
|
||||
that should be free.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
Yes, this is fucking satire, you fuck
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
I'm not actually saying all video editors should be basic as
|
||||
shit. What I'm saying is that all the problems we have with
|
||||
video editing apps are{" "}
|
||||
<strong>ones they create themselves</strong>. Video editors
|
||||
aren't broken by default - they edit videos, export them, and
|
||||
let you use basic features without constantly begging for
|
||||
money. CapCut breaks them. They turn them into payment
|
||||
processors with video editing as a side feature.
|
||||
</p>
|
||||
<p className="text-lg">
|
||||
<em>"Good software gets out of your way."</em>
|
||||
<br />- Some smart motherfucker who definitely wasn't working
|
||||
at CapCut
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DeleteProjectDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
projectName,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
projectName?: string;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{projectName ? (
|
||||
<>
|
||||
{"Delete '"}
|
||||
<span className="inline-block max-w-[300px] truncate align-bottom">
|
||||
{projectName}
|
||||
</span>
|
||||
{"'?"}
|
||||
</>
|
||||
) : (
|
||||
"Delete Project?"
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this project? This action cannot be
|
||||
undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { formatTimeCode, parseTimeCode } from "@/lib/time";
|
||||
import type { TTimeCode } from "@/types/time";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
interface EditableTimecodeProps {
|
||||
time: number;
|
||||
duration: number;
|
||||
format?: TTimeCode;
|
||||
fps: number;
|
||||
onTimeChange?: ({ time }: { time: number }) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function EditableTimecode({
|
||||
time,
|
||||
duration,
|
||||
format = "HH:MM:SS:FF",
|
||||
fps,
|
||||
onTimeChange,
|
||||
className,
|
||||
disabled = false,
|
||||
}: EditableTimecodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const enterPressedRef = useRef(false);
|
||||
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps });
|
||||
|
||||
const startEditing = () => {
|
||||
if (disabled) return;
|
||||
setIsEditing(true);
|
||||
setInputValue(formattedTime);
|
||||
setHasError(false);
|
||||
enterPressedRef.current = false;
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setIsEditing(false);
|
||||
setInputValue("");
|
||||
setHasError(false);
|
||||
enterPressedRef.current = false;
|
||||
};
|
||||
|
||||
const applyEdit = () => {
|
||||
const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
|
||||
|
||||
if (parsedTime === null) {
|
||||
setHasError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const clampedTime = Math.max(
|
||||
0,
|
||||
duration ? Math.min(duration, parsedTime) : parsedTime,
|
||||
);
|
||||
|
||||
onTimeChange?.({ time: clampedTime });
|
||||
setIsEditing(false);
|
||||
setInputValue("");
|
||||
setHasError(false);
|
||||
enterPressedRef.current = false;
|
||||
};
|
||||
|
||||
const handleKeyDown = ({
|
||||
key,
|
||||
preventDefault,
|
||||
}: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (key === "Enter") {
|
||||
preventDefault();
|
||||
enterPressedRef.current = true;
|
||||
applyEdit();
|
||||
} else if (key === "Escape") {
|
||||
preventDefault();
|
||||
cancelEditing();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = ({
|
||||
target,
|
||||
}: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(target.value);
|
||||
setHasError(false);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!enterPressedRef.current && isEditing) {
|
||||
applyEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisplayKeyDown = ({
|
||||
key,
|
||||
preventDefault,
|
||||
}: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (key === "Enter" || key === " ") {
|
||||
preventDefault();
|
||||
startEditing();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className={cn(
|
||||
"border-none bg-transparent font-mono text-xs outline-none",
|
||||
"focus:bg-background focus:border-primary focus:rounded focus:border focus:px-1",
|
||||
"text-primary tabular-nums",
|
||||
hasError && "text-destructive focus:border-destructive",
|
||||
className,
|
||||
)}
|
||||
style={{ width: `${formattedTime.length + 1}ch` }}
|
||||
placeholder={formattedTime}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEditing}
|
||||
onKeyDown={handleDisplayKeyDown}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"text-primary cursor-pointer font-mono text-xs tabular-nums",
|
||||
"hover:bg-muted/50 -mx-1 px-1 hover:rounded",
|
||||
disabled && "cursor-default hover:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
title={disabled ? undefined : "Click to edit time"}
|
||||
>
|
||||
{formattedTime}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
import React, { useEffect, useRef, useState } from "react";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
|
||||
interface AudioWaveformProps {
|
||||
audioUrl: string;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
||||
audioUrl,
|
||||
height = 32,
|
||||
className = "",
|
||||
}) => {
|
||||
const waveformRef = useRef<HTMLDivElement>(null);
|
||||
const wavesurfer = useRef<WaveSurfer | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
let ws = wavesurfer.current;
|
||||
|
||||
const initWaveSurfer = async () => {
|
||||
if (!waveformRef.current || !audioUrl) return;
|
||||
|
||||
try {
|
||||
// Clear any existing instance safely
|
||||
if (ws) {
|
||||
// Instead of immediately destroying, just set to null
|
||||
// We'll destroy it outside this function
|
||||
wavesurfer.current = null;
|
||||
}
|
||||
|
||||
// Create a fresh instance
|
||||
const newWaveSurfer = WaveSurfer.create({
|
||||
container: waveformRef.current,
|
||||
waveColor: "rgba(255, 255, 255, 0.6)",
|
||||
progressColor: "rgba(255, 255, 255, 0.9)",
|
||||
cursorColor: "transparent",
|
||||
barWidth: 2,
|
||||
barGap: 1,
|
||||
height,
|
||||
normalize: true,
|
||||
interact: false,
|
||||
});
|
||||
|
||||
// Assign to ref only if component is still mounted
|
||||
if (mounted) {
|
||||
wavesurfer.current = newWaveSurfer;
|
||||
} else {
|
||||
// Component unmounted during initialization, clean up
|
||||
try {
|
||||
newWaveSurfer.destroy();
|
||||
} catch (e) {
|
||||
// Ignore destroy errors
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
newWaveSurfer.on("ready", () => {
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
setError(false);
|
||||
}
|
||||
});
|
||||
|
||||
newWaveSurfer.on("error", (err) => {
|
||||
if (mounted) {
|
||||
console.error("WaveSurfer error:", err);
|
||||
setError(true);
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
await newWaveSurfer.load(audioUrl);
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
console.error("Failed to initialize WaveSurfer:", err);
|
||||
setError(true);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// First safely destroy previous instance if it exists
|
||||
if (ws) {
|
||||
// Use this pattern to safely destroy the previous instance
|
||||
const wsToDestroy = ws;
|
||||
// Detach from ref immediately
|
||||
wavesurfer.current = null;
|
||||
|
||||
// Wait a tick to destroy so any pending operations can complete
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
wsToDestroy.destroy();
|
||||
} catch (e) {
|
||||
// Ignore errors during destroy
|
||||
}
|
||||
// Only initialize new instance after destroying the old one
|
||||
if (mounted) {
|
||||
initWaveSurfer();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// No previous instance to clean up, initialize directly
|
||||
initWaveSurfer();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Mark component as unmounted
|
||||
mounted = false;
|
||||
|
||||
// Store reference to current wavesurfer instance
|
||||
const wsToDestroy = wavesurfer.current;
|
||||
|
||||
// Immediately clear the ref to prevent accessing it after unmount
|
||||
wavesurfer.current = null;
|
||||
|
||||
// If we have an instance to clean up, do it safely
|
||||
if (wsToDestroy) {
|
||||
// Delay destruction to avoid race conditions
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
wsToDestroy.destroy();
|
||||
} catch (e) {
|
||||
// Ignore destroy errors - they're expected
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [audioUrl, height]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center ${className}`}
|
||||
style={{ height }}
|
||||
>
|
||||
<span className="text-xs text-foreground/60">Audio unavailable</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs text-foreground/60">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={waveformRef}
|
||||
className={`w-full transition-opacity duration-200 ${isLoading ? "opacity-0" : "opacity-100"}`}
|
||||
style={{ height }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioWaveform;
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export function DeleteProjectDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
projectNames,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
projectNames: string[];
|
||||
}) {
|
||||
const count = projectNames.length;
|
||||
const isSingle = count === 1;
|
||||
const singleName = isSingle ? projectNames[0] : null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{singleName ? (
|
||||
<>
|
||||
{"Delete '"}
|
||||
<span className="inline-block max-w-[300px] truncate align-bottom">
|
||||
{singleName}
|
||||
</span>
|
||||
{"'?"}
|
||||
</>
|
||||
) : (
|
||||
`Delete ${count} projects?`
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Warning</AlertTitle>
|
||||
<AlertDescription>
|
||||
This will permanently delete{" "}
|
||||
{singleName ? `"${singleName}"` : `${count} projects`} and all
|
||||
associated files.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label className="text-xs font-semibold text-slate-500">
|
||||
Type "DELETE" to confirm
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="DELETE"
|
||||
size="lg"
|
||||
variant="destructive"
|
||||
/>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
Delete project
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function MigrationDialog() {
|
||||
const editor = useEditor();
|
||||
const migrationState = editor.project.getMigrationState();
|
||||
|
||||
if (!migrationState.isMigrating) return null;
|
||||
|
||||
const title = migrationState.projectName
|
||||
? "Updating project"
|
||||
: "Updating projects";
|
||||
const description = migrationState.projectName
|
||||
? `Upgrading "${migrationState.projectName}" from v${migrationState.fromVersion} to v${migrationState.toVersion}`
|
||||
: `Upgrading projects from v${migrationState.fromVersion} to v${migrationState.toVersion}`;
|
||||
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground size-8 animate-spin" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { TProjectMetadata } from "@/types/project";
|
||||
import { formatDate } from "@/utils/date";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between items-center py-0 last:pb-0">
|
||||
<span className="text-muted-foreground text-sm">{label}</span>
|
||||
<span className="text-sm font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectInfoDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
project,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project: TProjectMetadata;
|
||||
}) {
|
||||
const durationFormatted =
|
||||
project.duration > 0
|
||||
? formatTimeCode({
|
||||
timeInSeconds: project.duration,
|
||||
format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS",
|
||||
})
|
||||
: "0:00";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent onOpenAutoFocus={(event) => event.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="truncate max-w-[350px]">
|
||||
{project.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="flex flex-col">
|
||||
<InfoRow label="Duration" value={durationFormatted} />
|
||||
<InfoRow
|
||||
label="Created"
|
||||
value={formatDate({ date: project.createdAt })}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Modified"
|
||||
value={formatDate({ date: project.updatedAt })}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Project ID"
|
||||
value={
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{project.id.slice(0, 8)}
|
||||
</code>
|
||||
}
|
||||
/>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function RenameProjectDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
projectName,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (newName: string) => void;
|
||||
projectName: string;
|
||||
}) {
|
||||
const [name, setName] = useState(projectName);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
setName(projectName);
|
||||
}
|
||||
onOpenChange(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename project</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="gap-3">
|
||||
<Label>New name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onConfirm(name);
|
||||
}
|
||||
}}
|
||||
placeholder="Enter a new name"
|
||||
/>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => onConfirm(name)}>Rename</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type KeyboardShortcut,
|
||||
useKeyboardShortcutsHelp,
|
||||
} from "@/hooks/use-keyboard-shortcuts-help";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function ShortcutsDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [recordingShortcut, setRecordingShortcut] =
|
||||
useState<KeyboardShortcut | null>(null);
|
||||
|
||||
const {
|
||||
updateKeybinding,
|
||||
removeKeybinding,
|
||||
getKeybindingString,
|
||||
validateKeybinding,
|
||||
getKeybindingsForAction,
|
||||
setIsRecording,
|
||||
resetToDefaults,
|
||||
isRecording,
|
||||
} = useKeybindingsStore();
|
||||
|
||||
const { shortcuts } = useKeyboardShortcutsHelp();
|
||||
|
||||
const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRecording || !recordingShortcut) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const keyString = getKeybindingString(e);
|
||||
if (keyString) {
|
||||
const conflict = validateKeybinding(
|
||||
keyString,
|
||||
recordingShortcut.action,
|
||||
);
|
||||
if (conflict) {
|
||||
toast.error(
|
||||
`Key "${keyString}" is already bound to "${conflict.existingAction}"`,
|
||||
);
|
||||
setRecordingShortcut(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const oldKeys = getKeybindingsForAction(recordingShortcut.action);
|
||||
for (const key of oldKeys) {
|
||||
removeKeybinding(key);
|
||||
}
|
||||
|
||||
updateKeybinding(keyString, recordingShortcut.action);
|
||||
|
||||
setIsRecording(false);
|
||||
setRecordingShortcut(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
setRecordingShortcut(null);
|
||||
setIsRecording(false);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
};
|
||||
}, [
|
||||
recordingShortcut,
|
||||
getKeybindingString,
|
||||
updateKeybinding,
|
||||
removeKeybinding,
|
||||
validateKeybinding,
|
||||
getKeybindingsForAction,
|
||||
setIsRecording,
|
||||
isRecording,
|
||||
]);
|
||||
|
||||
const handleStartRecording = (shortcut: KeyboardShortcut) => {
|
||||
setRecordingShortcut(shortcut);
|
||||
setIsRecording(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col p-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Keyboard shortcuts</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="scrollbar-thin flex-grow overflow-y-auto">
|
||||
<div className="flex flex-col gap-6">
|
||||
{categories.map((category) => (
|
||||
<div key={category} className="flex flex-col gap-1">
|
||||
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{category}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
{shortcuts
|
||||
.filter((shortcut) => shortcut.category === category)
|
||||
.map((shortcut) => (
|
||||
<ShortcutItem
|
||||
key={shortcut.action}
|
||||
shortcut={shortcut}
|
||||
isRecording={
|
||||
shortcut.action === recordingShortcut?.action
|
||||
}
|
||||
onStartRecording={() => handleStartRecording(shortcut)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="destructive" onClick={resetToDefaults}>
|
||||
Reset to default
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutItem({
|
||||
shortcut,
|
||||
isRecording,
|
||||
onStartRecording,
|
||||
}: {
|
||||
shortcut: KeyboardShortcut;
|
||||
isRecording: boolean;
|
||||
onStartRecording: (params: { shortcut: KeyboardShortcut }) => void;
|
||||
}) {
|
||||
const displayKeys = shortcut.keys.filter((key: string) => {
|
||||
if (
|
||||
key.includes("Cmd") &&
|
||||
shortcut.keys.includes(key.replace("Cmd", "Ctrl"))
|
||||
)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{shortcut.icon && (
|
||||
<div className="text-muted-foreground">{shortcut.icon}</div>
|
||||
)}
|
||||
<span className="text-sm">{shortcut.description}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{displayKeys.map((key: string, index: number) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{key.split("+").map((keyPart: string, partIndex: number) => {
|
||||
const keyId = `${shortcut.id}-${index}-${partIndex}`;
|
||||
return (
|
||||
<EditableShortcutKey
|
||||
key={keyId}
|
||||
isRecording={isRecording}
|
||||
onStartRecording={() => onStartRecording({ shortcut })}
|
||||
>
|
||||
{keyPart}
|
||||
</EditableShortcutKey>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{index < displayKeys.length - 1 && (
|
||||
<span className="text-muted-foreground text-xs">or</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableShortcutKey({
|
||||
children,
|
||||
isRecording,
|
||||
onStartRecording,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isRecording: boolean;
|
||||
onStartRecording: () => void;
|
||||
}) {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onStartRecording();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
title={
|
||||
isRecording ? "Press any key combination..." : "Click to edit shortcut"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,130 +1,187 @@
|
|||
"use client";
|
||||
|
||||
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 { ChevronDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
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 { RenameProjectDialog } from "./dialogs/rename-project-dialog";
|
||||
import { DeleteProjectDialog } from "./dialogs/delete-project-dialog";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FaDiscord } from "react-icons/fa6";
|
||||
import { PanelPresetSelector } from "./panel-preset-selector";
|
||||
import { ExportButton } from "./export-button";
|
||||
import { ThemeToggle } from "../theme-toggle";
|
||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
import { toast } from "sonner";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
ArrowLeft02Icon,
|
||||
Edit03Icon,
|
||||
Delete02Icon,
|
||||
CommandIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
|
||||
|
||||
export function EditorHeader() {
|
||||
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"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<header className="bg-background flex h-[3.2rem] items-center justify-between px-3 pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProjectDropdown />
|
||||
</div>
|
||||
<nav className="flex items-center gap-2">
|
||||
<ExportButton />
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectDropdown() {
|
||||
const [openDialog, setOpenDialog] = useState<
|
||||
"delete" | "rename" | "shortcuts" | null
|
||||
>(null);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const router = useRouter();
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const handleExit = async () => {
|
||||
if (isExiting) return;
|
||||
setIsExiting(true);
|
||||
|
||||
try {
|
||||
await editor.project.prepareExit();
|
||||
editor.project.closeProject();
|
||||
} catch (error) {
|
||||
console.error("Failed to prepare project exit:", error);
|
||||
} finally {
|
||||
editor.project.closeProject();
|
||||
router.push("/projects");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProjectName = async (newName: string) => {
|
||||
if (
|
||||
activeProject &&
|
||||
newName.trim() &&
|
||||
newName !== activeProject.metadata.name
|
||||
) {
|
||||
try {
|
||||
await editor.project.renameProject({
|
||||
id: activeProject.metadata.id,
|
||||
name: newName.trim(),
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Failed to rename project", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
} finally {
|
||||
setOpenDialog(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (activeProject) {
|
||||
try {
|
||||
await editor.project.deleteProjects({
|
||||
ids: [activeProject.metadata.id],
|
||||
});
|
||||
router.push("/projects");
|
||||
} catch (error) {
|
||||
toast.error("Failed to delete project", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
} finally {
|
||||
setOpenDialog(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex h-auto items-center justify-center px-2.5 py-1.5"
|
||||
>
|
||||
<ChevronDown className="text-muted-foreground" />
|
||||
<span className="mr-2 text-[0.85rem]">
|
||||
{activeProject?.metadata.name}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="z-100 w-52">
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={handleExit}
|
||||
disabled={isExiting}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
|
||||
Exit project
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={() => setOpenDialog("rename")}
|
||||
>
|
||||
<HugeiconsIcon icon={Edit03Icon} className="size-4" />
|
||||
Rename project
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={() => setOpenDialog("delete")}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
|
||||
Delete project
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={() => setOpenDialog("shortcuts")}
|
||||
>
|
||||
<HugeiconsIcon icon={CommandIcon} className="size-4" />
|
||||
Keyboard shortcuts
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={SOCIAL_LINKS.discord}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<FaDiscord className="size-4" />
|
||||
Discord
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<RenameProjectDialog
|
||||
isOpen={openDialog === "rename"}
|
||||
onOpenChange={(isOpen) => setOpenDialog(isOpen ? "rename" : null)}
|
||||
onConfirm={(newName) => handleSaveProjectName(newName)}
|
||||
projectName={activeProject?.metadata.name || ""}
|
||||
/>
|
||||
<DeleteProjectDialog
|
||||
isOpen={openDialog === "delete"}
|
||||
onOpenChange={(isOpen) => setOpenDialog(isOpen ? "delete" : null)}
|
||||
onConfirm={handleDeleteProject}
|
||||
projectNames={[activeProject?.metadata.name || ""]}
|
||||
/>
|
||||
<ShortcutsDialog
|
||||
isOpen={openDialog === "shortcuts"}
|
||||
onOpenChange={(isOpen) => setOpenDialog(isOpen ? "shortcuts" : null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,317 +1,347 @@
|
|||
"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 { useState, useRef } from "react";
|
||||
import { TransitionTopIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
exportProject,
|
||||
getExportMimeType,
|
||||
getExportFileExtension,
|
||||
DEFAULT_EXPORT_OPTIONS,
|
||||
} from "@/lib/export";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { getExportMimeType, getExportFileExtension } from "@/lib/export";
|
||||
import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
|
||||
import { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
|
||||
import { PropertyGroup } from "./properties-panel/property-item";
|
||||
import {
|
||||
EXPORT_FORMAT_VALUES,
|
||||
EXPORT_QUALITY_VALUES,
|
||||
type ExportFormat,
|
||||
type ExportQuality,
|
||||
type ExportResult,
|
||||
} from "@/types/export";
|
||||
import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
|
||||
|
||||
export function ExportButton() {
|
||||
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
|
||||
const { activeProject } = useProjectStore();
|
||||
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
|
||||
const editor = useEditor();
|
||||
|
||||
const handleExport = () => {
|
||||
setIsExportPopoverOpen(true);
|
||||
};
|
||||
const handleExport = () => {
|
||||
setIsExportPopoverOpen(true);
|
||||
};
|
||||
|
||||
const hasProject = !!activeProject;
|
||||
const hasProject = !!editor.project.getActive();
|
||||
|
||||
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>
|
||||
</div>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
{hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />}
|
||||
</Popover>
|
||||
);
|
||||
return (
|
||||
<Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
|
||||
hasProject
|
||||
? "cursor-pointer hover:brightness-105"
|
||||
: "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="relative flex items-center gap-1.5 rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] px-4 py-1 shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
|
||||
<HugeiconsIcon icon={TransitionTopIcon} className="z-50 size-4" />
|
||||
<span className="z-50 text-[0.875rem]">Export</span>
|
||||
<div className="absolute top-0 left-0 z-10 flex size-full items-center justify-center rounded-[0.6rem] bg-linear-to-t from-white/0 to-white/50">
|
||||
<div className="absolute top-[0.08rem] z-50 h-[calc(100%-2px)] w-[calc(100%-2px)] rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
{hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportPopover({
|
||||
onOpenChange,
|
||||
onOpenChange,
|
||||
}: {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
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 editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
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 cancelRequestedRef = useRef(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!activeProject) return;
|
||||
const handleExport = async () => {
|
||||
if (!activeProject) return;
|
||||
|
||||
setIsExporting(true);
|
||||
setProgress(0);
|
||||
setExportResult(null);
|
||||
cancelRequestedRef.current = false;
|
||||
setIsExporting(true);
|
||||
setProgress(0);
|
||||
setExportResult(null);
|
||||
|
||||
const result = await exportProject({
|
||||
format,
|
||||
quality,
|
||||
fps: activeProject.fps,
|
||||
includeAudio,
|
||||
onProgress: setProgress,
|
||||
onCancel: () => false, // TODO: Add cancel functionality
|
||||
});
|
||||
const result = await editor.project.export({
|
||||
options: {
|
||||
format,
|
||||
quality,
|
||||
fps: activeProject.settings.fps,
|
||||
includeAudio,
|
||||
onProgress: ({ progress }) => setProgress(progress),
|
||||
onCancel: () => cancelRequestedRef.current,
|
||||
},
|
||||
});
|
||||
|
||||
setIsExporting(false);
|
||||
setExportResult(result);
|
||||
setIsExporting(false);
|
||||
|
||||
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);
|
||||
if (result.cancelled) {
|
||||
setExportResult(null);
|
||||
setProgress(0);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
setExportResult(result);
|
||||
|
||||
onOpenChange(false);
|
||||
setExportResult(null);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
if (result.success && result.buffer) {
|
||||
const mimeType = getExportMimeType({ format });
|
||||
const extension = getExportFileExtension({ format });
|
||||
const blob = new Blob([result.buffer], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isExporting) {
|
||||
onOpenChange(false);
|
||||
setExportResult(null);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${activeProject.metadata.name}${extension}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
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>
|
||||
onOpenChange(false);
|
||||
setExportResult(null);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
<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>
|
||||
const handleClose = () => {
|
||||
if (!isExporting) {
|
||||
onOpenChange(false);
|
||||
setExportResult(null);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
<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>
|
||||
const handleCancel = () => {
|
||||
cancelRequestedRef.current = true;
|
||||
};
|
||||
|
||||
<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>
|
||||
return (
|
||||
<PopoverContent className="bg-background mr-4 flex w-80 flex-col gap-3">
|
||||
{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="text-foreground/85 !size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleExport} className="w-full gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Export
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<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) => {
|
||||
if (isExportFormat(value)) {
|
||||
setFormat(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="mp4" id="mp4" />
|
||||
<Label htmlFor="mp4">
|
||||
MP4 (H.264) - Better compatibility
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="webm" id="webm" />
|
||||
<Label htmlFor="webm">
|
||||
WebM (VP9) - Smaller file size
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</PropertyGroup>
|
||||
|
||||
{isExporting && (
|
||||
<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>
|
||||
<PropertyGroup
|
||||
title="Quality"
|
||||
titleClassName="text-sm"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<RadioGroup
|
||||
value={quality}
|
||||
onValueChange={(value) => {
|
||||
if (isExportQuality(value)) {
|
||||
setQuality(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="low" id="low" />
|
||||
<Label htmlFor="low">Low - Smallest file size</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="medium" id="medium" />
|
||||
<Label htmlFor="medium">Medium - Balanced</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="high" id="high" />
|
||||
<Label htmlFor="high">High - Recommended</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="very_high" id="very_high" />
|
||||
<Label htmlFor="very_high">
|
||||
Very High - Largest file size
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</PropertyGroup>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-md w-full"
|
||||
onClick={() => {}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</PopoverContent>
|
||||
);
|
||||
<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="size-4" />
|
||||
Export
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isExporting && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between text-center">
|
||||
<p className="text-muted-foreground mb-2 text-sm">
|
||||
{Math.round(progress * 100)}%
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-2 text-sm">100%</p>
|
||||
</div>
|
||||
<Progress value={progress * 100} className="w-full" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full rounded-md"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
);
|
||||
}
|
||||
|
||||
function isExportFormat(value: string): value is ExportFormat {
|
||||
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
|
||||
}
|
||||
|
||||
function isExportQuality(value: string): value is ExportQuality {
|
||||
return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value);
|
||||
}
|
||||
|
||||
function ExportError({
|
||||
error,
|
||||
onRetry,
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
error: string;
|
||||
onRetry: () => void;
|
||||
error: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(error);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
};
|
||||
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>
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-destructive text-sm font-medium">Export failed</p>
|
||||
<p className="text-muted-foreground text-xs">{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>
|
||||
);
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 flex-1 text-xs"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check className="text-constructive" /> : <Copy />}
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 flex-1 text-xs"
|
||||
onClick={onRetry}
|
||||
>
|
||||
<RotateCcw />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,24 +4,24 @@ import { useEditorStore } from "@/stores/editor-store";
|
|||
import Image from "next/image";
|
||||
|
||||
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>
|
||||
);
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<Image
|
||||
src="/platform-guides/tiktok-blueprint.png"
|
||||
alt="TikTok layout guide"
|
||||
className="absolute inset-0 size-full object-contain"
|
||||
draggable={false}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutGuideOverlay() {
|
||||
const { layoutGuide } = useEditorStore();
|
||||
const { layoutGuide } = useEditorStore();
|
||||
|
||||
if (layoutGuide.platform === null) return null;
|
||||
if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
|
||||
if (layoutGuide.platform === null) return null;
|
||||
if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
|
||||
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
import { Upload, Plus, Image } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface MediaDragOverlayProps {
|
||||
isVisible: boolean;
|
||||
isProcessing?: boolean;
|
||||
progress?: number;
|
||||
onClick?: () => void;
|
||||
isEmptyState?: boolean;
|
||||
}
|
||||
|
||||
export function MediaDragOverlay({
|
||||
isVisible,
|
||||
isProcessing = false,
|
||||
progress = 0,
|
||||
onClick,
|
||||
isEmptyState = false,
|
||||
}: MediaDragOverlayProps) {
|
||||
if (!isVisible) return null;
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isProcessing || !onClick) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<Upload className="h-10 w-10 text-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground max-w-sm">
|
||||
{isProcessing
|
||||
? `Processing your files (${progress}%)`
|
||||
: "Drag and drop videos, photos, and audio files here"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isProcessing && (
|
||||
<div className="w-full max-w-xs">
|
||||
<div className="w-full bg-muted/50 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||