Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d62eb6aa9e | ||
|
|
fbb00cf9cc | ||
|
|
a63cdcfe7e | ||
|
|
1b870fd9a0 | ||
|
|
c7a00b6af1 | ||
|
|
9b6a1f7973 | ||
|
|
eee3233c97 | ||
|
|
14ae9b4f11 | ||
|
|
565d2595da | ||
|
|
a526066b61 | ||
|
|
b0165da764 | ||
|
|
228e2e4342 | ||
|
|
81e7a7fb32 | ||
|
|
887d1795dd | ||
|
|
9261589ff3 | ||
|
|
db50b5214d | ||
|
|
e7dcb586c0 | ||
|
|
93bea01c9e | ||
|
|
a2727f2ae5 | ||
|
|
82f8f76a84 | ||
|
|
2074dde3e3 | ||
|
|
feaeec8dbe | ||
|
|
3b74cddbfb | ||
|
|
a0695dadaa | ||
|
|
7bd0b43746 | ||
|
|
77a3708213 | ||
|
|
9a00e8ac5b | ||
|
|
51234e673b | ||
|
|
13237a04fd | ||
|
|
35dd209b4e | ||
|
|
5e7c27f542 | ||
|
|
354f4dec67 | ||
|
|
c6bffaa243 | ||
|
|
f3af18e0c7 | ||
|
|
85f41756d1 | ||
|
|
fe964c4a4a | ||
|
|
216e3e0c39 | ||
|
|
a352eb1aab | ||
|
|
a9e93471a7 | ||
|
|
50885fb976 | ||
|
|
6226ceb052 | ||
|
|
cb8f003478 | ||
|
|
956f3d201b | ||
|
|
49426f19cd | ||
|
|
9b94f89def | ||
|
|
07c2289168 | ||
|
|
987153266e | ||
|
|
6da55c7aee | ||
|
|
f96192289b | ||
|
|
f75b5d3fd2 | ||
|
|
4d77e3f2bb | ||
|
|
ab64c48c92 | ||
|
|
d352e58570 | ||
|
|
baa5f33407 | ||
|
|
dbfecbba59 | ||
|
|
86a3592c88 | ||
|
|
744419469d | ||
|
|
1762dc077d | ||
|
|
a982304bb3 | ||
|
|
5616d4e9f3 | ||
|
|
71970d5f5f | ||
|
|
93d1e3383c | ||
|
|
fca99d6126 | ||
|
|
33a8705f0d | ||
|
|
fe10cee3eb | ||
|
|
1d05f5ac56 | ||
|
|
395e415cc4 | ||
|
|
e50a817b97 | ||
|
|
484ce47867 | ||
|
|
681f006b35 | ||
|
|
0282ef2223 | ||
|
|
dc0b57cd6a | ||
|
|
a218a52d2d | ||
|
|
1c2cf80188 | ||
|
|
cdd29eb1dc | ||
|
|
34cfeacdbb | ||
|
|
6d55a34586 | ||
|
|
c6666fd385 | ||
|
|
9b5fd6eb07 | ||
|
|
00cf7f6ab7 | ||
|
|
a82817a703 | ||
|
|
39b6ea5613 | ||
|
|
ae45ef8154 | ||
|
|
49c4fcb229 | ||
|
|
464a6e83b9 | ||
|
|
d0b4b1a5fc | ||
|
|
2257ca2e6f | ||
|
|
490fc170c0 | ||
|
|
feb8a07c16 | ||
|
|
7366cb27e9 | ||
|
|
34daad9df7 | ||
|
|
9bc35363b9 | ||
|
|
e84c0cfda6 | ||
|
|
1dbc22b2f4 | ||
|
|
ad794fa43b | ||
|
|
7d5914353b | ||
|
|
ee7eaecc9f | ||
|
|
8ea3db833f |
@@ -37,6 +37,7 @@ Review every point below carefully to ensure files follow consistent code style
|
||||
- [ ] Each file has one single purpose/responsibility
|
||||
- Example: `timeline/index.tsx` should not define `validateElementTrackCompatibility` — that belongs in a lib file
|
||||
- Example: `lib/timeline-utils.ts` should not declare `TRACK_COLORS` — that belongs in `constants/`
|
||||
- [ ] File name accurately reflects what the file contains — a misleading name is a bug waiting to happen
|
||||
- [ ] Business logic lives in either `src/lib`, `src/core` or `src/services` folder
|
||||
|
||||
## Comments
|
||||
@@ -49,10 +50,18 @@ Review every point below carefully to ensure files follow consistent code style
|
||||
|
||||
- [ ] Readability over brevity — use `element` not `el`, `event` not `e`
|
||||
- [ ] Booleans are named `isSomething`, `hasSomething`, or `shouldSomething` — not `something`
|
||||
- [ ] No title case in text/UI — use `Hello world` not `Hello World`
|
||||
- [ ] No title case for multi-word text/UI — use `Hello world` not `Hello World`
|
||||
|
||||
## Tailwind & Styling
|
||||
|
||||
- [ ] Always use `cn()` for `className` — never string interpolation with `${}` or ternaries inline
|
||||
```tsx
|
||||
// ❌ wrong
|
||||
className={`base-class ${isActive && "active"} ${someHelper()}`}
|
||||
|
||||
// ✅ correct
|
||||
className={cn("base-class", isActive && "active", someHelper())}
|
||||
```
|
||||
- [ ] Use `gap-*` instead of `mb-*` or `mt-*` for consistent spacing
|
||||
- [ ] Use `size-*` instead of `h-* w-*` when width and height are the same
|
||||
- [ ] When using `size-*` on icons inside `<Button>`, use `!` modifier to override default `size-4`
|
||||
@@ -61,12 +70,15 @@ Review every point below carefully to ensure files follow consistent code style
|
||||
<PlusIcon className="!size-6" /> {/* ✅ correct */}
|
||||
<PlusIcon className="size-6" /> {/* ❌ wrong */}
|
||||
<PlusIcon className="!size-4" /> {/* ❌ unnecessary, size-4 is default */}
|
||||
<PlusIcon className="size-4" />{" "}
|
||||
{/* ❌ completely wrong, 1) doesn't override and 2) size-4 is default */}
|
||||
</Button>
|
||||
```
|
||||
|
||||
## State Management (Zustand)
|
||||
|
||||
- [ ] React components never use `someStore.getState()` — use the `useSomeStore` hook instead
|
||||
- [ ] High-frequency stores (timeline, playback, selections) use selectors — `useStore((s) => s.value)` not `const { value } = useStore()`
|
||||
- [ ] Store/manager methods are not passed as props — sub-components access them directly
|
||||
|
||||
```tsx
|
||||
@@ -91,6 +103,21 @@ Review every point below carefully to ensure files follow consistent code style
|
||||
|
||||
- [ ] Code is scannable — use variables and helper functions to make intent clear at a glance
|
||||
- [ ] Complex logic is extracted into well-named variables or helpers
|
||||
- [ ] No magic numbers or magic values — extract inline literals into named constants
|
||||
- Applies to colors, durations, thresholds, sizes, config values, etc.
|
||||
- If it's domain-specific to one file, a `const` at the top of that file is fine
|
||||
- If it's generic enough, it belongs in `constants/`
|
||||
|
||||
- [ ] No redundant single/plural function variants — if a function can operate on multiple items, it should accept an array and handle both cases. Don't create `doThing()` + `doThings()`.
|
||||
|
||||
```tsx
|
||||
// ❌ wrong — redundant variants
|
||||
function updateElement({ element }: { element: Element }) { ... }
|
||||
function updateElements({ elements }: { elements: Element[] }) { ... }
|
||||
|
||||
// ✅ correct — one function, accepts array
|
||||
function updateElements({ elements }: { elements: Element[] }) { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -106,4 +133,40 @@ Review every point below carefully to ensure files follow consistent code style
|
||||
|
||||
---
|
||||
|
||||
> Every decision, every edit must be carefully considered. Everything matters.
|
||||
## Review Methodology
|
||||
|
||||
Do NOT review by reading the file top-to-bottom and noting what jumps out. Instead:
|
||||
|
||||
1. Go through each checklist section **one at a time**
|
||||
2. For each section, scan the **entire file** for violations of that specific rule
|
||||
3. Only move to the next section after you've exhausted the current one
|
||||
4. After all sections are checked, do a final pass: re-read every checklist item and confirm you didn't skip it
|
||||
|
||||
Before outputting the review, list each checklist section and confirm you checked it:
|
||||
`Signatures ✓ | TypeScript ✓ | JSX ✓ | Organization ✓ | File Names ✓ | Comments ✓ | Naming ✓ | Tailwind ✓ | State ✓ | Quality ✓ | Keywords ✓`
|
||||
|
||||
---
|
||||
|
||||
## IMPORTANT: Review Rules
|
||||
|
||||
- **ONLY** flag issues that are explicitly covered by a checklist item above.
|
||||
- Do **NOT** invent your own rules, suggestions, or "nice to haves" as primary issues.
|
||||
- The review output must be a list of issues, each one mapping to a specific checklist item.
|
||||
- If something looks off but isn't covered by the checklist, you can mention it as a brief side note at the end — but keep it clearly separate from the actual review. Always default to fixing the issues covered by the checklist above, unless the user says otherwise.
|
||||
|
||||
> You WILL miss things if you try to review the whole file in one pass. Iterate rule by rule.
|
||||
|
||||
---
|
||||
|
||||
## Think Bigger
|
||||
|
||||
After the checklist review, step back and ask the hard questions. The biggest architectural problems get solved by the biggest questions.
|
||||
|
||||
- Does this abstraction actually need to exist? Could it be deleted entirely?
|
||||
- Is this the right layer for this logic? (wrong layer = future pain)
|
||||
- Is this solving a real problem, or a problem we invented?
|
||||
- Would a simpler data model make this whole file unnecessary?
|
||||
- Are we adding complexity to work around a bad decision made earlier?
|
||||
- Could this field be derived from other existing fields? Redundant data in a model is a source of bugs.
|
||||
|
||||
Don't be shy about flagging these. A "why does this exist?" question is often worth more than 10 style fixes.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Comment Guidelines
|
||||
|
||||
## Good Comments (Human-style)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Handling Uncertainty
|
||||
|
||||
## Principle
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Readability First
|
||||
|
||||
Optimize code for AI agents to understand and modify.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Separation of Concerns
|
||||
|
||||
## Core Principle
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
---
|
||||
description: Ultracite Rules - AI-Ready Formatter and Linter
|
||||
globs: "**/*.{ts,tsx,js,jsx}"
|
||||
alwaysApply: true
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Project Context
|
||||
|
||||
Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's formatter.
|
||||
@@ -28,7 +25,6 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c
|
||||
### Accessibility (a11y)
|
||||
|
||||
- Always include a `title` element for icons unless there's text beside the icon.
|
||||
- Always include a `type` attribute for button elements.
|
||||
- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.
|
||||
- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Scannable Code Guidelines/Separating Concerns.
|
||||
|
||||
## Core Principle
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"plugins": {
|
||||
"figma": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: frontend-design
|
||||
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
||||
|
||||
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
||||
|
||||
## Design Thinking
|
||||
|
||||
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
||||
|
||||
- **Purpose**: What problem does this interface solve? Who uses it?
|
||||
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
||||
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
||||
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
||||
|
||||
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
||||
|
||||
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
||||
|
||||
- Production-grade and functional
|
||||
- Visually striking and memorable
|
||||
- Cohesive with a clear aesthetic point-of-view
|
||||
- Meticulously refined in every detail
|
||||
|
||||
## Frontend Aesthetics Guidelines
|
||||
|
||||
Focus on:
|
||||
|
||||
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
||||
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
||||
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
||||
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
||||
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
||||
|
||||
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
|
||||
|
||||
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
||||
|
||||
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
||||
|
||||
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
||||
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env*
|
||||
!.env.example
|
||||
.cursor
|
||||
.vscode
|
||||
diffs
|
||||
docs
|
||||
agent-transcripts
|
||||
mcps
|
||||
terminals
|
||||
apps/web/.font-cache
|
||||
@@ -1,5 +1,15 @@
|
||||
# Contributing to OpenCut
|
||||
|
||||
⚠️ We are currently NOT accepting feature PRs while we build out the core editor.
|
||||
|
||||
If you want to contribute:
|
||||
|
||||
1. Open an issue first to discuss
|
||||
2. Wait for maintainer approval
|
||||
3. Only then start coding
|
||||
|
||||
Critical bug fixes may be accepted on a case-by-case basis.
|
||||
|
||||
Thank you for your interest in contributing to OpenCut! This document provides guidelines and instructions for contributing.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -1,50 +1,11 @@
|
||||
## Description
|
||||
⚠️ READ BEFORE SUBMITTING ⚠️
|
||||
|
||||
Please include a summary of the changes and the related issue. Please also include relevant motivation and context.
|
||||
We are not currently accepting PRs except for critical bugs.
|
||||
|
||||
Fixes # (issue)
|
||||
If this is a bug fix:
|
||||
- [ ] I've opened an issue first
|
||||
- [ ] This was approved by a maintainer
|
||||
|
||||
## Type of change
|
||||
If this is a feature:
|
||||
|
||||
Please delete options that are not relevant.
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
- [ ] Performance improvement
|
||||
- [ ] Code refactoring
|
||||
- [ ] Tests
|
||||
|
||||
## How Has This Been Tested?
|
||||
|
||||
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration
|
||||
|
||||
- [ ] Test A
|
||||
- [ ] Test B
|
||||
|
||||
**Test Configuration**:
|
||||
|
||||
- Node version:
|
||||
- Browser (if applicable):
|
||||
- Operating System:
|
||||
|
||||
## Screenshots (if applicable)
|
||||
|
||||
Add screenshots to help explain your changes.
|
||||
|
||||
## Checklist:
|
||||
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have added screenshots if ui has been changed
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] Any dependent changes have been merged and published in downstream modules
|
||||
|
||||
## Additional context
|
||||
|
||||
Add any other context about the pull request here.
|
||||
This PR will be closed. Please open an issue to discuss first.
|
||||
@@ -27,7 +27,16 @@ jobs:
|
||||
NEXT_PUBLIC_SITE_URL: "http://localhost:3000"
|
||||
UPSTASH_REDIS_REST_URL: "https://your-upstash-redis-url"
|
||||
UPSTASH_REDIS_REST_TOKEN: "your-upstash-redis-token"
|
||||
|
||||
NEXT_PUBLIC_MARBLE_API_URL: "https://placeholder.example.com"
|
||||
MARBLE_WORKSPACE_KEY: "placeholder"
|
||||
FREESOUND_CLIENT_ID: "placeholder"
|
||||
FREESOUND_API_KEY: "placeholder"
|
||||
CLOUDFLARE_ACCOUNT_ID: "placeholder"
|
||||
R2_ACCESS_KEY_ID: "placeholder"
|
||||
R2_SECRET_ACCESS_KEY: "placeholder"
|
||||
R2_BUCKET_NAME: "placeholder"
|
||||
MODAL_TRANSCRIPTION_URL: "https://placeholder.example.com"
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -13,5 +13,8 @@ node_modules
|
||||
# cursor
|
||||
bun.lockb
|
||||
|
||||
# content-collections
|
||||
.content-collections
|
||||
|
||||
# Twiggy
|
||||
.cursor/rules/file-structure.mdc
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][css][graphql]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnPaste": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
},
|
||||
"emmet.showExpandedAbbreviation": "never",
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][css][graphql]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnPaste": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
},
|
||||
"emmet.showExpandedAbbreviation": "never",
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 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>
|
||||
|
||||
## Why?
|
||||
|
||||
- **Privacy**: Your videos stay on your device
|
||||
@@ -38,110 +50,52 @@
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following installed on your system:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/) (v18 or later)
|
||||
- [Bun](https://bun.sh/docs/installation)
|
||||
(for `npm` alternative)
|
||||
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
|
||||
> **Note:** Docker is optional, but it's essential for running the local database and Redis services. If you're planning to run the frontend or want to contribute to frontend features, you can skip the Docker setup. If you have followed the steps below in [Setup](#setup), you're all set to go!
|
||||
> **Note:** Docker is optional but recommended for running the local database and Redis. If you only want to work on frontend features, you can skip it.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork locally
|
||||
3. Navigate to the web app directory: `cd apps/web`
|
||||
4. Copy `.env.example` to `.env.local`:
|
||||
1. Fork and clone the repository
|
||||
|
||||
2. Copy the environment file:
|
||||
|
||||
```bash
|
||||
# Unix/Linux/Mac
|
||||
cp .env.example .env.local
|
||||
|
||||
# Windows Command Prompt
|
||||
copy .env.example .env.local
|
||||
cp apps/web/.env.example apps/web/.env.local
|
||||
|
||||
# Windows PowerShell
|
||||
Copy-Item .env.example .env.local
|
||||
Copy-Item apps/web/.env.example apps/web/.env.local
|
||||
```
|
||||
|
||||
5. Install dependencies: `bun install`
|
||||
6. Start the development server: `bun dev`
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Local Development
|
||||
|
||||
1. Start the database and Redis services:
|
||||
3. Start the database and Redis:
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
docker-compose up -d
|
||||
docker compose up -d db redis serverless-redis-http
|
||||
```
|
||||
|
||||
2. Navigate to the web app directory:
|
||||
4. Install dependencies and start the dev server:
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
bun install
|
||||
bun dev:web
|
||||
```
|
||||
|
||||
3. Copy `.env.example` to `.env.local`:
|
||||
|
||||
```bash
|
||||
# Unix/Linux/Mac
|
||||
cp .env.example .env.local
|
||||
|
||||
# Windows Command Prompt
|
||||
copy .env.example .env.local
|
||||
|
||||
# Windows PowerShell
|
||||
Copy-Item .env.example .env.local
|
||||
```
|
||||
|
||||
4. Configure required environment variables in `.env.local`:
|
||||
|
||||
**Required Variables:**
|
||||
|
||||
```bash
|
||||
# Database (matches docker-compose.yaml)
|
||||
DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut"
|
||||
|
||||
# Generate a secure secret for Better Auth
|
||||
BETTER_AUTH_SECRET="your-generated-secret-here"
|
||||
BETTER_AUTH_URL="http://localhost:3000"
|
||||
|
||||
# Redis (matches docker-compose.yaml)
|
||||
UPSTASH_REDIS_REST_URL="http://localhost:8079"
|
||||
UPSTASH_REDIS_REST_TOKEN="example_token"
|
||||
|
||||
# Marble Blog
|
||||
MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key
|
||||
NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
|
||||
|
||||
# Development
|
||||
NODE_ENV="development"
|
||||
```
|
||||
|
||||
**Generate BETTER_AUTH_SECRET:**
|
||||
|
||||
```bash
|
||||
# Unix/Linux/Mac
|
||||
openssl rand -base64 32
|
||||
|
||||
# Windows PowerShell (simple method)
|
||||
[System.Web.Security.Membership]::GeneratePassword(32, 0)
|
||||
|
||||
# Cross-platform (using Node.js)
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
|
||||
# Or use an online generator: https://generate-secret.vercel.app/32
|
||||
```
|
||||
|
||||
5. Run database migrations: `bun run db:migrate` from (inside apps/web)
|
||||
6. Start the development server: `bun run dev` from (inside apps/web)
|
||||
|
||||
The application will be available at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box.
|
||||
|
||||
### Self-Hosting with Docker
|
||||
|
||||
To run everything (including a production build of the app) in Docker:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The app will be available at [http://localhost:3100](http://localhost:3100).
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
|
||||
@@ -158,22 +112,6 @@ See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instruc
|
||||
- Follow the setup instructions in CONTRIBUTING.md
|
||||
- Create a feature branch and submit a PR
|
||||
|
||||
## 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>
|
||||
|
||||
---
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FOpenCut-app%2FOpenCut&project-name=opencut&repository-name=opencut)
|
||||
|
||||
## License
|
||||
|
||||
[MIT LICENSE](LICENSE)
|
||||
|
||||
@@ -13,7 +13,7 @@ DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut"
|
||||
BETTER_AUTH_SECRET=your_better_auth_secret
|
||||
|
||||
UPSTASH_REDIS_REST_URL=http://localhost:8079
|
||||
UPSTASH_REDIS_REST_TOKEN=example_token_here
|
||||
UPSTASH_REDIS_REST_TOKEN=example_token
|
||||
|
||||
MARBLE_WORKSPACE_KEY=your_workspace_key_here
|
||||
|
||||
|
||||
@@ -5,4 +5,6 @@
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
.next/
|
||||
.next/
|
||||
|
||||
.font-cache/
|
||||
@@ -0,0 +1,70 @@
|
||||
FROM oven/bun:alpine AS base
|
||||
|
||||
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/env/package.json packages/env/package.json
|
||||
COPY packages/ui/package.json packages/ui/package.json
|
||||
|
||||
RUN bun install
|
||||
|
||||
COPY apps/web/ apps/web/
|
||||
COPY packages/env/ packages/env/
|
||||
COPY packages/ui/ packages/ui/
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Build-time env stubs to pass zod validation
|
||||
ENV DATABASE_URL="postgresql://opencut:opencut@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_SITE_URL="http://localhost:3000"
|
||||
ENV NEXT_PUBLIC_MARBLE_API_URL="https://api.marblecms.com"
|
||||
ENV MARBLE_WORKSPACE_KEY="build-placeholder"
|
||||
ENV CLOUDFLARE_ACCOUNT_ID="build-placeholder"
|
||||
ENV R2_ACCESS_KEY_ID="build-placeholder"
|
||||
ENV R2_SECRET_ACCESS_KEY="build-placeholder"
|
||||
ENV R2_BUCKET_NAME="build-placeholder"
|
||||
ENV MODAL_TRANSCRIPTION_URL="http://localhost:0"
|
||||
|
||||
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,21 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineCollection, defineConfig } from "@content-collections/core";
|
||||
import { z } from "zod";
|
||||
|
||||
const changelog = defineCollection({
|
||||
name: "changelog",
|
||||
directory: "content/changelog",
|
||||
include: "*.md",
|
||||
schema: z.object({
|
||||
version: z.string(),
|
||||
date: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
changes: z.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
transform: async (doc, { collection }) => {
|
||||
const allDocs = await collection.documents();
|
||||
const sorted = [...allDocs].sort((a, b) =>
|
||||
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
||||
);
|
||||
const isLatest = sorted[0]?.version === doc.version;
|
||||
return { ...doc, isLatest };
|
||||
},
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
content: [changelog],
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
version: "0.1.0"
|
||||
date: "2026-02-23"
|
||||
title: "Editor foundation"
|
||||
description: "This first release focuses on making editing faster, clearer, and more reliable for day-to-day use."
|
||||
changes:
|
||||
- type: new
|
||||
text: "Rebuilt the properties panel from scratch. Number inputs now support math expressions and click-drag scrubbing instead of just typing values in."
|
||||
- type: new
|
||||
text: "The properties panel went from a flat list of basic text styles to organized sections: transform, blending, typography, spacing, and background controls. Text elements finally have position, scale, and rotation."
|
||||
- type: new
|
||||
text: "New color picker with eyedropper, opacity control, and multiple color formats."
|
||||
- type: new
|
||||
text: "Bookmarks now support notes, color labels, and optional duration to plan scenes more clearly."
|
||||
- type: new
|
||||
text: "Move, scale, and rotate elements directly in the preview panel."
|
||||
- type: new
|
||||
text: "Blend modes (Multiply, Screen, Overlay, etc). Only available on text elements for now."
|
||||
- type: new
|
||||
text: "Paste images, videos, or audio from your clipboard straight into the editor."
|
||||
- type: new
|
||||
text: "Hold Shift while moving or resizing to temporarily turn off snapping."
|
||||
- type: improved
|
||||
text: "Expanded font selection from 7 to over 1,000."
|
||||
- type: improved
|
||||
text: "Fonts load much faster."
|
||||
- type: improved
|
||||
text: "All asset panel tabs now share a consistent layout."
|
||||
- type: improved
|
||||
text: "Text rendering properly supports multiple lines, line height, and letter spacing."
|
||||
- type: improved
|
||||
text: "Better contrast in the dark theme."
|
||||
- type: fixed
|
||||
text: "Fixed undo/redo for drag-and-drop so dropped items are reliably undoable."
|
||||
---
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
version: "0.2.0"
|
||||
date: "2026-02-30"
|
||||
title: "Motion & effects"
|
||||
description: "This release adds the foundation for motion and effects, alongside many improvements & fixes."
|
||||
changes:
|
||||
- type: new
|
||||
text: "Keyframe animation system for animating element properties over time."
|
||||
- type: new
|
||||
text: "Entirely new efects system. Effects can be applied per-clip or added as standalone elements on the timeline (with our first effect: Blur!)"
|
||||
- type: new
|
||||
text: "Added a changelog page to the site."
|
||||
- type: new
|
||||
text: "Ripple editing mode."
|
||||
- type: fixed
|
||||
text: "Fixed an issue where click-and-drag selection on one timeline track could accidentally select items from the track below."
|
||||
- type: fixed
|
||||
text: "Audio could flicker or cut out at the start of playback when certain elements were selected. The scheduler now recovers from timing slips without losing audio."
|
||||
- type: fixed
|
||||
text: "Dragging a clip onto the timeline and pressing Ctrl+Z now removes both the clip and the track it created in one step, instead of requiring two undos."
|
||||
- type: fixed
|
||||
text: "A few values in the properties panel were previously showing incorrect values. This has been fixed."
|
||||
- type: improved
|
||||
text: "New text elements no longer have a black background by default."
|
||||
- type: improved
|
||||
text: "Selection outline in the preview is now dashed."
|
||||
- type: improved
|
||||
text: "Adjacent clips on the timeline now have a small gap between them so selected clips are always clearly visible."
|
||||
- type: fixed
|
||||
text: "Timeline trim handles now sit outside the clip instead of inside them, so the clip's visible area accurately represents where it starts and ends."
|
||||
- type: fixed
|
||||
text: "Clips being dragged on the timeline now render on top of other clips instead of underneath them."
|
||||
- type: fixed
|
||||
text: "Undoing a trim-from-start operation now correctly restores the clip to its original position and length, instead of stretching it to the right."
|
||||
- type: fixed
|
||||
text: "Resizing a clip on the timeline would deselect it when the mouse was released. The clip now stays selected after a resize."
|
||||
- type: improved
|
||||
text: "Press Escape to deselect the current element."
|
||||
- type: fixed
|
||||
text: "Resizing a clip could extend it past another clip on the same track, causing an overlap. The resize now stops at the next clip's edge."
|
||||
- type: fixed
|
||||
text: "Dragging a clip to a different track would deselect it on drop. The clip stays selected after moving."
|
||||
- type: fixed
|
||||
text: "Finishing a resize or drag with the mouse over empty track space would deselect the clip."
|
||||
---
|
||||
@@ -4,20 +4,20 @@ import { webEnv } from "@opencut/env/web";
|
||||
|
||||
// Load the right env file based on environment
|
||||
if (webEnv.NODE_ENV === "production") {
|
||||
dotenv.config({ path: ".env.production" });
|
||||
dotenv.config({ path: ".env.production" });
|
||||
} else {
|
||||
dotenv.config({ path: ".env.local" });
|
||||
dotenv.config({ path: ".env.local" });
|
||||
}
|
||||
|
||||
export default {
|
||||
schema: "./src/schema.ts",
|
||||
dialect: "postgresql",
|
||||
migrations: {
|
||||
table: "drizzle_migrations",
|
||||
},
|
||||
dbCredentials: {
|
||||
url: webEnv.DATABASE_URL,
|
||||
},
|
||||
out: "./migrations",
|
||||
strict: webEnv.NODE_ENV === "production",
|
||||
schema: "./src/schema.ts",
|
||||
dialect: "postgresql",
|
||||
migrations: {
|
||||
table: "drizzle_migrations",
|
||||
},
|
||||
dbCredentials: {
|
||||
url: webEnv.DATABASE_URL,
|
||||
},
|
||||
out: "./migrations",
|
||||
strict: webEnv.NODE_ENV === "production",
|
||||
} satisfies Config;
|
||||
|
||||
@@ -1,344 +1,344 @@
|
||||
{
|
||||
"id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"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": {}
|
||||
}
|
||||
"id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"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,13 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1750753385927,
|
||||
"tag": "0000_brainy_saracen",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1750753385927,
|
||||
"tag": "0000_brainy_saracen",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,49 +1,58 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { withBotId } from "botid/next/config";
|
||||
import { withContentCollections } from "@content-collections/next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === "production",
|
||||
},
|
||||
reactStrictMode: true,
|
||||
productionBrowserSourceMaps: true,
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "plus.unsplash.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.marblecms.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "lh3.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "avatars.githubusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.iconify.design",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.simplesvg.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.unisvg.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
turbopack: {
|
||||
rules: {
|
||||
"*.glsl": {
|
||||
loaders: [require.resolve("raw-loader")],
|
||||
as: "*.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === "production",
|
||||
},
|
||||
reactStrictMode: true,
|
||||
productionBrowserSourceMaps: true,
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "plus.unsplash.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.marblecms.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "lh3.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "avatars.githubusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.iconify.design",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.simplesvg.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.unisvg.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default withBotId(nextConfig);
|
||||
export default withContentCollections(withBotId(nextConfig));
|
||||
|
||||
@@ -26,11 +26,16 @@
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"@opencut/env": "workspace:*",
|
||||
"@opencut/ui": "workspace:*",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-primitive": "^2.1.4",
|
||||
"@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",
|
||||
"@types/culori": "^4.0.1",
|
||||
"@upstash/ratelimit": "^2.0.6",
|
||||
"@upstash/redis": "^1.35.4",
|
||||
"@vercel/analytics": "^1.4.1",
|
||||
@@ -40,6 +45,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"culori": "^4.0.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
@@ -54,7 +60,7 @@
|
||||
"next-themes": "^0.4.4",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.2",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -63,6 +69,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-phone-number-input": "^3.4.11",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-window": "^2.2.7",
|
||||
"recharts": "^2.14.1",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-parse": "^9.0.1",
|
||||
@@ -74,12 +81,15 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unified": "^11.0.5",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"vaul": "^1.1.1",
|
||||
"vaul": "^1.1.2",
|
||||
"wavesurfer.js": "^7.9.8",
|
||||
"zod": "^3.25.67",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@content-collections/core": "^0.14.1",
|
||||
"@content-collections/next": "^0.2.11",
|
||||
"@napi-rs/canvas": "^0.1.92",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/bun": "latest",
|
||||
@@ -88,9 +98,11 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"cross-env": "^7.0.3",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"dotenv": "^16.5.0",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"postcss": "^8",
|
||||
"raw-loader": "^4.0.2",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.8.3"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 222 KiB |
@@ -1,44 +1,44 @@
|
||||
{
|
||||
"name": "OpenCut",
|
||||
"description": "A simple but powerful video editor that gets the job done. In your browser.",
|
||||
"display": "standalone",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
"name": "OpenCut",
|
||||
"description": "A simple but powerful video editor that gets the job done. In your browser.",
|
||||
"display": "standalone",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="44" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 126 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<polygon points="50,6 94,50 50,94 6,50" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 135 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<polygon points="28,8 72,8 94,50 72,92 28,92 6,50" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 146 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect x="10" y="10" width="80" height="80" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 138 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<polygon points="50,6 61,37 94,37 67,56 77,88 50,68 23,88 33,56 6,37 39,37" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 171 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<polygon points="50,8 94,92 6,92" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 129 B |
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Generates font sprite atlas for the font picker.
|
||||
*
|
||||
* Downloads Google Fonts from Fontsource, renders each font name as a sprite,
|
||||
* packs them into chunk images, and outputs a JSON atlas + AVIF images.
|
||||
*
|
||||
* Run: npx tsx scripts/generate-font-sprites.ts
|
||||
* Deps: @napi-rs/canvas sharp (install as devDependencies)
|
||||
*/
|
||||
|
||||
import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
|
||||
import sharp from "sharp";
|
||||
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const FONT_SIZE = 24;
|
||||
const ROW_HEIGHT = 40;
|
||||
const CANVAS_WIDTH = 1200;
|
||||
const MAX_CHUNK_HEIGHT = 800;
|
||||
const PADDING_X = 14;
|
||||
const WIDTH_BUFFER = 8;
|
||||
const CONCURRENT_DOWNLOADS = 30;
|
||||
|
||||
const OUTPUT_DIR = join(__dirname, "..", "public", "fonts");
|
||||
const CACHE_DIR = join(__dirname, "..", ".font-cache");
|
||||
const FONTSOURCE_API = "https://api.fontsource.org/v1/fonts";
|
||||
|
||||
interface FontsourceFont {
|
||||
id: string;
|
||||
family: string;
|
||||
subsets: string[];
|
||||
weights: number[];
|
||||
styles: string[];
|
||||
category: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface MeasuredFont {
|
||||
family: string;
|
||||
width: number;
|
||||
styles: string[];
|
||||
}
|
||||
|
||||
interface PackedFont {
|
||||
family: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
}
|
||||
|
||||
interface AtlasEntry {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
ch: number;
|
||||
s: string[];
|
||||
}
|
||||
|
||||
async function fetchFontList(): Promise<FontsourceFont[]> {
|
||||
console.log("Fetching font list from Fontsource...");
|
||||
const response = await fetch(FONTSOURCE_API);
|
||||
if (!response.ok) throw new Error(`Fontsource API error: ${response.status}`);
|
||||
|
||||
const data: FontsourceFont[] = await response.json();
|
||||
const filtered = data.filter(
|
||||
(font) =>
|
||||
font.type === "google" &&
|
||||
font.subsets.includes("latin") &&
|
||||
font.weights.includes(400),
|
||||
);
|
||||
|
||||
console.log(
|
||||
` ${filtered.length} Google fonts with Latin subset + 400 weight`,
|
||||
);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async function downloadFont({ id }: { id: string }): Promise<Buffer | null> {
|
||||
const cachePath = join(CACHE_DIR, `${id}.woff2`);
|
||||
|
||||
if (existsSync(cachePath)) {
|
||||
return readFile(cachePath);
|
||||
}
|
||||
|
||||
// Fontsource CDN serves woff2 for all Google fonts
|
||||
const url = `https://cdn.jsdelivr.net/fontsource/fonts/${id}@latest/latin-400-normal.woff2`;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
await writeFile(cachePath, buffer);
|
||||
return buffer;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAllFonts({
|
||||
fonts,
|
||||
concurrency,
|
||||
}: {
|
||||
fonts: FontsourceFont[];
|
||||
concurrency: number;
|
||||
}): Promise<Map<string, Buffer>> {
|
||||
console.log(
|
||||
`Downloading ${fonts.length} font files (${concurrency} concurrent)...`,
|
||||
);
|
||||
const results = new Map<string, Buffer>();
|
||||
let nextIndex = 0;
|
||||
let completed = 0;
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < fonts.length) {
|
||||
const index = nextIndex++;
|
||||
const font = fonts[index];
|
||||
const buffer = await downloadFont({ id: font.id });
|
||||
if (buffer) results.set(font.id, buffer);
|
||||
completed++;
|
||||
if (completed % 100 === 0 || completed === fonts.length) {
|
||||
process.stdout.write(`\r ${completed}/${fonts.length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
||||
console.log(`\n Downloaded ${results.size}/${fonts.length} fonts`);
|
||||
return results;
|
||||
}
|
||||
|
||||
function measureFonts({
|
||||
fonts,
|
||||
fontBuffers,
|
||||
}: {
|
||||
fonts: FontsourceFont[];
|
||||
fontBuffers: Map<string, Buffer>;
|
||||
}): MeasuredFont[] {
|
||||
console.log("Registering fonts and measuring text...");
|
||||
const measured: MeasuredFont[] = [];
|
||||
const canvas = createCanvas(CANVAS_WIDTH, ROW_HEIGHT);
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
for (const font of fonts) {
|
||||
const buffer = fontBuffers.get(font.id);
|
||||
if (!buffer) continue;
|
||||
|
||||
try {
|
||||
const ok = GlobalFonts.register(buffer, font.family);
|
||||
if (!ok) continue;
|
||||
|
||||
ctx.font = `${FONT_SIZE}px "${font.family}"`;
|
||||
const metrics = ctx.measureText(font.family);
|
||||
const width = Math.ceil(metrics.width) + WIDTH_BUFFER;
|
||||
|
||||
const styles: string[] = [];
|
||||
for (const weight of font.weights) {
|
||||
if (font.styles.includes("normal")) styles.push(String(weight));
|
||||
if (font.styles.includes("italic")) styles.push(`${weight}i`);
|
||||
}
|
||||
|
||||
measured.push({ family: font.family, width, styles });
|
||||
} catch {
|
||||
// skip fonts that fail to register
|
||||
}
|
||||
}
|
||||
|
||||
measured.sort((a, b) => a.family.localeCompare(b.family));
|
||||
console.log(` ${measured.length} fonts measured`);
|
||||
return measured;
|
||||
}
|
||||
|
||||
function packIntoChunks({ measured }: { measured: MeasuredFont[] }): {
|
||||
atlas: Record<string, AtlasEntry>;
|
||||
chunks: PackedFont[][];
|
||||
} {
|
||||
console.log("Packing into sprite chunks...");
|
||||
const atlas: Record<string, AtlasEntry> = {};
|
||||
const chunks: PackedFont[][] = [[]];
|
||||
let chunkIndex = 0;
|
||||
let cursorX = 0;
|
||||
let cursorY = 0;
|
||||
|
||||
for (const font of measured) {
|
||||
// New row if doesn't fit horizontally
|
||||
if (cursorX + font.width > CANVAS_WIDTH) {
|
||||
cursorX = 0;
|
||||
cursorY += ROW_HEIGHT;
|
||||
}
|
||||
|
||||
// New chunk if doesn't fit vertically
|
||||
if (cursorY + ROW_HEIGHT > MAX_CHUNK_HEIGHT) {
|
||||
chunkIndex++;
|
||||
chunks.push([]);
|
||||
cursorX = 0;
|
||||
cursorY = 0;
|
||||
}
|
||||
|
||||
// Same data goes to BOTH the atlas and the chunk render list
|
||||
const x = cursorX;
|
||||
const y = cursorY;
|
||||
|
||||
atlas[font.family] = {
|
||||
x,
|
||||
y,
|
||||
w: font.width,
|
||||
ch: chunkIndex,
|
||||
s: font.styles,
|
||||
};
|
||||
chunks[chunkIndex].push({ family: font.family, x, y, w: font.width });
|
||||
|
||||
cursorX += font.width + PADDING_X;
|
||||
}
|
||||
|
||||
console.log(` ${chunks.length} chunks`);
|
||||
return { atlas, chunks };
|
||||
}
|
||||
|
||||
async function renderChunks({
|
||||
chunks,
|
||||
}: {
|
||||
chunks: PackedFont[][];
|
||||
}): Promise<void> {
|
||||
console.log("Rendering sprite chunks...");
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
const chunkHeight = Math.max(...chunk.map((f) => f.y)) + ROW_HEIGHT;
|
||||
|
||||
const canvas = createCanvas(CANVAS_WIDTH, chunkHeight);
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
for (const font of chunk) {
|
||||
ctx.font = `${FONT_SIZE}px "${font.family}"`;
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(font.family, font.x, font.y + ROW_HEIGHT / 2);
|
||||
}
|
||||
|
||||
const pngBuffer = canvas.toBuffer("image/png");
|
||||
await sharp(pngBuffer)
|
||||
.avif({ quality: 80 })
|
||||
.toFile(join(OUTPUT_DIR, `font-chunk-${i}.avif`));
|
||||
|
||||
console.log(
|
||||
` Chunk ${i}: ${chunk.length} fonts, ${CANVAS_WIDTH}×${chunkHeight}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await mkdir(CACHE_DIR, { recursive: true });
|
||||
|
||||
const fonts = await fetchFontList();
|
||||
const fontBuffers = await downloadAllFonts({
|
||||
fonts,
|
||||
concurrency: CONCURRENT_DOWNLOADS,
|
||||
});
|
||||
const measured = measureFonts({ fonts, fontBuffers });
|
||||
const { atlas, chunks } = packIntoChunks({ measured });
|
||||
await renderChunks({ chunks });
|
||||
|
||||
// Write atlas JSON (compact for smaller file size)
|
||||
await writeFile(
|
||||
join(OUTPUT_DIR, "font-atlas.json"),
|
||||
JSON.stringify({ fonts: atlas }),
|
||||
);
|
||||
|
||||
const totalFonts = Object.keys(atlas).length;
|
||||
console.log(
|
||||
`\nDone! ${totalFonts} fonts in ${chunks.length} chunks → ${OUTPUT_DIR}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,280 +1,280 @@
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
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),
|
||||
});
|
||||
|
||||
const freesoundResultSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string().url(),
|
||||
previews: z
|
||||
.object({
|
||||
"preview-hq-mp3": z.string().url(),
|
||||
"preview-lq-mp3": z.string().url(),
|
||||
"preview-hq-ogg": z.string().url(),
|
||||
"preview-lq-ogg": z.string().url(),
|
||||
})
|
||||
.optional(),
|
||||
download: z.string().url().optional(),
|
||||
duration: z.number(),
|
||||
filesize: z.number(),
|
||||
type: z.string(),
|
||||
channels: z.number(),
|
||||
bitrate: z.number(),
|
||||
bitdepth: z.number(),
|
||||
samplerate: z.number(),
|
||||
username: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
license: z.string(),
|
||||
created: z.string(),
|
||||
num_downloads: z.number().optional(),
|
||||
avg_rating: z.number().optional(),
|
||||
num_ratings: z.number().optional(),
|
||||
});
|
||||
|
||||
const freesoundResponseSchema = z.object({
|
||||
count: z.number(),
|
||||
next: z.string().url().nullable(),
|
||||
previous: z.string().url().nullable(),
|
||||
results: z.array(freesoundResultSchema),
|
||||
});
|
||||
|
||||
const transformedResultSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string(),
|
||||
previewUrl: z.string().optional(),
|
||||
downloadUrl: z.string().optional(),
|
||||
duration: z.number(),
|
||||
filesize: z.number(),
|
||||
type: z.string(),
|
||||
channels: z.number(),
|
||||
bitrate: z.number(),
|
||||
bitdepth: z.number(),
|
||||
samplerate: z.number(),
|
||||
username: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
license: z.string(),
|
||||
created: z.string(),
|
||||
downloads: z.number().optional(),
|
||||
rating: z.number().optional(),
|
||||
ratingCount: z.number().optional(),
|
||||
});
|
||||
|
||||
const apiResponseSchema = z.object({
|
||||
count: z.number(),
|
||||
next: z.string().nullable(),
|
||||
previous: z.string().nullable(),
|
||||
results: z.array(transformedResultSchema),
|
||||
query: z.string().optional(),
|
||||
type: z.string(),
|
||||
page: z.number(),
|
||||
pageSize: z.number(),
|
||||
sort: z.string(),
|
||||
minRating: z.number().optional(),
|
||||
});
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
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),
|
||||
});
|
||||
|
||||
const freesoundResultSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string().url(),
|
||||
previews: z
|
||||
.object({
|
||||
"preview-hq-mp3": z.string().url(),
|
||||
"preview-lq-mp3": z.string().url(),
|
||||
"preview-hq-ogg": z.string().url(),
|
||||
"preview-lq-ogg": z.string().url(),
|
||||
})
|
||||
.optional(),
|
||||
download: z.string().url().optional(),
|
||||
duration: z.number(),
|
||||
filesize: z.number(),
|
||||
type: z.string(),
|
||||
channels: z.number(),
|
||||
bitrate: z.number(),
|
||||
bitdepth: z.number(),
|
||||
samplerate: z.number(),
|
||||
username: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
license: z.string(),
|
||||
created: z.string(),
|
||||
num_downloads: z.number().optional(),
|
||||
avg_rating: z.number().optional(),
|
||||
num_ratings: z.number().optional(),
|
||||
});
|
||||
|
||||
const freesoundResponseSchema = z.object({
|
||||
count: z.number(),
|
||||
next: z.string().url().nullable(),
|
||||
previous: z.string().url().nullable(),
|
||||
results: z.array(freesoundResultSchema),
|
||||
});
|
||||
|
||||
const transformedResultSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string(),
|
||||
previewUrl: z.string().optional(),
|
||||
downloadUrl: z.string().optional(),
|
||||
duration: z.number(),
|
||||
filesize: z.number(),
|
||||
type: z.string(),
|
||||
channels: z.number(),
|
||||
bitrate: z.number(),
|
||||
bitdepth: z.number(),
|
||||
samplerate: z.number(),
|
||||
username: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
license: z.string(),
|
||||
created: z.string(),
|
||||
downloads: z.number().optional(),
|
||||
rating: z.number().optional(),
|
||||
ratingCount: z.number().optional(),
|
||||
});
|
||||
|
||||
const apiResponseSchema = z.object({
|
||||
count: z.number(),
|
||||
next: z.string().nullable(),
|
||||
previous: z.string().nullable(),
|
||||
results: z.array(transformedResultSchema),
|
||||
query: z.string().optional(),
|
||||
type: z.string(),
|
||||
page: z.number(),
|
||||
pageSize: z.number(),
|
||||
sort: z.string(),
|
||||
minRating: z.number().optional(),
|
||||
});
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,18 +88,17 @@ function PostHeader({ post }: { post: Post }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{post.coverImage && <PostCoverImage post={post} />}
|
||||
<div className="flex flex-col items-center justify-center gap-8">
|
||||
<PostMeta date={formattedDate} publishedAt={post.publishedAt} />
|
||||
<PostTitle title={post.title} />
|
||||
<PostAuthor author={post.authors[0]} />
|
||||
</>
|
||||
{post.coverImage && <PostCoverImage post={post} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostCoverImage({ post }: { post: Post }) {
|
||||
return (
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg">
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg w-full mt-4">
|
||||
<Image
|
||||
src={post.coverImage}
|
||||
alt={post.title}
|
||||
@@ -121,31 +120,15 @@ function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) {
|
||||
|
||||
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>
|
||||
<h1 className="text-5xl font-bold tracking-tight md:text-4xl text-center">
|
||||
{title}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
function PostContent({ html }: { html: string }) {
|
||||
return (
|
||||
<section className="pt-8">
|
||||
<section className="">
|
||||
<Prose html={html} />
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
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";
|
||||
import type { Post } from "@/types/blog";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog - OpenCut",
|
||||
@@ -47,27 +46,7 @@ function BlogPostItem({ post }: { post: Post }) {
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { allChangelogs } from "content-collections";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { getSortedReleases } from "../utils";
|
||||
import {
|
||||
ReleaseArticle,
|
||||
ReleaseMeta,
|
||||
ReleaseTitle,
|
||||
ReleaseDescription,
|
||||
ReleaseChanges,
|
||||
} from "../components/release";
|
||||
import { CopyMarkdownButton } from "../components/copy-markdown-button";
|
||||
|
||||
type Props = { params: Promise<{ version: string }> };
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return allChangelogs.map((r) => ({ version: r.version }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { version } = await params;
|
||||
const release = allChangelogs.find((r) => r.version === version);
|
||||
if (!release) return {};
|
||||
return {
|
||||
title: `${release.title} (${release.version}) - OpenCut Changelog`,
|
||||
description: release.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ReleaseDetailPage({ params }: Props) {
|
||||
const { version } = await params;
|
||||
const releases = getSortedReleases();
|
||||
const index = releases.findIndex((r) => r.version === version);
|
||||
|
||||
if (index === -1) notFound();
|
||||
|
||||
const release = releases[index];
|
||||
const newer = index > 0 ? releases[index - 1] : null;
|
||||
const older = index < releases.length - 1 ? releases[index + 1] : null;
|
||||
|
||||
return (
|
||||
<BasePage>
|
||||
<div className="mx-auto w-full max-w-3xl flex flex-col gap-12">
|
||||
<Link
|
||||
href="/changelog"
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 w-fit"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
All releases
|
||||
</Link>
|
||||
|
||||
<ReleaseArticle variant="detail">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<ReleaseMeta release={release} />
|
||||
<CopyMarkdownButton
|
||||
description={release.description}
|
||||
changes={release.changes}
|
||||
/>
|
||||
</div>
|
||||
<ReleaseTitle as="h1">{release.title}</ReleaseTitle>
|
||||
{release.description && (
|
||||
<ReleaseDescription>{release.description}</ReleaseDescription>
|
||||
)}
|
||||
</div>
|
||||
<ReleaseChanges release={release} />
|
||||
</ReleaseArticle>
|
||||
|
||||
<nav className="flex items-center justify-between border-t pt-8">
|
||||
{older ? (
|
||||
<Link
|
||||
href={`/changelog/${older.version}`}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground/60">Older</span>
|
||||
<span className="font-medium">{older.title}</span>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{newer ? (
|
||||
<Link
|
||||
href={`/changelog/${newer.version}`}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground group text-right"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground/60">Newer</span>
|
||||
<span className="font-medium">{newer.title}</span>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckIcon, ClipboardIcon } from "lucide-react";
|
||||
import { getSectionTitle, groupAndOrderChanges } from "../utils";
|
||||
import type { Change } from "../utils";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function buildMarkdown({
|
||||
description,
|
||||
changes,
|
||||
}: {
|
||||
description?: string;
|
||||
changes: Change[];
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (description) {
|
||||
lines.push(description, "");
|
||||
}
|
||||
|
||||
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
|
||||
|
||||
for (const type of orderedTypes) {
|
||||
lines.push(`## ${getSectionTitle(type)}`);
|
||||
for (const change of grouped[type]) {
|
||||
lines.push(`- ${change.text}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n").trimEnd();
|
||||
}
|
||||
|
||||
export function CopyMarkdownButton({
|
||||
description,
|
||||
changes,
|
||||
}: {
|
||||
description?: string;
|
||||
changes: Change[];
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const markdown = buildMarkdown({ description, changes });
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
onClick={handleCopy}
|
||||
className={cn("flex items-center gap-1.5", copied && "pointer-events-none")}
|
||||
title="Copy as markdown"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-4" />
|
||||
)}
|
||||
{copied ? "Copied!" : "Copy markdown"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { getSectionTitle, groupAndOrderChanges } from "../utils";
|
||||
import type { Release } from "../utils";
|
||||
|
||||
export function ReleaseArticle({
|
||||
variant,
|
||||
isLatest,
|
||||
children,
|
||||
}: {
|
||||
variant: "list" | "detail";
|
||||
isLatest?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<article className="relative sm:pl-10">
|
||||
<div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
|
||||
<div
|
||||
className={cn(
|
||||
"size-[11px] rounded-full border-[1.5px]",
|
||||
isLatest
|
||||
? "border-foreground bg-foreground"
|
||||
: "border-muted-foreground/30 bg-background",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">{children}</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return <article className="flex flex-col gap-8">{children}</article>;
|
||||
}
|
||||
|
||||
export function ReleaseMeta({ release }: { release: Release }) {
|
||||
return (
|
||||
<span className="text-sm font-medium tracking-widest text-muted-foreground">
|
||||
{release.version} — {release.date}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const titleSizes: Record<"h1" | "h2", string> = {
|
||||
h1: "text-4xl",
|
||||
h2: "text-2xl",
|
||||
};
|
||||
|
||||
export function ReleaseTitle({
|
||||
as: As,
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
as: "h1" | "h2";
|
||||
href?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<As className={cn("font-bold tracking-tight", titleSizes[As])}>
|
||||
{href ? (
|
||||
<Link href={href} className="hover:underline underline-offset-4">
|
||||
{children}
|
||||
</Link>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</As>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseDescription({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="text-base text-foreground leading-relaxed max-w-xl">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseChanges({ release }: { release: Release }) {
|
||||
const { grouped, orderedTypes } = groupAndOrderChanges({
|
||||
changes: release.changes,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{orderedTypes.map((type) => (
|
||||
<div key={type} className="flex flex-col gap-1.5">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{getSectionTitle(type)}:
|
||||
</h3>
|
||||
<ul className="list-disc pl-5 space-y-1.5">
|
||||
{grouped[type].map((change) => (
|
||||
<li
|
||||
key={change.text}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
>
|
||||
{change.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Metadata } from "next";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { type Release as ReleaseType, getSortedReleases } from "./utils";
|
||||
import {
|
||||
ReleaseArticle,
|
||||
ReleaseMeta,
|
||||
ReleaseTitle,
|
||||
ReleaseDescription,
|
||||
ReleaseChanges,
|
||||
} from "./components/release";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Changelog - OpenCut",
|
||||
description: "Every update, improvement, and fix to OpenCut — documented.",
|
||||
openGraph: {
|
||||
title: "Changelog - OpenCut",
|
||||
description: "Every update, improvement, and fix to OpenCut — documented.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default function ChangelogPage() {
|
||||
const releases = getSortedReleases();
|
||||
|
||||
return (
|
||||
<BasePage title="Changelog" description="See what's new in OpenCut">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<div className="relative">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute top-2 bottom-0 left-[5px] w-px bg-border hidden sm:block"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col">
|
||||
{releases.map((release, releaseIndex) => (
|
||||
<div key={release.version} className="flex flex-col">
|
||||
<ReleaseEntry release={release} />
|
||||
{releaseIndex < releases.length - 1 && (
|
||||
<Separator className="my-10 sm:ml-1.5" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
function ReleaseEntry({ release }: { release: ReleaseType }) {
|
||||
return (
|
||||
<ReleaseArticle variant="list" isLatest={release.isLatest}>
|
||||
<ReleaseMeta release={release} />
|
||||
<div className="flex flex-col gap-4">
|
||||
<ReleaseTitle as="h2" href={`/changelog/${release.version}`}>
|
||||
{release.title}
|
||||
</ReleaseTitle>
|
||||
{release.description && (
|
||||
<ReleaseDescription>{release.description}</ReleaseDescription>
|
||||
)}
|
||||
</div>
|
||||
<ReleaseChanges release={release} />
|
||||
</ReleaseArticle>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { allChangelogs } from "content-collections";
|
||||
|
||||
export type Change = { type: string; text: string };
|
||||
export type Release = (typeof allChangelogs)[number];
|
||||
|
||||
const knownSectionOrder = ["new", "improved", "fixed", "breaking"];
|
||||
|
||||
const knownSectionTitles: Record<string, string> = {
|
||||
new: "Features",
|
||||
improved: "Improvements",
|
||||
fixed: "Fixes",
|
||||
breaking: "Breaking Changes",
|
||||
};
|
||||
|
||||
export function getSectionTitle(type: string): string {
|
||||
return (
|
||||
knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
|
||||
);
|
||||
}
|
||||
|
||||
export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
|
||||
const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
|
||||
if (!acc[change.type]) acc[change.type] = [];
|
||||
acc[change.type].push(change);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const customTypes = Object.keys(grouped).filter(
|
||||
(type) => !knownSectionOrder.includes(type),
|
||||
);
|
||||
const orderedTypes = [
|
||||
...knownSectionOrder.filter((type) => grouped[type]?.length > 0),
|
||||
...customTypes,
|
||||
];
|
||||
|
||||
return { grouped, orderedTypes };
|
||||
}
|
||||
|
||||
export function getSortedReleases() {
|
||||
return [...allChangelogs].sort((a, b) =>
|
||||
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
||||
);
|
||||
}
|
||||
@@ -8,33 +8,38 @@ import {
|
||||
} from "@/components/ui/resizable";
|
||||
import { AssetsPanel } from "@/components/editor/panels/assets";
|
||||
import { PropertiesPanel } from "@/components/editor/panels/properties";
|
||||
import { Timeline } from "@/components/editor/timeline";
|
||||
import { Timeline } from "@/components/editor/panels/timeline";
|
||||
import { PreviewPanel } from "@/components/editor/panels/preview";
|
||||
import { EditorHeader } from "@/components/editor/editor-header";
|
||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||
import { Onboarding } from "@/components/editor/onboarding";
|
||||
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { usePasteMedia } from "@/hooks/use-paste-media";
|
||||
import { MobileGate } from "@/components/editor/mobile-gate";
|
||||
|
||||
export default function Editor() {
|
||||
const params = useParams();
|
||||
const projectId = params.project_id as string;
|
||||
|
||||
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 />
|
||||
<MobileGate>
|
||||
<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>
|
||||
<Onboarding />
|
||||
<MigrationDialog />
|
||||
</div>
|
||||
</EditorProvider>
|
||||
</EditorProvider>
|
||||
</MobileGate>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorLayout() {
|
||||
usePasteMedia();
|
||||
const { panels, setPanel } = usePanelStore();
|
||||
|
||||
return (
|
||||
@@ -65,7 +70,7 @@ function EditorLayout() {
|
||||
defaultSize={panels.tools}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
className="min-w-0 rounded-sm"
|
||||
className="min-w-0"
|
||||
>
|
||||
<AssetsPanel />
|
||||
</ResizablePanel>
|
||||
@@ -86,7 +91,7 @@ function EditorLayout() {
|
||||
defaultSize={panels.properties}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
className="min-w-0 rounded-sm"
|
||||
className="min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
@@ -13,22 +13,23 @@
|
||||
--card: hsl(0, 0%, 100%);
|
||||
--card-foreground: hsl(0 0% 11%);
|
||||
--popover: hsl(0, 0%, 100%);
|
||||
--popover-hover: hsl(0, 0%, 96%);
|
||||
--popover-foreground: hsl(0 0% 2%);
|
||||
--primary: hsl(203, 100%, 50%);
|
||||
--primary-hover: hsl(203, 100%, 45%);
|
||||
--primary: #009dff;
|
||||
--primary-foreground: hsl(0, 0%, 100%);
|
||||
--secondary: hsl(216 13% 94%);
|
||||
--secondary-foreground: hsl(0 0% 2%);
|
||||
--secondary: hsl(204, 100%, 97%);
|
||||
--secondary-border: hsl(204, 100%, 94%);
|
||||
--secondary-foreground: hsl(200, 98%, 39%);
|
||||
--muted: hsl(0 0% 85.1%);
|
||||
--muted-foreground: hsl(0 0% 50%);
|
||||
--accent: hsl(216, 13%, 88%);
|
||||
--accent: hsl(0, 0%, 96%);
|
||||
--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%);
|
||||
--border: hsl(0 0% 91%);
|
||||
--input: hsl(0, 0%, 100%);
|
||||
--ring: hsl(0, 0%, 55%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
@@ -43,49 +44,71 @@
|
||||
--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%);
|
||||
}
|
||||
|
||||
.panel {
|
||||
--background: hsl(216 13% 98%);
|
||||
--foreground: hsl(0 0% 13%);
|
||||
--card: hsl(0, 0%, 98%);
|
||||
--card-foreground: hsl(0 0% 13%);
|
||||
--primary-foreground: hsl(0, 0%, 98%);
|
||||
--secondary: hsl(204, 100%, 95%);
|
||||
--secondary-border: hsl(204, 100%, 92%);
|
||||
--secondary-foreground: hsl(200, 98%, 37%);
|
||||
--muted: hsl(0 0% 83.1%);
|
||||
--muted-foreground: hsl(0 0% 48%);
|
||||
--accent: hsl(0, 0%, 93%);
|
||||
--accent-foreground: hsl(0 0% 5%);
|
||||
--destructive-foreground: hsl(0, 0%, 98%);
|
||||
--constructive-foreground: hsl(0, 0%, 98%);
|
||||
--border: hsl(0 0% 89%);
|
||||
--input: hsl(0 0% 93%);
|
||||
--ring: hsl(0, 0%, 53%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--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%);
|
||||
--background: hsl(0, 0%, 5%);
|
||||
--foreground: hsl(0 0% 87%);
|
||||
--card: hsl(0, 0%, 5%);
|
||||
--card-foreground: hsl(0 0% 87%);
|
||||
--popover: hsl(0, 0%, 5%);
|
||||
--popover-hover: hsl(0, 0%, 22%);
|
||||
--popover-foreground: hsl(0 0% 95%);
|
||||
--secondary: hsl(204, 100%, 12%);
|
||||
--secondary-border: hsl(204, 100%, 15%);
|
||||
--secondary-foreground: hsl(200, 98%, 61%);
|
||||
--muted: hsl(0 0% 20%);
|
||||
--accent: hsl(0, 0%, 14%);
|
||||
--accent-foreground: hsl(0 0% 95%);
|
||||
--border: hsl(0 0% 16%);
|
||||
--input: hsl(0 0% 5%);
|
||||
--ring: hsl(0, 0%, 50%);
|
||||
--sidebar-background: hsl(0 0% 8%);
|
||||
--sidebar-foreground: hsl(0 0% 95%);
|
||||
--sidebar-primary: hsl(0 0% 95%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 15%);
|
||||
--sidebar-accent: hsl(0 0% 20%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 95%);
|
||||
--sidebar-border: hsl(0 0% 20%);
|
||||
--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%);
|
||||
--sidebar: hsl(0 0% 6%);
|
||||
}
|
||||
|
||||
.dark .panel {
|
||||
--background: hsl(0 0% 10%);
|
||||
--foreground: hsl(0 0% 85%);
|
||||
--card: hsl(0, 0%, 10%);
|
||||
--card-foreground: hsl(0 0% 85%);
|
||||
--secondary: hsl(204, 67%, 9%);
|
||||
--secondary-border: hsl(204, 100%, 14%);
|
||||
--secondary-foreground: hsl(200, 98%, 63%);
|
||||
--muted: hsl(0 0% 22%);
|
||||
--accent: hsl(0, 0%, 15%);
|
||||
--accent-foreground: hsl(0 0% 93%);
|
||||
--border: hsl(0 0% 18%);
|
||||
--input: hsl(0 0% 22%);
|
||||
--ring: hsl(0, 0%, 52%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -113,6 +136,9 @@
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-primary/35 selection:text-primary-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -123,10 +149,10 @@
|
||||
--font-sans: var(--font-inter), sans-serif;
|
||||
|
||||
/* Font sizes */
|
||||
--text-xl: 1.20rem;
|
||||
--text-base: 0.92rem;
|
||||
--text-xs: 0.72rem;
|
||||
--text-sm: 0.79rem;
|
||||
--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 */
|
||||
@@ -142,12 +168,13 @@
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-hover: var(--popover-hover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary-hover: var(--primary-hover);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-border: var(--secondary-border);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
@@ -183,10 +210,6 @@
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
/* Panel */
|
||||
--color-panel: var(--panel-background);
|
||||
--color-panel-accent: var(--panel-accent);
|
||||
|
||||
/* Animations */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
@@ -28,6 +28,13 @@ export default function RootLayout({
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<BotIdClient protect={protectedRoutes} />
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<Script
|
||||
src="//unpkg.com/react-scan/dist/auto.global.js"
|
||||
crossOrigin="anonymous"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body className={`${siteFont.className} font-sans antialiased`}>
|
||||
<ThemeProvider
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function PrivacyPage() {
|
||||
<a
|
||||
href="https://www.databuddy.cc"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Databuddy
|
||||
|
||||
@@ -46,6 +46,13 @@ import {
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { OcVideoIcon } from "@opencut/ui/icons";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
@@ -71,6 +78,11 @@ const formatProjectDuration = ({
|
||||
return formatTimeCode({ timeInSeconds: duration, format });
|
||||
};
|
||||
|
||||
const VIEW_MODE_OPTIONS = [
|
||||
{ mode: "grid" as const, icon: GridViewIcon, label: "Grid view" },
|
||||
{ mode: "list" as const, icon: LeftToRightListDashIcon, label: "List view" },
|
||||
];
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
|
||||
const editor = useEditor();
|
||||
@@ -147,34 +159,23 @@ function ProjectsHeader() {
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<div className="hidden md:flex rounded-md border p-1 h-10">
|
||||
<button
|
||||
type="button"
|
||||
className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "grid" ? "bg-accent/75" : ""}`}
|
||||
onClick={() => setViewMode({ viewMode: "grid" })}
|
||||
onKeyDown={(event) =>
|
||||
event.key === "Enter" && setViewMode({ viewMode: "grid" })
|
||||
}
|
||||
aria-label="Grid view"
|
||||
aria-pressed={isHydrated && viewMode === "grid"}
|
||||
>
|
||||
<HugeiconsIcon icon={GridViewIcon} className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`p-2 rounded-sm cursor-pointer ${isHydrated && viewMode === "list" ? "bg-accent/75" : ""}`}
|
||||
onClick={() => setViewMode({ viewMode: "list" })}
|
||||
onKeyDown={(event) =>
|
||||
event.key === "Enter" && setViewMode({ viewMode: "list" })
|
||||
}
|
||||
aria-label="List view"
|
||||
aria-pressed={isHydrated && viewMode === "list"}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={LeftToRightListDashIcon}
|
||||
className="size-4"
|
||||
/>
|
||||
</button>
|
||||
<div className="hidden md:flex items-center rounded-md border p-1 px-1.5 h-10">
|
||||
{VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
|
||||
<Button
|
||||
key={mode}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"rounded-sm hover:bg-background",
|
||||
isHydrated && viewMode === mode && "!bg-accent",
|
||||
)}
|
||||
onClick={() => setViewMode({ viewMode: mode })}
|
||||
aria-label={label}
|
||||
aria-pressed={isHydrated && viewMode === mode}
|
||||
>
|
||||
<HugeiconsIcon icon={icon} className="size-4" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -251,7 +252,6 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
|
||||
</Button>
|
||||
</SortDropdown>
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
className="text-muted-foreground"
|
||||
onClick={() =>
|
||||
@@ -277,28 +277,21 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
|
||||
<div className="h-4 w-px bg-border/50 block md:hidden" />
|
||||
|
||||
<div className="flex md:hidden items-center gap-4">
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => setViewMode({ viewMode: "grid" })}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={GridViewIcon}
|
||||
className={cn(
|
||||
viewMode === "grid" ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => setViewMode({ viewMode: "list" })}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={LeftToRightListDashIcon}
|
||||
className={cn(
|
||||
viewMode === "list" ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
{VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => (
|
||||
<Button
|
||||
key={mode}
|
||||
variant="text"
|
||||
onClick={() => setViewMode({ viewMode: mode })}
|
||||
aria-label={label}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={icon}
|
||||
className={cn(
|
||||
viewMode === mode ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{selectedProjectCount > 0 ? <ProjectActions /> : null}
|
||||
@@ -548,10 +541,25 @@ function ProjectItem({
|
||||
const isSelected = selectedProjectIdSet.has(project.id);
|
||||
const selectedProjectCount = selectedProjectIds.length;
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false);
|
||||
const editor = useEditor();
|
||||
const durationLabel = formatProjectDuration({ duration: project.duration });
|
||||
const isMultiSelect = selectedProjectCount > 1;
|
||||
const isGridView = viewMode === "grid";
|
||||
|
||||
const handleRename = () => setIsRenameDialogOpen(true);
|
||||
const handleDuplicate = async () => {
|
||||
await duplicateProjects({ editor, ids: [project.id] });
|
||||
};
|
||||
const handleDeleteClick = () => setIsDeleteDialogOpen(true);
|
||||
const handleInfoClick = () => setIsInfoDialogOpen(true);
|
||||
const handleDeleteConfirm = async () => {
|
||||
await deleteProjects({ editor, ids: [project.id] });
|
||||
setIsDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleCheckboxChange = ({
|
||||
checked,
|
||||
shiftKey,
|
||||
@@ -636,9 +644,8 @@ function ProjectItem({
|
||||
|
||||
const listContent = (
|
||||
<div
|
||||
className={`flex items-center gap-4 py-2 px-4 border-b border-border/50 ${
|
||||
isSelected ? "bg-primary/5" : ""
|
||||
}`}
|
||||
className={`flex items-center gap-4 py-2 px-4 border-b border-border/50 ${isSelected ? "bg-primary/5" : ""
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
@@ -649,7 +656,7 @@ function ProjectItem({
|
||||
shiftKey: event.shiftKey,
|
||||
});
|
||||
}}
|
||||
onCheckedChange={() => {}}
|
||||
onCheckedChange={() => { }}
|
||||
className="size-5 shrink-0"
|
||||
/>
|
||||
|
||||
@@ -657,177 +664,71 @@ function ProjectItem({
|
||||
{listRowContent}
|
||||
</Link>
|
||||
|
||||
{!isMultiSelect && (
|
||||
<ProjectMenu
|
||||
isOpen={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
project={project}
|
||||
variant="list"
|
||||
/>
|
||||
)}
|
||||
{!isMultiSelect && (
|
||||
<ProjectMenu
|
||||
isOpen={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
variant="list"
|
||||
onRenameClick={handleRename}
|
||||
onDuplicateClick={handleDuplicate}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onInfoClick={handleInfoClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const cardContent = isGridView ? gridContent : listContent;
|
||||
|
||||
if (!isGridView) {
|
||||
return <div className="group relative">{listContent}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<Link href={`/editor/${project.id}`} className="block">
|
||||
{cardContent}
|
||||
</Link>
|
||||
|
||||
{isGridView && (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
handleCheckboxChange({
|
||||
checked: !isSelected,
|
||||
shiftKey: event.shiftKey,
|
||||
});
|
||||
}}
|
||||
onCheckedChange={() => {}}
|
||||
className={`absolute z-10 size-5 top-3 left-3 ${
|
||||
isSelected || isDropdownOpen
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{!isMultiSelect && (
|
||||
<ProjectMenu
|
||||
isOpen={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
project={project}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectMenu({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
project,
|
||||
variant = "grid",
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project: TProjectMetadata;
|
||||
variant?: "grid" | "list";
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false);
|
||||
|
||||
const handleMenuClick = ({
|
||||
event,
|
||||
}: {
|
||||
event: MouseEvent<HTMLButtonElement>;
|
||||
}) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleMenuKeyDown = ({
|
||||
event,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLButtonElement>;
|
||||
}) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleRename = () => {
|
||||
setIsRenameDialogOpen(true);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
await duplicateProjects({ editor, ids: [project.id] });
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
setIsDeleteDialogOpen(true);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
await deleteProjects({ editor, ids: [project.id] });
|
||||
setIsDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleInfoClick = () => {
|
||||
setIsInfoDialogOpen(true);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const isGrid = variant === "grid";
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="background"
|
||||
className={
|
||||
isGrid
|
||||
? `absolute z-10 top-3 right-3 ${isOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`
|
||||
: "!bg-transparent !shadow-none"
|
||||
}
|
||||
size="icon"
|
||||
aria-label="Project menu"
|
||||
onClick={(event) =>
|
||||
handleMenuClick({
|
||||
event: event as unknown as MouseEvent<HTMLButtonElement>,
|
||||
})
|
||||
}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) =>
|
||||
handleMenuKeyDown({
|
||||
event: event as unknown as KeyboardEvent<HTMLButtonElement>,
|
||||
})
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={MoreHorizontalIcon}
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48" align="end">
|
||||
<DropdownMenuItem onClick={handleRename}>
|
||||
<HugeiconsIcon icon={Edit03Icon} />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleDuplicate}>
|
||||
<HugeiconsIcon icon={Copy01Icon} />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleInfoClick}>
|
||||
<HugeiconsIcon icon={InformationCircleIcon} />
|
||||
Info
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDeleteClick}>
|
||||
<HugeiconsIcon icon={Delete02Icon} />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="group relative">
|
||||
{isGridView ? (
|
||||
<>
|
||||
<Link href={`/editor/${project.id}`} className="block">
|
||||
{gridContent}
|
||||
</Link>
|
||||
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
handleCheckboxChange({
|
||||
checked: !isSelected,
|
||||
shiftKey: event.shiftKey,
|
||||
});
|
||||
}}
|
||||
onCheckedChange={() => {}}
|
||||
className={`absolute z-10 size-5 top-3 left-3 ${
|
||||
isSelected || isDropdownOpen
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{!isMultiSelect && (
|
||||
<ProjectMenu
|
||||
isOpen={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
onRenameClick={handleRename}
|
||||
onDuplicateClick={handleDuplicate}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onInfoClick={handleInfoClick}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
listContent
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ProjectContextMenuContent
|
||||
onRenameClick={handleRename}
|
||||
onDuplicateClick={handleDuplicate}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
onInfoClick={handleInfoClick}
|
||||
/>
|
||||
</ContextMenu>
|
||||
|
||||
<RenameProjectDialog
|
||||
isOpen={isRenameDialogOpen}
|
||||
@@ -855,6 +756,162 @@ function ProjectMenu({
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectContextMenuContent({
|
||||
onRenameClick,
|
||||
onDuplicateClick,
|
||||
onDeleteClick,
|
||||
onInfoClick,
|
||||
}: {
|
||||
onRenameClick: () => void;
|
||||
onDuplicateClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onInfoClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={Edit03Icon} />}
|
||||
onClick={onRenameClick}
|
||||
>
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={Copy01Icon} />}
|
||||
onClick={onDuplicateClick}
|
||||
>
|
||||
Duplicate
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={InformationCircleIcon} />}
|
||||
onClick={onInfoClick}
|
||||
>
|
||||
Info
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
icon={<HugeiconsIcon icon={Delete02Icon} />}
|
||||
onClick={onDeleteClick}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectMenu({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
variant = "grid",
|
||||
onRenameClick,
|
||||
onDuplicateClick,
|
||||
onDeleteClick,
|
||||
onInfoClick,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
variant?: "grid" | "list";
|
||||
onRenameClick: () => void;
|
||||
onDuplicateClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onInfoClick: () => void;
|
||||
}) {
|
||||
const handleMenuClick = ({
|
||||
event,
|
||||
}: {
|
||||
event: MouseEvent<HTMLButtonElement>;
|
||||
}) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleMenuKeyDown = ({
|
||||
event,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLButtonElement>;
|
||||
}) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleRename = () => {
|
||||
onRenameClick();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
onDuplicateClick();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
onDeleteClick();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleInfoClick = () => {
|
||||
onInfoClick();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const isGrid = variant === "grid";
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="background"
|
||||
className={
|
||||
isGrid
|
||||
? `absolute z-10 top-3 right-3 ${isOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`
|
||||
: "!bg-transparent !shadow-none"
|
||||
}
|
||||
size="icon"
|
||||
aria-label="Project menu"
|
||||
onClick={(event) =>
|
||||
handleMenuClick({
|
||||
event: event as unknown as MouseEvent<HTMLButtonElement>,
|
||||
})
|
||||
}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) =>
|
||||
handleMenuKeyDown({
|
||||
event: event as unknown as KeyboardEvent<HTMLButtonElement>,
|
||||
})
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={MoreHorizontalIcon}
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48" align="end">
|
||||
<DropdownMenuItem onClick={handleRename}>
|
||||
<HugeiconsIcon icon={Edit03Icon} />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleDuplicate}>
|
||||
<HugeiconsIcon icon={Copy01Icon} />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleInfoClick}>
|
||||
<HugeiconsIcon icon={InformationCircleIcon} />
|
||||
Info
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={handleDeleteClick}>
|
||||
<HugeiconsIcon icon={Delete02Icon} />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectsSkeleton() {
|
||||
const skeletonIds = Array.from(
|
||||
{ length: 24 },
|
||||
@@ -862,7 +919,7 @@ function ProjectsSkeleton() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<div className="px-4 xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{skeletonIds.map((skeletonId) => (
|
||||
<Card
|
||||
key={skeletonId}
|
||||
|
||||
@@ -60,7 +60,7 @@ export const useProjectsStore = create<ProjectsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
searchQuery: "",
|
||||
sortKey: "createdAt",
|
||||
sortKey: "updatedAt",
|
||||
sortOrder: "desc",
|
||||
viewMode: "grid",
|
||||
selectedProjectIds: [],
|
||||
@@ -86,7 +86,9 @@ export const useProjectsStore = create<ProjectsState>()(
|
||||
projectId,
|
||||
isSelected,
|
||||
}),
|
||||
lastSelectedProjectId: isSelected ? projectId : state.lastSelectedProjectId,
|
||||
lastSelectedProjectId: isSelected
|
||||
? projectId
|
||||
: state.lastSelectedProjectId,
|
||||
})),
|
||||
selectProjectRange: ({ projectId, allProjectIds }) =>
|
||||
set((state) => {
|
||||
@@ -120,7 +122,11 @@ export const useProjectsStore = create<ProjectsState>()(
|
||||
}),
|
||||
{
|
||||
name: "projects-view-mode",
|
||||
partialize: (state) => ({ viewMode: state.viewMode }),
|
||||
partialize: (state) => ({
|
||||
viewMode: state.viewMode,
|
||||
sortKey: state.sortKey,
|
||||
sortOrder: state.sortOrder,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
state?.setIsHydrated({ isHydrated: true });
|
||||
},
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const LAST_UPDATED = "February 25, 2026";
|
||||
|
||||
type StatusType = "complete" | "pending" | "default" | "info";
|
||||
|
||||
interface Status {
|
||||
@@ -47,9 +49,9 @@ const roadmapItems: RoadmapItem[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Badge (potentially)",
|
||||
title: "Native app (mobile/desktop)",
|
||||
description:
|
||||
'An "Edit with OpenCut" badge web apps can integrate. Shows on video players.',
|
||||
"Native OpenCut apps for Mac, Windows, Linux, and iOS/Android.",
|
||||
status: {
|
||||
text: "Not started",
|
||||
type: "default",
|
||||
@@ -88,7 +90,7 @@ export default function RoadmapPage() {
|
||||
return (
|
||||
<BasePage
|
||||
title="Roadmap"
|
||||
description="What's coming next for OpenCut (last updated: July 14, 2025)"
|
||||
description={`What's coming next for OpenCut (last updated: ${LAST_UPDATED})`}
|
||||
>
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-16">
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -110,7 +112,7 @@ export default function RoadmapPage() {
|
||||
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">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-lg font-medium">
|
||||
<span className="leading-normal select-none">{index + 1}</span>
|
||||
<h3>{item.title}</h3>
|
||||
<StatusBadge status={item.status} className="ml-1" />
|
||||
|
||||
@@ -122,8 +122,8 @@ export function EditableTimecode({
|
||||
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",
|
||||
"-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none",
|
||||
"focus:bg-background focus:border-primary focus:rounded",
|
||||
"text-primary tabular-nums",
|
||||
hasError && "text-destructive focus:border-destructive",
|
||||
className,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -17,23 +16,21 @@ import { useRouter } from "next/navigation";
|
||||
import { FaDiscord } from "react-icons/fa6";
|
||||
import { ExportButton } from "./export-button";
|
||||
import { ThemeToggle } from "../theme-toggle";
|
||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
import { DEFAULT_LOGO_URL, 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 { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
export function EditorHeader() {
|
||||
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">
|
||||
<header className="bg-background flex h-[3.4rem] items-center justify-between px-3 pt-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<ProjectDropdown />
|
||||
<EditableProjectName />
|
||||
</div>
|
||||
<nav className="flex items-center gap-2">
|
||||
<ExportButton />
|
||||
@@ -111,56 +108,40 @@ function ProjectDropdown() {
|
||||
<>
|
||||
<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 variant="ghost" size="icon" className="p-1 rounded-sm size-8">
|
||||
<Image
|
||||
src={DEFAULT_LOGO_URL}
|
||||
alt="Project thumbnail"
|
||||
width={32}
|
||||
height={32}
|
||||
className="invert dark:invert-0 size-5"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="z-100 w-52">
|
||||
<DropdownMenuContent align="start" className="z-100 w-44">
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={handleExit}
|
||||
disabled={isExiting}
|
||||
icon={<HugeiconsIcon icon={Logout05Icon} />}
|
||||
>
|
||||
<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")}
|
||||
icon={<HugeiconsIcon icon={CommandIcon} />}
|
||||
>
|
||||
<HugeiconsIcon icon={CommandIcon} className="size-4" />
|
||||
Keyboard shortcuts
|
||||
Shortcuts
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild icon={<FaDiscord className="!size-4" />}>
|
||||
<Link
|
||||
href={SOCIAL_LINKS.discord}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<FaDiscord className="size-4" />
|
||||
Discord
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
@@ -185,3 +166,79 @@ function ProjectDropdown() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableProjectName() {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const originalNameRef = useRef("");
|
||||
|
||||
const projectName = activeProject?.metadata.name || "";
|
||||
|
||||
const startEditing = () => {
|
||||
if (isEditing) return;
|
||||
originalNameRef.current = projectName;
|
||||
setIsEditing(true);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.select();
|
||||
});
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!inputRef.current || !activeProject) return;
|
||||
const newName = inputRef.current.value.trim();
|
||||
setIsEditing(false);
|
||||
|
||||
if (!newName) {
|
||||
inputRef.current.value = originalNameRef.current;
|
||||
return;
|
||||
}
|
||||
|
||||
if (newName !== originalNameRef.current) {
|
||||
try {
|
||||
await editor.project.renameProject({
|
||||
id: activeProject.metadata.id,
|
||||
name: newName,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Failed to rename project", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
inputRef.current?.blur();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = originalNameRef.current;
|
||||
}
|
||||
setIsEditing(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
defaultValue={projectName}
|
||||
readOnly={!isEditing}
|
||||
onClick={startEditing}
|
||||
onBlur={saveEdit}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ fieldSizing: "content" }}
|
||||
className={cn(
|
||||
"text-[0.9rem] h-8 px-2 py-1 rounded-sm bg-transparent outline-none cursor-pointer hover:bg-accent hover:text-accent-foreground",
|
||||
isEditing && "ring-1 ring-ring cursor-text hover:bg-transparent",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useState } from "react";
|
||||
import { TransitionTopIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
@@ -14,46 +14,60 @@ 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 { getExportMimeType, getExportFileExtension, downloadBuffer } from "@/lib/export";
|
||||
import { Check, Copy, Download, RotateCcw } from "lucide-react";
|
||||
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 {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/editor/panels/properties/section";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function ExportButton() {
|
||||
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
|
||||
const editor = useEditor();
|
||||
|
||||
const handleExport = () => {
|
||||
setIsExportPopoverOpen(true);
|
||||
const hasProject = !!editor.project.getActiveOrNull();
|
||||
|
||||
const handlePopoverOpenChange = ({ open }: { open: boolean }) => {
|
||||
if (!open) {
|
||||
editor.project.cancelExport();
|
||||
editor.project.clearExportState();
|
||||
}
|
||||
setIsExportPopoverOpen(open);
|
||||
};
|
||||
|
||||
const hasProject = !!editor.project.getActive();
|
||||
|
||||
return (
|
||||
<Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
|
||||
<Popover open={isExportPopoverOpen} onOpenChange={(open) => handlePopoverOpenChange({ open })}>
|
||||
<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",
|
||||
hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
onClick={hasProject ? handleExport : undefined}
|
||||
onClick={hasProject ? () => setIsExportPopoverOpen(true) : undefined}
|
||||
disabled={!hasProject}
|
||||
onKeyDown={(event) => {
|
||||
if (hasProject && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
handleExport();
|
||||
setIsExportPopoverOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -78,83 +92,53 @@ function ExportPopover({
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const { isExporting, progress, result: exportResult } =
|
||||
editor.project.getExportState();
|
||||
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 [shouldIncludeAudio, setShouldIncludeAudio] = 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;
|
||||
|
||||
cancelRequestedRef.current = false;
|
||||
setIsExporting(true);
|
||||
setProgress(0);
|
||||
setExportResult(null);
|
||||
|
||||
const result = await editor.project.export({
|
||||
options: {
|
||||
format,
|
||||
quality,
|
||||
fps: activeProject.settings.fps,
|
||||
includeAudio,
|
||||
onProgress: ({ progress }) => setProgress(progress),
|
||||
onCancel: () => cancelRequestedRef.current,
|
||||
format,
|
||||
quality,
|
||||
fps: activeProject.settings.fps,
|
||||
includeAudio: shouldIncludeAudio,
|
||||
},
|
||||
});
|
||||
|
||||
setIsExporting(false);
|
||||
|
||||
if (result.cancelled) {
|
||||
setExportResult(null);
|
||||
setProgress(0);
|
||||
editor.project.clearExportState();
|
||||
return;
|
||||
}
|
||||
|
||||
setExportResult(result);
|
||||
|
||||
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 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);
|
||||
downloadBuffer({
|
||||
buffer: result.buffer,
|
||||
filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`,
|
||||
mimeType: getExportMimeType({ format }),
|
||||
});
|
||||
|
||||
editor.project.clearExportState();
|
||||
onOpenChange(false);
|
||||
setExportResult(null);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isExporting) {
|
||||
onOpenChange(false);
|
||||
setExportResult(null);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
cancelRequestedRef.current = true;
|
||||
editor.project.cancelExport();
|
||||
};
|
||||
|
||||
return (
|
||||
<PopoverContent className="bg-background mr-4 flex w-80 flex-col gap-3">
|
||||
<PopoverContent className="bg-background mr-4 flex w-80 flex-col p-0">
|
||||
{exportResult && !exportResult.success ? (
|
||||
<ExportError
|
||||
error={exportResult.error || "Unknown error occurred"}
|
||||
@@ -162,119 +146,121 @@ function ExportPopover({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<h3 className="font-medium text-sm">
|
||||
{isExporting ? "Exporting project" : "Export project"}
|
||||
</h3>
|
||||
<Button variant="text" size="icon" onClick={handleClose}>
|
||||
<X className="text-foreground/85 !size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{!isExporting && (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<PropertyGroup
|
||||
title="Format"
|
||||
titleClassName="text-sm"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<RadioGroup
|
||||
value={format}
|
||||
onValueChange={(value) => {
|
||||
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>
|
||||
<div className="flex flex-col">
|
||||
<Section collapsible defaultOpen={false} showTopBorder={false}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Format</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<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>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
|
||||
<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>
|
||||
<Section collapsible defaultOpen={false}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Quality</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<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>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
|
||||
<PropertyGroup
|
||||
title="Audio"
|
||||
titleClassName="text-sm"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="include-audio"
|
||||
checked={includeAudio}
|
||||
<Section collapsible defaultOpen={false}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Audio</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="include-audio"
|
||||
checked={shouldIncludeAudio}
|
||||
onCheckedChange={(checked) =>
|
||||
setIncludeAudio(!!checked)
|
||||
setShouldIncludeAudio(!!checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="include-audio">
|
||||
Include audio in export
|
||||
</Label>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
/>
|
||||
<Label htmlFor="include-audio">
|
||||
Include audio in export
|
||||
</Label>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleExport} className="w-full gap-2">
|
||||
<Download className="size-4" />
|
||||
Export
|
||||
</Button>
|
||||
<div className="p-3 pt-0">
|
||||
<Button onClick={handleExport} className="w-full gap-2">
|
||||
<Download className="size-4" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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 className="space-y-4 p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{Math.round(progress * 100)}%
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">100%</p>
|
||||
</div>
|
||||
<Progress value={progress * 100} className="w-full" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -292,14 +278,6 @@ function ExportPopover({
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -316,7 +294,7 @@ function ExportError({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 p-3">
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "../ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const STORAGE_KEY = "mobile-acknowledged";
|
||||
|
||||
interface MobileGateProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MobileGate({ children }: MobileGateProps) {
|
||||
const router = useRouter();
|
||||
const [show, setShow] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const isMobile = window.innerWidth < 1024;
|
||||
const acknowledged = localStorage.getItem(STORAGE_KEY) === "true";
|
||||
setShow(isMobile && !acknowledged);
|
||||
}, []);
|
||||
|
||||
if (show === null) return null;
|
||||
if (!show) return <>{children}</>;
|
||||
|
||||
const handleContinue = () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
setShow(false);
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dark bg-background relative flex h-screen w-screen flex-col overflow-hidden">
|
||||
<Button
|
||||
variant="text"
|
||||
className="absolute top-6 left-6 flex items-center gap-1 text-muted-foreground"
|
||||
onClick={handleGoBack}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} className="size-4" />
|
||||
<span className=" text-sm">Go back</span>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-1 flex-col justify-center gap-5 px-7">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h1 className="text-foreground text-3xl font-bold tracking-tight">
|
||||
Desktop only (for now)
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
OpenCut isn't optimized for mobile or iPad yet. Things will break
|
||||
and the layout will be a mess. Come back on a desktop for the real
|
||||
experience.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="foreground" onClick={handleContinue}>
|
||||
Take a look anyway
|
||||
</Button>
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/roadmap" className="flex items-center gap-1">
|
||||
Roadmap
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} size={14} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export function DraggableItem({
|
||||
<AspectRatio
|
||||
ratio={aspectRatio}
|
||||
className={cn(
|
||||
"bg-panel-accent relative overflow-hidden",
|
||||
"bg-accent relative overflow-hidden",
|
||||
isRounded && "rounded-sm",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
)}
|
||||
@@ -163,7 +163,9 @@ export function DraggableItem({
|
||||
<div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
|
||||
{preview}
|
||||
</div>
|
||||
<span className="w-full flex-1 truncate text-sm text-left">{name}</span>
|
||||
<span className="w-full flex-1 truncate text-sm text-left">
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -215,7 +217,7 @@ function PlusButton({
|
||||
<Button
|
||||
size="icon"
|
||||
className={cn(
|
||||
"bg-background hover:bg-panel text-foreground absolute right-2 bottom-2 size-5",
|
||||
"bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -4,11 +4,12 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
import { TabBar } from "./tabbar";
|
||||
import { Captions } from "./views/captions";
|
||||
import { MediaView } from "./views/media";
|
||||
import { MediaView } from "./views/assets";
|
||||
import { SettingsView } from "./views/settings";
|
||||
import { SoundsView } from "./views/sounds";
|
||||
import { StickersView } from "./views/stickers";
|
||||
import { TextView } from "./views/text";
|
||||
import { EffectsView } from "./views/effects";
|
||||
|
||||
export function AssetsPanel() {
|
||||
const { activeTab } = useAssetsPanelStore();
|
||||
@@ -18,11 +19,7 @@ export function AssetsPanel() {
|
||||
sounds: <SoundsView />,
|
||||
text: <TextView />,
|
||||
stickers: <StickersView />,
|
||||
effects: (
|
||||
<div className="text-muted-foreground p-4">
|
||||
Effects view coming soon...
|
||||
</div>
|
||||
),
|
||||
effects: <EffectsView />,
|
||||
transitions: (
|
||||
<div className="text-muted-foreground p-4">
|
||||
Transitions view coming soon...
|
||||
@@ -43,7 +40,7 @@ export function AssetsPanel() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-panel flex h-full">
|
||||
<div className="panel bg-background flex h-full rounded-sm border overflow-hidden">
|
||||
<TabBar />
|
||||
<Separator orientation="vertical" />
|
||||
<div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
TAB_KEYS,
|
||||
@@ -15,9 +16,9 @@ import {
|
||||
|
||||
export function TabBar() {
|
||||
const { activeTab, setActiveTab } = useAssetsPanelStore();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showTopFade, setShowTopFade] = useState(false);
|
||||
const [showBottomFade, setShowBottomFade] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const checkScrollPosition = useCallback(() => {
|
||||
const element = scrollRef.current;
|
||||
@@ -48,32 +49,25 @@ export function TabBar() {
|
||||
<div className="relative flex">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scrollbar-hidden relative flex size-full flex-col items-center justify-start gap-5 overflow-y-auto px-4 py-4"
|
||||
className="scrollbar-hidden relative flex size-full p-2 flex-col items-center justify-start gap-1.5 overflow-y-auto"
|
||||
>
|
||||
{TAB_KEYS.map((tabKey) => {
|
||||
const tab = tabs[tabKey];
|
||||
return (
|
||||
<Tooltip key={tabKey} delayDuration={10}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant={activeTab === tabKey ? "secondary" : "text"}
|
||||
aria-label={tab.label}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center gap-0.5 [&>svg]:size-4.5! opacity-100 hover:opacity-75",
|
||||
activeTab === tabKey
|
||||
? "text-primary !opacity-100"
|
||||
: "text-muted-foreground",
|
||||
"flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5",
|
||||
activeTab !== tabKey &&
|
||||
"border border-transparent text-muted-foreground",
|
||||
)}
|
||||
onClick={() => setActiveTab(tabKey)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setActiveTab(tabKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<tab.icon className=" " />
|
||||
</button>
|
||||
<tab.icon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
@@ -108,8 +102,8 @@ function FadeOverlay({
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-0 left-0 h-6",
|
||||
direction === "top" && show
|
||||
? "from-panel top-0 bg-gradient-to-b to-transparent"
|
||||
: "from-panel bottom-0 bg-gradient-to-t to-transparent",
|
||||
? "from-background top-0 bg-gradient-to-b to-transparent"
|
||||
: "from-background bottom-0 bg-gradient-to-t to-transparent",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
|
||||
import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay";
|
||||
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useFileUpload } from "@/hooks/use-file-upload";
|
||||
import { useRevealItem } from "@/hooks/use-reveal-item";
|
||||
import { processMediaAssets } from "@/lib/media/processing";
|
||||
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
|
||||
import {
|
||||
type MediaSortKey,
|
||||
type MediaSortOrder,
|
||||
type MediaViewMode,
|
||||
useAssetsPanelStore,
|
||||
} from "@/stores/assets-panel-store";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
CloudUploadIcon,
|
||||
GridViewIcon,
|
||||
LeftToRightListDashIcon,
|
||||
SortingOneNineIcon,
|
||||
Image02Icon,
|
||||
MusicNote03Icon,
|
||||
Video01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
|
||||
|
||||
export function MediaView() {
|
||||
const editor = useEditor();
|
||||
const mediaFiles = editor.media.getAssets();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const {
|
||||
mediaViewMode,
|
||||
setMediaViewMode,
|
||||
highlightMediaId,
|
||||
clearHighlight,
|
||||
mediaSortBy,
|
||||
mediaSortOrder,
|
||||
setMediaSort,
|
||||
} = useAssetsPanelStore();
|
||||
const { highlightedId, registerElement } = useRevealItem(
|
||||
highlightMediaId,
|
||||
clearHighlight,
|
||||
);
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const processFiles = async ({ files }: { files: FileList }) => {
|
||||
if (!files || files.length === 0) return;
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setProgress(0);
|
||||
try {
|
||||
const processedAssets = await processMediaAssets({
|
||||
files,
|
||||
onProgress: (progress: { progress: number }) =>
|
||||
setProgress(progress.progress),
|
||||
});
|
||||
for (const asset of processedAssets) {
|
||||
await editor.media.addMediaAsset({
|
||||
projectId: activeProject.metadata.id,
|
||||
asset,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing files:", error);
|
||||
toast.error("Failed to process files");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const { isDragOver, dragProps, openFilePicker, fileInputProps } =
|
||||
useFileUpload({
|
||||
accept: "image/*,video/*,audio/*",
|
||||
multiple: true,
|
||||
onFilesSelected: (files) => processFiles({ files }),
|
||||
});
|
||||
|
||||
const handleRemove = async ({
|
||||
event,
|
||||
id,
|
||||
}: {
|
||||
event: React.MouseEvent;
|
||||
id: string;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
await editor.media.removeMediaAsset({
|
||||
projectId: activeProject.metadata.id,
|
||||
id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSort = ({ key }: { key: MediaSortKey }) => {
|
||||
if (mediaSortBy === key) {
|
||||
setMediaSort(key, mediaSortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setMediaSort(key, "asc");
|
||||
}
|
||||
};
|
||||
|
||||
const filteredMediaItems = useMemo(() => {
|
||||
const filtered = mediaFiles.filter((item) => !item.ephemeral);
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
let valueA: string | number;
|
||||
let valueB: string | number;
|
||||
|
||||
switch (mediaSortBy) {
|
||||
case "name":
|
||||
valueA = a.name.toLowerCase();
|
||||
valueB = b.name.toLowerCase();
|
||||
break;
|
||||
case "type":
|
||||
valueA = a.type;
|
||||
valueB = b.type;
|
||||
break;
|
||||
case "duration":
|
||||
valueA = a.duration || 0;
|
||||
valueB = b.duration || 0;
|
||||
break;
|
||||
case "size":
|
||||
valueA = a.file.size;
|
||||
valueB = b.file.size;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (valueA < valueB) return mediaSortOrder === "asc" ? -1 : 1;
|
||||
if (valueA > valueB) return mediaSortOrder === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [mediaFiles, mediaSortBy, mediaSortOrder]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input {...fileInputProps} />
|
||||
|
||||
<PanelView
|
||||
title="Assets"
|
||||
actions={
|
||||
<MediaActions
|
||||
mediaViewMode={mediaViewMode}
|
||||
setMediaViewMode={setMediaViewMode}
|
||||
isProcessing={isProcessing}
|
||||
sortBy={mediaSortBy}
|
||||
sortOrder={mediaSortOrder}
|
||||
onSort={handleSort}
|
||||
onImport={openFilePicker}
|
||||
/>
|
||||
}
|
||||
className={cn(isDragOver && "bg-accent/30")}
|
||||
{...dragProps}
|
||||
>
|
||||
{isDragOver || filteredMediaItems.length === 0 ? (
|
||||
<MediaDragOverlay
|
||||
isVisible={true}
|
||||
isProcessing={isProcessing}
|
||||
progress={progress}
|
||||
onClick={openFilePicker}
|
||||
/>
|
||||
) : (
|
||||
<MediaItemList
|
||||
items={filteredMediaItems}
|
||||
mode={mediaViewMode}
|
||||
onRemove={handleRemove}
|
||||
highlightedId={highlightedId}
|
||||
registerElement={registerElement}
|
||||
/>
|
||||
)}
|
||||
</PanelView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaAssetDraggable({
|
||||
item,
|
||||
preview,
|
||||
isHighlighted,
|
||||
variant,
|
||||
isRounded,
|
||||
}: {
|
||||
item: MediaAsset;
|
||||
preview: React.ReactNode;
|
||||
isHighlighted: boolean;
|
||||
variant: "card" | "compact";
|
||||
isRounded?: boolean;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const addElementAtTime = ({
|
||||
asset,
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
}) => {
|
||||
const duration =
|
||||
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
const element = buildElementFromMedia({
|
||||
mediaId: asset.id,
|
||||
mediaType: asset.type,
|
||||
name: asset.name,
|
||||
duration,
|
||||
startTime,
|
||||
});
|
||||
editor.timeline.insertElement({
|
||||
element,
|
||||
placement: { mode: "auto" },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableItem
|
||||
name={item.name}
|
||||
preview={preview}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: "media",
|
||||
mediaType: item.type,
|
||||
name: item.name,
|
||||
...(item.type !== "audio" && {
|
||||
targetElementTypes: ["video", "image"] as const,
|
||||
}),
|
||||
}}
|
||||
shouldShowPlusOnDrag={false}
|
||||
onAddToTimeline={({ currentTime }) =>
|
||||
addElementAtTime({ asset: item, startTime: currentTime })
|
||||
}
|
||||
variant={variant}
|
||||
isRounded={isRounded}
|
||||
isHighlighted={isHighlighted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaItemWithContextMenu({
|
||||
item,
|
||||
children,
|
||||
onRemove,
|
||||
}: {
|
||||
item: MediaAsset;
|
||||
children: React.ReactNode;
|
||||
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Export clips</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={(event) => onRemove({ event, id: item.id })}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaItemList({
|
||||
items,
|
||||
mode,
|
||||
onRemove,
|
||||
highlightedId,
|
||||
registerElement,
|
||||
}: {
|
||||
items: MediaAsset[];
|
||||
mode: MediaViewMode;
|
||||
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
|
||||
highlightedId: string | null;
|
||||
registerElement: (id: string, element: HTMLElement | null) => void;
|
||||
}) {
|
||||
const isGrid = mode === "grid";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(isGrid ? "grid gap-2" : "flex flex-col gap-1")}
|
||||
style={
|
||||
isGrid ? { gridTemplateColumns: "repeat(auto-fill, 160px)" } : undefined
|
||||
}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} ref={(element) => registerElement(item.id, element)}>
|
||||
<MediaItemWithContextMenu item={item} onRemove={onRemove}>
|
||||
<MediaAssetDraggable
|
||||
item={item}
|
||||
preview={
|
||||
<MediaPreview
|
||||
item={item}
|
||||
variant={isGrid ? "grid" : "compact"}
|
||||
/>
|
||||
}
|
||||
variant={isGrid ? "card" : "compact"}
|
||||
isRounded={isGrid ? false : undefined}
|
||||
isHighlighted={highlightedId === item.id}
|
||||
/>
|
||||
</MediaItemWithContextMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDuration({ duration }: { duration: number }) {
|
||||
const min = Math.floor(duration / 60);
|
||||
const sec = Math.floor(duration % 60);
|
||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function MediaDurationBadge({ duration }: { duration?: number }) {
|
||||
if (!duration) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute right-1 bottom-1 rounded bg-black/70 px-1 text-xs text-white">
|
||||
{formatDuration({ duration })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaDurationLabel({ duration }: { duration?: number }) {
|
||||
if (!duration) return null;
|
||||
|
||||
return (
|
||||
<span className="text-xs opacity-70">{formatDuration({ duration })}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaTypePlaceholder({
|
||||
icon,
|
||||
label,
|
||||
duration,
|
||||
variant,
|
||||
}: {
|
||||
icon: IconSvgElement;
|
||||
label: string;
|
||||
duration?: number;
|
||||
variant: "muted" | "bordered";
|
||||
}) {
|
||||
const iconClassName = cn("size-6", variant === "bordered" && "mb-1");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground flex size-full flex-col items-center justify-center rounded",
|
||||
variant === "muted" ? "bg-muted/30" : "border",
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon icon={icon} className={iconClassName} />
|
||||
<span className="text-xs">{label}</span>
|
||||
<MediaDurationLabel duration={duration} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaPreview({
|
||||
item,
|
||||
variant = "grid",
|
||||
}: {
|
||||
item: MediaAsset;
|
||||
variant?: "grid" | "compact";
|
||||
}) {
|
||||
const shouldShowDurationBadge = variant === "grid";
|
||||
|
||||
if (item.type === "image") {
|
||||
return (
|
||||
<div className="relative flex size-full items-center justify-center">
|
||||
<Image
|
||||
src={item.url ?? ""}
|
||||
alt={item.name}
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-cover"
|
||||
loading="lazy"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "video") {
|
||||
if (item.thumbnailUrl) {
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
<Image
|
||||
src={item.thumbnailUrl}
|
||||
alt={item.name}
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="rounded object-cover"
|
||||
loading="lazy"
|
||||
unoptimized
|
||||
/>
|
||||
{shouldShowDurationBadge ? (
|
||||
<MediaDurationBadge duration={item.duration} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaTypePlaceholder
|
||||
icon={Video01Icon}
|
||||
label="Video"
|
||||
duration={item.duration}
|
||||
variant="muted"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "audio") {
|
||||
return (
|
||||
<MediaTypePlaceholder
|
||||
icon={MusicNote03Icon}
|
||||
label="Audio"
|
||||
duration={item.duration}
|
||||
variant="bordered"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaTypePlaceholder icon={Image02Icon} label="Unknown" variant="muted" />
|
||||
);
|
||||
}
|
||||
|
||||
function MediaActions({
|
||||
mediaViewMode,
|
||||
setMediaViewMode,
|
||||
isProcessing,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSort,
|
||||
onImport,
|
||||
}: {
|
||||
mediaViewMode: MediaViewMode;
|
||||
setMediaViewMode: (mode: MediaViewMode) => void;
|
||||
isProcessing: boolean;
|
||||
sortBy: MediaSortKey;
|
||||
sortOrder: MediaSortOrder;
|
||||
onSort: ({ key }: { key: MediaSortKey }) => void;
|
||||
onImport: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-1.5">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid")
|
||||
}
|
||||
disabled={isProcessing}
|
||||
className="items-center justify-center"
|
||||
>
|
||||
{mediaViewMode === "grid" ? (
|
||||
<HugeiconsIcon icon={LeftToRightListDashIcon} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={GridViewIcon} />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{mediaViewMode === "grid"
|
||||
? "Switch to list view"
|
||||
: "Switch to grid view"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<DropdownMenu>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={isProcessing}
|
||||
className="items-center justify-center"
|
||||
>
|
||||
<HugeiconsIcon icon={SortingOneNineIcon} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<SortMenuItem
|
||||
label="Name"
|
||||
sortKey="name"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="Type"
|
||||
sortKey="type"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="Duration"
|
||||
sortKey="duration"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="File size"
|
||||
sortKey="size"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={onSort}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Sort by {sortBy} (
|
||||
{sortOrder === "asc" ? "ascending" : "descending"})
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onImport}
|
||||
disabled={isProcessing}
|
||||
size="sm"
|
||||
className="items-center justify-center gap-1.5"
|
||||
>
|
||||
<HugeiconsIcon icon={CloudUploadIcon} />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortMenuItem({
|
||||
label,
|
||||
sortKey,
|
||||
currentSortBy,
|
||||
currentSortOrder,
|
||||
onSort,
|
||||
}: {
|
||||
label: string;
|
||||
sortKey: MediaSortKey;
|
||||
currentSortBy: MediaSortKey;
|
||||
currentSortOrder: MediaSortOrder;
|
||||
onSort: ({ key }: { key: MediaSortKey }) => void;
|
||||
}) {
|
||||
const isActive = currentSortBy === sortKey;
|
||||
const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : "";
|
||||
|
||||
return (
|
||||
<DropdownMenuItem onClick={() => onSort({ key: sortKey })}>
|
||||
{label} {arrow}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
actions?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
contentClassName?: string;
|
||||
hideHeader?: boolean;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
onScroll?: React.UIEventHandler<HTMLDivElement>;
|
||||
scrollRef?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function PanelView({
|
||||
title,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
hideHeader = false,
|
||||
ref,
|
||||
onScroll,
|
||||
scrollRef,
|
||||
...rest
|
||||
}: PanelViewProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex h-full flex-col", className)}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
>
|
||||
{!hideHeader && (
|
||||
<div className="bg-background h-11 shrink-0 px-4 pr-2 flex items-center justify-between border-b">
|
||||
<span className="text-muted-foreground text-sm">{title}</span>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"scrollbar-thin size-full overflow-y-auto",
|
||||
hideHeader ? "pt-4" : "pt-2",
|
||||
)}
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
<div className={cn("w-full flex-1 px-2 pt-0", contentClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -21,6 +20,7 @@ import { transcriptionService } from "@/services/transcription/service";
|
||||
import { decodeAudioToFloat32 } from "@/lib/media/audio";
|
||||
import { buildCaptionChunks } from "@/lib/transcription/caption";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function Captions() {
|
||||
const [selectedLanguage, setSelectedLanguage] =
|
||||
@@ -108,16 +108,14 @@ export function Captions() {
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseView
|
||||
ref={containerRef}
|
||||
className="flex h-full flex-col justify-between"
|
||||
>
|
||||
<PropertyGroup title="Language">
|
||||
<PanelView title="Captions" ref={containerRef}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>Language</Label>
|
||||
<Select
|
||||
value={selectedLanguage}
|
||||
onValueChange={(value) => handleLanguageChange({ value })}
|
||||
>
|
||||
<SelectTrigger className="bg-panel-accent h-8 w-full text-xs">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -129,7 +127,7 @@ export function Captions() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PropertyGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && (
|
||||
@@ -147,6 +145,6 @@ export function Captions() {
|
||||
{isProcessing ? processingStep : "Generate transcript"}
|
||||
</Button>
|
||||
</div>
|
||||
</BaseView>
|
||||
</PanelView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
|
||||
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
|
||||
import { getAllEffects, EFFECT_TARGET_ELEMENT_TYPES } from "@/lib/effects";
|
||||
import {
|
||||
effectPreviewService,
|
||||
onPreviewImageReady,
|
||||
} from "@/services/renderer/effect-preview";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { buildEffectElement } from "@/lib/timeline/element-utils";
|
||||
import type { EffectDefinition } from "@/types/effects";
|
||||
|
||||
export function EffectsView() {
|
||||
const effects = getAllEffects();
|
||||
|
||||
return (
|
||||
<PanelView title="Effects">
|
||||
<EffectsGrid effects={effects} />
|
||||
</PanelView>
|
||||
);
|
||||
}
|
||||
|
||||
function EffectsGrid({ effects }: { effects: EffectDefinition[] }) {
|
||||
return (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(96px, 1fr))" }}
|
||||
>
|
||||
{effects.map((effect) => (
|
||||
<EffectItem key={effect.type} effect={effect} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EffectPreviewCanvas({ effectType }: { effectType: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const render = () => {
|
||||
if (canvasRef.current) {
|
||||
effectPreviewService.renderPreview({
|
||||
effectType,
|
||||
params: {},
|
||||
targetCanvas: canvasRef.current,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render();
|
||||
return onPreviewImageReady({ callback: render });
|
||||
}, [effectType]);
|
||||
|
||||
return <canvas ref={canvasRef} className="size-full" />;
|
||||
}
|
||||
|
||||
function EffectItem({ effect }: { effect: EffectDefinition }) {
|
||||
const editor = useEditor();
|
||||
|
||||
const handleAddToTimeline = useCallback(() => {
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const element = buildEffectElement({
|
||||
effectType: effect.type,
|
||||
startTime: currentTime,
|
||||
});
|
||||
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "auto", trackType: "effect" },
|
||||
element,
|
||||
});
|
||||
}, [editor, effect.type]);
|
||||
|
||||
const preview = <EffectPreviewCanvas effectType={effect.type} />;
|
||||
|
||||
return (
|
||||
<DraggableItem
|
||||
name={effect.name}
|
||||
preview={preview}
|
||||
dragData={{
|
||||
id: effect.type,
|
||||
name: effect.name,
|
||||
type: "effect",
|
||||
effectType: effect.type,
|
||||
targetElementTypes: EFFECT_TARGET_ELEMENT_TYPES,
|
||||
}}
|
||||
onAddToTimeline={handleAddToTimeline}
|
||||
aspectRatio={1}
|
||||
isRounded
|
||||
variant="card"
|
||||
containerClassName="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,675 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay";
|
||||
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useFileUpload } from "@/hooks/use-file-upload";
|
||||
import { useRevealItem } from "@/hooks/use-reveal-item";
|
||||
import { processMediaAssets } from "@/lib/media/processing";
|
||||
import {
|
||||
buildImageElement,
|
||||
buildUploadAudioElement,
|
||||
buildVideoElement,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import type { CreateTimelineElement } from "@/types/timeline";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
CloudUploadIcon,
|
||||
GridViewIcon,
|
||||
LeftToRightListDashIcon,
|
||||
SortingOneNineIcon,
|
||||
Image02Icon,
|
||||
MusicNote03Icon,
|
||||
Video01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
|
||||
|
||||
export function MediaView() {
|
||||
const editor = useEditor();
|
||||
const mediaFiles = editor.media.getAssets();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } =
|
||||
useAssetsPanelStore();
|
||||
const { highlightedId, registerElement } = useRevealItem(
|
||||
highlightMediaId,
|
||||
clearHighlight,
|
||||
);
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
|
||||
"name",
|
||||
);
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
|
||||
const processFiles = async ({ files }: { files: FileList }) => {
|
||||
if (!files || files.length === 0) return;
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setProgress(0);
|
||||
try {
|
||||
const processedAssets = await processMediaAssets({
|
||||
files,
|
||||
onProgress: (progress: { progress: number }) =>
|
||||
setProgress(progress.progress),
|
||||
});
|
||||
for (const asset of processedAssets) {
|
||||
await editor.media.addMediaAsset({
|
||||
projectId: activeProject.metadata.id,
|
||||
asset,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing files:", error);
|
||||
toast.error("Failed to process files");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const { isDragOver, dragProps, openFilePicker, fileInputProps } =
|
||||
useFileUpload({
|
||||
accept: "image/*,video/*,audio/*",
|
||||
multiple: true,
|
||||
onFilesSelected: (files) => processFiles({ files }),
|
||||
});
|
||||
|
||||
const handleRemove = async ({
|
||||
event,
|
||||
id,
|
||||
}: {
|
||||
event: React.MouseEvent;
|
||||
id: string;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
await editor.media.removeMediaAsset({
|
||||
projectId: activeProject.metadata.id,
|
||||
id,
|
||||
});
|
||||
};
|
||||
|
||||
const addElementAtTime = ({
|
||||
asset,
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
}): boolean => {
|
||||
const element = createElementFromMedia({ asset, startTime });
|
||||
editor.timeline.insertElement({
|
||||
element,
|
||||
placement: { mode: "auto" },
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const filteredMediaItems = useMemo(() => {
|
||||
const filtered = mediaFiles.filter((item) => !item.ephemeral);
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
let valueA: string | number;
|
||||
let valueB: string | number;
|
||||
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
valueA = a.name.toLowerCase();
|
||||
valueB = b.name.toLowerCase();
|
||||
break;
|
||||
case "type":
|
||||
valueA = a.type;
|
||||
valueB = b.type;
|
||||
break;
|
||||
case "duration":
|
||||
valueA = a.duration || 0;
|
||||
valueB = b.duration || 0;
|
||||
break;
|
||||
case "size":
|
||||
valueA = a.file.size;
|
||||
valueB = b.file.size;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (valueA < valueB) return sortOrder === "asc" ? -1 : 1;
|
||||
if (valueA > valueB) return sortOrder === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [mediaFiles, sortBy, sortOrder]);
|
||||
|
||||
const previewComponents = useMemo(() => {
|
||||
const previews = new Map<string, React.ReactNode>();
|
||||
|
||||
filteredMediaItems.forEach((item) => {
|
||||
previews.set(item.id, <MediaPreview item={item} />);
|
||||
previews.set(
|
||||
`compact-${item.id}`,
|
||||
<MediaPreview item={item} variant="compact" />,
|
||||
);
|
||||
});
|
||||
|
||||
return previews;
|
||||
}, [filteredMediaItems]);
|
||||
|
||||
const renderPreview = (item: MediaAsset) => previewComponents.get(item.id);
|
||||
const renderCompactPreview = (item: MediaAsset) =>
|
||||
previewComponents.get(`compact-${item.id}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input {...fileInputProps} />
|
||||
|
||||
<div
|
||||
className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`}
|
||||
{...dragProps}
|
||||
>
|
||||
<div className="bg-panel py-2 px-4 flex items-center justify-between border-b">
|
||||
<span className="text-muted-foreground text-sm">Assets</span>
|
||||
<div className="flex items-center gap-0">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="text"
|
||||
onClick={() =>
|
||||
setMediaViewMode(
|
||||
mediaViewMode === "grid" ? "list" : "grid",
|
||||
)
|
||||
}
|
||||
disabled={isProcessing}
|
||||
className="items-center justify-center"
|
||||
>
|
||||
{mediaViewMode === "grid" ? (
|
||||
<HugeiconsIcon icon={LeftToRightListDashIcon} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={GridViewIcon} />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{mediaViewMode === "grid"
|
||||
? "Switch to list view"
|
||||
: "Switch to grid view"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
<Tooltip>
|
||||
<DropdownMenu>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="text"
|
||||
disabled={isProcessing}
|
||||
className="items-center justify-center"
|
||||
>
|
||||
<HugeiconsIcon icon={SortingOneNineIcon} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<SortMenuItem
|
||||
label="Name"
|
||||
sortKey="name"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="Type"
|
||||
sortKey="type"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="Duration"
|
||||
sortKey="duration"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="File size"
|
||||
sortKey="size"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Sort by {sortBy} (
|
||||
{sortOrder === "asc" ? "ascending" : "descending"})
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={openFilePicker}
|
||||
disabled={isProcessing}
|
||||
size="sm"
|
||||
className="items-center justify-center gap-1.5 ml-1.5"
|
||||
>
|
||||
<HugeiconsIcon icon={CloudUploadIcon} />
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-thin size-full overflow-y-auto pt-1">
|
||||
<div className="w-full flex-1 p-3 pt-0">
|
||||
{isDragOver || filteredMediaItems.length === 0 ? (
|
||||
<MediaDragOverlay
|
||||
isVisible={true}
|
||||
isProcessing={isProcessing}
|
||||
progress={progress}
|
||||
onClick={openFilePicker}
|
||||
/>
|
||||
) : mediaViewMode === "grid" ? (
|
||||
<GridView
|
||||
items={filteredMediaItems}
|
||||
renderPreview={renderPreview}
|
||||
onRemove={handleRemove}
|
||||
onAddToTimeline={addElementAtTime}
|
||||
highlightedId={highlightedId}
|
||||
registerElement={registerElement}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
items={filteredMediaItems}
|
||||
renderPreview={renderCompactPreview}
|
||||
onRemove={handleRemove}
|
||||
onAddToTimeline={addElementAtTime}
|
||||
highlightedId={highlightedId}
|
||||
registerElement={registerElement}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaItemWithContextMenu({
|
||||
item,
|
||||
children,
|
||||
onRemove,
|
||||
}: {
|
||||
item: MediaAsset;
|
||||
children: React.ReactNode;
|
||||
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Export clips</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={(event) => onRemove({ event, id: item.id })}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function GridView({
|
||||
items,
|
||||
renderPreview,
|
||||
onRemove,
|
||||
onAddToTimeline,
|
||||
highlightedId,
|
||||
registerElement,
|
||||
}: {
|
||||
items: MediaAsset[];
|
||||
renderPreview: (item: MediaAsset) => React.ReactNode;
|
||||
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
|
||||
onAddToTimeline: ({
|
||||
asset,
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
}) => boolean;
|
||||
highlightedId: string | null;
|
||||
registerElement: (id: string, element: HTMLElement | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, 160px)",
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
|
||||
<MediaItemWithContextMenu item={item} onRemove={onRemove}>
|
||||
<DraggableItem
|
||||
name={item.name}
|
||||
preview={renderPreview(item)}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: "media",
|
||||
mediaType: item.type,
|
||||
name: item.name,
|
||||
}}
|
||||
shouldShowPlusOnDrag={false}
|
||||
onAddToTimeline={({ currentTime }) =>
|
||||
onAddToTimeline({ asset: item, startTime: currentTime })
|
||||
}
|
||||
isRounded={false}
|
||||
variant="card"
|
||||
isHighlighted={highlightedId === item.id}
|
||||
/>
|
||||
</MediaItemWithContextMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListView({
|
||||
items,
|
||||
renderPreview,
|
||||
onRemove,
|
||||
onAddToTimeline,
|
||||
highlightedId,
|
||||
registerElement,
|
||||
}: {
|
||||
items: MediaAsset[];
|
||||
renderPreview: (item: MediaAsset) => React.ReactNode;
|
||||
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
|
||||
onAddToTimeline: ({
|
||||
asset,
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
}) => boolean;
|
||||
highlightedId: string | null;
|
||||
registerElement: (id: string, element: HTMLElement | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
|
||||
<MediaItemWithContextMenu item={item} onRemove={onRemove}>
|
||||
<DraggableItem
|
||||
name={item.name}
|
||||
preview={renderPreview(item)}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: "media",
|
||||
mediaType: item.type,
|
||||
name: item.name,
|
||||
}}
|
||||
shouldShowPlusOnDrag={false}
|
||||
onAddToTimeline={({ currentTime }) =>
|
||||
onAddToTimeline({ asset: item, startTime: currentTime })
|
||||
}
|
||||
variant="compact"
|
||||
isHighlighted={highlightedId === item.id}
|
||||
/>
|
||||
</MediaItemWithContextMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatDuration = ({ duration }: { duration: number }) => {
|
||||
const min = Math.floor(duration / 60);
|
||||
const sec = Math.floor(duration % 60);
|
||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
function MediaDurationBadge({ duration }: { duration?: number }) {
|
||||
if (!duration) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute right-1 bottom-1 rounded bg-black/70 px-1 text-xs text-white">
|
||||
{formatDuration({ duration })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaDurationLabel({ duration }: { duration?: number }) {
|
||||
if (!duration) return null;
|
||||
|
||||
return (
|
||||
<span className="text-xs opacity-70">{formatDuration({ duration })}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaTypePlaceholder({
|
||||
icon,
|
||||
label,
|
||||
duration,
|
||||
variant,
|
||||
}: {
|
||||
icon: IconSvgElement;
|
||||
label: string;
|
||||
duration?: number;
|
||||
variant: "muted" | "bordered";
|
||||
}) {
|
||||
const iconClassName = cn("size-6", variant === "bordered" && "mb-1");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground flex size-full flex-col items-center justify-center rounded",
|
||||
variant === "muted" ? "bg-muted/30" : "border",
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon icon={icon} className={iconClassName} />
|
||||
<span className="text-xs">{label}</span>
|
||||
<MediaDurationLabel duration={duration} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaPreview({
|
||||
item,
|
||||
variant = "grid",
|
||||
}: {
|
||||
item: MediaAsset;
|
||||
variant?: "grid" | "compact";
|
||||
}) {
|
||||
const shouldShowDurationBadge = variant === "grid";
|
||||
|
||||
if (item.type === "image") {
|
||||
return (
|
||||
<div className="relative flex size-full items-center justify-center">
|
||||
<Image
|
||||
src={item.url ?? ""}
|
||||
alt={item.name}
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-cover"
|
||||
loading="lazy"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "video") {
|
||||
if (item.thumbnailUrl) {
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
<Image
|
||||
src={item.thumbnailUrl}
|
||||
alt={item.name}
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="rounded object-cover"
|
||||
loading="lazy"
|
||||
unoptimized
|
||||
/>
|
||||
{shouldShowDurationBadge ? (
|
||||
<MediaDurationBadge duration={item.duration} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaTypePlaceholder
|
||||
icon={Video01Icon}
|
||||
label="Video"
|
||||
duration={item.duration}
|
||||
variant="muted"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "audio") {
|
||||
return (
|
||||
<MediaTypePlaceholder
|
||||
icon={MusicNote03Icon}
|
||||
label="Audio"
|
||||
duration={item.duration}
|
||||
variant="bordered"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaTypePlaceholder icon={Image02Icon} label="Unknown" variant="muted" />
|
||||
);
|
||||
}
|
||||
|
||||
function SortMenuItem({
|
||||
label,
|
||||
sortKey,
|
||||
currentSortBy,
|
||||
currentSortOrder,
|
||||
onSort,
|
||||
}: {
|
||||
label: string;
|
||||
sortKey: "name" | "type" | "duration" | "size";
|
||||
currentSortBy: string;
|
||||
currentSortOrder: "asc" | "desc";
|
||||
onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void;
|
||||
}) {
|
||||
const isActive = currentSortBy === sortKey;
|
||||
const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : "";
|
||||
|
||||
return (
|
||||
<DropdownMenuItem onClick={() => onSort({ key: sortKey })}>
|
||||
{label} {arrow}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function createElementFromMedia({
|
||||
asset,
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
}): CreateTimelineElement {
|
||||
const duration =
|
||||
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
|
||||
switch (asset.type) {
|
||||
case "video":
|
||||
return buildVideoElement({
|
||||
mediaId: asset.id,
|
||||
name: asset.name,
|
||||
duration,
|
||||
startTime,
|
||||
});
|
||||
case "image":
|
||||
return buildImageElement({
|
||||
mediaId: asset.id,
|
||||
name: asset.name,
|
||||
duration,
|
||||
startTime,
|
||||
});
|
||||
case "audio":
|
||||
return buildUploadAudioElement({
|
||||
mediaId: asset.id,
|
||||
name: asset.name,
|
||||
duration,
|
||||
startTime,
|
||||
});
|
||||
default:
|
||||
throw new Error(`Unsupported media type: ${asset.type}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// @ts-nocheck
|
||||
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
BLUR_INTENSITY_PRESETS,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
DEFAULT_COLOR,
|
||||
FPS_PRESETS,
|
||||
} from "@/constants/project-constants";
|
||||
import { patternCraftGradients } from "@/data/colors/pattern-craft";
|
||||
import { colors } from "@/data/colors/solid";
|
||||
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { dimensionToAspectRatio } from "@/utils/geometry";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
PropertyGroup,
|
||||
PropertyItem,
|
||||
PropertyItemLabel,
|
||||
PropertyItemValue,
|
||||
} from "@/components/editor/panels/properties/section";
|
||||
|
||||
export function SettingsView() {
|
||||
return (
|
||||
<PanelView title="Project settings" contentClassName="px-0">
|
||||
<div className="flex flex-col gap-8">
|
||||
<ProjectInfoView />
|
||||
<BackgroundView />
|
||||
</div>
|
||||
</PanelView>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectInfoView() {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const { canvasPresets } = useEditorStore();
|
||||
|
||||
const findPresetIndexByAspectRatio = ({
|
||||
presets,
|
||||
targetAspectRatio,
|
||||
}: {
|
||||
presets: Array<{ width: number; height: number }>;
|
||||
targetAspectRatio: string;
|
||||
}) => {
|
||||
for (let index = 0; index < presets.length; index++) {
|
||||
const preset = presets[index];
|
||||
const presetAspectRatio = dimensionToAspectRatio({
|
||||
width: preset.width,
|
||||
height: preset.height,
|
||||
});
|
||||
if (presetAspectRatio === targetAspectRatio) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const currentCanvasSize = activeProject.settings.canvasSize;
|
||||
const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
|
||||
const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
|
||||
const presetIndex = findPresetIndexByAspectRatio({
|
||||
presets: canvasPresets,
|
||||
targetAspectRatio: currentAspectRatio,
|
||||
});
|
||||
const originalPresetValue = "original";
|
||||
const selectedPresetValue =
|
||||
presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
|
||||
|
||||
const handleAspectRatioChange = ({ value }: { value: string }) => {
|
||||
if (value === originalPresetValue) {
|
||||
const canvasSize = originalCanvasSize ?? currentCanvasSize;
|
||||
editor.project.updateSettings({
|
||||
settings: { canvasSize },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const index = parseInt(value, 10);
|
||||
const preset = canvasPresets[index];
|
||||
if (preset) {
|
||||
editor.project.updateSettings({ settings: { canvasSize: preset } });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFpsChange = (value: string) => {
|
||||
const fps = parseFloat(value);
|
||||
editor.project.updateSettings({ settings: { fps } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Name</PropertyItemLabel>
|
||||
<PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Aspect ratio</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<Select
|
||||
value={selectedPresetValue}
|
||||
onValueChange={(value) => handleAspectRatioChange({ value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an aspect ratio" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={originalPresetValue}>Original</SelectItem>
|
||||
{canvasPresets.map((preset, index) => {
|
||||
const label = dimensionToAspectRatio({
|
||||
width: preset.width,
|
||||
height: preset.height,
|
||||
});
|
||||
return (
|
||||
<SelectItem key={label} value={index.toString()}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Frame rate</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<Select
|
||||
value={activeProject.settings.fps.toString()}
|
||||
onValueChange={handleFpsChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FPS_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BlurPreview = memo(
|
||||
({
|
||||
blur,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
blur: { label: string; value: number };
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) => (
|
||||
<button
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
|
||||
isSelected && "border-primary border-2",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
aria-label={`Select ${blur.label} blur`}
|
||||
>
|
||||
<Image
|
||||
src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
|
||||
alt={`Blur preview ${blur.label}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
style={{ filter: `blur(${blur.value}px)` }}
|
||||
loading="eager"
|
||||
/>
|
||||
<div className="absolute right-1 bottom-1 left-1 text-center">
|
||||
<span className="rounded bg-black/50 px-1 text-xs text-white">
|
||||
{blur.label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
),
|
||||
);
|
||||
|
||||
BlurPreview.displayName = "BlurPreview";
|
||||
|
||||
const BackgroundPreviews = memo(
|
||||
({
|
||||
backgrounds,
|
||||
currentBackgroundColor,
|
||||
isColorBackground,
|
||||
handleColorSelect,
|
||||
useBackgroundColor = false,
|
||||
}: {
|
||||
backgrounds: string[];
|
||||
currentBackgroundColor: string;
|
||||
isColorBackground: boolean;
|
||||
handleColorSelect: ({ bg }: { bg: string }) => void;
|
||||
useBackgroundColor?: boolean;
|
||||
}) => {
|
||||
return useMemo(
|
||||
() =>
|
||||
backgrounds.map((bg, index) => (
|
||||
<button
|
||||
key={`${index}-${bg}`}
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
|
||||
isColorBackground &&
|
||||
bg === currentBackgroundColor &&
|
||||
"border-primary border-2",
|
||||
)}
|
||||
style={
|
||||
useBackgroundColor
|
||||
? { backgroundColor: bg }
|
||||
: {
|
||||
background: bg,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}
|
||||
}
|
||||
onClick={() => handleColorSelect({ bg })}
|
||||
type="button"
|
||||
aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`}
|
||||
/>
|
||||
)),
|
||||
[
|
||||
backgrounds,
|
||||
isColorBackground,
|
||||
currentBackgroundColor,
|
||||
handleColorSelect,
|
||||
useBackgroundColor,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BackgroundPreviews.displayName = "BackgroundPreviews";
|
||||
|
||||
function BackgroundView() {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
|
||||
|
||||
const handleBlurSelect = useCallback(
|
||||
async ({ blurIntensity }: { blurIntensity: number }) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "blur", blurIntensity } },
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const handleColorSelect = useCallback(
|
||||
async ({ color }: { color: string }) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "color", color } },
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const currentBlurIntensity =
|
||||
activeProject.settings.background.type === "blur"
|
||||
? activeProject.settings.background.blurIntensity
|
||||
: DEFAULT_BLUR_INTENSITY;
|
||||
|
||||
const currentBackgroundColor =
|
||||
activeProject.settings.background.type === "color"
|
||||
? activeProject.settings.background.color
|
||||
: DEFAULT_COLOR;
|
||||
|
||||
const isBlurBackground = activeProject.settings.background.type === "blur";
|
||||
const isColorBackground = activeProject.settings.background.type === "color";
|
||||
|
||||
const blurPreviews = useMemo(
|
||||
() =>
|
||||
blurLevels.map((blur) => (
|
||||
<BlurPreview
|
||||
key={blur.value}
|
||||
blur={blur}
|
||||
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
|
||||
onSelect={() => handleBlurSelect({ blurIntensity: blur.value })}
|
||||
/>
|
||||
)),
|
||||
[blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
|
||||
);
|
||||
|
||||
const backgroundSections = [
|
||||
{ title: "Colors", backgrounds: colors, useBackgroundColor: true },
|
||||
{ title: "Pattern craft", backgrounds: patternCraftGradients },
|
||||
{ title: "Syntax UI", backgrounds: syntaxUIGradients },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}>
|
||||
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
|
||||
</PropertyGroup>
|
||||
|
||||
{backgroundSections.map((section) => (
|
||||
<PropertyGroup
|
||||
key={section.title}
|
||||
title={section.title}
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BackgroundPreviews
|
||||
backgrounds={section.backgrounds}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
||||
useBackgroundColor={section.useBackgroundColor}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -10,126 +8,92 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
BLUR_INTENSITY_PRESETS,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
DEFAULT_COLOR,
|
||||
FPS_PRESETS,
|
||||
} from "@/constants/project-constants";
|
||||
import { patternCraftGradients } from "@/data/colors/pattern-craft";
|
||||
import { colors } from "@/data/colors/solid";
|
||||
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
||||
import { FPS_PRESETS } from "@/constants/project-constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { TProject } from "@/types/project";
|
||||
import { dimensionToAspectRatio } from "@/utils/geometry";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
PropertyGroup,
|
||||
PropertyItem,
|
||||
PropertyItemLabel,
|
||||
PropertyItemValue,
|
||||
} from "@/components/editor/panels/properties/property-item";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { DropperIcon } from "@hugeicons/core-free-icons";
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/editor/panels/properties/section";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
export function SettingsView() {
|
||||
return <ProjectSettingsTabs />;
|
||||
const ORIGINAL_PRESET_VALUE = "original";
|
||||
|
||||
export function findPresetIndexByAspectRatio({
|
||||
presets,
|
||||
targetAspectRatio,
|
||||
}: {
|
||||
presets: Array<{ width: number; height: number }>;
|
||||
targetAspectRatio: string;
|
||||
}) {
|
||||
for (let index = 0; index < presets.length; index++) {
|
||||
const preset = presets[index];
|
||||
const presetAspectRatio = dimensionToAspectRatio({
|
||||
width: preset.width,
|
||||
height: preset.height,
|
||||
});
|
||||
if (presetAspectRatio === targetAspectRatio) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function ProjectSettingsTabs() {
|
||||
export function SettingsView() {
|
||||
return (
|
||||
<BaseView
|
||||
defaultTab="project-info"
|
||||
tabs={[
|
||||
{
|
||||
value: "project-info",
|
||||
label: "Project info",
|
||||
content: (
|
||||
<div className="p-5">
|
||||
<ProjectInfoView />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "background",
|
||||
label: "Background",
|
||||
content: (
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div className="flex-1 p-5">
|
||||
<BackgroundView />
|
||||
<PanelView contentClassName="px-0" hideHeader>
|
||||
<div className="flex flex-col">
|
||||
<Section showTopBorder={false}>
|
||||
<SectionContent>
|
||||
<ProjectInfoContent />
|
||||
</SectionContent>
|
||||
</Section>
|
||||
<Popover>
|
||||
<Section className="cursor-pointer">
|
||||
<PopoverTrigger asChild>
|
||||
<div>
|
||||
<SectionHeader
|
||||
trailing={<div className="size-4 rounded-sm bg-red-500" />}
|
||||
>
|
||||
<SectionTitle>Background</SectionTitle>
|
||||
</SectionHeader>
|
||||
</div>
|
||||
{/* <div className="bg-panel/85 sticky -bottom-0 flex flex-col backdrop-blur-lg">
|
||||
<Separator />
|
||||
<Button className="text-muted-foreground hover:text-foreground/85 h-auto w-fit !bg-transparent p-5 py-4 text-xs shadow-none">
|
||||
Custom background
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</div> */}
|
||||
|
||||
{/* another ui */}
|
||||
{/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0">
|
||||
<Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full">
|
||||
<span className="text-sm">Custom</span>
|
||||
<PlusIcon className="" />
|
||||
</Button>
|
||||
</div> */}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
className="flex h-full flex-col justify-between p-0"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
</Section>
|
||||
<PopoverContent>
|
||||
<div className="size-4 rounded-sm bg-red-500" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</PanelView>
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentCanvasSize({ activeProject }: { activeProject: TProject }) {
|
||||
const { canvasSize } = activeProject.settings;
|
||||
|
||||
return {
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
};
|
||||
}
|
||||
|
||||
function ProjectInfoView() {
|
||||
function ProjectInfoContent() {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const { canvasPresets } = useEditorStore();
|
||||
|
||||
const findPresetIndexByAspectRatio = ({
|
||||
presets,
|
||||
targetAspectRatio,
|
||||
}: {
|
||||
presets: Array<{ width: number; height: number }>;
|
||||
targetAspectRatio: string;
|
||||
}) => {
|
||||
for (let index = 0; index < presets.length; index++) {
|
||||
const preset = presets[index];
|
||||
const presetAspectRatio = dimensionToAspectRatio({
|
||||
width: preset.width,
|
||||
height: preset.height,
|
||||
});
|
||||
if (presetAspectRatio === targetAspectRatio) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const currentCanvasSize = getCurrentCanvasSize({ activeProject });
|
||||
const currentCanvasSize = activeProject.settings.canvasSize;
|
||||
const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
|
||||
const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
|
||||
const presetIndex = findPresetIndexByAspectRatio({
|
||||
presets: canvasPresets,
|
||||
targetAspectRatio: currentAspectRatio,
|
||||
});
|
||||
const originalPresetValue = "original";
|
||||
const selectedPresetValue =
|
||||
presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
|
||||
presetIndex !== -1 ? presetIndex.toString() : ORIGINAL_PRESET_VALUE;
|
||||
|
||||
const handleAspectRatioChange = ({ value }: { value: string }) => {
|
||||
if (value === originalPresetValue) {
|
||||
if (value === ORIGINAL_PRESET_VALUE) {
|
||||
const canvasSize = originalCanvasSize ?? currentCanvasSize;
|
||||
editor.project.updateSettings({
|
||||
settings: { canvasSize },
|
||||
@@ -143,252 +107,62 @@ function ProjectInfoView() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFpsChange = (value: string) => {
|
||||
const handleFpsChange = ({ value }: { value: string }) => {
|
||||
const fps = parseFloat(value);
|
||||
editor.project.updateSettings({ settings: { fps } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Name</PropertyItemLabel>
|
||||
<PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Aspect ratio</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<Select
|
||||
value={selectedPresetValue}
|
||||
onValueChange={(value) => handleAspectRatioChange({ value })}
|
||||
>
|
||||
<SelectTrigger className="bg-panel-accent">
|
||||
<SelectValue placeholder="Select an aspect ratio" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={originalPresetValue}>Original</SelectItem>
|
||||
{canvasPresets.map((preset, index) => {
|
||||
const label = dimensionToAspectRatio({
|
||||
width: preset.width,
|
||||
height: preset.height,
|
||||
});
|
||||
return (
|
||||
<SelectItem key={label} value={index.toString()}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Frame rate</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<Select
|
||||
value={activeProject.settings.fps.toString()}
|
||||
onValueChange={handleFpsChange}
|
||||
>
|
||||
<SelectTrigger className="bg-panel-accent">
|
||||
<SelectValue placeholder="Select a frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FPS_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BlurPreview = memo(
|
||||
({
|
||||
blur,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
blur: { label: string; value: number };
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) => (
|
||||
<button
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary relative aspect-square w-full cursor-pointer overflow-hidden rounded-sm border",
|
||||
isSelected && "border-primary border-2",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
aria-label={`Select ${blur.label} blur`}
|
||||
>
|
||||
<Image
|
||||
src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
|
||||
alt={`Blur preview ${blur.label}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
style={{ filter: `blur(${blur.value}px)` }}
|
||||
loading="eager"
|
||||
/>
|
||||
<div className="absolute right-1 bottom-1 left-1 text-center">
|
||||
<span className="rounded bg-black/50 px-1 text-xs text-white">
|
||||
{blur.label}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Name</Label>
|
||||
<span className="leading-none text-sm">
|
||||
{activeProject.metadata.name}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
),
|
||||
);
|
||||
|
||||
BlurPreview.displayName = "BlurPreview";
|
||||
|
||||
const BackgroundPreviews = memo(
|
||||
({
|
||||
backgrounds,
|
||||
currentBackgroundColor,
|
||||
isColorBackground,
|
||||
handleColorSelect,
|
||||
useBackgroundColor = false,
|
||||
}: {
|
||||
backgrounds: string[];
|
||||
currentBackgroundColor: string;
|
||||
isColorBackground: boolean;
|
||||
handleColorSelect: ({ bg }: { bg: string }) => void;
|
||||
useBackgroundColor?: boolean;
|
||||
}) => {
|
||||
return useMemo(
|
||||
() =>
|
||||
backgrounds.map((bg, index) => (
|
||||
<button
|
||||
key={`${index}-${bg}`}
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary aspect-square w-full cursor-pointer rounded-sm border",
|
||||
isColorBackground &&
|
||||
bg === currentBackgroundColor &&
|
||||
"border-primary border-2",
|
||||
)}
|
||||
style={
|
||||
useBackgroundColor
|
||||
? { backgroundColor: bg }
|
||||
: {
|
||||
background: bg,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}
|
||||
}
|
||||
onClick={() => handleColorSelect({ bg })}
|
||||
type="button"
|
||||
aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`}
|
||||
/>
|
||||
)),
|
||||
[
|
||||
backgrounds,
|
||||
isColorBackground,
|
||||
currentBackgroundColor,
|
||||
handleColorSelect,
|
||||
useBackgroundColor,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BackgroundPreviews.displayName = "BackgroundPreviews";
|
||||
|
||||
function BackgroundView() {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
|
||||
|
||||
const handleBlurSelect = useCallback(
|
||||
async ({ blurIntensity }: { blurIntensity: number }) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "blur", blurIntensity } },
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const handleColorSelect = useCallback(
|
||||
async ({ color }: { color: string }) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "color", color } },
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const currentBlurIntensity =
|
||||
activeProject.settings.background.type === "blur"
|
||||
? activeProject.settings.background.blurIntensity
|
||||
: DEFAULT_BLUR_INTENSITY;
|
||||
|
||||
const currentBackgroundColor =
|
||||
activeProject.settings.background.type === "color"
|
||||
? activeProject.settings.background.color
|
||||
: DEFAULT_COLOR;
|
||||
|
||||
const isBlurBackground = activeProject.settings.background.type === "blur";
|
||||
const isColorBackground = activeProject.settings.background.type === "color";
|
||||
|
||||
const blurPreviews = useMemo(
|
||||
() =>
|
||||
blurLevels.map((blur) => (
|
||||
<BlurPreview
|
||||
key={blur.value}
|
||||
blur={blur}
|
||||
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
|
||||
onSelect={() => handleBlurSelect({ blurIntensity: blur.value })}
|
||||
/>
|
||||
)),
|
||||
[blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<PropertyGroup title="Blur" defaultExpanded={false}>
|
||||
<div className="grid w-full grid-cols-4 gap-2">{blurPreviews}</div>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup title="Colors" defaultExpanded={false}>
|
||||
<div className="grid w-full grid-cols-4 gap-2">
|
||||
<div className="border-foreground/15 hover:border-primary flex aspect-square w-full cursor-pointer items-center justify-center rounded-sm border">
|
||||
<HugeiconsIcon icon={DropperIcon} className="size-4" />
|
||||
</div>
|
||||
<BackgroundPreviews
|
||||
backgrounds={colors}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
||||
useBackgroundColor={true}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup title="Pattern craft" defaultExpanded={false}>
|
||||
<div className="grid w-full grid-cols-4 gap-2">
|
||||
<BackgroundPreviews
|
||||
backgrounds={patternCraftGradients}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup title="Syntax UI" defaultExpanded={false}>
|
||||
<div className="grid w-full grid-cols-4 gap-2">
|
||||
<BackgroundPreviews
|
||||
backgrounds={syntaxUIGradients}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Aspect ratio</Label>
|
||||
<Select
|
||||
value={selectedPresetValue}
|
||||
onValueChange={(value) => handleAspectRatioChange({ value })}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Select an aspect ratio" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ORIGINAL_PRESET_VALUE}>Original</SelectItem>
|
||||
{canvasPresets.map((preset, index) => {
|
||||
const label = dimensionToAspectRatio({
|
||||
width: preset.width,
|
||||
height: preset.height,
|
||||
});
|
||||
return (
|
||||
<SelectItem key={label} value={index.toString()}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Frame rate</Label>
|
||||
<Select
|
||||
value={activeProject.settings.fps.toString()}
|
||||
onValueChange={(value) => handleFpsChange({ value })}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Select a frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FPS_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ function SoundEffectsView() {
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
placeholder="Search sound effects"
|
||||
className="bg-panel-accent w-full"
|
||||
className="w-full"
|
||||
containerClassName="w-full"
|
||||
value={searchQuery}
|
||||
onChange={({ currentTarget }) =>
|
||||
@@ -406,7 +406,7 @@ function SavedSoundsView() {
|
||||
|
||||
if (savedSounds.length === 0) {
|
||||
return (
|
||||
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<HugeiconsIcon
|
||||
icon={FavouriteIcon}
|
||||
className="text-muted-foreground size-10"
|
||||
|
||||
@@ -4,144 +4,104 @@ import Image from "next/image";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
|
||||
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputWithBack } from "@/components/ui/input-with-back";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { STICKER_CATEGORIES } from "@/constants/stickers-constants";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
import {
|
||||
buildIconSvgUrl,
|
||||
getIconSvgUrl,
|
||||
ICONIFY_HOSTS,
|
||||
POPULAR_COLLECTIONS,
|
||||
} from "@/lib/iconify-api";
|
||||
resolveStickerId,
|
||||
type StickerItem as StickerData,
|
||||
} from "@/lib/stickers";
|
||||
import { useStickersStore } from "@/stores/stickers-store";
|
||||
import type { StickerCategory } from "@/types/stickers";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
HappyIcon,
|
||||
ClockIcon,
|
||||
LayoutGridIcon,
|
||||
MultiplicationSignIcon,
|
||||
SparklesIcon,
|
||||
HashtagIcon,
|
||||
Search01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
function isStickerCategory(value: string): value is StickerCategory {
|
||||
return STICKER_CATEGORIES.includes(value as StickerCategory);
|
||||
}
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { OcSlidersVerticalIcon } from "@opencut/ui/icons";
|
||||
import type { StickerCategory } from "@/types/stickers";
|
||||
import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
|
||||
import { parseStickerId } from "@/lib/stickers/sticker-id";
|
||||
|
||||
export function StickersView() {
|
||||
const { selectedCategory, setSelectedCategory } = useStickersStore();
|
||||
|
||||
return (
|
||||
<BaseView
|
||||
value={selectedCategory}
|
||||
onValueChange={(v) => {
|
||||
if (isStickerCategory(v)) {
|
||||
setSelectedCategory({ category: v });
|
||||
}
|
||||
}}
|
||||
tabs={[
|
||||
{
|
||||
value: "all",
|
||||
label: "All",
|
||||
icon: <HugeiconsIcon icon={LayoutGridIcon} className="size-3" />,
|
||||
content: <StickersContentView category="all" />,
|
||||
},
|
||||
{
|
||||
value: "general",
|
||||
label: "Icons",
|
||||
icon: <HugeiconsIcon icon={SparklesIcon} className="size-3" />,
|
||||
content: <StickersContentView category="general" />,
|
||||
},
|
||||
{
|
||||
value: "brands",
|
||||
label: "Brands",
|
||||
icon: <HugeiconsIcon icon={HashtagIcon} className="size-3" />,
|
||||
content: <StickersContentView category="brands" />,
|
||||
},
|
||||
{
|
||||
value: "emoji",
|
||||
label: "Emoji",
|
||||
icon: <HugeiconsIcon icon={HappyIcon} className="size-3" />,
|
||||
content: <StickersContentView category="emoji" />,
|
||||
},
|
||||
]}
|
||||
className="flex h-full flex-col overflow-hidden p-0"
|
||||
/>
|
||||
<PanelView
|
||||
title="Stickers"
|
||||
actions={
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
value={selectedCategory}
|
||||
onValueChange={(value: StickerCategory) =>
|
||||
setSelectedCategory({ category: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger variant="outline" size="sm" className="mr-1.5">
|
||||
<SelectValue placeholder="All" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(STICKER_CATEGORIES).map(([category, label]) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="ghost" size="icon">
|
||||
<HugeiconsIcon icon={Search01Icon} className="!size-3.5" />
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon">
|
||||
<OcSlidersVerticalIcon className="!size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<StickersContentView />
|
||||
</PanelView>
|
||||
);
|
||||
}
|
||||
|
||||
function StickerGrid({
|
||||
icons,
|
||||
onAdd,
|
||||
addingSticker,
|
||||
capSize = false,
|
||||
items,
|
||||
shouldCapSize = false,
|
||||
}: {
|
||||
icons: string[];
|
||||
onAdd: (iconName: string) => void;
|
||||
addingSticker: string | null;
|
||||
capSize?: boolean;
|
||||
items: StickerData[];
|
||||
shouldCapSize?: boolean;
|
||||
}) {
|
||||
const gridStyle: CSSProperties & {
|
||||
"--sticker-min": string;
|
||||
"--sticker-max"?: string;
|
||||
} = {
|
||||
gridTemplateColumns: capSize
|
||||
gridTemplateColumns: shouldCapSize
|
||||
? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))"
|
||||
: "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))",
|
||||
"--sticker-min": "96px",
|
||||
...(capSize ? { "--sticker-max": "160px" } : {}),
|
||||
...(shouldCapSize ? { "--sticker-max": "160px" } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-2" style={gridStyle}>
|
||||
{icons.map((iconName) => (
|
||||
<StickerItem
|
||||
key={iconName}
|
||||
iconName={iconName}
|
||||
onAdd={onAdd}
|
||||
isAdding={addingSticker === iconName}
|
||||
capSize={capSize}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionGrid({
|
||||
collections,
|
||||
onSelectCollection,
|
||||
}: {
|
||||
collections: Array<{
|
||||
prefix: string;
|
||||
name: string;
|
||||
total: number;
|
||||
category?: string;
|
||||
}>;
|
||||
onSelectCollection: ({ prefix }: { prefix: string }) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{collections.map((collection) => (
|
||||
<CollectionItem
|
||||
key={collection.prefix}
|
||||
title={collection.name}
|
||||
subtitle={`${collection.total.toLocaleString()} icons${collection.category ? ` • ${collection.category}` : ""}`}
|
||||
onClick={() => onSelectCollection({ prefix: collection.prefix })}
|
||||
/>
|
||||
{items.map((item) => (
|
||||
<StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -149,7 +109,7 @@ function CollectionGrid({
|
||||
|
||||
function EmptyView({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<HugeiconsIcon
|
||||
icon={HappyIcon}
|
||||
className="text-muted-foreground size-10"
|
||||
@@ -162,372 +122,125 @@ function EmptyView({ message }: { message: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StickersContentView({ category }: { category: StickerCategory }) {
|
||||
function StickersContentView() {
|
||||
const {
|
||||
searchQuery,
|
||||
selectedCollection,
|
||||
viewMode,
|
||||
collections,
|
||||
currentCollection,
|
||||
searchResults,
|
||||
recentStickers,
|
||||
isLoadingCollections,
|
||||
isLoadingCollection,
|
||||
isSearching,
|
||||
setSearchQuery,
|
||||
setSelectedCollection,
|
||||
loadCollections,
|
||||
searchStickers,
|
||||
addStickerToTimeline,
|
||||
clearRecentStickers,
|
||||
setSelectedCategory,
|
||||
addingSticker,
|
||||
} = useStickersStore();
|
||||
|
||||
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
|
||||
const [collectionsToShow, setCollectionsToShow] = useState(20);
|
||||
const [showCollectionItems, setShowCollectionItems] = useState(false);
|
||||
|
||||
const filteredCollections = useMemo(() => {
|
||||
if (category === "all") {
|
||||
return Object.entries(collections).map(([prefix, collection]) => ({
|
||||
prefix,
|
||||
name: collection.name,
|
||||
total: collection.total,
|
||||
category: collection.category,
|
||||
}));
|
||||
const itemsToDisplay = useMemo(() => {
|
||||
if (viewMode === "search" && searchResults) {
|
||||
return searchResults.items;
|
||||
}
|
||||
|
||||
const collectionList =
|
||||
POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS];
|
||||
if (!collectionList) return [];
|
||||
return [];
|
||||
}, [viewMode, searchResults]);
|
||||
|
||||
return collectionList
|
||||
.map((c) => {
|
||||
const collection = collections[c.prefix];
|
||||
return collection
|
||||
? {
|
||||
prefix: c.prefix,
|
||||
name: c.name,
|
||||
total: collection.total,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
prefix: string;
|
||||
name: string;
|
||||
total: number;
|
||||
}>;
|
||||
}, [collections, category]);
|
||||
|
||||
const { scrollAreaRef, handleScroll } = useInfiniteScroll({
|
||||
onLoadMore: () => setCollectionsToShow((prev) => prev + 20),
|
||||
hasMore: filteredCollections.length > collectionsToShow,
|
||||
isLoading: isLoadingCollections,
|
||||
enabled: viewMode === "browse" && !selectedCollection && category === "all",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(collections).length === 0) {
|
||||
loadCollections();
|
||||
}
|
||||
}, [collections, loadCollections]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (localSearchQuery !== searchQuery) {
|
||||
setSearchQuery({ query: localSearchQuery });
|
||||
if (localSearchQuery.trim()) {
|
||||
searchStickers({ query: localSearchQuery });
|
||||
}
|
||||
const recentStickerItems = useMemo(() => {
|
||||
const items: StickerData[] = [];
|
||||
for (const stickerId of recentStickers) {
|
||||
const recentStickerItem = toRecentStickerItem({ stickerId });
|
||||
if (recentStickerItem) {
|
||||
items.push(recentStickerItem);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
return items;
|
||||
}, [recentStickers]);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]);
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
{recentStickerItems.length > 0 && viewMode === "browse" && (
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<HugeiconsIcon
|
||||
icon={ClockIcon}
|
||||
className="text-muted-foreground size-4"
|
||||
/>
|
||||
<span className="text-sm font-medium">Recent</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearRecentStickers}
|
||||
className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={MultiplicationSignIcon}
|
||||
className="text-muted-foreground size-3"
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Clear recent stickers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<StickerGrid items={recentStickerItems.slice(0, 12)} shouldCapSize />
|
||||
</div>
|
||||
)}
|
||||
|
||||
const handleAddSticker = async (iconName: string) => {
|
||||
{viewMode === "search" && (
|
||||
<div className="h-full">
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
) : searchResults?.items.length ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{searchResults.total} results
|
||||
</span>
|
||||
</div>
|
||||
<StickerGrid items={itemsToDisplay} shouldCapSize />
|
||||
</div>
|
||||
) : searchQuery ? (
|
||||
<EmptyView message={`No stickers found for "${searchQuery}"`} />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StickerItemProps {
|
||||
item: StickerData;
|
||||
shouldCapSize?: boolean;
|
||||
}
|
||||
|
||||
function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
|
||||
const { addingSticker, addStickerToTimeline } = useStickersStore();
|
||||
const isAdding = addingSticker === item.id;
|
||||
const [hasImageError, setHasImageError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!item.id) {
|
||||
return;
|
||||
}
|
||||
setHasImageError(false);
|
||||
}, [item.id]);
|
||||
|
||||
const displayName = item.name;
|
||||
|
||||
const handleAdd = async () => {
|
||||
try {
|
||||
await addStickerToTimeline({ iconName });
|
||||
await addStickerToTimeline({
|
||||
stickerId: item.id,
|
||||
name: item.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to add sticker:", error);
|
||||
toast.error("Failed to add sticker to timeline");
|
||||
}
|
||||
};
|
||||
|
||||
const iconsToDisplay = useMemo(() => {
|
||||
if (viewMode === "search" && searchResults) {
|
||||
return searchResults.icons;
|
||||
}
|
||||
|
||||
if (viewMode === "collection" && currentCollection) {
|
||||
const icons: string[] = [];
|
||||
|
||||
if (currentCollection.uncategorized) {
|
||||
icons.push(
|
||||
...currentCollection.uncategorized.map(
|
||||
(name) => `${currentCollection.prefix}:${name}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (currentCollection.categories) {
|
||||
Object.values(currentCollection.categories).forEach((categoryIcons) => {
|
||||
icons.push(
|
||||
...categoryIcons.map(
|
||||
(name) => `${currentCollection.prefix}:${name}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return icons.slice(0, 100);
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [viewMode, searchResults, currentCollection]);
|
||||
|
||||
const isInCollection = viewMode === "collection" && !!selectedCollection;
|
||||
|
||||
useEffect(() => {
|
||||
if (isInCollection) {
|
||||
setShowCollectionItems(false);
|
||||
const timer = setTimeout(() => setShowCollectionItems(true), 350);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setShowCollectionItems(false);
|
||||
}
|
||||
}, [isInCollection]);
|
||||
|
||||
return (
|
||||
<div className="mt-1 flex h-full flex-col gap-5 p-4">
|
||||
<div className="space-y-3">
|
||||
<InputWithBack
|
||||
isExpanded={isInCollection}
|
||||
setIsExpanded={(expanded) => {
|
||||
if (!expanded && isInCollection) {
|
||||
setSelectedCollection({ collection: null });
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
category === "all"
|
||||
? "Search all stickers"
|
||||
: category === "general"
|
||||
? "Search icons"
|
||||
: category === "brands"
|
||||
? "Search brands"
|
||||
: "Search Emojis"
|
||||
}
|
||||
value={localSearchQuery}
|
||||
onChange={setLocalSearchQuery}
|
||||
disableAnimation={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full overflow-hidden">
|
||||
<ScrollArea
|
||||
className="h-full flex-1"
|
||||
ref={scrollAreaRef}
|
||||
onScrollCapture={handleScroll}
|
||||
>
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
{recentStickers.length > 0 && viewMode === "browse" && (
|
||||
<div className="h-full">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<HugeiconsIcon
|
||||
icon={ClockIcon}
|
||||
className="text-muted-foreground size-4"
|
||||
/>
|
||||
<span className="text-sm font-medium">Recent</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearRecentStickers}
|
||||
className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={MultiplicationSignIcon}
|
||||
className="text-muted-foreground size-3"
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Clear recent stickers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<StickerGrid
|
||||
icons={recentStickers.slice(0, 12)}
|
||||
onAdd={handleAddSticker}
|
||||
addingSticker={addingSticker}
|
||||
capSize
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "collection" && selectedCollection && (
|
||||
<div className="h-full">
|
||||
{isLoadingCollection ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
) : showCollectionItems ? (
|
||||
<StickerGrid
|
||||
icons={iconsToDisplay}
|
||||
onAdd={handleAddSticker}
|
||||
addingSticker={addingSticker}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "search" && (
|
||||
<div className="h-full">
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
) : searchResults?.icons.length ? (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{searchResults.total} results
|
||||
</span>
|
||||
</div>
|
||||
<StickerGrid
|
||||
icons={iconsToDisplay}
|
||||
onAdd={handleAddSticker}
|
||||
addingSticker={addingSticker}
|
||||
capSize
|
||||
/>
|
||||
</>
|
||||
) : searchQuery ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||
<EmptyView
|
||||
message={`No stickers found for "${searchQuery}"`}
|
||||
/>
|
||||
{category !== "all" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const q = localSearchQuery || searchQuery;
|
||||
if (q) {
|
||||
setSearchQuery({ query: q });
|
||||
}
|
||||
setSelectedCategory({ category: "all" });
|
||||
if (q) {
|
||||
searchStickers({ query: q });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Search in all icons
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "browse" && !selectedCollection && (
|
||||
<div className="h-full space-y-4">
|
||||
{isLoadingCollections ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{category !== "all" && (
|
||||
<div className="h-full">
|
||||
<CollectionGrid
|
||||
collections={filteredCollections}
|
||||
onSelectCollection={({ prefix }) =>
|
||||
setSelectedCollection({ collection: prefix })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{category === "all" && filteredCollections.length > 0 && (
|
||||
<div className="h-full">
|
||||
<CollectionGrid
|
||||
collections={filteredCollections.slice(
|
||||
0,
|
||||
collectionsToShow,
|
||||
)}
|
||||
onSelectCollection={({ prefix }) =>
|
||||
setSelectedCollection({ collection: prefix })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CollectionItemProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto justify-between rounded-md py-2"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="font-medium">{title}</p>
|
||||
<p className="text-muted-foreground text-xs">{subtitle}</p>
|
||||
</div>
|
||||
<HugeiconsIcon icon={ArrowRightIcon} className="size-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface StickerItemProps {
|
||||
iconName: string;
|
||||
onAdd: (iconName: string) => void;
|
||||
isAdding?: boolean;
|
||||
capSize?: boolean;
|
||||
}
|
||||
|
||||
function StickerItem({
|
||||
iconName,
|
||||
onAdd,
|
||||
isAdding,
|
||||
capSize = false,
|
||||
}: StickerItemProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [hostIndex, setHostIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iconName) {
|
||||
return;
|
||||
}
|
||||
setImageError(false);
|
||||
setHostIndex(0);
|
||||
}, [iconName]);
|
||||
|
||||
const displayName = iconName.split(":")[1] || iconName;
|
||||
const collectionPrefix = iconName.split(":")[0];
|
||||
|
||||
const preview = imageError ? (
|
||||
const preview = hasImageError ? (
|
||||
<div className="flex size-full items-center justify-center p-2">
|
||||
<span className="text-muted-foreground text-center text-xs break-all">
|
||||
{displayName}
|
||||
@@ -536,21 +249,13 @@ function StickerItem({
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center p-4">
|
||||
<Image
|
||||
src={
|
||||
hostIndex === 0
|
||||
? getIconSvgUrl(iconName, { width: 64, height: 64 })
|
||||
: buildIconSvgUrl(
|
||||
ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)],
|
||||
iconName,
|
||||
{ width: 64, height: 64 },
|
||||
)
|
||||
}
|
||||
src={item.previewUrl}
|
||||
alt={displayName}
|
||||
width={64}
|
||||
height={64}
|
||||
className="size-full object-contain"
|
||||
style={
|
||||
capSize
|
||||
shouldCapSize
|
||||
? {
|
||||
maxWidth: "var(--sticker-max, 160px)",
|
||||
maxHeight: "var(--sticker-max, 160px)",
|
||||
@@ -558,12 +263,7 @@ function StickerItem({
|
||||
: undefined
|
||||
}
|
||||
onError={() => {
|
||||
const next = hostIndex + 1;
|
||||
if (next < ICONIFY_HOSTS.length) {
|
||||
setHostIndex(next);
|
||||
} else {
|
||||
setImageError(true);
|
||||
}
|
||||
setHasImageError(true);
|
||||
}}
|
||||
loading="lazy"
|
||||
unoptimized
|
||||
@@ -572,44 +272,63 @@ function StickerItem({
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
isAdding && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
<DraggableItem
|
||||
name={displayName}
|
||||
preview={preview}
|
||||
dragData={{
|
||||
id: iconName,
|
||||
type: "sticker",
|
||||
name: displayName,
|
||||
iconName,
|
||||
}}
|
||||
onAddToTimeline={() => onAdd(iconName)}
|
||||
aspectRatio={1}
|
||||
shouldShowLabel={false}
|
||||
isRounded={true}
|
||||
variant="card"
|
||||
className=""
|
||||
containerClassName="w-full"
|
||||
/>
|
||||
{isAdding && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
|
||||
<Spinner className="size-6 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn("relative", isAdding && "pointer-events-none opacity-50")}
|
||||
>
|
||||
<DraggableItem
|
||||
name={displayName}
|
||||
preview={preview}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: "sticker",
|
||||
name: displayName,
|
||||
stickerId: item.id,
|
||||
}}
|
||||
onAddToTimeline={handleAdd}
|
||||
aspectRatio={1}
|
||||
shouldShowLabel={false}
|
||||
isRounded
|
||||
variant="card"
|
||||
containerClassName="w-full"
|
||||
/>
|
||||
{isAdding && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
|
||||
<Spinner className="size-6 text-white" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{displayName}</p>
|
||||
<p className="text-muted-foreground text-xs">{collectionPrefix}</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
|
||||
const stickerIdParts = stickerId.split(":");
|
||||
if (stickerIdParts.length <= 1) {
|
||||
return stickerId;
|
||||
}
|
||||
return (
|
||||
stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
|
||||
stickerId
|
||||
);
|
||||
}
|
||||
|
||||
function toRecentStickerItem({
|
||||
stickerId,
|
||||
}: {
|
||||
stickerId: string;
|
||||
}): StickerData | null {
|
||||
try {
|
||||
const { providerId } = parseStickerId({ stickerId });
|
||||
return {
|
||||
id: stickerId,
|
||||
provider: providerId,
|
||||
name: getStickerNameFromId({ stickerId }),
|
||||
previewUrl: resolveStickerId({
|
||||
stickerId,
|
||||
options: { width: 64, height: 64 },
|
||||
}),
|
||||
metadata: {},
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
import { buildTextElement } from "@/lib/timeline/element-utils";
|
||||
@@ -23,11 +23,11 @@ export function TextView() {
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseView>
|
||||
<PanelView title="Text">
|
||||
<DraggableItem
|
||||
name="Default text"
|
||||
preview={
|
||||
<div className="bg-panel-accent flex size-full items-center justify-center rounded">
|
||||
<div className="bg-accent flex size-full items-center justify-center rounded">
|
||||
<span className="text-xs select-none">Default text</span>
|
||||
</div>
|
||||
}
|
||||
@@ -41,6 +41,6 @@ export function TextView() {
|
||||
onAddToTimeline={handleAddToTimeline}
|
||||
shouldShowLabel={false}
|
||||
/>
|
||||
</BaseView>
|
||||
</PanelView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
interface PanelBaseViewProps {
|
||||
children?: React.ReactNode;
|
||||
defaultTab?: string;
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
tabs?: {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
}[];
|
||||
className?: string;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function ViewContent({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className={cn("p-5", className)}>{children}</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelBaseView({
|
||||
children,
|
||||
defaultTab,
|
||||
value,
|
||||
onValueChange,
|
||||
tabs,
|
||||
className = "",
|
||||
ref,
|
||||
}: PanelBaseViewProps) {
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)} ref={ref}>
|
||||
{!tabs || tabs.length === 0 ? (
|
||||
<ViewContent className={className}>{children}</ViewContent>
|
||||
) : (
|
||||
<Tabs
|
||||
defaultValue={defaultTab}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
<div className="bg-panel sticky top-0 z-10">
|
||||
<div className="px-3 pt-3 pb-0">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.icon ? (
|
||||
<span className="mr-1 inline-flex items-center">
|
||||
{tab.icon}
|
||||
</span>
|
||||
) : null}
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<Separator className="mt-3" />
|
||||
</div>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="mt-0 flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<ViewContent className={className}>{tab.content}</ViewContent>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { findCurrentScene } from "@/lib/scenes";
|
||||
import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks";
|
||||
|
||||
export function BookmarkNoteOverlay() {
|
||||
const editor = useEditor();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
if (!activeProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeScene = findCurrentScene({
|
||||
scenes: activeProject.scenes,
|
||||
currentSceneId: activeProject.currentSceneId,
|
||||
});
|
||||
|
||||
if (!activeScene) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bookmarks = activeScene.bookmarks;
|
||||
const activeBookmarks = getBookmarksActiveAtTime({
|
||||
bookmarks,
|
||||
time: currentTime,
|
||||
});
|
||||
const bookmarksWithNotes = activeBookmarks.filter(
|
||||
(bookmark) => bookmark.note != null && bookmark.note.trim() !== "",
|
||||
);
|
||||
|
||||
if (bookmarksWithNotes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute top-2 left-2 flex flex-col gap-1.5"
|
||||
aria-live="polite"
|
||||
>
|
||||
{bookmarksWithNotes.map((bookmark) => (
|
||||
<div
|
||||
key={bookmark.time}
|
||||
className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm"
|
||||
style={{
|
||||
backgroundColor: "rgb(0 0 0 / 0.5)",
|
||||
borderLeft: bookmark.color
|
||||
? `3px solid ${bookmark.color}`
|
||||
: "3px solid var(--primary)",
|
||||
}}
|
||||
>
|
||||
{bookmark.note}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { usePreviewStore } from "@/stores/preview-store";
|
||||
|
||||
export function PreviewContextMenu({
|
||||
onToggleFullscreen,
|
||||
containerRef,
|
||||
}: {
|
||||
onToggleFullscreen: () => void;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const { overlays, setOverlayVisibility } = usePreviewStore();
|
||||
|
||||
return (
|
||||
<ContextMenuContent className="w-56" container={containerRef.current}>
|
||||
<ContextMenuItem onClick={onToggleFullscreen} inset>
|
||||
Full screen
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => editor.renderer.saveSnapshot()} inset>
|
||||
Save snapshot
|
||||
</ContextMenuItem>
|
||||
<ContextMenuCheckboxItem
|
||||
checked={overlays.bookmarks}
|
||||
onCheckedChange={(checked) =>
|
||||
setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked })
|
||||
}
|
||||
>
|
||||
Show bookmarks
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuItem inset>Show grid</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,18 @@ import { useCallback, useMemo, useRef } from "react";
|
||||
import useDeepCompareEffect from "use-deep-compare-effect";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useRafLoop } from "@/hooks/use-raf-loop";
|
||||
import { useContainerSize } from "@/hooks/use-container-size";
|
||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
||||
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
||||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
import { getLastFrameTime } from "@/lib/time";
|
||||
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
|
||||
import { BookmarkNoteOverlay } from "./bookmark-note-overlay";
|
||||
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||||
import { usePreviewStore } from "@/stores/preview-store";
|
||||
import { PreviewContextMenu } from "./context-menu";
|
||||
import { PreviewToolbar } from "./toolbar";
|
||||
|
||||
function usePreviewSize() {
|
||||
const editor = useEditor();
|
||||
@@ -19,6 +27,30 @@ function usePreviewSize() {
|
||||
};
|
||||
}
|
||||
|
||||
export function PreviewPanel() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border"
|
||||
>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0">
|
||||
<PreviewCanvas
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
<RenderTreeController />
|
||||
</div>
|
||||
<PreviewToolbar
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderTreeController() {
|
||||
const editor = useEditor();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
@@ -37,6 +69,7 @@ function RenderTreeController() {
|
||||
duration,
|
||||
canvasSize: { width, height },
|
||||
background: activeProject.settings.background,
|
||||
isPreview: true,
|
||||
});
|
||||
|
||||
editor.renderer.setRenderTree({ renderTree });
|
||||
@@ -45,38 +78,66 @@ function RenderTreeController() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PreviewPanel() {
|
||||
return (
|
||||
<div className="bg-panel relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2">
|
||||
<PreviewCanvas />
|
||||
<RenderTreeController />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewCanvas() {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
function PreviewCanvas({
|
||||
onToggleFullscreen,
|
||||
containerRef,
|
||||
}: {
|
||||
onToggleFullscreen: () => void;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const outerContainerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasBoundsRef = useRef<HTMLDivElement>(null);
|
||||
const lastFrameRef = useRef(-1);
|
||||
const lastSceneRef = useRef<RootNode | null>(null);
|
||||
const renderingRef = useRef(false);
|
||||
const { width, height } = usePreviewSize();
|
||||
const { width: nativeWidth, height: nativeHeight } = usePreviewSize();
|
||||
const containerSize = useContainerSize({ containerRef: outerContainerRef });
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const { overlays } = usePreviewStore();
|
||||
|
||||
const renderer = useMemo(() => {
|
||||
return new CanvasRenderer({
|
||||
width,
|
||||
height,
|
||||
width: nativeWidth,
|
||||
height: nativeHeight,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
}, [width, height, activeProject.settings.fps]);
|
||||
}, [nativeWidth, nativeHeight, activeProject.settings.fps]);
|
||||
|
||||
const displaySize = useMemo(() => {
|
||||
if (
|
||||
!nativeWidth ||
|
||||
!nativeHeight ||
|
||||
containerSize.width === 0 ||
|
||||
containerSize.height === 0
|
||||
) {
|
||||
return { width: nativeWidth ?? 0, height: nativeHeight ?? 0 };
|
||||
}
|
||||
|
||||
const paddingBuffer = 4;
|
||||
const availableWidth = containerSize.width - paddingBuffer;
|
||||
const availableHeight = containerSize.height - paddingBuffer;
|
||||
|
||||
const aspectRatio = nativeWidth / nativeHeight;
|
||||
const containerAspect = availableWidth / availableHeight;
|
||||
|
||||
const displayWidth =
|
||||
containerAspect > aspectRatio
|
||||
? availableHeight * aspectRatio
|
||||
: availableWidth;
|
||||
const displayHeight =
|
||||
containerAspect > aspectRatio
|
||||
? availableHeight
|
||||
: availableWidth / aspectRatio;
|
||||
|
||||
return { width: displayWidth, height: displayHeight };
|
||||
}, [nativeWidth, nativeHeight, containerSize.width, containerSize.height]);
|
||||
|
||||
const renderTree = editor.renderer.getRenderTree();
|
||||
|
||||
const render = useCallback(() => {
|
||||
if (ref.current && renderTree && !renderingRef.current) {
|
||||
if (canvasRef.current && renderTree && !renderingRef.current) {
|
||||
const time = editor.playback.getCurrentTime();
|
||||
const lastFrameTime = getLastFrameTime({
|
||||
duration: renderTree.duration,
|
||||
@@ -96,7 +157,7 @@ function PreviewCanvas() {
|
||||
.renderToCanvas({
|
||||
node: renderTree,
|
||||
time: renderTime,
|
||||
targetCanvas: ref.current,
|
||||
targetCanvas: canvasRef.current,
|
||||
})
|
||||
.then(() => {
|
||||
renderingRef.current = false;
|
||||
@@ -108,17 +169,43 @@ function PreviewCanvas() {
|
||||
useRafLoop(render);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={ref}
|
||||
width={width}
|
||||
height={height}
|
||||
className="block max-h-full max-w-full border"
|
||||
style={{
|
||||
background:
|
||||
activeProject.settings.background.type === "blur"
|
||||
? "transparent"
|
||||
: activeProject?.settings.background.color,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={outerContainerRef}
|
||||
className="relative flex size-full items-center justify-center"
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
ref={canvasBoundsRef}
|
||||
className="relative"
|
||||
style={{ width: displaySize.width, height: displaySize.height }}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={nativeWidth}
|
||||
height={nativeHeight}
|
||||
className="block border"
|
||||
style={{
|
||||
width: displaySize.width,
|
||||
height: displaySize.height,
|
||||
background:
|
||||
activeProject.settings.background.type === "blur"
|
||||
? "transparent"
|
||||
: activeProject?.settings.background.color,
|
||||
}}
|
||||
/>
|
||||
<PreviewInteractionOverlay
|
||||
canvasRef={canvasRef}
|
||||
containerRef={canvasBoundsRef}
|
||||
/>
|
||||
{overlays.bookmarks && <BookmarkNoteOverlay />}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<PreviewContextMenu
|
||||
onToggleFullscreen={onToggleFullscreen}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import Image from "next/image";
|
||||
|
||||
function TikTokGuide() {
|
||||
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();
|
||||
|
||||
if (layoutGuide.platform === null) return null;
|
||||
if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
|
||||
|
||||
return null;
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { usePreviewStore } from "@/stores/preview-store";
|
||||
import Image from "next/image";
|
||||
|
||||
function TikTokGuide() {
|
||||
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 } = usePreviewStore();
|
||||
|
||||
if (layoutGuide.platform === null) return null;
|
||||
if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
|
||||
import { TransformHandles } from "./transform-handles";
|
||||
import { SnapGuides } from "./snap-guides";
|
||||
import { TextEditOverlay } from "./text-edit-overlay";
|
||||
|
||||
export function PreviewInteractionOverlay({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
}: {
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const {
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onDoubleClick,
|
||||
snapLines,
|
||||
editingText,
|
||||
commitTextEdit,
|
||||
cancelTextEdit,
|
||||
} = usePreviewInteraction({ canvasRef });
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0">
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: canvas overlay, pointer-only interaction */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-auto"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onDoubleClick={onDoubleClick}
|
||||
/>
|
||||
{editingText ? (
|
||||
<TextEditOverlay
|
||||
canvasRef={canvasRef}
|
||||
containerRef={containerRef}
|
||||
trackId={editingText.trackId}
|
||||
elementId={editingText.elementId}
|
||||
element={editingText.element}
|
||||
onCommit={commitTextEdit}
|
||||
onCancel={cancelTextEdit}
|
||||
/>
|
||||
) : (
|
||||
<TransformHandles canvasRef={canvasRef} containerRef={containerRef} />
|
||||
)}
|
||||
<SnapGuides
|
||||
lines={snapLines}
|
||||
canvasRef={canvasRef}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import type { SnapLine } from "@/lib/preview/preview-snap";
|
||||
import { positionToOverlay } from "@/lib/preview/preview-coords";
|
||||
|
||||
export function SnapGuides({
|
||||
lines,
|
||||
canvasRef,
|
||||
containerRef,
|
||||
}: {
|
||||
lines: SnapLine[];
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||
|
||||
if (!canvasRect || !containerRect || lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toOverlayX = (logicalX: number) =>
|
||||
positionToOverlay({
|
||||
positionX: logicalX,
|
||||
positionY: 0,
|
||||
canvasRect,
|
||||
containerRect,
|
||||
canvasSize,
|
||||
}).x;
|
||||
|
||||
const toOverlayY = (logicalY: number) =>
|
||||
positionToOverlay({
|
||||
positionX: 0,
|
||||
positionY: logicalY,
|
||||
canvasRect,
|
||||
containerRect,
|
||||
canvasSize,
|
||||
}).y;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0" aria-hidden>
|
||||
{lines.map((line) => {
|
||||
if (line.type === "vertical") {
|
||||
return (
|
||||
<div
|
||||
key={`vertical-${line.position}`}
|
||||
className="absolute top-0 bottom-0 w-px bg-white/70"
|
||||
style={{ left: toOverlayX(line.position) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`horizontal-${line.position}`}
|
||||
className="absolute left-0 right-0 h-px bg-white/70"
|
||||
style={{ top: toOverlayY(line.position) }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import type { TextElement } from "@/types/timeline";
|
||||
import {
|
||||
positionToOverlay,
|
||||
getDisplayScale,
|
||||
} from "@/lib/preview/preview-coords";
|
||||
import {
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_SCALE_REFERENCE,
|
||||
} from "@/constants/text-constants";
|
||||
|
||||
const TEXT_BACKGROUND_PADDING = "4px 8px";
|
||||
const TEXT_EDIT_VERTICAL_OFFSET_EM = 0.06;
|
||||
|
||||
export function TextEditOverlay({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
trackId,
|
||||
elementId,
|
||||
element,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: {
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
element: TextElement;
|
||||
onCommit: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const div = divRef.current;
|
||||
if (!div) return;
|
||||
div.focus();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(div);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}, []);
|
||||
|
||||
const handleInput = useCallback(() => {
|
||||
const div = divRef.current;
|
||||
if (!div) return;
|
||||
const text = div.innerText;
|
||||
editor.timeline.previewElements({
|
||||
updates: [{ trackId, elementId, updates: { content: text } }],
|
||||
});
|
||||
}, [editor.timeline, trackId, elementId]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
({ event }: { event: React.KeyboardEvent }) => {
|
||||
const { key } = event;
|
||||
if (key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
|
||||
if (!canvasRect || !containerRect || !canvasSize) return null;
|
||||
|
||||
const { x: posX, y: posY } = positionToOverlay({
|
||||
positionX: element.transform.position.x,
|
||||
positionY: element.transform.position.y,
|
||||
canvasRect,
|
||||
containerRect,
|
||||
canvasSize,
|
||||
});
|
||||
|
||||
const { x: displayScaleX } = getDisplayScale({
|
||||
canvasRect,
|
||||
canvasSize,
|
||||
});
|
||||
|
||||
const displayFontSize =
|
||||
element.fontSize *
|
||||
(canvasSize.height / FONT_SIZE_SCALE_REFERENCE) *
|
||||
displayScaleX;
|
||||
|
||||
const verticalAlignmentOffset =
|
||||
displayFontSize * TEXT_EDIT_VERTICAL_OFFSET_EM;
|
||||
|
||||
const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT;
|
||||
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
|
||||
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
|
||||
const letterSpacing = element.letterSpacing ?? 0;
|
||||
const shouldShowBackground =
|
||||
element.background.enabled &&
|
||||
element.background.color &&
|
||||
element.background.color !== "transparent";
|
||||
const backgroundColor = shouldShowBackground
|
||||
? element.background.color
|
||||
: "transparent";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: posX,
|
||||
top: posY - verticalAlignmentOffset,
|
||||
transform: `translate(-50%, -50%) scale(${element.transform.scale}) rotate(${element.transform.rotate}deg)`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
|
||||
<div
|
||||
ref={divRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
tabIndex={0}
|
||||
role="textbox"
|
||||
aria-label="Edit text"
|
||||
className="cursor-text select-text outline-none whitespace-pre"
|
||||
style={{
|
||||
fontSize: displayFontSize,
|
||||
fontFamily: element.fontFamily,
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
textAlign: element.textAlign,
|
||||
letterSpacing: `${letterSpacing}px`,
|
||||
lineHeight,
|
||||
color: element.color,
|
||||
backgroundColor,
|
||||
minHeight: displayFontSize * lineHeight,
|
||||
textDecoration: element.textDecoration ?? "none",
|
||||
padding: shouldShowBackground ? TEXT_BACKGROUND_PADDING : 0,
|
||||
minWidth: 1,
|
||||
}}
|
||||
onInput={handleInput}
|
||||
onBlur={onCommit}
|
||||
onKeyDown={(event) => handleKeyDown({ event })}
|
||||
>
|
||||
{element.content || ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { EditableTimecode } from "@/components/editable-timecode";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FullScreenIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { OcSocialIcon } from "@opencut/ui/icons";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export function PreviewToolbar({
|
||||
isFullscreen,
|
||||
onToggleFullscreen,
|
||||
}: {
|
||||
isFullscreen: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const totalDuration = editor.timeline.getTotalDuration();
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
|
||||
<div className="flex items-center">
|
||||
<EditableTimecode
|
||||
time={currentTime}
|
||||
duration={totalDuration}
|
||||
format="HH:MM:SS:FF"
|
||||
fps={fps}
|
||||
onTimeChange={({ time }) => editor.playback.seek({ time })}
|
||||
className="text-center"
|
||||
/>
|
||||
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{formatTimeCode({
|
||||
timeInSeconds: totalDuration,
|
||||
format: "HH:MM:SS:FF",
|
||||
fps,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={() => invokeAction("toggle-play")}
|
||||
>
|
||||
<HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
|
||||
</Button>
|
||||
|
||||
<div className="justify-self-end flex items-center gap-2.5">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="[&_svg]:size-auto px-1 h-7"
|
||||
onClick={onToggleFullscreen}
|
||||
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
||||
>
|
||||
<OcSocialIcon size={20} />
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={onToggleFullscreen}
|
||||
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
||||
>
|
||||
<HugeiconsIcon icon={FullScreenIcon} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { useTransformHandles } from "@/hooks/use-transform-handles";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import { SnapGuides } from "./snap-guides";
|
||||
import { canvasToOverlay, getDisplayScale } from "@/lib/preview/preview-coords";
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Rotate01Icon } from "@hugeicons/core-free-icons";
|
||||
|
||||
const HANDLE_SIZE = 10;
|
||||
const ROTATION_HANDLE_OFFSET = 24;
|
||||
const ROTATION_HANDLE_RADIUS = 10;
|
||||
const CORNER_HIT_AREA_SIZE = 18;
|
||||
|
||||
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
const CORNERS: Corner[] = [
|
||||
"top-left",
|
||||
"top-right",
|
||||
"bottom-left",
|
||||
"bottom-right",
|
||||
];
|
||||
|
||||
export function getCornerPosition({
|
||||
bounds,
|
||||
corner,
|
||||
}: {
|
||||
bounds: ElementBounds;
|
||||
corner: Corner;
|
||||
}): { x: number; y: number } {
|
||||
const halfW = bounds.width / 2;
|
||||
const halfH = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
|
||||
const localX =
|
||||
corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
|
||||
const localY =
|
||||
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
|
||||
|
||||
return {
|
||||
x: bounds.cx + (localX * cos - localY * sin),
|
||||
y: bounds.cy + (localX * sin + localY * cos),
|
||||
};
|
||||
}
|
||||
|
||||
export function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
|
||||
x: number;
|
||||
y: number;
|
||||
} {
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET;
|
||||
return {
|
||||
x: bounds.cx - localY * sin,
|
||||
y: bounds.cy + localY * cos,
|
||||
};
|
||||
}
|
||||
|
||||
export function getOverlayContext({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
}: {
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}): {
|
||||
canvasRect: DOMRect;
|
||||
containerRect: DOMRect;
|
||||
} | null {
|
||||
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||
if (!canvasRect || !containerRect) return null;
|
||||
return { canvasRect, containerRect };
|
||||
}
|
||||
|
||||
export function TransformHandles({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
}: {
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const {
|
||||
selectedWithBounds,
|
||||
hasVisualSelection,
|
||||
snapLines,
|
||||
handleCornerPointerDown,
|
||||
handleRotationPointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
} = useTransformHandles({ canvasRef });
|
||||
|
||||
const editor = useEditor();
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
|
||||
if (!hasVisualSelection || !selectedWithBounds) return null;
|
||||
|
||||
const overlayContext = getOverlayContext({ canvasRef, containerRef });
|
||||
if (!overlayContext) return null;
|
||||
|
||||
const { bounds, element } = selectedWithBounds;
|
||||
if (!isVisualElement(element)) return null;
|
||||
|
||||
const { canvasRect, containerRect } = overlayContext;
|
||||
const displayScale = getDisplayScale({ canvasRect, canvasSize });
|
||||
|
||||
const toOverlay = ({
|
||||
canvasX,
|
||||
canvasY,
|
||||
}: {
|
||||
canvasX: number;
|
||||
canvasY: number;
|
||||
}) =>
|
||||
canvasToOverlay({
|
||||
canvasX,
|
||||
canvasY,
|
||||
canvasRect,
|
||||
containerRect,
|
||||
canvasSize,
|
||||
});
|
||||
|
||||
const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy });
|
||||
const outlineWidth = bounds.width * displayScale.x;
|
||||
const outlineHeight = bounds.height * displayScale.y;
|
||||
|
||||
const rotationHandle = getRotationHandlePosition({ bounds });
|
||||
const rotationHandleScreen = toOverlay({
|
||||
canvasX: rotationHandle.x,
|
||||
canvasY: rotationHandle.y,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0" aria-hidden>
|
||||
<SnapGuides
|
||||
lines={snapLines}
|
||||
canvasRef={canvasRef}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
<BoundingBoxOutline
|
||||
center={center}
|
||||
outlineWidth={outlineWidth}
|
||||
outlineHeight={outlineHeight}
|
||||
rotation={bounds.rotation}
|
||||
/>
|
||||
{CORNERS.map((corner) => {
|
||||
const cornerPosition = getCornerPosition({ bounds, corner });
|
||||
const screen = toOverlay({
|
||||
canvasX: cornerPosition.x,
|
||||
canvasY: cornerPosition.y,
|
||||
});
|
||||
return (
|
||||
<CornerHandle
|
||||
key={corner}
|
||||
corner={corner}
|
||||
screen={screen}
|
||||
onPointerDown={(event) =>
|
||||
handleCornerPointerDown({ event, corner })
|
||||
}
|
||||
onPointerMove={(event) => handlePointerMove({ event })}
|
||||
onPointerUp={(event) => handlePointerUp({ event })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<RotationHandle
|
||||
screen={rotationHandleScreen}
|
||||
onPointerDown={(event) => handleRotationPointerDown({ event })}
|
||||
onPointerMove={(event) => handlePointerMove({ event })}
|
||||
onPointerUp={(event) => handlePointerUp({ event })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BoundingBoxOutline({
|
||||
center,
|
||||
outlineWidth,
|
||||
outlineHeight,
|
||||
rotation,
|
||||
}: {
|
||||
center: { x: number; y: number };
|
||||
outlineWidth: number;
|
||||
outlineHeight: number;
|
||||
rotation: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: center.x - outlineWidth / 2,
|
||||
top: center.y - outlineHeight / 2,
|
||||
width: outlineWidth,
|
||||
height: outlineHeight,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
border: "1px dashed white",
|
||||
boxSizing: "border-box",
|
||||
opacity: 0.75,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CornerHandle({
|
||||
corner,
|
||||
screen,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
corner: Corner;
|
||||
screen: { x: number; y: number };
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
const isNesw = corner === "top-right" || corner === "bottom-left";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute flex items-center justify-center outline-none",
|
||||
isNesw ? "cursor-nesw-resize" : "cursor-nwse-resize",
|
||||
)}
|
||||
style={{
|
||||
left: screen.x - CORNER_HIT_AREA_SIZE / 2,
|
||||
top: screen.y - CORNER_HIT_AREA_SIZE / 2,
|
||||
width: CORNER_HIT_AREA_SIZE,
|
||||
height: CORNER_HIT_AREA_SIZE,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
>
|
||||
<div
|
||||
className="rounded-sm bg-white"
|
||||
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RotationHandle({
|
||||
screen,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
screen: { x: number; y: number };
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute flex items-center justify-center rounded-full bg-white text-black shadow-sm outline-none"
|
||||
style={{
|
||||
left: screen.x - ROTATION_HANDLE_RADIUS,
|
||||
top: screen.y - ROTATION_HANDLE_RADIUS,
|
||||
width: ROTATION_HANDLE_RADIUS * 2,
|
||||
height: ROTATION_HANDLE_RADIUS * 2,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
>
|
||||
<HugeiconsIcon icon={Rotate01Icon} className="size-3" strokeWidth={2.5} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { AudioElement } from "@/types/timeline";
|
||||
|
||||
export function AudioProperties({ _element }: { _element: AudioElement }) {
|
||||
export function AudioProperties() {
|
||||
return <div className="space-y-4 p-5">Audio properties</div>;
|
||||
}
|
||||
|
||||