Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de64bba7d6 | ||
|
|
05f428f92f | ||
|
|
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 | ||
|
|
9ca32e5e39 | ||
|
|
5120e2efa6 | ||
|
|
1aec9c5cf5 | ||
|
|
e88a059222 | ||
|
|
c3f0d640ef | ||
|
|
7d5914353b | ||
|
|
ee7eaecc9f | ||
|
|
8ea3db833f | ||
|
|
22ea6b8925 | ||
|
|
dfccf905bb | ||
|
|
3e4584edde | ||
|
|
4cd15fcf00 | ||
|
|
748856f4c4 | ||
|
|
2759d7ed31 | ||
|
|
03c2b16064 | ||
|
|
e15ccefbe5 | ||
|
|
5e1e509c11 | ||
|
|
bfcf20e108 | ||
|
|
4f64ff50cd | ||
|
|
58f23e8f2a | ||
|
|
33f4a54768 | ||
|
|
1d55b903d7 | ||
|
|
af047ba4a7 | ||
|
|
ef3563745a | ||
|
|
d06b7c3b0f | ||
|
|
b7293dc552 | ||
|
|
82d92934cb | ||
|
|
b2a976ba82 | ||
|
|
02d4f9c314 | ||
|
|
1d31b7a586 | ||
|
|
e00ba706fe | ||
|
|
f820d9a894 | ||
|
|
7878c8bb7b | ||
|
|
1f618601c7 | ||
|
|
59c189ca27 | ||
|
|
7bf0984698 |
@@ -0,0 +1,148 @@
|
||||
# Code Review Checklist
|
||||
|
||||
Review every point below carefully to ensure files follow consistent code style and best practices.
|
||||
|
||||
---
|
||||
|
||||
## Function Signatures & Parameters
|
||||
|
||||
- [ ] Every function accepts a single object parameter with destructuring in the signature (for readability and future extensibility)
|
||||
- Exception: tiny one-liner callbacks (e.g. `array.find(x => ...)`, `map`, `filter`, `sort`) do not need destructuring if it hurts readability
|
||||
|
||||
```tsx
|
||||
// ❌ wrong
|
||||
function formatTime(seconds: number, fps: number) { ... }
|
||||
|
||||
// ✅ correct
|
||||
function formatTime({ seconds, fps }: { seconds: number; fps: number }) { ... }
|
||||
```
|
||||
|
||||
## TypeScript & Type Safety
|
||||
|
||||
- [ ] No `any` references
|
||||
- [ ] General interfaces are in the `types` folder, not scattered in components
|
||||
- Example: `TimelineTrack` interface belongs in `src/types/timeline.ts`, not `src/components/timeline/index.tsx`
|
||||
|
||||
## JSX & Components
|
||||
|
||||
- [ ] JSX is clean — no comments explaining what each part does
|
||||
- [ ] Complex/reusable JSX is extracted into sub-components (placed below the main component)
|
||||
- [ ] Components shared across multiple files are in separate files
|
||||
- [ ] File order: constants specific to file (top) -> utils specific to file -> main component → sub-components (bottom)
|
||||
- [ ] Components render UI only — domain logic lives in hooks, utilities, or managers
|
||||
- Simple interaction logic (gestures, modifier keys) can stay if not complex
|
||||
|
||||
## Code Organization & File Structure
|
||||
|
||||
- [ ] 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/`
|
||||
- [ ] Business logic lives in either `src/lib`, `src/core` or `src/services` folder
|
||||
|
||||
## Comments
|
||||
|
||||
- [ ] No AI comments — only human comments that explain _why_, not _what_
|
||||
- Bad: changelog-style comments, explaining readable code, using more words than necessary
|
||||
- [ ] All comments are lowercase
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- [ ] Readability over brevity — use `element` not `el`, `event` not `e`
|
||||
- [ ] Booleans are named `isSomething`, `hasSomething`, or `shouldSomething` — not `something`
|
||||
- [ ] No title case for multi-word text/UI — use `Hello world` not `Hello World`
|
||||
|
||||
## Tailwind & Styling
|
||||
|
||||
- [ ] 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`
|
||||
```tsx
|
||||
<Button>
|
||||
<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
|
||||
// ❌ wrong
|
||||
function Parent() {
|
||||
const { selectedElements } = useTimelineStore();
|
||||
return <Child selectedElements={selectedElements} />;
|
||||
}
|
||||
|
||||
// ✅ correct
|
||||
function Parent() {
|
||||
return <Child />;
|
||||
}
|
||||
function Child() {
|
||||
const { selectedElements } = useTimelineStore();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Components and hooks should use the `useEditor` hook. Only use `EditorCore.getInstance()` if you are outside of a react component/hook. Eg: in a utility function, event handler.
|
||||
|
||||
## Code Quality
|
||||
|
||||
- [ ] 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[] }) { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Function Keywords
|
||||
|
||||
| Context | Keyword |
|
||||
| --------------------------------- | ------------------------- |
|
||||
| Next.js page components | `export default function` |
|
||||
| Main react component | `export function` |
|
||||
| Sub-components | `function` |
|
||||
| Utility functions | `export function` |
|
||||
| Functions inside react components | `const` |
|
||||
|
||||
---
|
||||
|
||||
## 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 table, list each checklist section and confirm you checked it:
|
||||
`Signatures ✓ | TypeScript ✓ | JSX ✓ | Organization ✓ | 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 table 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.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"plugins": {
|
||||
"figma": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,3 +119,29 @@ Each command extends `Command` from `@/lib/commands/base-command` and implements
|
||||
- `undo()` - restores the saved state
|
||||
|
||||
Actions and commands work together: actions are "what triggered this", commands are "how to do it (and undo it)".
|
||||
|
||||
## Cloud-specific instructions
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Bun** (v1.2.18) must be installed. The update script handles this via `~/.bun/bin/bun`.
|
||||
- **No Docker required** for frontend-only development. Docker (PostgreSQL + Redis) is only needed for auth features, and no `docker-compose.yaml` exists in the repo yet.
|
||||
|
||||
### Environment variables
|
||||
|
||||
All env vars are validated by Zod at startup (`packages/env/src/web.ts`). Every field is required (no `.optional()`), so `.env.local` must exist under `apps/web/` with valid-looking values for all variables. For local development without external services, placeholder values work for: `MARBLE_WORKSPACE_KEY`, `FREESOUND_CLIENT_ID`, `FREESOUND_API_KEY`, `CLOUDFLARE_ACCOUNT_ID`, `R2_*`, and `MODAL_TRANSCRIPTION_URL` (must be a valid URL like `http://localhost:9999/transcription`).
|
||||
|
||||
### Running services
|
||||
|
||||
| Service | Command | Notes |
|
||||
|---------|---------|-------|
|
||||
| Dev server | `bun run dev` (from `apps/web`) | Runs Next.js 16 with Turbopack on port 3000 |
|
||||
| Lint | `bun run lint` (from `apps/web`) | Uses Biome. Pre-existing CSS/Tailwind directive warnings are expected. |
|
||||
| Tests | `bun test` (from repo root) | 60 tests across 7 files (storage migrations + sticker-id) |
|
||||
| Format | `bun run format` (from `apps/web`) | Biome formatter |
|
||||
|
||||
### Gotchas
|
||||
|
||||
- `@biomejs/biome` is a root devDependency needed for the lint/format scripts in `apps/web`. The lint script calls `biome` directly.
|
||||
- The env schema at `packages/env/src/web.ts` uses `z.url()` for several fields (`NEXT_PUBLIC_SITE_URL`, `NEXT_PUBLIC_MARBLE_API_URL`, `UPSTASH_REDIS_REST_URL`, `MODAL_TRANSCRIPTION_URL`), meaning they must be full valid URLs (with protocol). Plain strings will fail validation.
|
||||
- The repo's README references `docker-compose up -d` but no `docker-compose.yaml` exists. Auth features (login, signup) won't work without PostgreSQL and Redis.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="left" width="120">
|
||||
<img src="apps/web/public/logos/opencut/1k/logo.png" alt="OpenCut Logo" width="100" />
|
||||
<img src="apps/web/public/logos/opencut/1k/logo-white-black.png" alt="OpenCut Logo" width="100" />
|
||||
</td>
|
||||
<td align="right">
|
||||
<h1>OpenCut</span></h1>
|
||||
@@ -13,7 +13,7 @@
|
||||
## Why?
|
||||
|
||||
- **Privacy**: Your videos stay on your device
|
||||
- **Free features**: Every basic feature of CapCut is paywalled now
|
||||
- **Free features**: Most basic CapCut features are now paywalled
|
||||
- **Simple**: People want editors that are easy to use - CapCut proved that
|
||||
|
||||
## Features
|
||||
|
||||
@@ -5,4 +5,6 @@
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
.next/
|
||||
.next/
|
||||
|
||||
.font-cache/
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,48 +2,48 @@ import type { NextConfig } from "next";
|
||||
import { withBotId } from "botid/next/config";
|
||||
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
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);
|
||||
|
||||
@@ -1,98 +1,107 @@
|
||||
{
|
||||
"name": "@opencut/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1.2.18",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "biome check src/",
|
||||
"lint:fix": "biome check src/ --write",
|
||||
"format": "biome format src/ --write",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
|
||||
"db:push:prod": "cross-env NODE_ENV=production drizzle-kit push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ffmpeg/core": "^0.12.10",
|
||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||
"@ffmpeg/util": "^0.12.2",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.4",
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"@opencut/env": "workspace:*",
|
||||
"@opencut/ui": "workspace:*",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@upstash/ratelimit": "^2.0.6",
|
||||
"@upstash/redis": "^1.35.4",
|
||||
"@vercel/analytics": "^1.4.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"better-auth": "^1.2.7",
|
||||
"botid": "^1.4.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"feed": "^5.1.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mediabunny": "^1.29.1",
|
||||
"motion": "^12.18.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "16.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.2",
|
||||
"react": "^18.2.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.54.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-phone-number-input": "^3.4.11",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "^2.14.1",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-parse": "^9.0.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"sonner": "^1.7.1",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unified": "^11.0.5",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"vaul": "^1.1.1",
|
||||
"wavesurfer.js": "^7.9.8",
|
||||
"zod": "^3.25.67",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"cross-env": "^7.0.3",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"dotenv": "^16.5.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
"name": "@opencut/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1.2.18",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "biome check src/",
|
||||
"lint:fix": "biome check src/ --write",
|
||||
"format": "biome format src/ --write",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
|
||||
"db:push:prod": "cross-env NODE_ENV=production drizzle-kit push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ffmpeg/core": "^0.12.10",
|
||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||
"@ffmpeg/util": "^0.12.2",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.4",
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"@opencut/env": "workspace:*",
|
||||
"@opencut/ui": "workspace:*",
|
||||
"@radix-ui/react-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",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"better-auth": "^1.2.7",
|
||||
"botid": "^1.4.2",
|
||||
"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",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"feed": "^5.1.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mediabunny": "^1.29.1",
|
||||
"motion": "^12.18.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "16.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"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",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"sonner": "^1.7.1",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unified": "^11.0.5",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"vaul": "^1.1.2",
|
||||
"wavesurfer.js": "^7.9.8",
|
||||
"zod": "^3.25.67",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.92",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^16.5.0",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"postcss": "^8",
|
||||
"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: 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 |
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,44 +1,44 @@
|
||||
{
|
||||
"name": "OpenCut",
|
||||
"description": "A simple but powerful video editor that gets the job done. In your browser.",
|
||||
"display": "standalone",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
"name": "OpenCut",
|
||||
"description": "A simple but powerful video editor that gets the job done. In your browser.",
|
||||
"display": "standalone",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,13 +8,14 @@ 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";
|
||||
|
||||
export default function Editor() {
|
||||
const params = useParams();
|
||||
@@ -35,6 +36,7 @@ export default function Editor() {
|
||||
}
|
||||
|
||||
function EditorLayout() {
|
||||
usePasteMedia();
|
||||
const { panels, setPanel } = usePanelStore();
|
||||
|
||||
return (
|
||||
@@ -65,7 +67,7 @@ function EditorLayout() {
|
||||
defaultSize={panels.tools}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
className="min-w-0 rounded-sm"
|
||||
className="min-w-0"
|
||||
>
|
||||
<AssetsPanel />
|
||||
</ResizablePanel>
|
||||
@@ -86,7 +88,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,75 @@
|
||||
--sidebar-accent-foreground: hsl(0 0% 2%);
|
||||
--sidebar-border: hsl(0 0% 85.1%);
|
||||
--sidebar-ring: hsl(0 0% 16.9%);
|
||||
--panel-background: hsl(216 13% 94%);
|
||||
--panel-accent: hsl(216, 8%, 88%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
}
|
||||
.dark {
|
||||
--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%);
|
||||
|
||||
.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: hsl(0, 83%, 50%);
|
||||
--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%);
|
||||
--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%, 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%);
|
||||
--constructive: hsl(141, 71%, 52%);
|
||||
--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, 100%, 12%);
|
||||
--secondary-border: hsl(204, 100%, 17%);
|
||||
--secondary-foreground: hsl(200, 98%, 63%);
|
||||
--muted: hsl(0 0% 22%);
|
||||
--accent: hsl(0, 0%, 15%);
|
||||
--accent-foreground: hsl(0 0% 93%);
|
||||
--constructive: hsl(141, 71%, 52%);
|
||||
--border: hsl(0 0% 18%);
|
||||
--input: hsl(0 0% 22%);
|
||||
--ring: hsl(0, 0%, 52%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -113,6 +140,9 @@
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-primary/35 selection:text-primary-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -123,10 +153,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 +172,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 +214,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;
|
||||
|
||||
@@ -71,6 +71,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 +152,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 +245,6 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
|
||||
</Button>
|
||||
</SortDropdown>
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
className="text-muted-foreground"
|
||||
onClick={() =>
|
||||
@@ -277,28 +270,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}
|
||||
@@ -585,7 +571,7 @@ function ProjectItem({
|
||||
</div>
|
||||
|
||||
{durationLabel && (
|
||||
<div className="absolute bottom-2 right-2 bg-black/60 text-white text-xs font-semibold px-2 py-1 rounded-lg">
|
||||
<div className="absolute bottom-2 right-2 bg-black/60 text-white text-xs font-semibold px-2 py-1 rounded-sm">
|
||||
{durationLabel}
|
||||
</div>
|
||||
)}
|
||||
@@ -781,7 +767,6 @@ function ProjectMenu({
|
||||
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="background"
|
||||
className={
|
||||
isGrid
|
||||
@@ -862,7 +847,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 });
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Progress } from "@/components/ui/progress";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { getExportMimeType, getExportFileExtension } from "@/lib/export";
|
||||
import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
|
||||
import { Check, Copy, Download, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
EXPORT_FORMAT_VALUES,
|
||||
EXPORT_QUALITY_VALUES,
|
||||
@@ -23,7 +23,11 @@ import {
|
||||
type ExportQuality,
|
||||
type ExportResult,
|
||||
} from "@/types/export";
|
||||
import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
} from "@/components/editor/panels/properties/section";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
|
||||
|
||||
@@ -44,9 +48,7 @@ export function ExportButton() {
|
||||
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}
|
||||
disabled={!hasProject}
|
||||
@@ -141,20 +143,12 @@ function ExportPopover({
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isExporting) {
|
||||
onOpenChange(false);
|
||||
setExportResult(null);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
cancelRequestedRef.current = true;
|
||||
};
|
||||
|
||||
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,110 +156,106 @@ 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} hasBorderTop={false}>
|
||||
<SectionHeader title="Format" />
|
||||
<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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Section collapsible defaultOpen={false}>
|
||||
<SectionHeader title="Quality" />
|
||||
<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>
|
||||
|
||||
<Section collapsible defaultOpen={false}>
|
||||
<SectionHeader title="Audio" />
|
||||
<SectionContent>
|
||||
<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
|
||||
<Checkbox
|
||||
id="include-audio"
|
||||
checked={includeAudio}
|
||||
onCheckedChange={(checked) =>
|
||||
setIncludeAudio(!!checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="include-audio">
|
||||
Include audio in export
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup
|
||||
title="Audio"
|
||||
titleClassName="text-sm"
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="include-audio"
|
||||
checked={includeAudio}
|
||||
onCheckedChange={(checked) =>
|
||||
setIncludeAudio(!!checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="include-audio">
|
||||
Include audio in export
|
||||
</Label>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
</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="space-y-4 p-3">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between text-center">
|
||||
<p className="text-muted-foreground mb-2 text-sm">
|
||||
|
||||
@@ -55,8 +55,8 @@ export function Onboarding() {
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-3">
|
||||
<Title title={getStepTitle()} />
|
||||
<Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." />
|
||||
<Description description="A lot of features are still missing, like the export functionality. We're working hard to build them out!" />
|
||||
<Description description="There's still a ton of things to do to make this editor amazing." />
|
||||
<Description description="A lot of features are still missing. We're working hard to build them out!" />
|
||||
<Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" />
|
||||
</div>
|
||||
<NextButton onClick={handleNext}>Next</NextButton>
|
||||
@@ -85,9 +85,7 @@ export function Onboarding() {
|
||||
<DialogTitle>
|
||||
<span className="sr-only">{getStepTitle()}</span>
|
||||
</DialogTitle>
|
||||
<DialogBody>
|
||||
{renderStepContent()}
|
||||
</DialogBody>
|
||||
<DialogBody>{renderStepContent()}</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -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,7 +4,7 @@ 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";
|
||||
@@ -43,7 +43,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",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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";
|
||||
@@ -29,14 +30,9 @@ 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 { buildElementFromMedia } 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,
|
||||
@@ -132,7 +128,15 @@ export function MediaView() {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
}): boolean => {
|
||||
const element = createElementFromMedia({ asset, startTime });
|
||||
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" },
|
||||
@@ -194,171 +198,166 @@ export function MediaView() {
|
||||
const renderCompactPreview = (item: MediaAsset) =>
|
||||
previewComponents.get(`compact-${item.id}`);
|
||||
|
||||
const mediaActions = (
|
||||
<div>
|
||||
<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>
|
||||
<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={({ 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} />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input {...fileInputProps} />
|
||||
|
||||
<div
|
||||
className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`}
|
||||
<PanelView
|
||||
title="Assets"
|
||||
actions={mediaActions}
|
||||
className={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>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
</PanelView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -636,40 +635,3 @@ function SortMenuItem({
|
||||
</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,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,
|
||||
@@ -17,10 +16,11 @@ import type {
|
||||
TranscriptionLanguage,
|
||||
TranscriptionProgress,
|
||||
} from "@/types/transcription";
|
||||
import { transcriptionService } from "@/services/transcription";
|
||||
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,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,89 +8,54 @@ 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,
|
||||
} from "@/components/editor/panels/properties/section";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useState } from "react";
|
||||
|
||||
export function SettingsView() {
|
||||
return <ProjectSettingsTabs />;
|
||||
}
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
function ProjectSettingsTabs() {
|
||||
return (
|
||||
<BaseView
|
||||
defaultTab="project-info"
|
||||
tabs={[
|
||||
{
|
||||
value: "project-info",
|
||||
label: "Project info",
|
||||
content: (
|
||||
<div className="p-5">
|
||||
<ProjectInfoView />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "background",
|
||||
label: "Background",
|
||||
content: (
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div className="flex-1 p-5">
|
||||
<BackgroundView />
|
||||
<PanelView contentClassName="px-0" hideHeader>
|
||||
<div className="flex flex-col">
|
||||
<Section hasBorderTop={false}>
|
||||
<SectionContent>
|
||||
<ProjectInfoContent />
|
||||
</SectionContent>
|
||||
</Section>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Section className="cursor-pointer">
|
||||
<PopoverTrigger asChild>
|
||||
<div>
|
||||
<SectionHeader title="Background">
|
||||
<div className="size-4 rounded-sm bg-red-500" />
|
||||
</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();
|
||||
@@ -117,7 +80,7 @@ function ProjectInfoView() {
|
||||
return -1;
|
||||
};
|
||||
|
||||
const currentCanvasSize = getCurrentCanvasSize({ activeProject });
|
||||
const currentCanvasSize = activeProject.settings.canvasSize;
|
||||
const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
|
||||
const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
|
||||
const presetIndex = findPresetIndexByAspectRatio({
|
||||
@@ -150,245 +113,55 @@ function ProjectInfoView() {
|
||||
|
||||
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={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>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Frame rate</Label>
|
||||
<Select
|
||||
value={activeProject.settings.fps.toString()}
|
||||
onValueChange={handleFpsChange}
|
||||
>
|
||||
<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.RefObject<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,35 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { usePreviewStore } from "@/stores/preview-store";
|
||||
|
||||
export function PreviewContextMenu({
|
||||
onToggleFullscreen,
|
||||
containerRef,
|
||||
}: {
|
||||
onToggleFullscreen: () => void;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const { overlays, setOverlayVisibility } = usePreviewStore();
|
||||
|
||||
return (
|
||||
<ContextMenuContent className="w-56" container={containerRef.current}>
|
||||
<ContextMenuItem onClick={onToggleFullscreen} inset>
|
||||
Full screen
|
||||
</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();
|
||||
@@ -45,38 +77,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 +156,7 @@ function PreviewCanvas() {
|
||||
.renderToCanvas({
|
||||
node: renderTree,
|
||||
time: renderTime,
|
||||
targetCanvas: ref.current,
|
||||
targetCanvas: canvasRef.current,
|
||||
})
|
||||
.then(() => {
|
||||
renderingRef.current = false;
|
||||
@@ -108,17 +168,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,149 @@
|
||||
"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: 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 backgroundColor =
|
||||
element.backgroundColor === "transparent"
|
||||
? "transparent"
|
||||
: element.backgroundColor;
|
||||
|
||||
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:
|
||||
backgroundColor === "transparent" ? 0 : TEXT_BACKGROUND_PADDING,
|
||||
minWidth: 1,
|
||||
}}
|
||||
onInput={handleInput}
|
||||
onBlur={onCommit}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{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",
|
||||
];
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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 solid 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Settings05Icon } from "@hugeicons/core-free-icons";
|
||||
|
||||
export function EmptyView() {
|
||||
return (
|
||||
<div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<HugeiconsIcon
|
||||
icon={Settings05Icon}
|
||||
className="text-muted-foreground/75 size-10"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<p className="text-lg font-medium ">It's empty here</p>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
Click an element on the timeline to edit its properties
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useReducer, useRef } from "react";
|
||||
import { evaluateMathExpression } from "@/utils/math";
|
||||
|
||||
function looksLikeExpression({ input }: { input: string }): boolean {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/[+*/]/.test(input)) return true;
|
||||
const minusIndex = trimmed.indexOf("-");
|
||||
return minusIndex > 0;
|
||||
}
|
||||
|
||||
export function usePropertyDraft<T>({
|
||||
displayValue: sourceDisplay,
|
||||
parse,
|
||||
onPreview,
|
||||
onCommit,
|
||||
supportsExpressions = true,
|
||||
}: {
|
||||
displayValue: string;
|
||||
parse: (input: string) => T | null;
|
||||
onPreview: (value: T) => void;
|
||||
onCommit: () => void;
|
||||
supportsExpressions?: boolean;
|
||||
}) {
|
||||
const [, forceRender] = useReducer(
|
||||
(renderVersion: number) => renderVersion + 1,
|
||||
0,
|
||||
);
|
||||
const isEditing = useRef(false);
|
||||
const draft = useRef("");
|
||||
|
||||
return {
|
||||
displayValue: isEditing.current ? draft.current : sourceDisplay,
|
||||
scrubTo: (value: number) => {
|
||||
const parsed = parse(String(value));
|
||||
if (parsed !== null) onPreview(parsed);
|
||||
},
|
||||
commitScrub: onCommit,
|
||||
onFocus: () => {
|
||||
isEditing.current = true;
|
||||
draft.current = sourceDisplay;
|
||||
forceRender();
|
||||
},
|
||||
onChange: (
|
||||
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
draft.current = event.target.value;
|
||||
forceRender();
|
||||
|
||||
const parsed = parse(event.target.value);
|
||||
if (parsed !== null) {
|
||||
onPreview(parsed);
|
||||
}
|
||||
},
|
||||
onBlur: () => {
|
||||
if (
|
||||
supportsExpressions &&
|
||||
looksLikeExpression({ input: draft.current })
|
||||
) {
|
||||
const evaluated = evaluateMathExpression({ input: draft.current });
|
||||
if (evaluated !== null) {
|
||||
const parsed = parse(String(evaluated));
|
||||
if (parsed !== null) onPreview(parsed);
|
||||
}
|
||||
}
|
||||
onCommit();
|
||||
isEditing.current = false;
|
||||
draft.current = "";
|
||||
forceRender();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,8 +4,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { AudioProperties } from "./audio-properties";
|
||||
import { VideoProperties } from "./video-properties";
|
||||
import { TextProperties } from "./text-properties";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Settings05Icon } from "@hugeicons/core-free-icons";
|
||||
import { EmptyView } from "./empty-view";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
|
||||
@@ -18,9 +17,9 @@ export function PropertiesPanel() {
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="panel bg-background h-full rounded-sm border overflow-hidden">
|
||||
{selectedElements.length > 0 ? (
|
||||
<ScrollArea className="bg-panel h-full rounded-sm">
|
||||
<ScrollArea className="h-full">
|
||||
{elementsWithTracks.map(({ track, element }) => {
|
||||
if (element.type === "text") {
|
||||
return (
|
||||
@@ -45,24 +44,6 @@ export function PropertiesPanel() {
|
||||
) : (
|
||||
<EmptyView />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyView() {
|
||||
return (
|
||||
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<HugeiconsIcon
|
||||
icon={Settings05Icon}
|
||||
className="text-muted-foreground/75 size-10"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<p className="text-lg font-medium">It's empty here</p>
|
||||
<p className="text-muted-foreground text-sm text-balance">
|
||||
Click an element on the timeline to edit its properties
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
interface PropertyItemProps {
|
||||
direction?: "row" | "column";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PropertyItem({
|
||||
direction = "row",
|
||||
children,
|
||||
className,
|
||||
}: PropertyItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2",
|
||||
direction === "row"
|
||||
? "items-center justify-between gap-6"
|
||||
: "flex-col gap-1.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PropertyItemLabel({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("text-muted-foreground text-xs", className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PropertyItemValue({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("flex-1 text-sm", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
interface PropertyGroupProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
}
|
||||
|
||||
export function PropertyGroup({
|
||||
title,
|
||||
children,
|
||||
defaultExpanded = true,
|
||||
className,
|
||||
titleClassName,
|
||||
}: PropertyGroupProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<PropertyItem direction="column" className={cn("gap-3", className)}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<PropertyItemLabel className={cn(titleClassName)}>
|
||||
{title}
|
||||
</PropertyItemLabel>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDownIcon}
|
||||
className={cn("size-3", !isExpanded && "-rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
{isExpanded && <PropertyItemValue>{children}</PropertyItemValue>}
|
||||
</PropertyItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
const sectionExpandedCache = new Map<string, boolean>();
|
||||
|
||||
interface SectionContext {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
collapsible: boolean;
|
||||
}
|
||||
|
||||
const SectionCtx = createContext<SectionContext | null>(null);
|
||||
|
||||
function useSectionContext() {
|
||||
return useContext(SectionCtx);
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
children: React.ReactNode;
|
||||
collapsible?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
sectionKey?: string;
|
||||
className?: string;
|
||||
hasBorderTop?: boolean;
|
||||
hasBorderBottom?: boolean;
|
||||
}
|
||||
|
||||
export function Section({
|
||||
children,
|
||||
collapsible = false,
|
||||
defaultOpen = true,
|
||||
sectionKey,
|
||||
className,
|
||||
hasBorderTop = true,
|
||||
hasBorderBottom = true,
|
||||
}: SectionProps) {
|
||||
const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined;
|
||||
const [isOpen, setIsOpen] = useState(cached ?? defaultOpen);
|
||||
|
||||
const toggle = () => {
|
||||
const next = !isOpen;
|
||||
setIsOpen(next);
|
||||
if (sectionKey) sectionExpandedCache.set(sectionKey, next);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCtx.Provider value={{ isOpen, toggle, collapsible }}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
hasBorderTop && "border-t",
|
||||
hasBorderBottom && "last:border-b",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SectionCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
}: SectionHeaderProps) {
|
||||
const ctx = useSectionContext();
|
||||
const isCollapsible = ctx?.collapsible ?? false;
|
||||
const isOpen = ctx?.isOpen ?? true;
|
||||
const isInteractive = isCollapsible || !!onClick;
|
||||
|
||||
const handleClick = isCollapsible ? ctx?.toggle : onClick;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isOpen ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{children}
|
||||
{isCollapsible && (
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDownIcon}
|
||||
className={cn(
|
||||
"size-3 shrink-0 transition-transform duration-200 ease-out",
|
||||
isOpen
|
||||
? "rotate-0 text-foreground"
|
||||
: "-rotate-90 text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const baseClassName = cn(
|
||||
"flex w-full items-center justify-between h-11 px-3.5",
|
||||
className,
|
||||
);
|
||||
|
||||
if (isInteractive) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(baseClassName, "cursor-pointer text-left")}
|
||||
onClick={(event) => {
|
||||
handleClick?.();
|
||||
event.currentTarget.blur();
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={baseClassName}>{content}</div>;
|
||||
}
|
||||
|
||||
export function SectionContent({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const ctx = useSectionContext();
|
||||
const isCollapsible = ctx?.collapsible ?? false;
|
||||
const isOpen = ctx?.isOpen ?? true;
|
||||
|
||||
if (isCollapsible) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-100 ease-out",
|
||||
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className={cn("p-4 pt-0", className)}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={cn("p-4 pt-0", className)}>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import {
|
||||
DEFAULT_BLEND_MODE,
|
||||
DEFAULT_OPACITY,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { OcCheckerboardIcon } from "@opencut/ui/icons";
|
||||
import { Fragment, useRef } from "react";
|
||||
import { Section, SectionContent, SectionHeader } from "../section";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { BlendMode } from "@/types/rendering";
|
||||
import type { ElementType } from "@/types/timeline";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { RainDropIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
type BlendingElement = {
|
||||
id: string;
|
||||
opacity: number;
|
||||
type: ElementType;
|
||||
blendMode?: BlendMode;
|
||||
};
|
||||
|
||||
const BLEND_MODE_GROUPS = [
|
||||
[{ value: "normal", label: "Normal" }],
|
||||
[
|
||||
{ value: "darken", label: "Darken" },
|
||||
{ value: "multiply", label: "Multiply" },
|
||||
{ value: "color-burn", label: "Color Burn" },
|
||||
],
|
||||
[
|
||||
{ value: "lighten", label: "Lighten" },
|
||||
{ value: "screen", label: "Screen" },
|
||||
{ value: "plus-lighter", label: "Plus Lighter" },
|
||||
{ value: "color-dodge", label: "Color Dodge" },
|
||||
],
|
||||
[
|
||||
{ value: "overlay", label: "Overlay" },
|
||||
{ value: "soft-light", label: "Soft Light" },
|
||||
{ value: "hard-light", label: "Hard Light" },
|
||||
],
|
||||
[
|
||||
{ value: "difference", label: "Difference" },
|
||||
{ value: "exclusion", label: "Exclusion" },
|
||||
],
|
||||
[
|
||||
{ value: "hue", label: "Hue" },
|
||||
{ value: "saturation", label: "Saturation" },
|
||||
{ value: "color", label: "Color" },
|
||||
{ value: "luminosity", label: "Luminosity" },
|
||||
],
|
||||
];
|
||||
|
||||
export function BlendingSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: BlendingElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE;
|
||||
const didSelectRef = useRef(false);
|
||||
const committedBlendModeRef = useRef(blendMode);
|
||||
if (!editor.timeline.isPreviewActive()) {
|
||||
committedBlendModeRef.current = blendMode;
|
||||
}
|
||||
|
||||
const previewBlendMode = (value: BlendMode) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { blendMode: value } },
|
||||
],
|
||||
});
|
||||
|
||||
const commitBlendMode = (value: string) => {
|
||||
if (editor.timeline.isPreviewActive()) {
|
||||
editor.timeline.commitPreview();
|
||||
} else {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { blendMode: value as BlendMode },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
didSelectRef.current = true;
|
||||
};
|
||||
|
||||
const handleBlendModeOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
if (!didSelectRef.current) editor.timeline.discardPreview();
|
||||
didSelectRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const opacity = usePropertyDraft({
|
||||
displayValue: Math.round(element.opacity * 100).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
|
||||
},
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { opacity: value } },
|
||||
],
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey={`${element.type}:blending`}>
|
||||
<SectionHeader title="Blending" />
|
||||
<SectionContent>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-1/2 space-y-1.5">
|
||||
<NumberField
|
||||
className="w-full"
|
||||
icon={
|
||||
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
|
||||
}
|
||||
value={opacity.displayValue}
|
||||
min={0}
|
||||
max={100}
|
||||
onFocus={opacity.onFocus}
|
||||
onChange={opacity.onChange}
|
||||
onBlur={opacity.onBlur}
|
||||
onScrub={opacity.scrubTo}
|
||||
onScrubEnd={opacity.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: DEFAULT_OPACITY },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={element.opacity === DEFAULT_OPACITY}
|
||||
dragSensitivity="slow"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/2 space-y-1.5">
|
||||
<Select
|
||||
value={committedBlendModeRef.current}
|
||||
onOpenChange={handleBlendModeOpenChange}
|
||||
onValueChange={commitBlendMode}
|
||||
>
|
||||
<SelectTrigger
|
||||
icon={<HugeiconsIcon icon={RainDropIcon} />}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue placeholder="Select blend mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-36">
|
||||
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
|
||||
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
|
||||
{group.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onPointerEnter={() =>
|
||||
previewBlendMode(option.value as BlendMode)
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
|
||||
<SelectSeparator />
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./transform";
|
||||
export * from "./blending";
|
||||
@@ -0,0 +1,248 @@
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||
import { clamp } from "@/utils/math";
|
||||
import type { ElementType, Transform } from "@/types/timeline";
|
||||
import { Section, SectionContent, SectionHeader } from "../section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
ArrowExpandIcon,
|
||||
Link05Icon,
|
||||
RotateClockwiseIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { useState } from "react";
|
||||
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
|
||||
|
||||
type TransformElement = {
|
||||
id: string;
|
||||
transform: Transform;
|
||||
type: ElementType;
|
||||
};
|
||||
|
||||
function parseFloat_({ input }: { input: string }): number | null {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
export function TransformSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: TransformElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const [isScaleLocked, setIsScaleLocked] = useState(false);
|
||||
|
||||
const previewTransform = (transform: Partial<Transform>) => {
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { transform: { ...element.transform, ...transform } },
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const commit = () => editor.timeline.commitPreview();
|
||||
|
||||
const positionX = usePropertyDraft({
|
||||
displayValue: Math.round(element.transform.position.x).toString(),
|
||||
parse: (input) => parseFloat_({ input }),
|
||||
onPreview: (value) =>
|
||||
previewTransform({
|
||||
position: { ...element.transform.position, x: value },
|
||||
}),
|
||||
onCommit: commit,
|
||||
});
|
||||
|
||||
const positionY = usePropertyDraft({
|
||||
displayValue: Math.round(element.transform.position.y).toString(),
|
||||
parse: (input) => parseFloat_({ input }),
|
||||
onPreview: (value) =>
|
||||
previewTransform({
|
||||
position: { ...element.transform.position, y: value },
|
||||
}),
|
||||
onCommit: commit,
|
||||
});
|
||||
|
||||
const scale = usePropertyDraft({
|
||||
displayValue: Math.round(element.transform.scale * 100).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat_({ input });
|
||||
if (parsed === null) return null;
|
||||
return Math.max(parsed, 1) / 100;
|
||||
},
|
||||
onPreview: (value) => previewTransform({ scale: value }),
|
||||
onCommit: commit,
|
||||
});
|
||||
const scaleFieldProps = {
|
||||
className: "flex-1",
|
||||
value: scale.displayValue,
|
||||
onFocus: scale.onFocus,
|
||||
onChange: scale.onChange,
|
||||
onBlur: scale.onBlur,
|
||||
dragSensitivity: "slow" as const,
|
||||
onScrub: scale.scrubTo,
|
||||
onScrubEnd: scale.commitScrub,
|
||||
onReset: () =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
transform: {
|
||||
...element.transform,
|
||||
scale: DEFAULT_TRANSFORM.scale,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
isDefault: element.transform.scale === DEFAULT_TRANSFORM.scale,
|
||||
};
|
||||
|
||||
const rotation = usePropertyDraft({
|
||||
displayValue: Math.round(element.transform.rotate).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat_({ input });
|
||||
if (parsed === null) return null;
|
||||
return clamp({ value: parsed, min: -360, max: 360 });
|
||||
},
|
||||
onPreview: (value) => previewTransform({ rotate: value }),
|
||||
onCommit: commit,
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey={`${element.type}:transform`}>
|
||||
<SectionHeader title="Transform" />
|
||||
<SectionContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{isScaleLocked ? (
|
||||
<>
|
||||
<NumberField icon="W" {...scaleFieldProps} />
|
||||
<NumberField icon="H" {...scaleFieldProps} />
|
||||
</>
|
||||
) : (
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
|
||||
{...scaleFieldProps}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant={isScaleLocked ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
aria-pressed={isScaleLocked}
|
||||
onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
|
||||
>
|
||||
<HugeiconsIcon icon={Link05Icon} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberField
|
||||
icon="X"
|
||||
className="flex-1"
|
||||
value={positionX.displayValue}
|
||||
onFocus={positionX.onFocus}
|
||||
onChange={positionX.onChange}
|
||||
onBlur={positionX.onBlur}
|
||||
onScrub={positionX.scrubTo}
|
||||
onScrubEnd={positionX.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: {
|
||||
...element.transform.position,
|
||||
x: DEFAULT_TRANSFORM.position.x,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={
|
||||
element.transform.position.x === DEFAULT_TRANSFORM.position.x
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
icon="Y"
|
||||
className="flex-1"
|
||||
value={positionY.displayValue}
|
||||
onFocus={positionY.onFocus}
|
||||
onChange={positionY.onChange}
|
||||
onBlur={positionY.onBlur}
|
||||
onScrub={positionY.scrubTo}
|
||||
onScrubEnd={positionY.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: {
|
||||
...element.transform.position,
|
||||
y: DEFAULT_TRANSFORM.position.y,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={
|
||||
element.transform.position.y === DEFAULT_TRANSFORM.position.y
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
|
||||
className="flex-1"
|
||||
value={rotation.displayValue}
|
||||
onFocus={rotation.onFocus}
|
||||
onChange={rotation.onChange}
|
||||
onBlur={rotation.onBlur}
|
||||
dragSensitivity="slow"
|
||||
onScrub={rotation.scrubTo}
|
||||
onScrubEnd={rotation.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
transform: {
|
||||
...element.transform,
|
||||
rotate: DEFAULT_TRANSFORM.rotate,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,31 @@
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FontPicker } from "@/components/ui/font-picker";
|
||||
import type { FontFamily } from "@/constants/font-constants";
|
||||
import type { TextElement } from "@/types/timeline";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState, useRef } from "react";
|
||||
import { PanelBaseView } from "@/components/editor/panels/panel-base-view";
|
||||
import {
|
||||
TEXT_PROPERTIES_TABS,
|
||||
isTextPropertiesTab,
|
||||
useTextPropertiesStore,
|
||||
} from "@/stores/text-properties-store";
|
||||
import {
|
||||
PropertyItem,
|
||||
PropertyItemLabel,
|
||||
PropertyItemValue,
|
||||
} from "./property-item";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { useRef } from "react";
|
||||
import { Section, SectionContent, SectionHeader } from "./section";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { capitalizeFirstLetter, uppercase } from "@/utils/string";
|
||||
import { uppercase } from "@/utils/string";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { LayoutGridIcon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_COLOR } from "@/constants/project-constants";
|
||||
import {
|
||||
DEFAULT_TEXT_ELEMENT,
|
||||
MAX_FONT_SIZE,
|
||||
MIN_FONT_SIZE,
|
||||
} from "@/constants/text-constants";
|
||||
import { usePropertyDraft } from "./hooks/use-property-draft";
|
||||
import { TransformSection, BlendingSection } from "./sections";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import { OcFontWeightIcon } from "@opencut/ui/icons";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function TextProperties({
|
||||
element,
|
||||
@@ -38,373 +34,229 @@ export function TextProperties({
|
||||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const { activeTab, setActiveTab } = useTextPropertiesStore();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [fontSizeInput, setFontSizeInput] = useState(
|
||||
element.fontSize.toString(),
|
||||
);
|
||||
const [opacityInput, setOpacityInput] = useState(
|
||||
Math.round(element.opacity * 100).toString(),
|
||||
);
|
||||
|
||||
const lastSelectedColor = useRef(DEFAULT_COLOR);
|
||||
|
||||
const handleFontSizeChange = ({ value }: { value: string }) => {
|
||||
setFontSizeInput(value);
|
||||
|
||||
if (value.trim() !== "") {
|
||||
const parsed = parseInt(value, 10);
|
||||
const fontSize = Number.isNaN(parsed)
|
||||
? element.fontSize
|
||||
: clamp({ value: parsed, min: 8, max: 300 });
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFontSizeBlur = () => {
|
||||
const parsed = parseInt(fontSizeInput, 10);
|
||||
const fontSize = Number.isNaN(parsed)
|
||||
? element.fontSize
|
||||
: clamp({ value: parsed, min: 8, max: 300 });
|
||||
setFontSizeInput(fontSize.toString());
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize },
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpacityChange = ({ value }: { value: string }) => {
|
||||
setOpacityInput(value);
|
||||
|
||||
if (value.trim() !== "") {
|
||||
const parsed = parseInt(value, 10);
|
||||
const opacityPercent = Number.isNaN(parsed)
|
||||
? Math.round(element.opacity * 100)
|
||||
: clamp({ value: parsed, min: 0, max: 100 });
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: opacityPercent / 100 },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpacityBlur = () => {
|
||||
const parsed = parseInt(opacityInput, 10);
|
||||
const opacityPercent = Number.isNaN(parsed)
|
||||
? Math.round(element.opacity * 100)
|
||||
: clamp({ value: parsed, min: 0, max: 100 });
|
||||
setOpacityInput(opacityPercent.toString());
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: opacityPercent / 100 },
|
||||
});
|
||||
};
|
||||
|
||||
const handleColorChange = ({ color }: { color: string }) => {
|
||||
if (color !== "transparent") {
|
||||
lastSelectedColor.current = color;
|
||||
}
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: color },
|
||||
});
|
||||
};
|
||||
|
||||
const handleTransparentToggle = ({
|
||||
isTransparent,
|
||||
}: {
|
||||
isTransparent: boolean;
|
||||
}) => {
|
||||
const newColor = isTransparent ? "transparent" : lastSelectedColor.current;
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: newColor },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelBaseView
|
||||
defaultTab="transform"
|
||||
value={activeTab}
|
||||
onValueChange={(v) => {
|
||||
if (isTextPropertiesTab(v)) setActiveTab(v);
|
||||
}}
|
||||
ref={containerRef}
|
||||
tabs={TEXT_PROPERTIES_TABS.map((t) => ({
|
||||
value: t.value,
|
||||
label: t.label,
|
||||
content:
|
||||
t.value === "transform" ? (
|
||||
<div className="space-y-6"></div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Textarea
|
||||
placeholder="Name"
|
||||
defaultValue={element.content}
|
||||
className="bg-panel-accent min-h-20"
|
||||
onChange={(e) =>
|
||||
editor.timeline.updateTextElement({
|
||||
<div className="flex h-full flex-col">
|
||||
<ContentSection element={element} trackId={trackId} />
|
||||
<TransformSection element={element} trackId={trackId} />
|
||||
<BlendingSection element={element} trackId={trackId} />
|
||||
<FontSection element={element} trackId={trackId} />
|
||||
<ColorSection element={element} trackId={trackId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const content = usePropertyDraft({
|
||||
displayValue: element.content,
|
||||
parse: (input) => input,
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { content: value } },
|
||||
],
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey="text:content" hasBorderTop={false}>
|
||||
<SectionHeader title="Content" />
|
||||
<SectionContent>
|
||||
<Textarea
|
||||
placeholder="Name"
|
||||
value={content.displayValue}
|
||||
className="min-h-20"
|
||||
onFocus={content.onFocus}
|
||||
onChange={content.onChange}
|
||||
onBlur={content.onBlur}
|
||||
/>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function FontSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const fontSize = usePropertyDraft({
|
||||
displayValue: element.fontSize.toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
|
||||
},
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { fontSize: value } },
|
||||
],
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey="text:font">
|
||||
<SectionHeader title="Font" />
|
||||
<SectionContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FontPicker
|
||||
defaultValue={element.fontFamily}
|
||||
onValueChange={(value) =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { content: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Font</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<FontPicker
|
||||
defaultValue={element.fontFamily}
|
||||
onValueChange={(value: FontFamily) =>
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontFamily: value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Style</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={
|
||||
element.fontWeight === "bold" ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
fontWeight:
|
||||
element.fontWeight === "bold" ? "normal" : "bold",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 font-bold"
|
||||
>
|
||||
B
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
element.fontStyle === "italic" ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
fontStyle:
|
||||
element.fontStyle === "italic"
|
||||
? "normal"
|
||||
: "italic",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 italic"
|
||||
>
|
||||
I
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
element.textDecoration === "underline"
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
textDecoration:
|
||||
element.textDecoration === "underline"
|
||||
? "none"
|
||||
: "underline",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 underline"
|
||||
>
|
||||
U
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
element.textDecoration === "line-through"
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
textDecoration:
|
||||
element.textDecoration === "line-through"
|
||||
? "none"
|
||||
: "line-through",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 line-through"
|
||||
>
|
||||
S
|
||||
</Button>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Font size</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[element.fontSize]}
|
||||
min={8}
|
||||
max={300}
|
||||
step={1}
|
||||
onValueChange={([value]) => {
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize: value },
|
||||
});
|
||||
setFontSizeInput(value.toString());
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={fontSizeInput}
|
||||
min={8}
|
||||
max={300}
|
||||
onChange={(e) =>
|
||||
handleFontSizeChange({ value: e.target.value })
|
||||
}
|
||||
onBlur={handleFontSizeBlur}
|
||||
className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Color</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<ColorPicker
|
||||
value={uppercase({
|
||||
string: (element.color || "FFFFFF").replace("#", ""),
|
||||
})}
|
||||
onChange={(color) => {
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { color: `#${color}` },
|
||||
});
|
||||
}}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Opacity</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[element.opacity * 100]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={([value]) => {
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: value / 100 },
|
||||
});
|
||||
setOpacityInput(value.toString());
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={opacityInput}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) =>
|
||||
handleOpacityChange({ value: e.target.value })
|
||||
}
|
||||
onBlur={handleOpacityBlur}
|
||||
className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Background</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<ColorPicker
|
||||
value={capitalizeFirstLetter({
|
||||
string:
|
||||
element.backgroundColor === "transparent"
|
||||
? lastSelectedColor.current.replace("#", "")
|
||||
: element.backgroundColor.replace("#", ""),
|
||||
})}
|
||||
onChange={(color) =>
|
||||
handleColorChange({ color: `#${color}` })
|
||||
}
|
||||
containerRef={containerRef}
|
||||
className={
|
||||
element.backgroundColor === "transparent"
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
updates: { fontFamily: value },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Select value={element.fontWeight}>
|
||||
<SelectTrigger className="w-full" icon={<OcFontWeightIcon />}>
|
||||
<SelectValue placeholder="Select weight" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="100">Thin</SelectItem>
|
||||
<SelectItem value="200">Extra Light</SelectItem>
|
||||
<SelectItem value="300">Light</SelectItem>
|
||||
<SelectItem value="400">Normal</SelectItem>
|
||||
<SelectItem value="500">Medium</SelectItem>
|
||||
<SelectItem value="600">Semi Bold</SelectItem>
|
||||
<SelectItem value="700">Bold</SelectItem>
|
||||
<SelectItem value="800">Extra Bold</SelectItem>
|
||||
<SelectItem value="900">Black</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleTransparentToggle({
|
||||
isTransparent:
|
||||
element.backgroundColor !== "transparent",
|
||||
})
|
||||
}
|
||||
className="bg-panel-accent size-9 overflow-hidden rounded-full p-0"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={LayoutGridIcon}
|
||||
className={cn(
|
||||
"text-foreground",
|
||||
element.backgroundColor === "transparent" &&
|
||||
"text-primary",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Transparent background</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
<Label>Font size</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[element.fontSize]}
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
step={1}
|
||||
onValueChange={([value]) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize: value },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
onValueCommit={() => editor.timeline.commitPreview()}
|
||||
className="w-full"
|
||||
/>
|
||||
<NumberField
|
||||
className="w-18 shrink-0"
|
||||
value={fontSize.displayValue}
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
onFocus={fontSize.onFocus}
|
||||
onChange={fontSize.onChange}
|
||||
onBlur={fontSize.onBlur}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
fontSize: DEFAULT_TEXT_ELEMENT.fontSize,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const lastSelectedColor = useRef(DEFAULT_COLOR);
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey="text:color" hasBorderBottom={false}>
|
||||
<SectionHeader title="Color" />
|
||||
<SectionContent>
|
||||
<div className="flex flex-col gap-6">
|
||||
<ColorPicker
|
||||
value={uppercase({
|
||||
string: (element.color || "FFFFFF").replace("#", ""),
|
||||
})}
|
||||
onChange={(color) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { color: `#${color}` },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
onChangeEnd={() => editor.timeline.commitPreview()}
|
||||
/>
|
||||
<ColorPicker
|
||||
value={
|
||||
element.backgroundColor === "transparent"
|
||||
? lastSelectedColor.current.replace("#", "")
|
||||
: element.backgroundColor.replace("#", "")
|
||||
}
|
||||
onChange={(color) => {
|
||||
const hexColor = `#${color}`;
|
||||
if (color !== "transparent") {
|
||||
lastSelectedColor.current = hexColor;
|
||||
}
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: hexColor },
|
||||
},
|
||||
],
|
||||
});
|
||||
}}
|
||||
onChangeEnd={() => editor.timeline.commitPreview()}
|
||||
className={
|
||||
element.backgroundColor === "transparent"
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { EditorCore } from "@/core";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
|
||||
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
|
||||
import {
|
||||
DEFAULT_BOOKMARK_COLOR,
|
||||
TIMELINE_CONSTANTS,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/project-constants";
|
||||
import { getSnappedSeekTime } from "@/lib/time";
|
||||
import {
|
||||
ArrowTurnBackwardIcon,
|
||||
Delete02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { Bookmark } from "@/types/timeline";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { uppercase } from "@/utils/string";
|
||||
import { clamp } from "@/utils/math";
|
||||
|
||||
const PIXELS_PER_SECOND = TIMELINE_CONSTANTS.PIXELS_PER_SECOND;
|
||||
const MIN_BOOKMARK_WIDTH_PX = 2;
|
||||
const BOOKMARK_MARKER_WIDTH_PX = 12;
|
||||
const BOOKMARK_MARKER_HEIGHT_PX = 15;
|
||||
const BOOKMARK_HALF_WIDTH_PX = BOOKMARK_MARKER_WIDTH_PX / 2;
|
||||
const BOOKMARK_MARKER_CLIP_PATH =
|
||||
"polygon(50% 100%, 12% 72%, 12% 10%, 88% 10%, 88% 72%)";
|
||||
|
||||
function seekToBookmarkTime({
|
||||
editor,
|
||||
time,
|
||||
}: {
|
||||
editor: EditorCore;
|
||||
time: number;
|
||||
}) {
|
||||
const activeProject = editor.project.getActive();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const fps = activeProject?.settings.fps ?? DEFAULT_FPS;
|
||||
const snappedTime = getSnappedSeekTime({ rawTime: time, duration, fps });
|
||||
editor.playback.seek({ time: snappedTime });
|
||||
}
|
||||
|
||||
interface TimelineBookmarksRowProps {
|
||||
zoomLevel: number;
|
||||
dynamicTimelineWidth: number;
|
||||
dragState: BookmarkDragState;
|
||||
onBookmarkMouseDown: (params: {
|
||||
event: React.MouseEvent;
|
||||
bookmark: Bookmark;
|
||||
}) => void;
|
||||
handleWheel: (event: React.WheelEvent) => void;
|
||||
handleTimelineContentClick: (event: React.MouseEvent) => void;
|
||||
handleRulerTrackingMouseDown: (event: React.MouseEvent) => void;
|
||||
handleRulerMouseDown: (event: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function TimelineBookmarksRow({
|
||||
zoomLevel,
|
||||
dynamicTimelineWidth,
|
||||
dragState,
|
||||
onBookmarkMouseDown,
|
||||
handleWheel,
|
||||
handleTimelineContentClick,
|
||||
handleRulerTrackingMouseDown,
|
||||
handleRulerMouseDown,
|
||||
}: TimelineBookmarksRowProps) {
|
||||
const editor = useEditor();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
|
||||
return (
|
||||
<div className="relative h-4 flex-1 overflow-hidden">
|
||||
<button
|
||||
className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0"
|
||||
style={{
|
||||
width: `${dynamicTimelineWidth}px`,
|
||||
}}
|
||||
aria-label="Timeline ruler"
|
||||
type="button"
|
||||
onWheel={handleWheel}
|
||||
onClick={(event) => {
|
||||
if (!event.currentTarget.contains(event.target as Node)) return;
|
||||
handleTimelineContentClick(event);
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
if (!event.currentTarget.contains(event.target as Node)) return;
|
||||
handleRulerMouseDown(event);
|
||||
handleRulerTrackingMouseDown(event);
|
||||
}}
|
||||
>
|
||||
{activeScene.bookmarks.map((bookmark) => (
|
||||
<TimelineBookmark
|
||||
key={`bookmark-${bookmark.time}`}
|
||||
bookmark={bookmark}
|
||||
zoomLevel={zoomLevel}
|
||||
dragState={dragState}
|
||||
onBookmarkMouseDown={onBookmarkMouseDown}
|
||||
/>
|
||||
))}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineBookmark({
|
||||
bookmark,
|
||||
zoomLevel,
|
||||
dragState,
|
||||
onBookmarkMouseDown,
|
||||
}: {
|
||||
bookmark: Bookmark;
|
||||
zoomLevel: number;
|
||||
dragState: BookmarkDragState;
|
||||
onBookmarkMouseDown: (params: {
|
||||
event: React.MouseEvent;
|
||||
bookmark: Bookmark;
|
||||
}) => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
||||
const isDragging =
|
||||
dragState.isDragging &&
|
||||
dragState.bookmarkTime !== null &&
|
||||
Math.abs(dragState.bookmarkTime - bookmark.time) < BOOKMARK_TIME_EPSILON;
|
||||
|
||||
const displayTime = isDragging ? dragState.currentTime : bookmark.time;
|
||||
const time = bookmark.time;
|
||||
const bookmarkDuration = bookmark.duration ?? 0;
|
||||
const durationWidth =
|
||||
bookmarkDuration > 0 ? bookmarkDuration * PIXELS_PER_SECOND * zoomLevel : 0;
|
||||
const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX;
|
||||
const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0);
|
||||
const left = displayTime * PIXELS_PER_SECOND * zoomLevel;
|
||||
const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX;
|
||||
const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0);
|
||||
const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR;
|
||||
|
||||
const handleSeek = () => seekToBookmarkTime({ editor, time });
|
||||
|
||||
const handleClick = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.detail === 2) {
|
||||
setIsPopoverOpen(true);
|
||||
} else {
|
||||
handleSeek();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
handleSeek();
|
||||
};
|
||||
|
||||
const handleMouseDown = (event: React.MouseEvent) => {
|
||||
onBookmarkMouseDown({ event, bookmark });
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<button
|
||||
className={`absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0 ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
|
||||
style={{
|
||||
left: `${bookmarkLeft}px`,
|
||||
width: `${bookmarkWidth}px`,
|
||||
}}
|
||||
aria-label={`Bookmark at ${time.toFixed(1)}s`}
|
||||
type="button"
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{hasDurationRange ? (
|
||||
<div
|
||||
className="absolute opacity-30"
|
||||
style={{
|
||||
top: 1.5,
|
||||
height: BOOKMARK_MARKER_HEIGHT_PX - 2.5,
|
||||
left: BOOKMARK_HALF_WIDTH_PX,
|
||||
width: durationWidth,
|
||||
backgroundColor: iconColor,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className="absolute top-0 overflow-hidden"
|
||||
style={{
|
||||
left: 0,
|
||||
width: BOOKMARK_HALF_WIDTH_PX,
|
||||
height: BOOKMARK_MARKER_HEIGHT_PX,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
width: BOOKMARK_MARKER_WIDTH_PX,
|
||||
height: BOOKMARK_MARKER_HEIGHT_PX,
|
||||
clipPath: BOOKMARK_MARKER_CLIP_PATH,
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
top: 1,
|
||||
left: 1,
|
||||
width: BOOKMARK_MARKER_WIDTH_PX - 2,
|
||||
height: BOOKMARK_MARKER_HEIGHT_PX - 2,
|
||||
clipPath: BOOKMARK_MARKER_CLIP_PATH,
|
||||
backgroundColor: iconColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="absolute top-0 overflow-hidden"
|
||||
style={{
|
||||
left: rightHalfLeft,
|
||||
width: BOOKMARK_HALF_WIDTH_PX,
|
||||
height: BOOKMARK_MARKER_HEIGHT_PX,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0"
|
||||
style={{
|
||||
left: -BOOKMARK_HALF_WIDTH_PX,
|
||||
width: BOOKMARK_MARKER_WIDTH_PX,
|
||||
height: BOOKMARK_MARKER_HEIGHT_PX,
|
||||
clipPath: BOOKMARK_MARKER_CLIP_PATH,
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
top: 1,
|
||||
left: 1 - BOOKMARK_HALF_WIDTH_PX,
|
||||
width: BOOKMARK_MARKER_WIDTH_PX - 2,
|
||||
height: BOOKMARK_MARKER_HEIGHT_PX - 2,
|
||||
clipPath: BOOKMARK_MARKER_CLIP_PATH,
|
||||
backgroundColor: iconColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
className="w-64 flex flex-col gap-3 p-3"
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<BookmarkPopoverContent
|
||||
bookmark={bookmark}
|
||||
time={time}
|
||||
timelineDuration={duration}
|
||||
onPopoverClose={() => setIsPopoverOpen(false)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function BookmarkPopoverContent({
|
||||
bookmark,
|
||||
time,
|
||||
timelineDuration,
|
||||
onPopoverClose,
|
||||
}: {
|
||||
bookmark: Bookmark;
|
||||
time: number;
|
||||
timelineDuration: number;
|
||||
onPopoverClose: () => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const [draftColorHex, setDraftColorHex] = useState(
|
||||
(bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftColorHex(
|
||||
(bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
|
||||
);
|
||||
}, [bookmark.color]);
|
||||
|
||||
const handleRemove = () => {
|
||||
editor.scenes.removeBookmark({ time });
|
||||
onPopoverClose();
|
||||
};
|
||||
|
||||
const handleUpdate = ({
|
||||
note,
|
||||
color,
|
||||
duration,
|
||||
}: Partial<{ note: string; color: string; duration: number }>) => {
|
||||
const updates: Partial<{ note: string; color: string; duration: number }> =
|
||||
{};
|
||||
if (note !== undefined && note !== bookmark.note) updates.note = note;
|
||||
if (
|
||||
color !== undefined &&
|
||||
color.toUpperCase() !== (bookmark.color ?? "").toUpperCase()
|
||||
) {
|
||||
updates.color = color;
|
||||
}
|
||||
if (duration !== undefined && duration !== bookmark.duration) {
|
||||
updates.duration = duration;
|
||||
}
|
||||
if (Object.keys(updates).length === 0) return;
|
||||
editor.scenes.updateBookmark({ time, updates });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs">Note</Label>
|
||||
<Input
|
||||
placeholder="Add a note..."
|
||||
value={bookmark.note ?? ""}
|
||||
onChange={(event) => handleUpdate({ note: event.target.value })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs">Color</Label>
|
||||
<div className="relative">
|
||||
<ColorPicker
|
||||
value={uppercase({ string: draftColorHex })}
|
||||
onChange={(color) => setDraftColorHex(uppercase({ string: color }))}
|
||||
onChangeEnd={(color) =>
|
||||
handleUpdate({ color: `#${uppercase({ string: color })}` })
|
||||
}
|
||||
className="bg-background border"
|
||||
/>
|
||||
{bookmark.color &&
|
||||
bookmark.color.replace(/^#/, "").toUpperCase() !==
|
||||
DEFAULT_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="text"
|
||||
aria-label="Reset to default color"
|
||||
className="absolute top-1/2 right-1 -translate-y-1/2 mr-1"
|
||||
onClick={() =>
|
||||
editor.scenes.updateBookmark({
|
||||
time,
|
||||
updates: { color: undefined },
|
||||
})
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowTurnBackwardIcon}
|
||||
className="!size-3.5"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs">Duration</Label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={bookmark.duration ?? 0}
|
||||
onChange={(event) => {
|
||||
const parsed = parseFloat(event.target.value);
|
||||
const value = Number.isNaN(parsed)
|
||||
? 0
|
||||
: clamp({
|
||||
value: parsed,
|
||||
min: 0,
|
||||
max: Math.max(0, timelineDuration - time),
|
||||
});
|
||||
handleUpdate({ duration: value });
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
containerClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={handleRemove}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
handleRemove();
|
||||
}
|
||||
}}
|
||||
aria-label="delete bookmark"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="!size-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -15,12 +15,12 @@ import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "../../ui/context-menu";
|
||||
} from "../../../ui/context-menu";
|
||||
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { TimelineTrackContent } from "./timeline-track";
|
||||
import { TimelinePlayhead } from "./timeline-playhead";
|
||||
import { SelectionBox } from "../selection-box";
|
||||
import { SelectionBox } from "../../selection-box";
|
||||
import { useSelectionBox } from "@/hooks/timeline/use-selection-box";
|
||||
import { SnapIndicator } from "./snap-indicator";
|
||||
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
@@ -47,6 +47,8 @@ import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
|
||||
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
|
||||
import { TimelineRuler } from "./timeline-ruler";
|
||||
import { TimelineBookmarksRow } from "./bookmarks";
|
||||
import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
|
||||
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
@@ -126,6 +128,17 @@ export function Timeline() {
|
||||
onSnapPointChange: handleSnapPointChange,
|
||||
});
|
||||
|
||||
const {
|
||||
dragState: bookmarkDragState,
|
||||
handleBookmarkMouseDown,
|
||||
lastMouseXRef: bookmarkLastMouseXRef,
|
||||
} = useBookmarkDrag({
|
||||
zoomLevel,
|
||||
scrollRef: tracksScrollRef,
|
||||
snappingEnabled,
|
||||
onSnapPointChange: handleSnapPointChange,
|
||||
});
|
||||
|
||||
const { handleRulerMouseDown: handlePlayheadRulerMouseDown } =
|
||||
useTimelinePlayhead({
|
||||
zoomLevel,
|
||||
@@ -168,10 +181,18 @@ export function Timeline() {
|
||||
containerWidth,
|
||||
);
|
||||
|
||||
useEdgeAutoScroll({
|
||||
isActive: bookmarkDragState.isDragging,
|
||||
getMouseClientX: () => bookmarkLastMouseXRef.current,
|
||||
rulerScrollRef: tracksScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: dynamicTimelineWidth,
|
||||
});
|
||||
|
||||
const showSnapIndicator =
|
||||
snappingEnabled &&
|
||||
currentSnapPoint !== null &&
|
||||
(dragState.isDragging || isResizing);
|
||||
(dragState.isDragging || bookmarkDragState.isDragging || isResizing);
|
||||
|
||||
const {
|
||||
handleTracksMouseDown,
|
||||
@@ -201,7 +222,7 @@ export function Timeline() {
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
"bg-panel relative flex h-full flex-col overflow-hidden rounded-sm"
|
||||
"panel bg-background relative flex h-full flex-col overflow-hidden rounded-sm border"
|
||||
}
|
||||
{...dragProps}
|
||||
aria-label="Timeline"
|
||||
@@ -226,17 +247,17 @@ export function Timeline() {
|
||||
isVisible={showSnapIndicator}
|
||||
/>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="bg-panel flex w-28 shrink-0 flex-col border-r">
|
||||
<div className="bg-panel flex h-4 items-center justify-between px-3">
|
||||
<div className="bg-background flex w-28 shrink-0 flex-col border-r">
|
||||
<div className="bg-background flex h-4 items-center justify-between px-3">
|
||||
<span className="opacity-0">.</span>
|
||||
</div>
|
||||
<div className="bg-panel flex h-4 items-center justify-between px-3">
|
||||
<div className="bg-background flex h-4 items-center justify-between px-3">
|
||||
<span className="opacity-0">.</span>
|
||||
</div>
|
||||
{tracks.length > 0 && (
|
||||
<div
|
||||
ref={trackLabelsRef}
|
||||
className="bg-panel flex-1 overflow-y-auto"
|
||||
className="bg-background flex-1 overflow-y-auto"
|
||||
style={{ paddingTop: TIMELINE_CONSTANTS.PADDING_TOP_PX }}
|
||||
>
|
||||
<ScrollArea className="size-full" ref={trackLabelsScrollRef}>
|
||||
@@ -351,12 +372,13 @@ export function Timeline() {
|
||||
>
|
||||
<div
|
||||
ref={timelineHeaderRef}
|
||||
className="bg-panel sticky top-0 z-30 flex flex-col"
|
||||
className="bg-background sticky top-0 flex flex-col"
|
||||
>
|
||||
<TimelineRuler
|
||||
zoomLevel={zoomLevel}
|
||||
dynamicTimelineWidth={dynamicTimelineWidth}
|
||||
rulerRef={rulerRef}
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
handleWheel={handleWheel}
|
||||
handleTimelineContentClick={handleRulerClick}
|
||||
handleRulerTrackingMouseDown={handleRulerMouseDown}
|
||||
@@ -365,6 +387,8 @@ export function Timeline() {
|
||||
<TimelineBookmarksRow
|
||||
zoomLevel={zoomLevel}
|
||||
dynamicTimelineWidth={dynamicTimelineWidth}
|
||||
dragState={bookmarkDragState}
|
||||
onBookmarkMouseDown={handleBookmarkMouseDown}
|
||||
handleWheel={handleWheel}
|
||||
handleTimelineContentClick={handleRulerClick}
|
||||
handleRulerTrackingMouseDown={handleRulerMouseDown}
|
||||
@@ -423,12 +447,16 @@ export function Timeline() {
|
||||
onResizeStateChange={handleResizeStateChange}
|
||||
onElementMouseDown={handleElementMouseDown}
|
||||
onElementClick={handleElementClick}
|
||||
onTrackMouseDown={handleSelectionMouseDown}
|
||||
onTrackMouseDown={(event) => {
|
||||
handleSelectionMouseDown(event);
|
||||
handleTracksMouseDown(event);
|
||||
}}
|
||||
onTrackClick={handleTracksClick}
|
||||
shouldIgnoreClick={shouldIgnoreClick}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="z-200 w-40">
|
||||
<ContextMenuContent className="w-40">
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
|
||||
onClick={(e) => {
|
||||
@@ -9,9 +9,9 @@ interface SnapIndicatorProps {
|
||||
zoomLevel: number;
|
||||
isVisible: boolean;
|
||||
tracks: TimelineTrack[];
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
trackLabelsRef?: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
timelineRef: React.RefObject<HTMLDivElement | null>;
|
||||
trackLabelsRef?: React.RefObject<HTMLDivElement | null>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function SnapIndicator({
|
||||
@@ -38,7 +38,7 @@ export function SnapIndicator({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute z-90"
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: topPosition,
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "../../ui/context-menu";
|
||||
} from "../../../ui/context-menu";
|
||||
import type {
|
||||
TimelineElement as TimelineElementType,
|
||||
TimelineTrack,
|
||||
@@ -29,6 +29,7 @@ import type { MediaAsset } from "@/types/assets";
|
||||
import { mediaSupportsAudio } from "@/lib/media/media-utils";
|
||||
import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import { resolveStickerId } from "@/lib/stickers";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
ScissorIcon,
|
||||
@@ -139,9 +140,7 @@ export function TimelineElement({
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={`absolute top-0 h-full select-none ${
|
||||
isBeingDragged ? "z-30" : "z-10"
|
||||
}`}
|
||||
className={`absolute top-0 h-full select-none`}
|
||||
style={{
|
||||
left: `${elementLeft}px`,
|
||||
width: `${elementWidth}px`,
|
||||
@@ -155,7 +154,6 @@ export function TimelineElement({
|
||||
element={element}
|
||||
track={track}
|
||||
isSelected={isSelected}
|
||||
isBeingDragged={isBeingDragged}
|
||||
hasAudio={hasAudio}
|
||||
isMuted={isMuted}
|
||||
mediaAssets={mediaAssets}
|
||||
@@ -165,8 +163,11 @@ export function TimelineElement({
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="z-200 w-64">
|
||||
<ActionMenuItem action="split" icon={<HugeiconsIcon icon={ScissorIcon} />}>
|
||||
<ContextMenuContent className="w-64">
|
||||
<ActionMenuItem
|
||||
action="split"
|
||||
icon={<HugeiconsIcon icon={ScissorIcon} />}
|
||||
>
|
||||
Split
|
||||
</ActionMenuItem>
|
||||
<CopyMenuItem />
|
||||
@@ -185,7 +186,10 @@ export function TimelineElement({
|
||||
/>
|
||||
)}
|
||||
{selectedElements.length === 1 && (
|
||||
<ActionMenuItem action="duplicate-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}>
|
||||
<ActionMenuItem
|
||||
action="duplicate-selected"
|
||||
icon={<HugeiconsIcon icon={Copy01Icon} />}
|
||||
>
|
||||
Duplicate
|
||||
</ActionMenuItem>
|
||||
)}
|
||||
@@ -221,7 +225,6 @@ function ElementInner({
|
||||
element,
|
||||
track,
|
||||
isSelected,
|
||||
isBeingDragged,
|
||||
hasAudio,
|
||||
isMuted,
|
||||
mediaAssets,
|
||||
@@ -232,7 +235,6 @@ function ElementInner({
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
isSelected: boolean;
|
||||
isBeingDragged: boolean;
|
||||
hasAudio: boolean;
|
||||
isMuted: boolean;
|
||||
mediaAssets: MediaAsset[];
|
||||
@@ -242,7 +244,7 @@ function ElementInner({
|
||||
element: TimelineElementType,
|
||||
) => void;
|
||||
handleResizeStart: (params: {
|
||||
e: React.MouseEvent;
|
||||
event: React.MouseEvent;
|
||||
elementId: string;
|
||||
side: "left" | "right";
|
||||
}) => void;
|
||||
@@ -253,7 +255,7 @@ function ElementInner({
|
||||
{
|
||||
type: track.type,
|
||||
},
|
||||
)} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
|
||||
)} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -315,7 +317,7 @@ function ResizeHandle({
|
||||
side: "left" | "right";
|
||||
elementId: string;
|
||||
handleResizeStart: (params: {
|
||||
e: React.MouseEvent;
|
||||
event: React.MouseEvent;
|
||||
elementId: string;
|
||||
side: "left" | "right";
|
||||
}) => void;
|
||||
@@ -324,8 +326,8 @@ function ResizeHandle({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`bg-primary absolute top-0 bottom-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
|
||||
onMouseDown={(e) => handleResizeStart({ e, elementId, side })}
|
||||
className={`bg-primary absolute top-0 bottom-0 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
|
||||
onMouseDown={(event) => handleResizeStart({ event, elementId, side })}
|
||||
aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
|
||||
>
|
||||
<div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
|
||||
@@ -356,7 +358,10 @@ function ElementContent({
|
||||
return (
|
||||
<div className="flex size-full items-center gap-2 pl-2">
|
||||
<Image
|
||||
src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`}
|
||||
src={resolveStickerId({
|
||||
stickerId: element.stickerId,
|
||||
options: { width: 20, height: 20 },
|
||||
})}
|
||||
alt={element.name}
|
||||
className="size-5 shrink-0"
|
||||
width={20}
|
||||
@@ -446,7 +451,10 @@ function ElementContent({
|
||||
|
||||
function CopyMenuItem() {
|
||||
return (
|
||||
<ActionMenuItem action="copy-selected" icon={<HugeiconsIcon icon={Copy01Icon} />}>
|
||||
<ActionMenuItem
|
||||
action="copy-selected"
|
||||
icon={<HugeiconsIcon icon={Copy01Icon} />}
|
||||
>
|
||||
Copy
|
||||
</ActionMenuItem>
|
||||
);
|
||||
@@ -502,7 +510,10 @@ function VisibilityMenuItem({
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionMenuItem action="toggle-elements-visibility-selected" icon={getIcon()}>
|
||||
<ActionMenuItem
|
||||
action="toggle-elements-visibility-selected"
|
||||
icon={getIcon()}
|
||||
>
|
||||
{isHidden ? "Show" : "Hide"}
|
||||
</ActionMenuItem>
|
||||
);
|
||||
@@ -7,11 +7,11 @@ import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
interface TimelinePlayheadProps {
|
||||
zoomLevel: number;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
||||
rulerRef: React.RefObject<HTMLDivElement | null>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
timelineRef: React.RefObject<HTMLDivElement | null>;
|
||||
playheadRef?: React.RefObject<HTMLDivElement | null>;
|
||||
isSnappingToPlayhead?: boolean;
|
||||
}
|
||||
|
||||
@@ -72,20 +72,22 @@ export function TimelinePlayhead({
|
||||
aria-valuemax={duration}
|
||||
aria-valuenow={playheadPosition}
|
||||
tabIndex={0}
|
||||
className="pointer-events-auto absolute z-60"
|
||||
className="pointer-events-none absolute z-5"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
height: `${totalHeight}px`,
|
||||
width: "2px",
|
||||
}}
|
||||
onMouseDown={handlePlayheadMouseDown}
|
||||
onKeyDown={handlePlayheadKeyDown}
|
||||
>
|
||||
<div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" />
|
||||
<div className="bg-foreground pointer-events-none absolute left-0 h-full w-0.5" />
|
||||
|
||||
<div
|
||||
className={`absolute top-1 left-1/2 size-3 -translate-x-1/2 transform rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Drag playhead"
|
||||
className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
|
||||
onMouseDown={handlePlayheadMouseDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1,13 +1,16 @@
|
||||
import { type JSX, useLayoutEffect, useRef } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/project-constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
|
||||
import { useScrollPosition } from "@/hooks/timeline/use-scroll-position";
|
||||
import { TimelineTick } from "./timeline-tick";
|
||||
|
||||
interface TimelineRulerProps {
|
||||
zoomLevel: number;
|
||||
dynamicTimelineWidth: number;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerRef: React.Ref<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLElement | null>;
|
||||
handleWheel: (e: React.WheelEvent) => void;
|
||||
handleTimelineContentClick: (e: React.MouseEvent) => void;
|
||||
handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
|
||||
@@ -18,6 +21,7 @@ export function TimelineRuler({
|
||||
zoomLevel,
|
||||
dynamicTimelineWidth,
|
||||
rulerRef,
|
||||
tracksScrollRef,
|
||||
handleWheel,
|
||||
handleTimelineContentClick,
|
||||
handleRulerTrackingMouseDown,
|
||||
@@ -36,8 +40,47 @@ export function TimelineRuler({
|
||||
});
|
||||
const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1;
|
||||
|
||||
const { scrollLeft, viewportWidth } = useScrollPosition({
|
||||
scrollRef: tracksScrollRef,
|
||||
});
|
||||
|
||||
/**
|
||||
* widens the virtualization buffer during zoom transitions.
|
||||
* useScrollPosition lags one frame behind the scroll adjustment
|
||||
* that useLayoutEffect applies after a zoom change.
|
||||
*/
|
||||
const prevZoomRef = useRef(zoomLevel);
|
||||
const isZoomTransition = zoomLevel !== prevZoomRef.current;
|
||||
const bufferPx = isZoomTransition
|
||||
? Math.max(200, (scrollLeft + viewportWidth) * 0.15)
|
||||
: 200;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
prevZoomRef.current = zoomLevel;
|
||||
}, [zoomLevel]);
|
||||
|
||||
const visibleStartTime = Math.max(
|
||||
0,
|
||||
(scrollLeft - bufferPx) / pixelsPerSecond,
|
||||
);
|
||||
const visibleEndTime =
|
||||
(scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond;
|
||||
|
||||
const startTickIndex = Math.max(
|
||||
0,
|
||||
Math.floor(visibleStartTime / tickIntervalSeconds),
|
||||
);
|
||||
const endTickIndex = Math.min(
|
||||
tickCount - 1,
|
||||
Math.ceil(visibleEndTime / tickIntervalSeconds),
|
||||
);
|
||||
|
||||
const timelineTicks: Array<JSX.Element> = [];
|
||||
for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) {
|
||||
for (
|
||||
let tickIndex = startTickIndex;
|
||||
tickIndex <= endTickIndex;
|
||||
tickIndex += 1
|
||||
) {
|
||||
const time = tickIndex * tickIntervalSeconds;
|
||||
if (time > effectiveDuration) break;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SkipBack, SplitSquareHorizontal } from "lucide-react";
|
||||
import { SplitSquareHorizontal } from "lucide-react";
|
||||
import {
|
||||
SplitButton,
|
||||
SplitButtonLeft,
|
||||
@@ -14,10 +14,9 @@ import {
|
||||
SplitButtonSeparator,
|
||||
} from "@/components/ui/split-button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { EditableTimecode } from "@/components/editable-timecode";
|
||||
import { ScenesView } from "../scenes-view";
|
||||
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
|
||||
import { ScenesView } from "../../scenes-view";
|
||||
import { type TAction, invokeAction } from "@/lib/actions";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
@@ -31,8 +30,6 @@ import {
|
||||
Link04Icon,
|
||||
SearchAddIcon,
|
||||
SearchMinusIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
Copy01Icon,
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
@@ -54,9 +51,9 @@ export function TimelineToolbar({
|
||||
direction === "in"
|
||||
? Math.min(
|
||||
TIMELINE_CONSTANTS.ZOOM_MAX,
|
||||
zoomLevel + TIMELINE_CONSTANTS.ZOOM_STEP,
|
||||
zoomLevel * TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR,
|
||||
)
|
||||
: Math.max(minZoom, zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP);
|
||||
: Math.max(minZoom, zoomLevel / TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR);
|
||||
setZoomLevel({ zoom: newZoomLevel });
|
||||
};
|
||||
|
||||
@@ -81,7 +78,6 @@ export function TimelineToolbar({
|
||||
function ToolbarLeftSection() {
|
||||
const editor = useEditor();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime });
|
||||
|
||||
const handleAction = ({
|
||||
@@ -98,46 +94,16 @@ function ToolbarLeftSection() {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<ToolbarButton
|
||||
icon={
|
||||
isPlaying ? (
|
||||
<HugeiconsIcon icon={PauseIcon} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={PlayIcon} />
|
||||
)
|
||||
}
|
||||
tooltip={isPlaying ? "Pause" : "Play"}
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "toggle-play", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
icon={<SkipBack />}
|
||||
tooltip="Go to start"
|
||||
onClick={({ event }) => handleAction({ action: "goto-start", event })}
|
||||
/>
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
||||
<TimeDisplay />
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
||||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={ScissorIcon} />}
|
||||
tooltip="Split element"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "split", event })
|
||||
}
|
||||
onClick={({ event }) => handleAction({ action: "split", event })}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={AlignLeftIcon} />}
|
||||
tooltip="Split left"
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "split-left", event })
|
||||
}
|
||||
onClick={({ event }) => handleAction({ action: "split-left", event })}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
@@ -182,12 +148,8 @@ function ToolbarLeftSection() {
|
||||
|
||||
<Tooltip>
|
||||
<ToolbarButton
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={Bookmark02Icon}
|
||||
className={currentBookmarked ? "fill-primary text-primary" : ""}
|
||||
/>
|
||||
}
|
||||
icon={<HugeiconsIcon icon={Bookmark02Icon} />}
|
||||
isActive={currentBookmarked}
|
||||
tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"}
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "toggle-bookmark", event })
|
||||
@@ -199,34 +161,6 @@ function ToolbarLeftSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function TimeDisplay() {
|
||||
const editor = useEditor();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const totalDuration = editor.timeline.getTotalDuration();
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-center px-2">
|
||||
<EditableTimecode
|
||||
time={currentTime}
|
||||
duration={totalDuration}
|
||||
format="HH:MM:SS:FF"
|
||||
fps={fps}
|
||||
onTimeChange={({ time }) => editor.playback.seek({ time })}
|
||||
className="text-center"
|
||||
/>
|
||||
<div className="text-muted-foreground px-2 font-mono text-xs">/</div>
|
||||
<div className="text-muted-foreground text-center font-mono text-xs">
|
||||
{formatTimeCode({
|
||||
timeInSeconds: totalDuration,
|
||||
format: "HH:MM:SS:FF",
|
||||
fps,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SceneSelector() {
|
||||
const editor = useEditor();
|
||||
const currentScene = editor.scenes.getActiveScene();
|
||||
@@ -237,7 +171,7 @@ function SceneSelector() {
|
||||
<SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
|
||||
<SplitButtonSeparator />
|
||||
<ScenesView>
|
||||
<SplitButtonRight onClick={() => {}} type="button">
|
||||
<SplitButtonRight onClick={() => {}}>
|
||||
<HugeiconsIcon icon={Layers01Icon} className="size-4" />
|
||||
</SplitButtonRight>
|
||||
</ScenesView>
|
||||
@@ -268,26 +202,15 @@ function ToolbarRightSection({
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<ToolbarButton
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={MagnetIcon}
|
||||
className={cn(snappingEnabled ? "text-primary" : "")}
|
||||
/>
|
||||
}
|
||||
icon={<HugeiconsIcon icon={MagnetIcon} />}
|
||||
isActive={snappingEnabled}
|
||||
tooltip="Auto snapping"
|
||||
onClick={() => toggleSnapping()}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={Link04Icon}
|
||||
className={cn(
|
||||
rippleEditingEnabled ? "text-primary" : "",
|
||||
"scale-110",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
icon={<HugeiconsIcon icon={Link04Icon} className="scale-110" />}
|
||||
isActive={rippleEditingEnabled}
|
||||
tooltip="Ripple editing"
|
||||
onClick={() => toggleRippleEditing()}
|
||||
/>
|
||||
@@ -299,26 +222,26 @@ function ToolbarRightSection({
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => onZoom({ direction: "out" })}
|
||||
>
|
||||
<HugeiconsIcon icon={SearchAddIcon} />
|
||||
<HugeiconsIcon icon={SearchMinusIcon} />
|
||||
</Button>
|
||||
<Slider
|
||||
className="w-24"
|
||||
value={[zoomLevel]}
|
||||
onValueChange={(values) => onZoomChange(values[0])}
|
||||
min={minZoom}
|
||||
max={TIMELINE_CONSTANTS.ZOOM_MAX}
|
||||
step={TIMELINE_CONSTANTS.ZOOM_STEP}
|
||||
className="w-28"
|
||||
value={[zoomToSlider({ zoomLevel, minZoom })]}
|
||||
onValueChange={(values) =>
|
||||
onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom }))
|
||||
}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.005}
|
||||
/>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => onZoom({ direction: "in" })}
|
||||
>
|
||||
<HugeiconsIcon icon={SearchMinusIcon} />
|
||||
<HugeiconsIcon icon={SearchAddIcon} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,21 +253,25 @@ function ToolbarButton({
|
||||
tooltip,
|
||||
onClick,
|
||||
disabled,
|
||||
isActive,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
tooltip: string;
|
||||
onClick: ({ event }: { event: React.MouseEvent }) => void;
|
||||
disabled?: boolean;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
variant={isActive ? "secondary" : "text"}
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={(event) => onClick({ event })}
|
||||
className={disabled ? "cursor-not-allowed opacity-50" : ""}
|
||||
className={cn(
|
||||
"rounded-sm",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
@@ -9,14 +9,13 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
||||
import type { ElementDragState } from "@/types/timeline";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
interface TimelineTrackContentProps {
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
dragState: ElementDragState;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
lastMouseXRef: React.RefObject<number>;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
onResizeStateChange?: (params: { isResizing: boolean }) => void;
|
||||
@@ -31,6 +30,7 @@ interface TimelineTrackContentProps {
|
||||
track: TimelineTrack;
|
||||
}) => void;
|
||||
onTrackMouseDown?: (event: React.MouseEvent) => void;
|
||||
onTrackClick?: (event: React.MouseEvent) => void;
|
||||
shouldIgnoreClick?: () => boolean;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export function TimelineTrackContent({
|
||||
onElementMouseDown,
|
||||
onElementClick,
|
||||
onTrackMouseDown,
|
||||
onTrackClick,
|
||||
shouldIgnoreClick,
|
||||
}: TimelineTrackContentProps) {
|
||||
const editor = useEditor();
|
||||
@@ -61,18 +62,16 @@ export function TimelineTrackContent({
|
||||
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
});
|
||||
|
||||
const hasSelectedElements = track.elements.some((element) =>
|
||||
isElementSelected({ trackId: track.id, elementId: element.id }),
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn("size-full", hasSelectedElements && "bg-panel-accent/35")}
|
||||
onClick={() => {
|
||||
className="size-full"
|
||||
onClick={(event) => {
|
||||
if (shouldIgnoreClick?.()) return;
|
||||
clearElementSelection();
|
||||
onTrackClick?.(event);
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onTrackMouseDown?.(event);
|
||||
}}
|
||||
type="button"
|
||||
@@ -5,7 +5,7 @@ import { useMemo } from "react";
|
||||
interface SelectionBoxProps {
|
||||
startPos: { x: number; y: number } | null;
|
||||
currentPos: { x: number; y: number } | null;
|
||||
containerRef: React.RefObject<HTMLElement>;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { getSnappedSeekTime } from "@/lib/time";
|
||||
import { Bookmark02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
||||
interface TimelineBookmarksRowProps {
|
||||
zoomLevel: number;
|
||||
dynamicTimelineWidth: number;
|
||||
handleWheel: (e: React.WheelEvent) => void;
|
||||
handleTimelineContentClick: (e: React.MouseEvent) => void;
|
||||
handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
|
||||
handleRulerMouseDown: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function TimelineBookmarksRow({
|
||||
zoomLevel,
|
||||
dynamicTimelineWidth,
|
||||
handleWheel,
|
||||
handleTimelineContentClick,
|
||||
handleRulerTrackingMouseDown,
|
||||
handleRulerMouseDown,
|
||||
}: TimelineBookmarksRowProps) {
|
||||
const editor = useEditor();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
|
||||
return (
|
||||
<div className="relative h-4 flex-1 overflow-hidden">
|
||||
<button
|
||||
className="relative h-4 w-full cursor-default select-none border-0 bg-transparent p-0"
|
||||
style={{
|
||||
width: `${dynamicTimelineWidth}px`,
|
||||
}}
|
||||
aria-label="Timeline ruler"
|
||||
type="button"
|
||||
onWheel={handleWheel}
|
||||
onClick={handleTimelineContentClick}
|
||||
onMouseDown={(event) => {
|
||||
handleRulerMouseDown(event);
|
||||
handleRulerTrackingMouseDown(event);
|
||||
}}
|
||||
>
|
||||
{activeScene.bookmarks.map((time: number) => (
|
||||
<TimelineBookmark
|
||||
key={`bookmark-row-${time}`}
|
||||
time={time}
|
||||
zoomLevel={zoomLevel}
|
||||
/>
|
||||
))}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimelineBookmark({
|
||||
time,
|
||||
zoomLevel,
|
||||
}: {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
|
||||
const handleBookmarkActivate = ({
|
||||
event,
|
||||
}: {
|
||||
event:
|
||||
| React.MouseEvent<HTMLButtonElement>
|
||||
| React.KeyboardEvent<HTMLButtonElement>;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
const framesPerSecond = activeProject?.settings.fps ?? 30;
|
||||
const snappedTime = getSnappedSeekTime({
|
||||
rawTime: time,
|
||||
duration,
|
||||
fps: framesPerSecond,
|
||||
});
|
||||
editor.playback.seek({ time: snappedTime });
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="absolute top-0 h-10 w-0.5 cursor-pointer border-0 bg-transparent p-0"
|
||||
style={{
|
||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
aria-label={`Seek to bookmark at ${time}s`}
|
||||
type="button"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => handleBookmarkActivate({ event })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
handleBookmarkActivate({ event });
|
||||
}}
|
||||
>
|
||||
<div className="text-primary absolute top-[-1px] left-[-5px]">
|
||||
<HugeiconsIcon
|
||||
icon={Bookmark02Icon}
|
||||
aria-hidden="true"
|
||||
className="fill-primary size-3"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Button } from "./ui/button";
|
||||
import Link from "next/link";
|
||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
import {
|
||||
GithubIcon,
|
||||
Link04Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { GithubIcon, Link04Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
||||
export function GitHubContributeSection({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useKeybindingDisabler,
|
||||
} from "@/hooks/use-keybindings";
|
||||
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
|
||||
import { prefetchFontAtlas } from "@/lib/fonts/google-fonts";
|
||||
|
||||
interface EditorProviderProps {
|
||||
projectId: string;
|
||||
@@ -42,6 +43,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
|
||||
if (cancelled) return;
|
||||
|
||||
setIsLoading(false);
|
||||
prefetchFontAtlas();
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
|
||||
|
||||