diff options
| author | Simon Orzel <simonorzel@gmail.com> | 2025-07-20 18:35:06 +0200 |
|---|---|---|
| committer | Simon Orzel <simonorzel@gmail.com> | 2025-07-20 18:35:06 +0200 |
| commit | b03287df8d6fa14153af264c6a3e3da5b2a06153 (patch) | |
| tree | 100b2c2a729295419dce61655bca418e74195ec4 /apps/web | |
| parent | 061624ab588afff18073ae62fbac425c1aa5cf3a (diff) | |
| parent | ba3da43728bc051aa033dc7b45042b0f0f788e63 (diff) | |
| download | opencut-b03287df8d6fa14153af264c6a3e3da5b2a06153.tar.gz | |
Merge branch 'main' into feat/project-preview-images
Diffstat (limited to 'apps/web')
166 files changed, 17156 insertions, 17223 deletions
diff --git a/apps/web/components.json b/apps/web/components.json index e3c915c1..0e8b6332 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -1,21 +1,21 @@ { - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "tailwind.config.ts", - "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": "tailwind.config.ts", + "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" +}
\ No newline at end of file diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts index e04fe52a..6b7a1605 100644 --- a/apps/web/drizzle.config.ts +++ b/apps/web/drizzle.config.ts @@ -1,19 +1,19 @@ -import type { Config } from "drizzle-kit"; -import * as dotenv from "dotenv"; - -// Load the right env file based on environment -if (process.env.NODE_ENV === "production") { - dotenv.config({ path: ".env.production" }); -} else { - dotenv.config({ path: ".env.local" }); -} - -export default { - schema: "../../packages/db/src/schema.ts", - dialect: "postgresql", - dbCredentials: { - url: process.env.DATABASE_URL!, - }, - out: "./migrations", - strict: process.env.NODE_ENV === "production", -} satisfies Config; +import type { Config } from "drizzle-kit";
+import * as dotenv from "dotenv";
+
+// Load the right env file based on environment
+if (process.env.NODE_ENV === "production") {
+ dotenv.config({ path: ".env.production" });
+} else {
+ dotenv.config({ path: ".env.local" });
+}
+
+export default {
+ schema: "../../packages/db/src/schema.ts",
+ dialect: "postgresql",
+ dbCredentials: {
+ url: process.env.DATABASE_URL!,
+ },
+ out: "./migrations",
+ strict: process.env.NODE_ENV === "production",
+} satisfies Config;
diff --git a/apps/web/migrations/meta/0000_snapshot.json b/apps/web/migrations/meta/0000_snapshot.json index ecf9fa25..7ea2b944 100644 --- a/apps/web/migrations/meta/0000_snapshot.json +++ b/apps/web/migrations/meta/0000_snapshot.json @@ -1,307 +1,319 @@ { - "id": "4440fb90-cf0e-4cb2-afad-08250ce3dc1e", - "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 - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} + "id": "4440fb90-cf0e-4cb2-afad-08250ce3dc1e", + "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 + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +}
\ No newline at end of file diff --git a/apps/web/migrations/meta/0001_snapshot.json b/apps/web/migrations/meta/0001_snapshot.json index 5457b219..78c3cffb 100644 --- a/apps/web/migrations/meta/0001_snapshot.json +++ b/apps/web/migrations/meta/0001_snapshot.json @@ -1,344 +1,358 @@ { - "id": "b7d920ca-6dd0-430f-8ee6-1d38fdf3e80f", - "prevId": "4440fb90-cf0e-4cb2-afad-08250ce3dc1e", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.accounts": { - "name": "accounts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "accounts_user_id_users_id_fk": { - "name": "accounts_user_id_users_id_fk", - "tableFrom": "accounts", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": true - }, - "public.sessions": { - "name": "sessions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "sessions_user_id_users_id_fk": { - "name": "sessions_user_id_users_id_fk", - "tableFrom": "sessions", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "sessions_token_unique": { - "name": "sessions_token_unique", - "nullsNotDistinct": false, - "columns": ["token"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": true - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_email_unique": { - "name": "users_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": true - }, - "public.verifications": { - "name": "verifications", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": true - }, - "public.waitlist": { - "name": "waitlist", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "waitlist_email_unique": { - "name": "waitlist_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": true - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} + "id": "b7d920ca-6dd0-430f-8ee6-1d38fdf3e80f", + "prevId": "4440fb90-cf0e-4cb2-afad-08250ce3dc1e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +}
\ No newline at end of file diff --git a/apps/web/migrations/meta/0002_snapshot.json b/apps/web/migrations/meta/0002_snapshot.json index fc268955..9155d971 100644 --- a/apps/web/migrations/meta/0002_snapshot.json +++ b/apps/web/migrations/meta/0002_snapshot.json @@ -1,345 +1,359 @@ { - "id": "1b9148ed-497b-4e53-8cf6-f556c8fe7f7f", - "prevId": "b7d920ca-6dd0-430f-8ee6-1d38fdf3e80f", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.accounts": { - "name": "accounts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "accounts_user_id_users_id_fk": { - "name": "accounts_user_id_users_id_fk", - "tableFrom": "accounts", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": true - }, - "public.sessions": { - "name": "sessions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "sessions_user_id_users_id_fk": { - "name": "sessions_user_id_users_id_fk", - "tableFrom": "sessions", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "sessions_token_unique": { - "name": "sessions_token_unique", - "nullsNotDistinct": false, - "columns": ["token"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": true - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_email_unique": { - "name": "users_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": true - }, - "public.verifications": { - "name": "verifications", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": true - }, - "public.waitlist": { - "name": "waitlist", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "waitlist_email_unique": { - "name": "waitlist_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": true - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} + "id": "1b9148ed-497b-4e53-8cf6-f556c8fe7f7f", + "prevId": "b7d920ca-6dd0-430f-8ee6-1d38fdf3e80f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +}
\ No newline at end of file diff --git a/apps/web/migrations/meta/_journal.json b/apps/web/migrations/meta/_journal.json index fb3893b6..309c040e 100644 --- a/apps/web/migrations/meta/_journal.json +++ b/apps/web/migrations/meta/_journal.json @@ -1,27 +1,27 @@ { - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1750581188229, - "tag": "0000_hot_the_fallen", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1750689835736, - "tag": "0001_tricky_jackpot", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1752569400809, - "tag": "0002_cuddly_pretty_boy", - "breakpoints": true - } - ] -} + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1750581188229, + "tag": "0000_hot_the_fallen", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1750689835736, + "tag": "0001_tricky_jackpot", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1752569400809, + "tag": "0002_cuddly_pretty_boy", + "breakpoints": true + } + ] +}
\ No newline at end of file diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 245e6a38..02261068 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,24 +2,24 @@ 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", - }, - ], - }, + 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", + }, + ], + }, }; export default withBotId(nextConfig); diff --git a/apps/web/package.json b/apps/web/package.json index 303fdf57..f7b09de8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,75 +1,75 @@ { - "name": "opencut", - "version": "0.1.0", - "private": true, - "packageManager": "bun@1.2.18", - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint", - "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", - "@opencut/auth": "workspace:*", - "@opencut/db": "workspace:*", - "@t3-oss/env-core": "^0.13.8", - "@t3-oss/env-nextjs": "^0.13.8", - "@upstash/ratelimit": "^2.0.5", - "@upstash/redis": "^1.35.0", - "@vercel/analytics": "^1.4.1", - "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", - "dotenv": "^16.5.0", - "drizzle-orm": "^0.44.2", - "embla-carousel-react": "^8.5.1", - "framer-motion": "^11.13.1", - "input-otp": "^1.4.1", - "lucide-react": "^0.468.0", - "motion": "^12.18.1", - "next": "^15.4.1", - "next-themes": "^0.4.4", - "pg": "^8.16.2", - "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", - "sonner": "^1.7.1", - "tailwind-merge": "^2.5.5", - "tailwindcss-animate": "^1.0.7", - "vaul": "^1.1.1", - "zod": "^3.25.67", - "zustand": "^5.0.2" - }, - "devDependencies": { - "@types/bun": "latest", - "@types/pg": "^8.15.4", - "@types/react": "^18.2.48", - "@types/react-dom": "^18.2.18", - "cross-env": "^7.0.3", - "drizzle-kit": "^0.31.4", - "postcss": "^8", - "prettier": "^3.4.2", - "tailwindcss": "^3.4.1", - "tsx": "^4.7.1", - "typescript": "^5.8.3" - } + "name": "opencut", + "version": "0.1.0", + "private": true, + "packageManager": "bun@1.2.18", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "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", + "@opencut/auth": "workspace:*", + "@opencut/db": "workspace:*", + "@t3-oss/env-core": "^0.13.8", + "@t3-oss/env-nextjs": "^0.13.8", + "@upstash/ratelimit": "^2.0.5", + "@upstash/redis": "^1.35.0", + "@vercel/analytics": "^1.4.1", + "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", + "dotenv": "^16.5.0", + "drizzle-orm": "^0.44.2", + "embla-carousel-react": "^8.5.1", + "framer-motion": "^11.13.1", + "input-otp": "^1.4.1", + "lucide-react": "^0.468.0", + "motion": "^12.18.1", + "next": "^15.4.1", + "next-themes": "^0.4.4", + "pg": "^8.16.2", + "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", + "sonner": "^1.7.1", + "tailwind-merge": "^2.5.5", + "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.1", + "zod": "^3.25.67", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/pg": "^8.15.4", + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "cross-env": "^7.0.3", + "drizzle-kit": "^0.31.4", + "postcss": "^8", + "prettier": "^3.4.2", + "tailwindcss": "^3.4.1", + "tsx": "^4.7.1", + "typescript": "^5.8.3" + } } diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs index f6c3605a..1a69fd2a 100644 --- a/apps/web/postcss.config.mjs +++ b/apps/web/postcss.config.mjs @@ -1,8 +1,8 @@ /** @type {import('postcss-load-config').Config} */ const config = { - plugins: { - tailwindcss: {}, - }, + plugins: { + tailwindcss: {}, + }, }; export default config; diff --git a/apps/web/public/ffmpeg/ffmpeg-core.js b/apps/web/public/ffmpeg/ffmpeg-core.js index 52e91608..83363183 100644 --- a/apps/web/public/ffmpeg/ffmpeg-core.js +++ b/apps/web/public/ffmpeg/ffmpeg-core.js @@ -1,20 +1,21 @@ + var createFFmpegCore = (() => { - var _scriptDir = - typeof document !== "undefined" && document.currentScript - ? document.currentScript.src - : undefined; + var _scriptDir = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : undefined; + + return ( +function(createFFmpegCore = {}) { + +var Module=typeof createFFmpegCore!="undefined"?createFFmpegCore:{}; // Ensure that createFFmpegCore is defined and available.var readyPromiseResolve,readyPromiseReject;Module["ready"]=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});const NULL=0;const SIZE_I32=Uint32Array.BYTES_PER_ELEMENT;const DEFAULT_ARGS=["./ffmpeg","-nostdin","-y"];const DEFAULT_ARGS_FFPROBE=["./ffprobe"];Module["NULL"]=NULL;Module["SIZE_I32"]=SIZE_I32;Module["DEFAULT_ARGS"]=DEFAULT_ARGS;Module["DEFAULT_ARGS_FFPROBE"]=DEFAULT_ARGS_FFPROBE;Module["ret"]=-1;Module["timeout"]=-1;Module["logger"]=()=>{};Module["progress"]=()=>{};function stringToPtr(str){const len=Module["lengthBytesUTF8"](str)+1;const ptr=Module["_malloc"](len);Module["stringToUTF8"](str,ptr,len);return ptr}function stringsToPtr(strs){const len=strs.length;const ptr=Module["_malloc"](len*SIZE_I32);for(let i=0;i<len;i++){Module["setValue"](ptr+SIZE_I32*i,stringToPtr(strs[i]),"i32")}return ptr}function print(message){Module["logger"]({type:"stdout",message:message})}function printErr(message){if(!message.startsWith("Aborted(native code called abort())"))Module["logger"]({type:"stderr",message:message})}function exec(..._args){const args=[...Module["DEFAULT_ARGS"],..._args];try{Module["_ffmpeg"](args.length,stringsToPtr(args))}catch(e){if(!e.message.startsWith("Aborted")){throw e}}return Module["ret"]}function ffprobe(..._args){const args=[...Module["DEFAULT_ARGS_FFPROBE"],..._args];try{Module["_ffprobe"](args.length,stringsToPtr(args))}catch(e){if(!e.message.startsWith("Aborted")){throw e}}return Module["ret"]}function setLogger(logger){Module["logger"]=logger}function setTimeout(timeout){Module["timeout"]=timeout}function setProgress(handler){Module["progress"]=handler}function receiveProgress(progress,time){Module["progress"]({progress:progress,time:time})}function reset(){Module["ret"]=-1;Module["timeout"]=-1}function _locateFile(path,prefix){const mainScriptUrlOrBlob=Module["mainScriptUrlOrBlob"];if(mainScriptUrlOrBlob){const{wasmURL:wasmURL,workerURL:workerURL}=JSON.parse(atob(mainScriptUrlOrBlob.slice(mainScriptUrlOrBlob.lastIndexOf("#")+1)));if(path.endsWith(".wasm"))return wasmURL;if(path.endsWith(".worker.js"))return workerURL}return prefix+path}Module["stringToPtr"]=stringToPtr;Module["stringsToPtr"]=stringsToPtr;Module["print"]=print;Module["printErr"]=printErr;Module["locateFile"]=_locateFile;Module["exec"]=exec;Module["ffprobe"]=ffprobe;Module["setLogger"]=setLogger;Module["setTimeout"]=setTimeout;Module["setProgress"]=setProgress;Module["reset"]=reset;Module["receiveProgress"]=receiveProgress;var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var ENVIRONMENT_IS_WEB=false;var ENVIRONMENT_IS_WORKER=true;var ENVIRONMENT_IS_NODE=false;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var read_,readAsync,readBinary,setWindowTitle;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptDir){scriptDirectory=_scriptDir}if(scriptDirectory.indexOf("blob:")!==0){scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}else{scriptDirectory=""}{read_=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.send(null);return xhr.responseText};if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=(url,onload,onerror)=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=()=>{if(xhr.status==200||xhr.status==0&&xhr.response){onload(xhr.response);return}onerror()};xhr.onerror=onerror;xhr.send(null)}}setWindowTitle=title=>document.title=title}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["quit"])quit_=Module["quit"];var wasmBinary;if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];var noExitRuntime=Module["noExitRuntime"]||true;if(typeof WebAssembly!="object"){abort("no native wasm support detected")}var wasmMemory;var ABORT=false;var EXITSTATUS;function assert(condition,text){if(!condition){abort(text)}}var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAP64,HEAPU64,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b);Module["HEAP64"]=HEAP64=new BigInt64Array(b);Module["HEAPU64"]=HEAPU64=new BigUint64Array(b)}var wasmTable;var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;var runtimeKeepaliveCounter=0;function keepRuntimeAlive(){return noExitRuntime||runtimeKeepaliveCounter>0}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;if(!Module["noFSInit"]&&!FS.init.initialized)FS.init();FS.ignorePermissions=false;TTY.init();SOCKFS.root=FS.mount(SOCKFS,{},null);callRuntimeCallbacks(__ATINIT__)}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function getUniqueRunDependency(id){return id}function addRunDependency(id){runDependencies++;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}}function removeRunDependency(id){runDependencies--;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){if(Module["onAbort"]){Module["onAbort"](what)}what="Aborted("+what+")";err(what);ABORT=true;EXITSTATUS=1;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";function isDataURI(filename){return filename.startsWith(dataURIPrefix)}var wasmBinaryFile;wasmBinaryFile="ffmpeg-core.wasm";if(!isDataURI(wasmBinaryFile)){wasmBinaryFile=locateFile(wasmBinaryFile)}function getBinary(file){try{if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}catch(err){abort(err)}}function getBinaryPromise(binaryFile){if(!wasmBinary&&(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER)){if(typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{if(!response["ok"]){throw"failed to load wasm binary file at '"+binaryFile+"'"}return response["arrayBuffer"]()}).catch(()=>getBinary(binaryFile))}}return Promise.resolve().then(()=>getBinary(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>{return WebAssembly.instantiate(binary,imports)}).then(instance=>{return instance}).then(receiver,reason=>{err("failed to asynchronously prepare wasm: "+reason);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err("wasm streaming compile failed: "+reason);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}else{return instantiateArrayBuffer(binaryFile,imports,callback)}}function createWasm(){var info={"a":wasmImports};function receiveInstance(instance,module){var exports=instance.exports;Module["asm"]=exports;wasmMemory=Module["asm"]["ra"];updateMemoryViews();wasmTable=Module["asm"]["ua"];addOnInit(Module["asm"]["sa"]);removeRunDependency("wasm-instantiate");return exports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err("Module.instantiateWasm callback failed with error: "+e);readyPromiseReject(e)}}instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}var ASM_CONSTS={6077464:$0=>{Module.ret=$0}};function send_progress(progress,time){Module.receiveProgress(progress,time)}function is_timeout(diff){if(Module.timeout===-1)return 0;else{return Module.timeout<=diff}}function ExitStatus(status){this.name="ExitStatus";this.message=`Program terminated with exit(${status})`;this.status=status}function callRuntimeCallbacks(callbacks){while(callbacks.length>0){callbacks.shift()(Module)}}var wasmTableMirror=[];function getWasmTableEntry(funcPtr){var func=wasmTableMirror[funcPtr];if(!func){if(funcPtr>=wasmTableMirror.length)wasmTableMirror.length=funcPtr+1;wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}return func}function getValue(ptr,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":return HEAP8[ptr>>0];case"i8":return HEAP8[ptr>>0];case"i16":return HEAP16[ptr>>1];case"i32":return HEAP32[ptr>>2];case"i64":return HEAP64[ptr>>3];case"float":return HEAPF32[ptr>>2];case"double":return HEAPF64[ptr>>3];case"*":return HEAPU32[ptr>>2];default:abort(`invalid type for getValue: ${type}`)}}function setValue(ptr,value,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":HEAP8[ptr>>0]=value;break;case"i8":HEAP8[ptr>>0]=value;break;case"i16":HEAP16[ptr>>1]=value;break;case"i32":HEAP32[ptr>>2]=value;break;case"i64":HEAP64[ptr>>3]=BigInt(value);break;case"float":HEAPF32[ptr>>2]=value;break;case"double":HEAPF64[ptr>>3]=value;break;case"*":HEAPU32[ptr>>2]=value;break;default:abort(`invalid type for setValue: ${type}`)}}var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder("utf8"):undefined;function UTF8ArrayToString(heapOrArray,idx,maxBytesToRead){var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx<endPtr){var u0=heapOrArray[idx++];if(!(u0&128)){str+=String.fromCharCode(u0);continue}var u1=heapOrArray[idx++]&63;if((u0&224)==192){str+=String.fromCharCode((u0&31)<<6|u1);continue}var u2=heapOrArray[idx++]&63;if((u0&240)==224){u0=(u0&15)<<12|u1<<6|u2}else{u0=(u0&7)<<18|u1<<12|u2<<6|heapOrArray[idx++]&63}if(u0<65536){str+=String.fromCharCode(u0)}else{var ch=u0-65536;str+=String.fromCharCode(55296|ch>>10,56320|ch&1023)}}return str}function UTF8ToString(ptr,maxBytesToRead){return ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):""}function ___assert_fail(condition,filename,line,func){abort(`Assertion failed: ${UTF8ToString(condition)}, at: `+[filename?UTF8ToString(filename):"unknown filename",line,func?UTF8ToString(func):"unknown function"])}function ExceptionInfo(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24;this.set_type=function(type){HEAPU32[this.ptr+4>>2]=type};this.get_type=function(){return HEAPU32[this.ptr+4>>2]};this.set_destructor=function(destructor){HEAPU32[this.ptr+8>>2]=destructor};this.get_destructor=function(){return HEAPU32[this.ptr+8>>2]};this.set_caught=function(caught){caught=caught?1:0;HEAP8[this.ptr+12>>0]=caught};this.get_caught=function(){return HEAP8[this.ptr+12>>0]!=0};this.set_rethrown=function(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13>>0]=rethrown};this.get_rethrown=function(){return HEAP8[this.ptr+13>>0]!=0};this.init=function(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)};this.set_adjusted_ptr=function(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr};this.get_adjusted_ptr=function(){return HEAPU32[this.ptr+16>>2]};this.get_exception_ptr=function(){var isPointer=___cxa_is_pointer_type(this.get_type());if(isPointer){return HEAPU32[this.excPtr>>2]}var adjusted=this.get_adjusted_ptr();if(adjusted!==0)return adjusted;return this.excPtr}}var exceptionLast=0;var uncaughtExceptionCount=0;function ___cxa_throw(ptr,type,destructor){var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast}var dlopenMissingError="To use dlopen, you need enable dynamic linking, see https://emscripten.org/docs/compiling/Dynamic-Linking.html";function ___dlsym(handle,symbol){abort(dlopenMissingError)}var PATH={isAbs:path=>path.charAt(0)==="/",splitPath:filename=>{var splitPathRe=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;return splitPathRe.exec(filename).slice(1)},normalizeArray:(parts,allowAboveRoot)=>{var up=0;for(var i=parts.length-1;i>=0;i--){var last=parts[i];if(last==="."){parts.splice(i,1)}else if(last===".."){parts.splice(i,1);up++}else if(up){parts.splice(i,1);up--}}if(allowAboveRoot){for(;up;up--){parts.unshift("..")}}return parts},normalize:path=>{var isAbsolute=PATH.isAbs(path),trailingSlash=path.substr(-1)==="/";path=PATH.normalizeArray(path.split("/").filter(p=>!!p),!isAbsolute).join("/");if(!path&&!isAbsolute){path="."}if(path&&trailingSlash){path+="/"}return(isAbsolute?"/":"")+path},dirname:path=>{var result=PATH.splitPath(path),root=result[0],dir=result[1];if(!root&&!dir){return"."}if(dir){dir=dir.substr(0,dir.length-1)}return root+dir},basename:path=>{if(path==="/")return"/";path=PATH.normalize(path);path=path.replace(/\/$/,"");var lastSlash=path.lastIndexOf("/");if(lastSlash===-1)return path;return path.substr(lastSlash+1)},join:function(){var paths=Array.prototype.slice.call(arguments);return PATH.normalize(paths.join("/"))},join2:(l,r)=>{return PATH.normalize(l+"/"+r)}};function initRandomFill(){if(typeof crypto=="object"&&typeof crypto["getRandomValues"]=="function"){return view=>crypto.getRandomValues(view)}else abort("initRandomDevice")}function randomFill(view){return(randomFill=initRandomFill())(view)}var PATH_FS={resolve:function(){var resolvedPath="",resolvedAbsolute=false;for(var i=arguments.length-1;i>=-1&&!resolvedAbsolute;i--){var path=i>=0?arguments[i]:FS.cwd();if(typeof path!="string"){throw new TypeError("Arguments to path.resolve must be strings")}else if(!path){return""}resolvedPath=path+"/"+resolvedPath;resolvedAbsolute=PATH.isAbs(path)}resolvedPath=PATH.normalizeArray(resolvedPath.split("/").filter(p=>!!p),!resolvedAbsolute).join("/");return(resolvedAbsolute?"/":"")+resolvedPath||"."},relative:(from,to)=>{from=PATH_FS.resolve(from).substr(1);to=PATH_FS.resolve(to).substr(1);function trim(arr){var start=0;for(;start<arr.length;start++){if(arr[start]!=="")break}var end=arr.length-1;for(;end>=0;end--){if(arr[end]!=="")break}if(start>end)return[];return arr.slice(start,end-start+1)}var fromParts=trim(from.split("/"));var toParts=trim(to.split("/"));var length=Math.min(fromParts.length,toParts.length);var samePartsLength=length;for(var i=0;i<length;i++){if(fromParts[i]!==toParts[i]){samePartsLength=i;break}}var outputParts=[];for(var i=samePartsLength;i<fromParts.length;i++){outputParts.push("..")}outputParts=outputParts.concat(toParts.slice(samePartsLength));return outputParts.join("/")}};function lengthBytesUTF8(str){var len=0;for(var i=0;i<str.length;++i){var c=str.charCodeAt(i);if(c<=127){len++}else if(c<=2047){len+=2}else if(c>=55296&&c<=57343){len+=4;++i}else{len+=3}}return len}function stringToUTF8Array(str,heap,outIdx,maxBytesToWrite){if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i<str.length;++i){var u=str.charCodeAt(i);if(u>=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx}function intArrayFromString(stringy,dontAddNull,length){var len=length>0?length:lengthBytesUTF8(stringy)+1;var u8array=new Array(len);var numBytesWritten=stringToUTF8Array(stringy,u8array,0,u8array.length);if(dontAddNull)u8array.length=numBytesWritten;return u8array}var TTY={ttys:[],init:function(){},shutdown:function(){},register:function(dev,ops){TTY.ttys[dev]={input:[],output:[],ops:ops};FS.registerDevice(dev,TTY.stream_ops)},stream_ops:{open:function(stream){var tty=TTY.ttys[stream.node.rdev];if(!tty){throw new FS.ErrnoError(43)}stream.tty=tty;stream.seekable=false},close:function(stream){stream.tty.ops.fsync(stream.tty)},fsync:function(stream){stream.tty.ops.fsync(stream.tty)},read:function(stream,buffer,offset,length,pos){if(!stream.tty||!stream.tty.ops.get_char){throw new FS.ErrnoError(60)}var bytesRead=0;for(var i=0;i<length;i++){var result;try{result=stream.tty.ops.get_char(stream.tty)}catch(e){throw new FS.ErrnoError(29)}if(result===undefined&&bytesRead===0){throw new FS.ErrnoError(6)}if(result===null||result===undefined)break;bytesRead++;buffer[offset+i]=result}if(bytesRead){stream.node.timestamp=Date.now()}return bytesRead},write:function(stream,buffer,offset,length,pos){if(!stream.tty||!stream.tty.ops.put_char){throw new FS.ErrnoError(60)}try{for(var i=0;i<length;i++){stream.tty.ops.put_char(stream.tty,buffer[offset+i])}}catch(e){throw new FS.ErrnoError(29)}if(length){stream.node.timestamp=Date.now()}return i}},default_tty_ops:{get_char:function(tty){if(!tty.input.length){var result=null;if(typeof window!="undefined"&&typeof window.prompt=="function"){result=window.prompt("Input: ");if(result!==null){result+="\n"}}else if(typeof readline=="function"){result=readline();if(result!==null){result+="\n"}}if(!result){return null}tty.input=intArrayFromString(result,true)}return tty.input.shift()},put_char:function(tty,val){if(val===null||val===10){out(UTF8ArrayToString(tty.output,0));tty.output=[]}else{if(val!=0)tty.output.push(val)}},fsync:function(tty){if(tty.output&&tty.output.length>0){out(UTF8ArrayToString(tty.output,0));tty.output=[]}}},default_tty1_ops:{put_char:function(tty,val){if(val===null||val===10){err(UTF8ArrayToString(tty.output,0));tty.output=[]}else{if(val!=0)tty.output.push(val)}},fsync:function(tty){if(tty.output&&tty.output.length>0){err(UTF8ArrayToString(tty.output,0));tty.output=[]}}}};function zeroMemory(address,size){HEAPU8.fill(0,address,address+size);return address}function alignMemory(size,alignment){return Math.ceil(size/alignment)*alignment}function mmapAlloc(size){size=alignMemory(size,65536);var ptr=_emscripten_builtin_memalign(65536,size);if(!ptr)return 0;return zeroMemory(ptr,size)}var MEMFS={ops_table:null,mount:function(mount){return MEMFS.createNode(null,"/",16384|511,0)},createNode:function(parent,name,mode,dev){if(FS.isBlkdev(mode)||FS.isFIFO(mode)){throw new FS.ErrnoError(63)}if(!MEMFS.ops_table){MEMFS.ops_table={dir:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,lookup:MEMFS.node_ops.lookup,mknod:MEMFS.node_ops.mknod,rename:MEMFS.node_ops.rename,unlink:MEMFS.node_ops.unlink,rmdir:MEMFS.node_ops.rmdir,readdir:MEMFS.node_ops.readdir,symlink:MEMFS.node_ops.symlink},stream:{llseek:MEMFS.stream_ops.llseek}},file:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:{llseek:MEMFS.stream_ops.llseek,read:MEMFS.stream_ops.read,write:MEMFS.stream_ops.write,allocate:MEMFS.stream_ops.allocate,mmap:MEMFS.stream_ops.mmap,msync:MEMFS.stream_ops.msync}},link:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,readlink:MEMFS.node_ops.readlink},stream:{}},chrdev:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:FS.chrdev_stream_ops}}}var node=FS.createNode(parent,name,mode,dev);if(FS.isDir(node.mode)){node.node_ops=MEMFS.ops_table.dir.node;node.stream_ops=MEMFS.ops_table.dir.stream;node.contents={}}else if(FS.isFile(node.mode)){node.node_ops=MEMFS.ops_table.file.node;node.stream_ops=MEMFS.ops_table.file.stream;node.usedBytes=0;node.contents=null}else if(FS.isLink(node.mode)){node.node_ops=MEMFS.ops_table.link.node;node.stream_ops=MEMFS.ops_table.link.stream}else if(FS.isChrdev(node.mode)){node.node_ops=MEMFS.ops_table.chrdev.node;node.stream_ops=MEMFS.ops_table.chrdev.stream}node.timestamp=Date.now();if(parent){parent.contents[name]=node;parent.timestamp=node.timestamp}return node},getFileDataAsTypedArray:function(node){if(!node.contents)return new Uint8Array(0);if(node.contents.subarray)return node.contents.subarray(0,node.usedBytes);return new Uint8Array(node.contents)},expandFileStorage:function(node,newCapacity){var prevCapacity=node.contents?node.contents.length:0;if(prevCapacity>=newCapacity)return;var CAPACITY_DOUBLING_MAX=1024*1024;newCapacity=Math.max(newCapacity,prevCapacity*(prevCapacity<CAPACITY_DOUBLING_MAX?2:1.125)>>>0);if(prevCapacity!=0)newCapacity=Math.max(newCapacity,256);var oldContents=node.contents;node.contents=new Uint8Array(newCapacity);if(node.usedBytes>0)node.contents.set(oldContents.subarray(0,node.usedBytes),0)},resizeFileStorage:function(node,newSize){if(node.usedBytes==newSize)return;if(newSize==0){node.contents=null;node.usedBytes=0}else{var oldContents=node.contents;node.contents=new Uint8Array(newSize);if(oldContents){node.contents.set(oldContents.subarray(0,Math.min(newSize,node.usedBytes)))}node.usedBytes=newSize}},node_ops:{getattr:function(node){var attr={};attr.dev=FS.isChrdev(node.mode)?node.id:1;attr.ino=node.id;attr.mode=node.mode;attr.nlink=1;attr.uid=0;attr.gid=0;attr.rdev=node.rdev;if(FS.isDir(node.mode)){attr.size=4096}else if(FS.isFile(node.mode)){attr.size=node.usedBytes}else if(FS.isLink(node.mode)){attr.size=node.link.length}else{attr.size=0}attr.atime=new Date(node.timestamp);attr.mtime=new Date(node.timestamp);attr.ctime=new Date(node.timestamp);attr.blksize=4096;attr.blocks=Math.ceil(attr.size/attr.blksize);return attr},setattr:function(node,attr){if(attr.mode!==undefined){node.mode=attr.mode}if(attr.timestamp!==undefined){node.timestamp=attr.timestamp}if(attr.size!==undefined){MEMFS.resizeFileStorage(node,attr.size)}},lookup:function(parent,name){throw FS.genericErrors[44]},mknod:function(parent,name,mode,dev){return MEMFS.createNode(parent,name,mode,dev)},rename:function(old_node,new_dir,new_name){if(FS.isDir(old_node.mode)){var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(new_node){for(var i in new_node.contents){throw new FS.ErrnoError(55)}}}delete old_node.parent.contents[old_node.name];old_node.parent.timestamp=Date.now();old_node.name=new_name;new_dir.contents[new_name]=old_node;new_dir.timestamp=old_node.parent.timestamp;old_node.parent=new_dir},unlink:function(parent,name){delete parent.contents[name];parent.timestamp=Date.now()},rmdir:function(parent,name){var node=FS.lookupNode(parent,name);for(var i in node.contents){throw new FS.ErrnoError(55)}delete parent.contents[name];parent.timestamp=Date.now()},readdir:function(node){var entries=[".",".."];for(var key in node.contents){if(!node.contents.hasOwnProperty(key)){continue}entries.push(key)}return entries},symlink:function(parent,newname,oldpath){var node=MEMFS.createNode(parent,newname,511|40960,0);node.link=oldpath;return node},readlink:function(node){if(!FS.isLink(node.mode)){throw new FS.ErrnoError(28)}return node.link}},stream_ops:{read:function(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=stream.node.usedBytes)return 0;var size=Math.min(stream.node.usedBytes-position,length);if(size>8&&contents.subarray){buffer.set(contents.subarray(position,position+size),offset)}else{for(var i=0;i<size;i++)buffer[offset+i]=contents[position+i]}return size},write:function(stream,buffer,offset,length,position,canOwn){if(buffer.buffer===HEAP8.buffer){canOwn=false}if(!length)return 0;var node=stream.node;node.timestamp=Date.now();if(buffer.subarray&&(!node.contents||node.contents.subarray)){if(canOwn){node.contents=buffer.subarray(offset,offset+length);node.usedBytes=length;return length}else if(node.usedBytes===0&&position===0){node.contents=buffer.slice(offset,offset+length);node.usedBytes=length;return length}else if(position+length<=node.usedBytes){node.contents.set(buffer.subarray(offset,offset+length),position);return length}}MEMFS.expandFileStorage(node,position+length);if(node.contents.subarray&&buffer.subarray){node.contents.set(buffer.subarray(offset,offset+length),position)}else{for(var i=0;i<length;i++){node.contents[position+i]=buffer[offset+i]}}node.usedBytes=Math.max(node.usedBytes,position+length);return length},llseek:function(stream,offset,whence){var position=offset;if(whence===1){position+=stream.position}else if(whence===2){if(FS.isFile(stream.node.mode)){position+=stream.node.usedBytes}}if(position<0){throw new FS.ErrnoError(28)}return position},allocate:function(stream,offset,length){MEMFS.expandFileStorage(stream.node,offset+length);stream.node.usedBytes=Math.max(stream.node.usedBytes,offset+length)},mmap:function(stream,length,position,prot,flags){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}var ptr;var allocated;var contents=stream.node.contents;if(!(flags&2)&&contents.buffer===HEAP8.buffer){allocated=false;ptr=contents.byteOffset}else{if(position>0||position+length<contents.length){if(contents.subarray){contents=contents.subarray(position,position+length)}else{contents=Array.prototype.slice.call(contents,position,position+length)}}allocated=true;ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}HEAP8.set(contents,ptr)}return{ptr:ptr,allocated:allocated}},msync:function(stream,buffer,offset,length,mmapFlags){MEMFS.stream_ops.write(stream,buffer,0,length,offset,false);return 0}}};function asyncLoad(url,onload,onerror,noRunDep){var dep=!noRunDep?getUniqueRunDependency(`al ${url}`):"";readAsync(url,arrayBuffer=>{assert(arrayBuffer,`Loading data file "${url}" failed (no arrayBuffer).`);onload(new Uint8Array(arrayBuffer));if(dep)removeRunDependency(dep)},event=>{if(onerror){onerror()}else{throw`Loading data file "${url}" failed.`}});if(dep)addRunDependency(dep)}var preloadPlugins=Module["preloadPlugins"]||[];function FS_handledByPreloadPlugin(byteArray,fullname,finish,onerror){if(typeof Browser!="undefined")Browser.init();var handled=false;preloadPlugins.forEach(function(plugin){if(handled)return;if(plugin["canHandle"](fullname)){plugin["handle"](byteArray,fullname,finish,onerror);handled=true}});return handled}function FS_createPreloadedFile(parent,name,url,canRead,canWrite,onload,onerror,dontCreateFile,canOwn,preFinish){var fullname=name?PATH_FS.resolve(PATH.join2(parent,name)):parent;var dep=getUniqueRunDependency(`cp ${fullname}`);function processData(byteArray){function finish(byteArray){if(preFinish)preFinish();if(!dontCreateFile){FS.createDataFile(parent,name,byteArray,canRead,canWrite,canOwn)}if(onload)onload();removeRunDependency(dep)}if(FS_handledByPreloadPlugin(byteArray,fullname,finish,()=>{if(onerror)onerror();removeRunDependency(dep)})){return}finish(byteArray)}addRunDependency(dep);if(typeof url=="string"){asyncLoad(url,byteArray=>processData(byteArray),onerror)}else{processData(url)}}function FS_modeStringToFlags(str){var flagModes={"r":0,"r+":2,"w":512|64|1,"w+":512|64|2,"a":1024|64|1,"a+":1024|64|2};var flags=flagModes[str];if(typeof flags=="undefined"){throw new Error(`Unknown file open mode: ${str}`)}return flags}function FS_getMode(canRead,canWrite){var mode=0;if(canRead)mode|=292|73;if(canWrite)mode|=146;return mode}var WORKERFS={DIR_MODE:16895,FILE_MODE:33279,reader:null,mount:function(mount){assert(ENVIRONMENT_IS_WORKER);if(!WORKERFS.reader)WORKERFS.reader=new FileReaderSync;var root=WORKERFS.createNode(null,"/",WORKERFS.DIR_MODE,0);var createdParents={};function ensureParent(path){var parts=path.split("/");var parent=root;for(var i=0;i<parts.length-1;i++){var curr=parts.slice(0,i+1).join("/");if(!createdParents[curr]){createdParents[curr]=WORKERFS.createNode(parent,parts[i],WORKERFS.DIR_MODE,0)}parent=createdParents[curr]}return parent}function base(path){var parts=path.split("/");return parts[parts.length-1]}Array.prototype.forEach.call(mount.opts["files"]||[],function(file){WORKERFS.createNode(ensureParent(file.name),base(file.name),WORKERFS.FILE_MODE,0,file,file.lastModifiedDate)});(mount.opts["blobs"]||[]).forEach(function(obj){WORKERFS.createNode(ensureParent(obj["name"]),base(obj["name"]),WORKERFS.FILE_MODE,0,obj["data"])});(mount.opts["packages"]||[]).forEach(function(pack){pack["metadata"].files.forEach(function(file){var name=file.filename.substr(1);WORKERFS.createNode(ensureParent(name),base(name),WORKERFS.FILE_MODE,0,pack["blob"].slice(file.start,file.end))})});return root},createNode:function(parent,name,mode,dev,contents,mtime){var node=FS.createNode(parent,name,mode);node.mode=mode;node.node_ops=WORKERFS.node_ops;node.stream_ops=WORKERFS.stream_ops;node.timestamp=(mtime||new Date).getTime();assert(WORKERFS.FILE_MODE!==WORKERFS.DIR_MODE);if(mode===WORKERFS.FILE_MODE){node.size=contents.size;node.contents=contents}else{node.size=4096;node.contents={}}if(parent){parent.contents[name]=node}return node},node_ops:{getattr:function(node){return{dev:1,ino:node.id,mode:node.mode,nlink:1,uid:0,gid:0,rdev:undefined,size:node.size,atime:new Date(node.timestamp),mtime:new Date(node.timestamp),ctime:new Date(node.timestamp),blksize:4096,blocks:Math.ceil(node.size/4096)}},setattr:function(node,attr){if(attr.mode!==undefined){node.mode=attr.mode}if(attr.timestamp!==undefined){node.timestamp=attr.timestamp}},lookup:function(parent,name){throw new FS.ErrnoError(44)},mknod:function(parent,name,mode,dev){throw new FS.ErrnoError(63)},rename:function(oldNode,newDir,newName){throw new FS.ErrnoError(63)},unlink:function(parent,name){throw new FS.ErrnoError(63)},rmdir:function(parent,name){throw new FS.ErrnoError(63)},readdir:function(node){var entries=[".",".."];for(var key in node.contents){if(!node.contents.hasOwnProperty(key)){continue}entries.push(key)}return entries},symlink:function(parent,newName,oldPath){throw new FS.ErrnoError(63)}},stream_ops:{read:function(stream,buffer,offset,length,position){if(position>=stream.node.size)return 0;var chunk=stream.node.contents.slice(position,position+length);var ab=WORKERFS.reader.readAsArrayBuffer(chunk);buffer.set(new Uint8Array(ab),offset);return chunk.size},write:function(stream,buffer,offset,length,position){throw new FS.ErrnoError(29)},llseek:function(stream,offset,whence){var position=offset;if(whence===1){position+=stream.position}else if(whence===2){if(FS.isFile(stream.node.mode)){position+=stream.node.size}}if(position<0){throw new FS.ErrnoError(28)}return position}}};var FS={root:null,mounts:[],devices:{},streams:[],nextInode:1,nameTable:null,currentPath:"/",initialized:false,ignorePermissions:true,ErrnoError:null,genericErrors:{},filesystems:null,syncFSRequests:0,lookupPath:(path,opts={})=>{path=PATH_FS.resolve(path);if(!path)return{path:"",node:null};var defaults={follow_mount:true,recurse_count:0};opts=Object.assign(defaults,opts);if(opts.recurse_count>8){throw new FS.ErrnoError(32)}var parts=path.split("/").filter(p=>!!p);var current=FS.root;var current_path="/";for(var i=0;i<parts.length;i++){var islast=i===parts.length-1;if(islast&&opts.parent){break}current=FS.lookupNode(current,parts[i]);current_path=PATH.join2(current_path,parts[i]);if(FS.isMountpoint(current)){if(!islast||islast&&opts.follow_mount){current=current.mounted.root}}if(!islast||opts.follow){var count=0;while(FS.isLink(current.mode)){var link=FS.readlink(current_path);current_path=PATH_FS.resolve(PATH.dirname(current_path),link);var lookup=FS.lookupPath(current_path,{recurse_count:opts.recurse_count+1});current=lookup.node;if(count++>40){throw new FS.ErrnoError(32)}}}}return{path:current_path,node:current}},getPath:node=>{var path;while(true){if(FS.isRoot(node)){var mount=node.mount.mountpoint;if(!path)return mount;return mount[mount.length-1]!=="/"?`${mount}/${path}`:mount+path}path=path?`${node.name}/${path}`:node.name;node=node.parent}},hashName:(parentid,name)=>{var hash=0;for(var i=0;i<name.length;i++){hash=(hash<<5)-hash+name.charCodeAt(i)|0}return(parentid+hash>>>0)%FS.nameTable.length},hashAddNode:node=>{var hash=FS.hashName(node.parent.id,node.name);node.name_next=FS.nameTable[hash];FS.nameTable[hash]=node},hashRemoveNode:node=>{var hash=FS.hashName(node.parent.id,node.name);if(FS.nameTable[hash]===node){FS.nameTable[hash]=node.name_next}else{var current=FS.nameTable[hash];while(current){if(current.name_next===node){current.name_next=node.name_next;break}current=current.name_next}}},lookupNode:(parent,name)=>{var errCode=FS.mayLookup(parent);if(errCode){throw new FS.ErrnoError(errCode,parent)}var hash=FS.hashName(parent.id,name);for(var node=FS.nameTable[hash];node;node=node.name_next){var nodeName=node.name;if(node.parent.id===parent.id&&nodeName===name){return node}}return FS.lookup(parent,name)},createNode:(parent,name,mode,rdev)=>{var node=new FS.FSNode(parent,name,mode,rdev);FS.hashAddNode(node);return node},destroyNode:node=>{FS.hashRemoveNode(node)},isRoot:node=>{return node===node.parent},isMountpoint:node=>{return!!node.mounted},isFile:mode=>{return(mode&61440)===32768},isDir:mode=>{return(mode&61440)===16384},isLink:mode=>{return(mode&61440)===40960},isChrdev:mode=>{return(mode&61440)===8192},isBlkdev:mode=>{return(mode&61440)===24576},isFIFO:mode=>{return(mode&61440)===4096},isSocket:mode=>{return(mode&49152)===49152},flagsToPermissionString:flag=>{var perms=["r","w","rw"][flag&3];if(flag&512){perms+="w"}return perms},nodePermissions:(node,perms)=>{if(FS.ignorePermissions){return 0}if(perms.includes("r")&&!(node.mode&292)){return 2}else if(perms.includes("w")&&!(node.mode&146)){return 2}else if(perms.includes("x")&&!(node.mode&73)){return 2}return 0},mayLookup:dir=>{var errCode=FS.nodePermissions(dir,"x");if(errCode)return errCode;if(!dir.node_ops.lookup)return 2;return 0},mayCreate:(dir,name)=>{try{var node=FS.lookupNode(dir,name);return 20}catch(e){}return FS.nodePermissions(dir,"wx")},mayDelete:(dir,name,isdir)=>{var node;try{node=FS.lookupNode(dir,name)}catch(e){return e.errno}var errCode=FS.nodePermissions(dir,"wx");if(errCode){return errCode}if(isdir){if(!FS.isDir(node.mode)){return 54}if(FS.isRoot(node)||FS.getPath(node)===FS.cwd()){return 10}}else{if(FS.isDir(node.mode)){return 31}}return 0},mayOpen:(node,flags)=>{if(!node){return 44}if(FS.isLink(node.mode)){return 32}else if(FS.isDir(node.mode)){if(FS.flagsToPermissionString(flags)!=="r"||flags&512){return 31}}return FS.nodePermissions(node,FS.flagsToPermissionString(flags))},MAX_OPEN_FDS:4096,nextfd:()=>{for(var fd=0;fd<=FS.MAX_OPEN_FDS;fd++){if(!FS.streams[fd]){return fd}}throw new FS.ErrnoError(33)},getStream:fd=>FS.streams[fd],createStream:(stream,fd=-1)=>{if(!FS.FSStream){FS.FSStream=function(){this.shared={}};FS.FSStream.prototype={};Object.defineProperties(FS.FSStream.prototype,{object:{get:function(){return this.node},set:function(val){this.node=val}},isRead:{get:function(){return(this.flags&2097155)!==1}},isWrite:{get:function(){return(this.flags&2097155)!==0}},isAppend:{get:function(){return this.flags&1024}},flags:{get:function(){return this.shared.flags},set:function(val){this.shared.flags=val}},position:{get:function(){return this.shared.position},set:function(val){this.shared.position=val}}})}stream=Object.assign(new FS.FSStream,stream);if(fd==-1){fd=FS.nextfd()}stream.fd=fd;FS.streams[fd]=stream;return stream},closeStream:fd=>{FS.streams[fd]=null},chrdev_stream_ops:{open:stream=>{var device=FS.getDevice(stream.node.rdev);stream.stream_ops=device.stream_ops;if(stream.stream_ops.open){stream.stream_ops.open(stream)}},llseek:()=>{throw new FS.ErrnoError(70)}},major:dev=>dev>>8,minor:dev=>dev&255,makedev:(ma,mi)=>ma<<8|mi,registerDevice:(dev,ops)=>{FS.devices[dev]={stream_ops:ops}},getDevice:dev=>FS.devices[dev],getMounts:mount=>{var mounts=[];var check=[mount];while(check.length){var m=check.pop();mounts.push(m);check.push.apply(check,m.mounts)}return mounts},syncfs:(populate,callback)=>{if(typeof populate=="function"){callback=populate;populate=false}FS.syncFSRequests++;if(FS.syncFSRequests>1){err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`)}var mounts=FS.getMounts(FS.root.mount);var completed=0;function doCallback(errCode){FS.syncFSRequests--;return callback(errCode)}function done(errCode){if(errCode){if(!done.errored){done.errored=true;return doCallback(errCode)}return}if(++completed>=mounts.length){doCallback(null)}}mounts.forEach(mount=>{if(!mount.type.syncfs){return done(null)}mount.type.syncfs(mount,populate,done)})},mount:(type,opts,mountpoint)=>{var root=mountpoint==="/";var pseudo=!mountpoint;var node;if(root&&FS.root){throw new FS.ErrnoError(10)}else if(!root&&!pseudo){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});mountpoint=lookup.path;node=lookup.node;if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}if(!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}}var mount={type:type,opts:opts,mountpoint:mountpoint,mounts:[]};var mountRoot=type.mount(mount);mountRoot.mount=mount;mount.root=mountRoot;if(root){FS.root=mountRoot}else if(node){node.mounted=mount;if(node.mount){node.mount.mounts.push(mount)}}return mountRoot},unmount:mountpoint=>{var lookup=FS.lookupPath(mountpoint,{follow_mount:false});if(!FS.isMountpoint(lookup.node)){throw new FS.ErrnoError(28)}var node=lookup.node;var mount=node.mounted;var mounts=FS.getMounts(mount);Object.keys(FS.nameTable).forEach(hash=>{var current=FS.nameTable[hash];while(current){var next=current.name_next;if(mounts.includes(current.mount)){FS.destroyNode(current)}current=next}});node.mounted=null;var idx=node.mount.mounts.indexOf(mount);node.mount.mounts.splice(idx,1)},lookup:(parent,name)=>{return parent.node_ops.lookup(parent,name)},mknod:(path,mode,dev)=>{var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);if(!name||name==="."||name===".."){throw new FS.ErrnoError(28)}var errCode=FS.mayCreate(parent,name);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.mknod){throw new FS.ErrnoError(63)}return parent.node_ops.mknod(parent,name,mode,dev)},create:(path,mode)=>{mode=mode!==undefined?mode:438;mode&=4095;mode|=32768;return FS.mknod(path,mode,0)},mkdir:(path,mode)=>{mode=mode!==undefined?mode:511;mode&=511|512;mode|=16384;return FS.mknod(path,mode,0)},mkdirTree:(path,mode)=>{var dirs=path.split("/");var d="";for(var i=0;i<dirs.length;++i){if(!dirs[i])continue;d+="/"+dirs[i];try{FS.mkdir(d,mode)}catch(e){if(e.errno!=20)throw e}}},mkdev:(path,mode,dev)=>{if(typeof dev=="undefined"){dev=mode;mode=438}mode|=8192;return FS.mknod(path,mode,dev)},symlink:(oldpath,newpath)=>{if(!PATH_FS.resolve(oldpath)){throw new FS.ErrnoError(44)}var lookup=FS.lookupPath(newpath,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var newname=PATH.basename(newpath);var errCode=FS.mayCreate(parent,newname);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.symlink){throw new FS.ErrnoError(63)}return parent.node_ops.symlink(parent,newname,oldpath)},rename:(old_path,new_path)=>{var old_dirname=PATH.dirname(old_path);var new_dirname=PATH.dirname(new_path);var old_name=PATH.basename(old_path);var new_name=PATH.basename(new_path);var lookup,old_dir,new_dir;lookup=FS.lookupPath(old_path,{parent:true});old_dir=lookup.node;lookup=FS.lookupPath(new_path,{parent:true});new_dir=lookup.node;if(!old_dir||!new_dir)throw new FS.ErrnoError(44);if(old_dir.mount!==new_dir.mount){throw new FS.ErrnoError(75)}var old_node=FS.lookupNode(old_dir,old_name);var relative=PATH_FS.relative(old_path,new_dirname);if(relative.charAt(0)!=="."){throw new FS.ErrnoError(28)}relative=PATH_FS.relative(new_path,old_dirname);if(relative.charAt(0)!=="."){throw new FS.ErrnoError(55)}var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(old_node===new_node){return}var isdir=FS.isDir(old_node.mode);var errCode=FS.mayDelete(old_dir,old_name,isdir);if(errCode){throw new FS.ErrnoError(errCode)}errCode=new_node?FS.mayDelete(new_dir,new_name,isdir):FS.mayCreate(new_dir,new_name);if(errCode){throw new FS.ErrnoError(errCode)}if(!old_dir.node_ops.rename){throw new FS.ErrnoError(63)}if(FS.isMountpoint(old_node)||new_node&&FS.isMountpoint(new_node)){throw new FS.ErrnoError(10)}if(new_dir!==old_dir){errCode=FS.nodePermissions(old_dir,"w");if(errCode){throw new FS.ErrnoError(errCode)}}FS.hashRemoveNode(old_node);try{old_dir.node_ops.rename(old_node,new_dir,new_name)}catch(e){throw e}finally{FS.hashAddNode(old_node)}},rmdir:path=>{var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,true);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.rmdir){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.rmdir(parent,name);FS.destroyNode(node)},readdir:path=>{var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;if(!node.node_ops.readdir){throw new FS.ErrnoError(54)}return node.node_ops.readdir(node)},unlink:path=>{var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,false);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.unlink){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.unlink(parent,name);FS.destroyNode(node)},readlink:path=>{var lookup=FS.lookupPath(path);var link=lookup.node;if(!link){throw new FS.ErrnoError(44)}if(!link.node_ops.readlink){throw new FS.ErrnoError(28)}return PATH_FS.resolve(FS.getPath(link.parent),link.node_ops.readlink(link))},stat:(path,dontFollow)=>{var lookup=FS.lookupPath(path,{follow:!dontFollow});var node=lookup.node;if(!node){throw new FS.ErrnoError(44)}if(!node.node_ops.getattr){throw new FS.ErrnoError(63)}return node.node_ops.getattr(node)},lstat:path=>{return FS.stat(path,true)},chmod:(path,mode,dontFollow)=>{var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}if(!node.node_ops.setattr){throw new FS.ErrnoError(63)}node.node_ops.setattr(node,{mode:mode&4095|node.mode&~4095,timestamp:Date.now()})},lchmod:(path,mode)=>{FS.chmod(path,mode,true)},fchmod:(fd,mode)=>{var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}FS.chmod(stream.node,mode)},chown:(path,uid,gid,dontFollow)=>{var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}if(!node.node_ops.setattr){throw new FS.ErrnoError(63)}node.node_ops.setattr(node,{timestamp:Date.now()})},lchown:(path,uid,gid)=>{FS.chown(path,uid,gid,true)},fchown:(fd,uid,gid)=>{var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}FS.chown(stream.node,uid,gid)},truncate:(path,len)=>{if(len<0){throw new FS.ErrnoError(28)}var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:true});node=lookup.node}else{node=path}if(!node.node_ops.setattr){throw new FS.ErrnoError(63)}if(FS.isDir(node.mode)){throw new FS.ErrnoError(31)}if(!FS.isFile(node.mode)){throw new FS.ErrnoError(28)}var errCode=FS.nodePermissions(node,"w");if(errCode){throw new FS.ErrnoError(errCode)}node.node_ops.setattr(node,{size:len,timestamp:Date.now()})},ftruncate:(fd,len)=>{var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(28)}FS.truncate(stream.node,len)},utime:(path,atime,mtime)=>{var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;node.node_ops.setattr(node,{timestamp:Math.max(atime,mtime)})},open:(path,flags,mode)=>{if(path===""){throw new FS.ErrnoError(44)}flags=typeof flags=="string"?FS_modeStringToFlags(flags):flags;mode=typeof mode=="undefined"?438:mode;if(flags&64){mode=mode&4095|32768}else{mode=0}var node;if(typeof path=="object"){node=path}else{path=PATH.normalize(path);try{var lookup=FS.lookupPath(path,{follow:!(flags&131072)});node=lookup.node}catch(e){}}var created=false;if(flags&64){if(node){if(flags&128){throw new FS.ErrnoError(20)}}else{node=FS.mknod(path,mode,0);created=true}}if(!node){throw new FS.ErrnoError(44)}if(FS.isChrdev(node.mode)){flags&=~512}if(flags&65536&&!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}if(!created){var errCode=FS.mayOpen(node,flags);if(errCode){throw new FS.ErrnoError(errCode)}}if(flags&512&&!created){FS.truncate(node,0)}flags&=~(128|512|131072);var stream=FS.createStream({node:node,path:FS.getPath(node),flags:flags,seekable:true,position:0,stream_ops:node.stream_ops,ungotten:[],error:false});if(stream.stream_ops.open){stream.stream_ops.open(stream)}if(Module["logReadFiles"]&&!(flags&1)){if(!FS.readFiles)FS.readFiles={};if(!(path in FS.readFiles)){FS.readFiles[path]=1}}return stream},close:stream=>{if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(stream.getdents)stream.getdents=null;try{if(stream.stream_ops.close){stream.stream_ops.close(stream)}}catch(e){throw e}finally{FS.closeStream(stream.fd)}stream.fd=null},isClosed:stream=>{return stream.fd===null},llseek:(stream,offset,whence)=>{if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(!stream.seekable||!stream.stream_ops.llseek){throw new FS.ErrnoError(70)}if(whence!=0&&whence!=1&&whence!=2){throw new FS.ErrnoError(28)}stream.position=stream.stream_ops.llseek(stream,offset,whence);stream.ungotten=[];return stream.position},read:(stream,buffer,offset,length,position)=>{if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.read){throw new FS.ErrnoError(28)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesRead=stream.stream_ops.read(stream,buffer,offset,length,position);if(!seeking)stream.position+=bytesRead;return bytesRead},write:(stream,buffer,offset,length,position,canOwn)=>{if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.write){throw new FS.ErrnoError(28)}if(stream.seekable&&stream.flags&1024){FS.llseek(stream,0,2)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesWritten=stream.stream_ops.write(stream,buffer,offset,length,position,canOwn);if(!seeking)stream.position+=bytesWritten;return bytesWritten},allocate:(stream,offset,length)=>{if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(offset<0||length<=0){throw new FS.ErrnoError(28)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(!FS.isFile(stream.node.mode)&&!FS.isDir(stream.node.mode)){throw new FS.ErrnoError(43)}if(!stream.stream_ops.allocate){throw new FS.ErrnoError(138)}stream.stream_ops.allocate(stream,offset,length)},mmap:(stream,length,position,prot,flags)=>{if((prot&2)!==0&&(flags&2)===0&&(stream.flags&2097155)!==2){throw new FS.ErrnoError(2)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(2)}if(!stream.stream_ops.mmap){throw new FS.ErrnoError(43)}return stream.stream_ops.mmap(stream,length,position,prot,flags)},msync:(stream,buffer,offset,length,mmapFlags)=>{if(!stream.stream_ops.msync){return 0}return stream.stream_ops.msync(stream,buffer,offset,length,mmapFlags)},munmap:stream=>0,ioctl:(stream,cmd,arg)=>{if(!stream.stream_ops.ioctl){throw new FS.ErrnoError(59)}return stream.stream_ops.ioctl(stream,cmd,arg)},readFile:(path,opts={})=>{opts.flags=opts.flags||0;opts.encoding=opts.encoding||"binary";if(opts.encoding!=="utf8"&&opts.encoding!=="binary"){throw new Error(`Invalid encoding type "${opts.encoding}"`)}var ret;var stream=FS.open(path,opts.flags);var stat=FS.stat(path);var length=stat.size;var buf=new Uint8Array(length);FS.read(stream,buf,0,length,0);if(opts.encoding==="utf8"){ret=UTF8ArrayToString(buf,0)}else if(opts.encoding==="binary"){ret=buf}FS.close(stream);return ret},writeFile:(path,data,opts={})=>{opts.flags=opts.flags||577;var stream=FS.open(path,opts.flags,opts.mode);if(typeof data=="string"){var buf=new Uint8Array(lengthBytesUTF8(data)+1);var actualNumBytes=stringToUTF8Array(data,buf,0,buf.length);FS.write(stream,buf,0,actualNumBytes,undefined,opts.canOwn)}else if(ArrayBuffer.isView(data)){FS.write(stream,data,0,data.byteLength,undefined,opts.canOwn)}else{throw new Error("Unsupported data type")}FS.close(stream)},cwd:()=>FS.currentPath,chdir:path=>{var lookup=FS.lookupPath(path,{follow:true});if(lookup.node===null){throw new FS.ErrnoError(44)}if(!FS.isDir(lookup.node.mode)){throw new FS.ErrnoError(54)}var errCode=FS.nodePermissions(lookup.node,"x");if(errCode){throw new FS.ErrnoError(errCode)}FS.currentPath=lookup.path},createDefaultDirectories:()=>{FS.mkdir("/tmp");FS.mkdir("/home");FS.mkdir("/home/web_user")},createDefaultDevices:()=>{FS.mkdir("/dev");FS.registerDevice(FS.makedev(1,3),{read:()=>0,write:(stream,buffer,offset,length,pos)=>length});FS.mkdev("/dev/null",FS.makedev(1,3));TTY.register(FS.makedev(5,0),TTY.default_tty_ops);TTY.register(FS.makedev(6,0),TTY.default_tty1_ops);FS.mkdev("/dev/tty",FS.makedev(5,0));FS.mkdev("/dev/tty1",FS.makedev(6,0));var randomBuffer=new Uint8Array(1024),randomLeft=0;var randomByte=()=>{if(randomLeft===0){randomLeft=randomFill(randomBuffer).byteLength}return randomBuffer[--randomLeft]};FS.createDevice("/dev","random",randomByte);FS.createDevice("/dev","urandom",randomByte);FS.mkdir("/dev/shm");FS.mkdir("/dev/shm/tmp")},createSpecialDirectories:()=>{FS.mkdir("/proc");var proc_self=FS.mkdir("/proc/self");FS.mkdir("/proc/self/fd");FS.mount({mount:()=>{var node=FS.createNode(proc_self,"fd",16384|511,73);node.node_ops={lookup:(parent,name)=>{var fd=+name;var stream=FS.getStream(fd);if(!stream)throw new FS.ErrnoError(8);var ret={parent:null,mount:{mountpoint:"fake"},node_ops:{readlink:()=>stream.path}};ret.parent=ret;return ret}};return node}},{},"/proc/self/fd")},createStandardStreams:()=>{if(Module["stdin"]){FS.createDevice("/dev","stdin",Module["stdin"])}else{FS.symlink("/dev/tty","/dev/stdin")}if(Module["stdout"]){FS.createDevice("/dev","stdout",null,Module["stdout"])}else{FS.symlink("/dev/tty","/dev/stdout")}if(Module["stderr"]){FS.createDevice("/dev","stderr",null,Module["stderr"])}else{FS.symlink("/dev/tty1","/dev/stderr")}var stdin=FS.open("/dev/stdin",0);var stdout=FS.open("/dev/stdout",1);var stderr=FS.open("/dev/stderr",1)},ensureErrnoError:()=>{if(FS.ErrnoError)return;FS.ErrnoError=function ErrnoError(errno,node){this.name="ErrnoError";this.node=node;this.setErrno=function(errno){this.errno=errno};this.setErrno(errno);this.message="FS error"};FS.ErrnoError.prototype=new Error;FS.ErrnoError.prototype.constructor=FS.ErrnoError;[44].forEach(code=>{FS.genericErrors[code]=new FS.ErrnoError(code);FS.genericErrors[code].stack="<generic error, no stack>"})},staticInit:()=>{FS.ensureErrnoError();FS.nameTable=new Array(4096);FS.mount(MEMFS,{},"/");FS.createDefaultDirectories();FS.createDefaultDevices();FS.createSpecialDirectories();FS.filesystems={"MEMFS":MEMFS,"WORKERFS":WORKERFS}},init:(input,output,error)=>{FS.init.initialized=true;FS.ensureErrnoError();Module["stdin"]=input||Module["stdin"];Module["stdout"]=output||Module["stdout"];Module["stderr"]=error||Module["stderr"];FS.createStandardStreams()},quit:()=>{FS.init.initialized=false;for(var i=0;i<FS.streams.length;i++){var stream=FS.streams[i];if(!stream){continue}FS.close(stream)}},findObject:(path,dontResolveLastLink)=>{var ret=FS.analyzePath(path,dontResolveLastLink);if(!ret.exists){return null}return ret.object},analyzePath:(path,dontResolveLastLink)=>{try{var lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});path=lookup.path}catch(e){}var ret={isRoot:false,exists:false,error:0,name:null,path:null,object:null,parentExists:false,parentPath:null,parentObject:null};try{var lookup=FS.lookupPath(path,{parent:true});ret.parentExists=true;ret.parentPath=lookup.path;ret.parentObject=lookup.node;ret.name=PATH.basename(path);lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});ret.exists=true;ret.path=lookup.path;ret.object=lookup.node;ret.name=lookup.node.name;ret.isRoot=lookup.path==="/"}catch(e){ret.error=e.errno}return ret},createPath:(parent,path,canRead,canWrite)=>{parent=typeof parent=="string"?parent:FS.getPath(parent);var parts=path.split("/").reverse();while(parts.length){var part=parts.pop();if(!part)continue;var current=PATH.join2(parent,part);try{FS.mkdir(current)}catch(e){}parent=current}return current},createFile:(parent,name,properties,canRead,canWrite)=>{var path=PATH.join2(typeof parent=="string"?parent:FS.getPath(parent),name);var mode=FS_getMode(canRead,canWrite);return FS.create(path,mode)},createDataFile:(parent,name,data,canRead,canWrite,canOwn)=>{var path=name;if(parent){parent=typeof parent=="string"?parent:FS.getPath(parent);path=name?PATH.join2(parent,name):parent}var mode=FS_getMode(canRead,canWrite);var node=FS.create(path,mode);if(data){if(typeof data=="string"){var arr=new Array(data.length);for(var i=0,len=data.length;i<len;++i)arr[i]=data.charCodeAt(i);data=arr}FS.chmod(node,mode|146);var stream=FS.open(node,577);FS.write(stream,data,0,data.length,0,canOwn);FS.close(stream);FS.chmod(node,mode)}return node},createDevice:(parent,name,input,output)=>{var path=PATH.join2(typeof parent=="string"?parent:FS.getPath(parent),name);var mode=FS_getMode(!!input,!!output);if(!FS.createDevice.major)FS.createDevice.major=64;var dev=FS.makedev(FS.createDevice.major++,0);FS.registerDevice(dev,{open:stream=>{stream.seekable=false},close:stream=>{if(output&&output.buffer&&output.buffer.length){output(10)}},read:(stream,buffer,offset,length,pos)=>{var bytesRead=0;for(var i=0;i<length;i++){var result;try{result=input()}catch(e){throw new FS.ErrnoError(29)}if(result===undefined&&bytesRead===0){throw new FS.ErrnoError(6)}if(result===null||result===undefined)break;bytesRead++;buffer[offset+i]=result}if(bytesRead){stream.node.timestamp=Date.now()}return bytesRead},write:(stream,buffer,offset,length,pos)=>{for(var i=0;i<length;i++){try{output(buffer[offset+i])}catch(e){throw new FS.ErrnoError(29)}}if(length){stream.node.timestamp=Date.now()}return i}});return FS.mkdev(path,mode,dev)},forceLoadFile:obj=>{if(obj.isDevice||obj.isFolder||obj.link||obj.contents)return true;if(typeof XMLHttpRequest!="undefined"){throw new Error("Lazy loading should have been performed (contents set) in createLazyFile, but it was not. Lazy loading only works in web workers. Use --embed-file or --preload-file in emcc on the main thread.")}else if(read_){try{obj.contents=intArrayFromString(read_(obj.url),true);obj.usedBytes=obj.contents.length}catch(e){throw new FS.ErrnoError(29)}}else{throw new Error("Cannot load without read() or XMLHttpRequest.")}},createLazyFile:(parent,name,url,canRead,canWrite)=>{function LazyUint8Array(){this.lengthKnown=false;this.chunks=[]}LazyUint8Array.prototype.get=function LazyUint8Array_get(idx){if(idx>this.length-1||idx<0){return undefined}var chunkOffset=idx%this.chunkSize;var chunkNum=idx/this.chunkSize|0;return this.getter(chunkNum)[chunkOffset]};LazyUint8Array.prototype.setDataGetter=function LazyUint8Array_setDataGetter(getter){this.getter=getter};LazyUint8Array.prototype.cacheLength=function LazyUint8Array_cacheLength(){var xhr=new XMLHttpRequest;xhr.open("HEAD",url,false);xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error("Couldn't load "+url+". Status: "+xhr.status);var datalength=Number(xhr.getResponseHeader("Content-length"));var header;var hasByteServing=(header=xhr.getResponseHeader("Accept-Ranges"))&&header==="bytes";var usesGzip=(header=xhr.getResponseHeader("Content-Encoding"))&&header==="gzip";var chunkSize=1024*1024;if(!hasByteServing)chunkSize=datalength;var doXHR=(from,to)=>{if(from>to)throw new Error("invalid range ("+from+", "+to+") or no bytes requested!");if(to>datalength-1)throw new Error("only "+datalength+" bytes available! programmer error!");var xhr=new XMLHttpRequest;xhr.open("GET",url,false);if(datalength!==chunkSize)xhr.setRequestHeader("Range","bytes="+from+"-"+to);xhr.responseType="arraybuffer";if(xhr.overrideMimeType){xhr.overrideMimeType("text/plain; charset=x-user-defined")}xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error("Couldn't load "+url+". Status: "+xhr.status);if(xhr.response!==undefined){return new Uint8Array(xhr.response||[])}return intArrayFromString(xhr.responseText||"",true)};var lazyArray=this;lazyArray.setDataGetter(chunkNum=>{var start=chunkNum*chunkSize;var end=(chunkNum+1)*chunkSize-1;end=Math.min(end,datalength-1);if(typeof lazyArray.chunks[chunkNum]=="undefined"){lazyArray.chunks[chunkNum]=doXHR(start,end)}if(typeof lazyArray.chunks[chunkNum]=="undefined")throw new Error("doXHR failed!");return lazyArray.chunks[chunkNum]});if(usesGzip||!datalength){chunkSize=datalength=1;datalength=this.getter(0).length;chunkSize=datalength;out("LazyFiles on gzip forces download of the whole file when length is accessed")}this._length=datalength;this._chunkSize=chunkSize;this.lengthKnown=true};if(typeof XMLHttpRequest!="undefined"){if(!ENVIRONMENT_IS_WORKER)throw"Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc";var lazyArray=new LazyUint8Array;Object.defineProperties(lazyArray,{length:{get:function(){if(!this.lengthKnown){this.cacheLength()}return this._length}},chunkSize:{get:function(){if(!this.lengthKnown){this.cacheLength()}return this._chunkSize}}});var properties={isDevice:false,contents:lazyArray}}else{var properties={isDevice:false,url:url}}var node=FS.createFile(parent,name,properties,canRead,canWrite);if(properties.contents){node.contents=properties.contents}else if(properties.url){node.contents=null;node.url=properties.url}Object.defineProperties(node,{usedBytes:{get:function(){return this.contents.length}}});var stream_ops={};var keys=Object.keys(node.stream_ops);keys.forEach(key=>{var fn=node.stream_ops[key];stream_ops[key]=function forceLoadLazyFile(){FS.forceLoadFile(node);return fn.apply(null,arguments)}});function writeChunks(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=contents.length)return 0;var size=Math.min(contents.length-position,length);if(contents.slice){for(var i=0;i<size;i++){buffer[offset+i]=contents[position+i]}}else{for(var i=0;i<size;i++){buffer[offset+i]=contents.get(position+i)}}return size}stream_ops.read=(stream,buffer,offset,length,position)=>{FS.forceLoadFile(node);return writeChunks(stream,buffer,offset,length,position)};stream_ops.mmap=(stream,length,position,prot,flags)=>{FS.forceLoadFile(node);var ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}writeChunks(stream,HEAP8,ptr,length,position);return{ptr:ptr,allocated:true}};node.stream_ops=stream_ops;return node}};var SYSCALLS={DEFAULT_POLLMASK:5,calculateAt:function(dirfd,path,allowEmpty){if(PATH.isAbs(path)){return path}var dir;if(dirfd===-100){dir=FS.cwd()}else{var dirstream=SYSCALLS.getStreamFromFD(dirfd);dir=dirstream.path}if(path.length==0){if(!allowEmpty){throw new FS.ErrnoError(44)}return dir}return PATH.join2(dir,path)},doStat:function(func,path,buf){try{var stat=func(path)}catch(e){if(e&&e.node&&PATH.normalize(path)!==PATH.normalize(FS.getPath(e.node))){return-54}throw e}HEAP32[buf>>2]=stat.dev;HEAP32[buf+8>>2]=stat.ino;HEAP32[buf+12>>2]=stat.mode;HEAPU32[buf+16>>2]=stat.nlink;HEAP32[buf+20>>2]=stat.uid;HEAP32[buf+24>>2]=stat.gid;HEAP32[buf+28>>2]=stat.rdev;HEAP64[buf+40>>3]=BigInt(stat.size);HEAP32[buf+48>>2]=4096;HEAP32[buf+52>>2]=stat.blocks;var atime=stat.atime.getTime();var mtime=stat.mtime.getTime();var ctime=stat.ctime.getTime();HEAP64[buf+56>>3]=BigInt(Math.floor(atime/1e3));HEAPU32[buf+64>>2]=atime%1e3*1e3;HEAP64[buf+72>>3]=BigInt(Math.floor(mtime/1e3));HEAPU32[buf+80>>2]=mtime%1e3*1e3;HEAP64[buf+88>>3]=BigInt(Math.floor(ctime/1e3));HEAPU32[buf+96>>2]=ctime%1e3*1e3;HEAP64[buf+104>>3]=BigInt(stat.ino);return 0},doMsync:function(addr,stream,len,flags,offset){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}if(flags&2){return 0}var buffer=HEAPU8.slice(addr,addr+len);FS.msync(stream,buffer,offset,len,flags)},varargs:undefined,get:function(){SYSCALLS.varargs+=4;var ret=HEAP32[SYSCALLS.varargs-4>>2];return ret},getStr:function(ptr){var ret=UTF8ToString(ptr);return ret},getStreamFromFD:function(fd){var stream=FS.getStream(fd);if(!stream)throw new FS.ErrnoError(8);return stream}};function ___syscall__newselect(nfds,readfds,writefds,exceptfds,timeout){try{var total=0;var srcReadLow=readfds?HEAP32[readfds>>2]:0,srcReadHigh=readfds?HEAP32[readfds+4>>2]:0;var srcWriteLow=writefds?HEAP32[writefds>>2]:0,srcWriteHigh=writefds?HEAP32[writefds+4>>2]:0;var srcExceptLow=exceptfds?HEAP32[exceptfds>>2]:0,srcExceptHigh=exceptfds?HEAP32[exceptfds+4>>2]:0;var dstReadLow=0,dstReadHigh=0;var dstWriteLow=0,dstWriteHigh=0;var dstExceptLow=0,dstExceptHigh=0;var allLow=(readfds?HEAP32[readfds>>2]:0)|(writefds?HEAP32[writefds>>2]:0)|(exceptfds?HEAP32[exceptfds>>2]:0);var allHigh=(readfds?HEAP32[readfds+4>>2]:0)|(writefds?HEAP32[writefds+4>>2]:0)|(exceptfds?HEAP32[exceptfds+4>>2]:0);var check=function(fd,low,high,val){return fd<32?low&val:high&val};for(var fd=0;fd<nfds;fd++){var mask=1<<fd%32;if(!check(fd,allLow,allHigh,mask)){continue}var stream=SYSCALLS.getStreamFromFD(fd);var flags=SYSCALLS.DEFAULT_POLLMASK;if(stream.stream_ops.poll){flags=stream.stream_ops.poll(stream)}if(flags&1&&check(fd,srcReadLow,srcReadHigh,mask)){fd<32?dstReadLow=dstReadLow|mask:dstReadHigh=dstReadHigh|mask;total++}if(flags&4&&check(fd,srcWriteLow,srcWriteHigh,mask)){fd<32?dstWriteLow=dstWriteLow|mask:dstWriteHigh=dstWriteHigh|mask;total++}if(flags&2&&check(fd,srcExceptLow,srcExceptHigh,mask)){fd<32?dstExceptLow=dstExceptLow|mask:dstExceptHigh=dstExceptHigh|mask;total++}}if(readfds){HEAP32[readfds>>2]=dstReadLow;HEAP32[readfds+4>>2]=dstReadHigh}if(writefds){HEAP32[writefds>>2]=dstWriteLow;HEAP32[writefds+4>>2]=dstWriteHigh}if(exceptfds){HEAP32[exceptfds>>2]=dstExceptLow;HEAP32[exceptfds+4>>2]=dstExceptHigh}return total}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var SOCKFS={mount:function(mount){Module["websocket"]=Module["websocket"]&&"object"===typeof Module["websocket"]?Module["websocket"]:{};Module["websocket"]._callbacks={};Module["websocket"]["on"]=function(event,callback){if("function"===typeof callback){this._callbacks[event]=callback}return this};Module["websocket"].emit=function(event,param){if("function"===typeof this._callbacks[event]){this._callbacks[event].call(this,param)}};return FS.createNode(null,"/",16384|511,0)},createSocket:function(family,type,protocol){type&=~526336;var streaming=type==1;if(streaming&&protocol&&protocol!=6){throw new FS.ErrnoError(66)}var sock={family:family,type:type,protocol:protocol,server:null,error:null,peers:{},pending:[],recv_queue:[],sock_ops:SOCKFS.websocket_sock_ops};var name=SOCKFS.nextname();var node=FS.createNode(SOCKFS.root,name,49152,0);node.sock=sock;var stream=FS.createStream({path:name,node:node,flags:2,seekable:false,stream_ops:SOCKFS.stream_ops});sock.stream=stream;return sock},getSocket:function(fd){var stream=FS.getStream(fd);if(!stream||!FS.isSocket(stream.node.mode)){return null}return stream.node.sock},stream_ops:{poll:function(stream){var sock=stream.node.sock;return sock.sock_ops.poll(sock)},ioctl:function(stream,request,varargs){var sock=stream.node.sock;return sock.sock_ops.ioctl(sock,request,varargs)},read:function(stream,buffer,offset,length,position){var sock=stream.node.sock;var msg=sock.sock_ops.recvmsg(sock,length);if(!msg){return 0}buffer.set(msg.buffer,offset);return msg.buffer.length},write:function(stream,buffer,offset,length,position){var sock=stream.node.sock;return sock.sock_ops.sendmsg(sock,buffer,offset,length)},close:function(stream){var sock=stream.node.sock;sock.sock_ops.close(sock)}},nextname:function(){if(!SOCKFS.nextname.current){SOCKFS.nextname.current=0}return"socket["+SOCKFS.nextname.current+++"]"},websocket_sock_ops:{createPeer:function(sock,addr,port){var ws;if(typeof addr=="object"){ws=addr;addr=null;port=null}if(ws){if(ws._socket){addr=ws._socket.remoteAddress;port=ws._socket.remotePort}else{var result=/ws[s]?:\/\/([^:]+):(\d+)/.exec(ws.url);if(!result){throw new Error("WebSocket URL must be in the format ws(s)://address:port")}addr=result[1];port=parseInt(result[2],10)}}else{try{var runtimeConfig=Module["websocket"]&&"object"===typeof Module["websocket"];var url="ws:#".replace("#","//");if(runtimeConfig){if("string"===typeof Module["websocket"]["url"]){url=Module["websocket"]["url"]}}if(url==="ws://"||url==="wss://"){var parts=addr.split("/");url=url+parts[0]+":"+port+"/"+parts.slice(1).join("/")}var subProtocols="binary";if(runtimeConfig){if("string"===typeof Module["websocket"]["subprotocol"]){subProtocols=Module["websocket"]["subprotocol"]}}var opts=undefined;if(subProtocols!=="null"){subProtocols=subProtocols.replace(/^ +| +$/g,"").split(/ *, */);opts=subProtocols}if(runtimeConfig&&null===Module["websocket"]["subprotocol"]){subProtocols="null";opts=undefined}var WebSocketConstructor;{WebSocketConstructor=WebSocket}ws=new WebSocketConstructor(url,opts);ws.binaryType="arraybuffer"}catch(e){throw new FS.ErrnoError(23)}}var peer={addr:addr,port:port,socket:ws,dgram_send_queue:[]};SOCKFS.websocket_sock_ops.addPeer(sock,peer);SOCKFS.websocket_sock_ops.handlePeerEvents(sock,peer);if(sock.type===2&&typeof sock.sport!="undefined"){peer.dgram_send_queue.push(new Uint8Array([255,255,255,255,"p".charCodeAt(0),"o".charCodeAt(0),"r".charCodeAt(0),"t".charCodeAt(0),(sock.sport&65280)>>8,sock.sport&255]))}return peer},getPeer:function(sock,addr,port){return sock.peers[addr+":"+port]},addPeer:function(sock,peer){sock.peers[peer.addr+":"+peer.port]=peer},removePeer:function(sock,peer){delete sock.peers[peer.addr+":"+peer.port]},handlePeerEvents:function(sock,peer){var first=true;var handleOpen=function(){Module["websocket"].emit("open",sock.stream.fd);try{var queued=peer.dgram_send_queue.shift();while(queued){peer.socket.send(queued);queued=peer.dgram_send_queue.shift()}}catch(e){peer.socket.close()}};function handleMessage(data){if(typeof data=="string"){var encoder=new TextEncoder;data=encoder.encode(data)}else{assert(data.byteLength!==undefined);if(data.byteLength==0){return}data=new Uint8Array(data)}var wasfirst=first;first=false;if(wasfirst&&data.length===10&&data[0]===255&&data[1]===255&&data[2]===255&&data[3]===255&&data[4]==="p".charCodeAt(0)&&data[5]==="o".charCodeAt(0)&&data[6]==="r".charCodeAt(0)&&data[7]==="t".charCodeAt(0)){var newport=data[8]<<8|data[9];SOCKFS.websocket_sock_ops.removePeer(sock,peer);peer.port=newport;SOCKFS.websocket_sock_ops.addPeer(sock,peer);return}sock.recv_queue.push({addr:peer.addr,port:peer.port,data:data});Module["websocket"].emit("message",sock.stream.fd)}if(ENVIRONMENT_IS_NODE){peer.socket.on("open",handleOpen);peer.socket.on("message",function(data,isBinary){if(!isBinary){return}handleMessage(new Uint8Array(data).buffer)});peer.socket.on("close",function(){Module["websocket"].emit("close",sock.stream.fd)});peer.socket.on("error",function(error){sock.error=14;Module["websocket"].emit("error",[sock.stream.fd,sock.error,"ECONNREFUSED: Connection refused"])})}else{peer.socket.onopen=handleOpen;peer.socket.onclose=function(){Module["websocket"].emit("close",sock.stream.fd)};peer.socket.onmessage=function peer_socket_onmessage(event){handleMessage(event.data)};peer.socket.onerror=function(error){sock.error=14;Module["websocket"].emit("error",[sock.stream.fd,sock.error,"ECONNREFUSED: Connection refused"])}}},poll:function(sock){if(sock.type===1&&sock.server){return sock.pending.length?64|1:0}var mask=0;var dest=sock.type===1?SOCKFS.websocket_sock_ops.getPeer(sock,sock.daddr,sock.dport):null;if(sock.recv_queue.length||!dest||dest&&dest.socket.readyState===dest.socket.CLOSING||dest&&dest.socket.readyState===dest.socket.CLOSED){mask|=64|1}if(!dest||dest&&dest.socket.readyState===dest.socket.OPEN){mask|=4}if(dest&&dest.socket.readyState===dest.socket.CLOSING||dest&&dest.socket.readyState===dest.socket.CLOSED){mask|=16}return mask},ioctl:function(sock,request,arg){switch(request){case 21531:var bytes=0;if(sock.recv_queue.length){bytes=sock.recv_queue[0].data.length}HEAP32[arg>>2]=bytes;return 0;default:return 28}},close:function(sock){if(sock.server){try{sock.server.close()}catch(e){}sock.server=null}var peers=Object.keys(sock.peers);for(var i=0;i<peers.length;i++){var peer=sock.peers[peers[i]];try{peer.socket.close()}catch(e){}SOCKFS.websocket_sock_ops.removePeer(sock,peer)}return 0},bind:function(sock,addr,port){if(typeof sock.saddr!="undefined"||typeof sock.sport!="undefined"){throw new FS.ErrnoError(28)}sock.saddr=addr;sock.sport=port;if(sock.type===2){if(sock.server){sock.server.close();sock.server=null}try{sock.sock_ops.listen(sock,0)}catch(e){if(!(e.name==="ErrnoError"))throw e;if(e.errno!==138)throw e}}},connect:function(sock,addr,port){if(sock.server){throw new FS.ErrnoError(138)}if(typeof sock.daddr!="undefined"&&typeof sock.dport!="undefined"){var dest=SOCKFS.websocket_sock_ops.getPeer(sock,sock.daddr,sock.dport);if(dest){if(dest.socket.readyState===dest.socket.CONNECTING){throw new FS.ErrnoError(7)}else{throw new FS.ErrnoError(30)}}}var peer=SOCKFS.websocket_sock_ops.createPeer(sock,addr,port);sock.daddr=peer.addr;sock.dport=peer.port;throw new FS.ErrnoError(26)},listen:function(sock,backlog){if(!ENVIRONMENT_IS_NODE){throw new FS.ErrnoError(138)}},accept:function(listensock){if(!listensock.server||!listensock.pending.length){throw new FS.ErrnoError(28)}var newsock=listensock.pending.shift();newsock.stream.flags=listensock.stream.flags;return newsock},getname:function(sock,peer){var addr,port;if(peer){if(sock.daddr===undefined||sock.dport===undefined){throw new FS.ErrnoError(53)}addr=sock.daddr;port=sock.dport}else{addr=sock.saddr||0;port=sock.sport||0}return{addr:addr,port:port}},sendmsg:function(sock,buffer,offset,length,addr,port){if(sock.type===2){if(addr===undefined||port===undefined){addr=sock.daddr;port=sock.dport}if(addr===undefined||port===undefined){throw new FS.ErrnoError(17)}}else{addr=sock.daddr;port=sock.dport}var dest=SOCKFS.websocket_sock_ops.getPeer(sock,addr,port);if(sock.type===1){if(!dest||dest.socket.readyState===dest.socket.CLOSING||dest.socket.readyState===dest.socket.CLOSED){throw new FS.ErrnoError(53)}else if(dest.socket.readyState===dest.socket.CONNECTING){throw new FS.ErrnoError(6)}}if(ArrayBuffer.isView(buffer)){offset+=buffer.byteOffset;buffer=buffer.buffer}var data;data=buffer.slice(offset,offset+length);if(sock.type===2){if(!dest||dest.socket.readyState!==dest.socket.OPEN){if(!dest||dest.socket.readyState===dest.socket.CLOSING||dest.socket.readyState===dest.socket.CLOSED){dest=SOCKFS.websocket_sock_ops.createPeer(sock,addr,port)}dest.dgram_send_queue.push(data);return length}}try{dest.socket.send(data);return length}catch(e){throw new FS.ErrnoError(28)}},recvmsg:function(sock,length){if(sock.type===1&&sock.server){throw new FS.ErrnoError(53)}var queued=sock.recv_queue.shift();if(!queued){if(sock.type===1){var dest=SOCKFS.websocket_sock_ops.getPeer(sock,sock.daddr,sock.dport);if(!dest){throw new FS.ErrnoError(53)}if(dest.socket.readyState===dest.socket.CLOSING||dest.socket.readyState===dest.socket.CLOSED){return null}throw new FS.ErrnoError(6)}throw new FS.ErrnoError(6)}var queuedLength=queued.data.byteLength||queued.data.length;var queuedOffset=queued.data.byteOffset||0;var queuedBuffer=queued.data.buffer||queued.data;var bytesRead=Math.min(length,queuedLength);var res={buffer:new Uint8Array(queuedBuffer,queuedOffset,bytesRead),addr:queued.addr,port:queued.port};if(sock.type===1&&bytesRead<queuedLength){var bytesRemaining=queuedLength-bytesRead;queued.data=new Uint8Array(queuedBuffer,queuedOffset+bytesRead,bytesRemaining);sock.recv_queue.unshift(queued)}return res}}};function getSocketFromFD(fd){var socket=SOCKFS.getSocket(fd);if(!socket)throw new FS.ErrnoError(8);return socket}function setErrNo(value){HEAP32[___errno_location()>>2]=value;return value}function inetPton4(str){var b=str.split(".");for(var i=0;i<4;i++){var tmp=Number(b[i]);if(isNaN(tmp))return null;b[i]=tmp}return(b[0]|b[1]<<8|b[2]<<16|b[3]<<24)>>>0}function jstoi_q(str){return parseInt(str)}function inetPton6(str){var words;var w,offset,z;var valid6regx=/^((?=.*::)(?!.*::.+::)(::)?([\dA-F]{1,4}:(:|\b)|){5}|([\dA-F]{1,4}:){6})((([\dA-F]{1,4}((?!\3)::|:\b|$))|(?!\2\3)){2}|(((2[0-4]|1\d|[1-9])?\d|25[0-5])\.?\b){4})$/i;var parts=[];if(!valid6regx.test(str)){return null}if(str==="::"){return[0,0,0,0,0,0,0,0]}if(str.startsWith("::")){str=str.replace("::","Z:")}else{str=str.replace("::",":Z:")}if(str.indexOf(".")>0){str=str.replace(new RegExp("[.]","g"),":");words=str.split(":");words[words.length-4]=jstoi_q(words[words.length-4])+jstoi_q(words[words.length-3])*256;words[words.length-3]=jstoi_q(words[words.length-2])+jstoi_q(words[words.length-1])*256;words=words.slice(0,words.length-2)}else{words=str.split(":")}offset=0;z=0;for(w=0;w<words.length;w++){if(typeof words[w]=="string"){if(words[w]==="Z"){for(z=0;z<8-words.length+1;z++){parts[w+z]=0}offset=z-1}else{parts[w+offset]=_htons(parseInt(words[w],16))}}else{parts[w+offset]=words[w]}}return[parts[1]<<16|parts[0],parts[3]<<16|parts[2],parts[5]<<16|parts[4],parts[7]<<16|parts[6]]}function writeSockaddr(sa,family,addr,port,addrlen){switch(family){case 2:addr=inetPton4(addr);zeroMemory(sa,16);if(addrlen){HEAP32[addrlen>>2]=16}HEAP16[sa>>1]=family;HEAP32[sa+4>>2]=addr;HEAP16[sa+2>>1]=_htons(port);break;case 10:addr=inetPton6(addr);zeroMemory(sa,28);if(addrlen){HEAP32[addrlen>>2]=28}HEAP32[sa>>2]=family;HEAP32[sa+8>>2]=addr[0];HEAP32[sa+12>>2]=addr[1];HEAP32[sa+16>>2]=addr[2];HEAP32[sa+20>>2]=addr[3];HEAP16[sa+2>>1]=_htons(port);break;default:return 5}return 0}var DNS={address_map:{id:1,addrs:{},names:{}},lookup_name:function(name){var res=inetPton4(name);if(res!==null){return name}res=inetPton6(name);if(res!==null){return name}var addr;if(DNS.address_map.addrs[name]){addr=DNS.address_map.addrs[name]}else{var id=DNS.address_map.id++;assert(id<65535,"exceeded max address mappings of 65535");addr="172.29."+(id&255)+"."+(id&65280);DNS.address_map.names[addr]=name;DNS.address_map.addrs[name]=addr}return addr},lookup_addr:function(addr){if(DNS.address_map.names[addr]){return DNS.address_map.names[addr]}return null}};function ___syscall_accept4(fd,addr,addrlen,flags,d1,d2){try{var sock=getSocketFromFD(fd);var newsock=sock.sock_ops.accept(sock);if(addr){var errno=writeSockaddr(addr,newsock.family,DNS.lookup_name(newsock.daddr),newsock.dport,addrlen)}return newsock.stream.fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function inetNtop4(addr){return(addr&255)+"."+(addr>>8&255)+"."+(addr>>16&255)+"."+(addr>>24&255)}function inetNtop6(ints){var str="";var word=0;var longest=0;var lastzero=0;var zstart=0;var len=0;var i=0;var parts=[ints[0]&65535,ints[0]>>16,ints[1]&65535,ints[1]>>16,ints[2]&65535,ints[2]>>16,ints[3]&65535,ints[3]>>16];var hasipv4=true;var v4part="";for(i=0;i<5;i++){if(parts[i]!==0){hasipv4=false;break}}if(hasipv4){v4part=inetNtop4(parts[6]|parts[7]<<16);if(parts[5]===-1){str="::ffff:";str+=v4part;return str}if(parts[5]===0){str="::";if(v4part==="0.0.0.0")v4part="";if(v4part==="0.0.0.1")v4part="1";str+=v4part;return str}}for(word=0;word<8;word++){if(parts[word]===0){if(word-lastzero>1){len=0}lastzero=word;len++}if(len>longest){longest=len;zstart=word-longest+1}}for(word=0;word<8;word++){if(longest>1){if(parts[word]===0&&word>=zstart&&word<zstart+longest){if(word===zstart){str+=":";if(zstart===0)str+=":"}continue}}str+=Number(_ntohs(parts[word]&65535)).toString(16);str+=word<7?":":""}return str}function readSockaddr(sa,salen){var family=HEAP16[sa>>1];var port=_ntohs(HEAPU16[sa+2>>1]);var addr;switch(family){case 2:if(salen!==16){return{errno:28}}addr=HEAP32[sa+4>>2];addr=inetNtop4(addr);break;case 10:if(salen!==28){return{errno:28}}addr=[HEAP32[sa+8>>2],HEAP32[sa+12>>2],HEAP32[sa+16>>2],HEAP32[sa+20>>2]];addr=inetNtop6(addr);break;default:return{errno:5}}return{family:family,addr:addr,port:port}}function getSocketAddress(addrp,addrlen,allowNull){if(allowNull&&addrp===0)return null;var info=readSockaddr(addrp,addrlen);if(info.errno)throw new FS.ErrnoError(info.errno);info.addr=DNS.lookup_addr(info.addr)||info.addr;return info}function ___syscall_bind(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);var info=getSocketAddress(addr,addrlen);sock.sock_ops.bind(sock,info.addr,info.port);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_connect(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);var info=getSocketAddress(addr,addrlen);sock.sock_ops.connect(sock,info.addr,info.port);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_faccessat(dirfd,path,amode,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(amode&~7){return-28}var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;if(!node){return-44}var perms="";if(amode&4)perms+="r";if(amode&2)perms+="w";if(amode&1)perms+="x";if(perms&&FS.nodePermissions(node,perms)){return-2}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_fcntl64(fd,cmd,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(cmd){case 0:{var arg=SYSCALLS.get();if(arg<0){return-28}var newStream;newStream=FS.createStream(stream,arg);return newStream.fd}case 1:case 2:return 0;case 3:return stream.flags;case 4:{var arg=SYSCALLS.get();stream.flags|=arg;return 0}case 5:{var arg=SYSCALLS.get();var offset=0;HEAP16[arg+offset>>1]=2;return 0}case 6:case 7:return 0;case 16:case 8:return-28;case 9:setErrNo(28);return-1;default:{return-28}}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_fstat64(fd,buf){try{var stream=SYSCALLS.getStreamFromFD(fd);return SYSCALLS.doStat(FS.stat,stream.path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function stringToUTF8(str,outPtr,maxBytesToWrite){return stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite)}function ___syscall_getdents64(fd,dirp,count){try{var stream=SYSCALLS.getStreamFromFD(fd);if(!stream.getdents){stream.getdents=FS.readdir(stream.path)}var struct_size=280;var pos=0;var off=FS.llseek(stream,0,1);var idx=Math.floor(off/struct_size);while(idx<stream.getdents.length&&pos+struct_size<=count){var id;var type;var name=stream.getdents[idx];if(name==="."){id=stream.node.id;type=4}else if(name===".."){var lookup=FS.lookupPath(stream.path,{parent:true});id=lookup.node.id;type=4}else{var child=FS.lookupNode(stream.node,name);id=child.id;type=FS.isChrdev(child.mode)?2:FS.isDir(child.mode)?4:FS.isLink(child.mode)?10:8}HEAP64[dirp+pos>>3]=BigInt(id);HEAP64[dirp+pos+8>>3]=BigInt((idx+1)*struct_size);HEAP16[dirp+pos+16>>1]=280;HEAP8[dirp+pos+18>>0]=type;stringToUTF8(name,dirp+pos+19,256);pos+=struct_size;idx+=1}FS.llseek(stream,idx*struct_size,0);return pos}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_getpeername(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);if(!sock.daddr){return-53}var errno=writeSockaddr(addr,sock.family,DNS.lookup_name(sock.daddr),sock.dport,addrlen);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_getsockname(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);var errno=writeSockaddr(addr,sock.family,DNS.lookup_name(sock.saddr||"0.0.0.0"),sock.sport,addrlen);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_getsockopt(fd,level,optname,optval,optlen,d1){try{var sock=getSocketFromFD(fd);if(level===1){if(optname===4){HEAP32[optval>>2]=sock.error;HEAP32[optlen>>2]=4;sock.error=null;return 0}}return-50}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_ioctl(fd,op,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(op){case 21509:case 21505:{if(!stream.tty)return-59;return 0}case 21510:case 21511:case 21512:case 21506:case 21507:case 21508:{if(!stream.tty)return-59;return 0}case 21519:{if(!stream.tty)return-59;var argp=SYSCALLS.get();HEAP32[argp>>2]=0;return 0}case 21520:{if(!stream.tty)return-59;return-28}case 21531:{var argp=SYSCALLS.get();return FS.ioctl(stream,op,argp)}case 21523:{if(!stream.tty)return-59;return 0}case 21524:{if(!stream.tty)return-59;return 0}default:return-28}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_listen(fd,backlog){try{var sock=getSocketFromFD(fd);sock.sock_ops.listen(sock,backlog);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_lstat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.lstat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_mkdirat(dirfd,path,mode){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);path=PATH.normalize(path);if(path[path.length-1]==="/")path=path.substr(0,path.length-1);FS.mkdir(path,mode,0);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_newfstatat(dirfd,path,buf,flags){try{path=SYSCALLS.getStr(path);var nofollow=flags&256;var allowEmpty=flags&4096;flags=flags&~6400;path=SYSCALLS.calculateAt(dirfd,path,allowEmpty);return SYSCALLS.doStat(nofollow?FS.lstat:FS.stat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_openat(dirfd,path,flags,varargs){SYSCALLS.varargs=varargs;try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);var mode=varargs?SYSCALLS.get():0;return FS.open(path,flags,mode).fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_poll(fds,nfds,timeout){try{var nonzero=0;for(var i=0;i<nfds;i++){var pollfd=fds+8*i;var fd=HEAP32[pollfd>>2];var events=HEAP16[pollfd+4>>1];var mask=32;var stream=FS.getStream(fd);if(stream){mask=SYSCALLS.DEFAULT_POLLMASK;if(stream.stream_ops.poll){mask=stream.stream_ops.poll(stream)}}mask&=events|8|16;if(mask)nonzero++;HEAP16[pollfd+6>>1]=mask}return nonzero}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_recvfrom(fd,buf,len,flags,addr,addrlen){try{var sock=getSocketFromFD(fd);var msg=sock.sock_ops.recvmsg(sock,len);if(!msg)return 0;if(addr){var errno=writeSockaddr(addr,sock.family,DNS.lookup_name(msg.addr),msg.port,addrlen)}HEAPU8.set(msg.buffer,buf);return msg.buffer.byteLength}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_renameat(olddirfd,oldpath,newdirfd,newpath){try{oldpath=SYSCALLS.getStr(oldpath);newpath=SYSCALLS.getStr(newpath);oldpath=SYSCALLS.calculateAt(olddirfd,oldpath);newpath=SYSCALLS.calculateAt(newdirfd,newpath);FS.rename(oldpath,newpath);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_rmdir(path){try{path=SYSCALLS.getStr(path);FS.rmdir(path);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_sendto(fd,message,length,flags,addr,addr_len){try{var sock=getSocketFromFD(fd);var dest=getSocketAddress(addr,addr_len,true);if(!dest){return FS.write(sock.stream,HEAP8,message,length)}return sock.sock_ops.sendmsg(sock,HEAP8,message,length,dest.addr,dest.port)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_socket(domain,type,protocol){try{var sock=SOCKFS.createSocket(domain,type,protocol);return sock.stream.fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_stat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.stat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_unlinkat(dirfd,path,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(flags===0){FS.unlink(path)}else if(flags===512){FS.rmdir(path)}else{abort("Invalid flags passed to unlinkat")}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var nowIsMonotonic=true;function __emscripten_get_now_is_monotonic(){return nowIsMonotonic}function __emscripten_throw_longjmp(){throw Infinity}function readI53FromI64(ptr){return HEAPU32[ptr>>2]+HEAP32[ptr+4>>2]*4294967296}function __gmtime_js(time,tmPtr){var date=new Date(readI53FromI64(time)*1e3);HEAP32[tmPtr>>2]=date.getUTCSeconds();HEAP32[tmPtr+4>>2]=date.getUTCMinutes();HEAP32[tmPtr+8>>2]=date.getUTCHours();HEAP32[tmPtr+12>>2]=date.getUTCDate();HEAP32[tmPtr+16>>2]=date.getUTCMonth();HEAP32[tmPtr+20>>2]=date.getUTCFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getUTCDay();var start=Date.UTC(date.getUTCFullYear(),0,1,0,0,0,0);var yday=(date.getTime()-start)/(1e3*60*60*24)|0;HEAP32[tmPtr+28>>2]=yday}function isLeapYear(year){return year%4===0&&(year%100!==0||year%400===0)}var MONTH_DAYS_LEAP_CUMULATIVE=[0,31,60,91,121,152,182,213,244,274,305,335];var MONTH_DAYS_REGULAR_CUMULATIVE=[0,31,59,90,120,151,181,212,243,273,304,334];function ydayFromDate(date){var leap=isLeapYear(date.getFullYear());var monthDaysCumulative=leap?MONTH_DAYS_LEAP_CUMULATIVE:MONTH_DAYS_REGULAR_CUMULATIVE;var yday=monthDaysCumulative[date.getMonth()]+date.getDate()-1;return yday}function __localtime_js(time,tmPtr){var date=new Date(readI53FromI64(time)*1e3);HEAP32[tmPtr>>2]=date.getSeconds();HEAP32[tmPtr+4>>2]=date.getMinutes();HEAP32[tmPtr+8>>2]=date.getHours();HEAP32[tmPtr+12>>2]=date.getDate();HEAP32[tmPtr+16>>2]=date.getMonth();HEAP32[tmPtr+20>>2]=date.getFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getDay();var yday=ydayFromDate(date)|0;HEAP32[tmPtr+28>>2]=yday;HEAP32[tmPtr+36>>2]=-(date.getTimezoneOffset()*60);var start=new Date(date.getFullYear(),0,1);var summerOffset=new Date(date.getFullYear(),6,1).getTimezoneOffset();var winterOffset=start.getTimezoneOffset();var dst=(summerOffset!=winterOffset&&date.getTimezoneOffset()==Math.min(winterOffset,summerOffset))|0;HEAP32[tmPtr+32>>2]=dst}function __mktime_js(tmPtr){var date=new Date(HEAP32[tmPtr+20>>2]+1900,HEAP32[tmPtr+16>>2],HEAP32[tmPtr+12>>2],HEAP32[tmPtr+8>>2],HEAP32[tmPtr+4>>2],HEAP32[tmPtr>>2],0);var dst=HEAP32[tmPtr+32>>2];var guessedOffset=date.getTimezoneOffset();var start=new Date(date.getFullYear(),0,1);var summerOffset=new Date(date.getFullYear(),6,1).getTimezoneOffset();var winterOffset=start.getTimezoneOffset();var dstOffset=Math.min(winterOffset,summerOffset);if(dst<0){HEAP32[tmPtr+32>>2]=Number(summerOffset!=winterOffset&&dstOffset==guessedOffset)}else if(dst>0!=(dstOffset==guessedOffset)){var nonDstOffset=Math.max(winterOffset,summerOffset);var trueOffset=dst>0?dstOffset:nonDstOffset;date.setTime(date.getTime()+(trueOffset-guessedOffset)*6e4)}HEAP32[tmPtr+24>>2]=date.getDay();var yday=ydayFromDate(date)|0;HEAP32[tmPtr+28>>2]=yday;HEAP32[tmPtr>>2]=date.getSeconds();HEAP32[tmPtr+4>>2]=date.getMinutes();HEAP32[tmPtr+8>>2]=date.getHours();HEAP32[tmPtr+12>>2]=date.getDate();HEAP32[tmPtr+16>>2]=date.getMonth();HEAP32[tmPtr+20>>2]=date.getYear();return date.getTime()/1e3|0}function __mmap_js(len,prot,flags,fd,off,allocated,addr){try{var stream=SYSCALLS.getStreamFromFD(fd);var res=FS.mmap(stream,len,off,prot,flags);var ptr=res.ptr;HEAP32[allocated>>2]=res.allocated;HEAPU32[addr>>2]=ptr;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function __munmap_js(addr,len,prot,flags,fd,offset){try{var stream=SYSCALLS.getStreamFromFD(fd);if(prot&2){SYSCALLS.doMsync(addr,stream,len,flags,offset)}FS.munmap(stream)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function stringToNewUTF8(str){var size=lengthBytesUTF8(str)+1;var ret=_malloc(size);if(ret)stringToUTF8(str,ret,size);return ret}function __tzset_js(timezone,daylight,tzname){var currentYear=(new Date).getFullYear();var winter=new Date(currentYear,0,1);var summer=new Date(currentYear,6,1);var winterOffset=winter.getTimezoneOffset();var summerOffset=summer.getTimezoneOffset();var stdTimezoneOffset=Math.max(winterOffset,summerOffset);HEAPU32[timezone>>2]=stdTimezoneOffset*60;HEAP32[daylight>>2]=Number(winterOffset!=summerOffset);function extractZone(date){var match=date.toTimeString().match(/\(([A-Za-z ]+)\)$/);return match?match[1]:"GMT"}var winterName=extractZone(winter);var summerName=extractZone(summer);var winterNamePtr=stringToNewUTF8(winterName);var summerNamePtr=stringToNewUTF8(summerName);if(summerOffset<winterOffset){HEAPU32[tzname>>2]=winterNamePtr;HEAPU32[tzname+4>>2]=summerNamePtr}else{HEAPU32[tzname>>2]=summerNamePtr;HEAPU32[tzname+4>>2]=winterNamePtr}}function _abort(){abort("")}Module["_abort"]=_abort;function _dlopen(handle){abort(dlopenMissingError)}var readEmAsmArgsArray=[];function readEmAsmArgs(sigPtr,buf){readEmAsmArgsArray.length=0;var ch;buf>>=2;while(ch=HEAPU8[sigPtr++]){buf+=ch!=105&buf;readEmAsmArgsArray.push(ch==105?HEAP32[buf]:(ch==106?HEAP64:HEAPF64)[buf++>>1]);++buf}return readEmAsmArgsArray}function runEmAsmFunction(code,sigPtr,argbuf){var args=readEmAsmArgs(sigPtr,argbuf);return ASM_CONSTS[code].apply(null,args)}function _emscripten_asm_const_int(code,sigPtr,argbuf){return runEmAsmFunction(code,sigPtr,argbuf)}function _emscripten_date_now(){return Date.now()}function getHeapMax(){return 2147483648}function _emscripten_get_heap_max(){return getHeapMax()}var _emscripten_get_now;_emscripten_get_now=()=>performance.now();function _emscripten_memcpy_big(dest,src,num){HEAPU8.copyWithin(dest,src,src+num)}function emscripten_realloc_buffer(size){var b=wasmMemory.buffer;try{wasmMemory.grow(size-b.byteLength+65535>>>16);updateMemoryViews();return 1}catch(e){}}function _emscripten_resize_heap(requestedSize){var oldSize=HEAPU8.length;requestedSize=requestedSize>>>0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}var alignUp=(x,multiple)=>x+(multiple-x%multiple)%multiple;for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignUp(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=emscripten_realloc_buffer(newSize);if(replacement){return true}}return false}var ENV={};function getExecutableName(){return thisProgram||"./this.program"}function getEnvStrings(){if(!getEnvStrings.strings){var lang=(typeof navigator=="object"&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8";var env={"USER":"web_user","LOGNAME":"web_user","PATH":"/","PWD":"/","HOME":"/home/web_user","LANG":lang,"_":getExecutableName()};for(var x in ENV){if(ENV[x]===undefined)delete env[x];else env[x]=ENV[x]}var strings=[];for(var x in env){strings.push(`${x}=${env[x]}`)}getEnvStrings.strings=strings}return getEnvStrings.strings}function stringToAscii(str,buffer){for(var i=0;i<str.length;++i){HEAP8[buffer++>>0]=str.charCodeAt(i)}HEAP8[buffer>>0]=0}function _environ_get(__environ,environ_buf){var bufSize=0;getEnvStrings().forEach(function(string,i){var ptr=environ_buf+bufSize;HEAPU32[__environ+i*4>>2]=ptr;stringToAscii(string,ptr);bufSize+=string.length+1});return 0}function _environ_sizes_get(penviron_count,penviron_buf_size){var strings=getEnvStrings();HEAPU32[penviron_count>>2]=strings.length;var bufSize=0;strings.forEach(function(string){bufSize+=string.length+1});HEAPU32[penviron_buf_size>>2]=bufSize;return 0}function _proc_exit(code){EXITSTATUS=code;if(!keepRuntimeAlive()){if(Module["onExit"])Module["onExit"](code);ABORT=true}quit_(code,new ExitStatus(code))}function exitJS(status,implicit){EXITSTATUS=status;_proc_exit(status)}var _exit=exitJS;function _fd_close(fd){try{var stream=SYSCALLS.getStreamFromFD(fd);FS.close(stream);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_fdstat_get(fd,pbuf){try{var rightsBase=0;var rightsInheriting=0;var flags=0;{var stream=SYSCALLS.getStreamFromFD(fd);var type=stream.tty?2:FS.isDir(stream.mode)?3:FS.isLink(stream.mode)?7:4}HEAP8[pbuf>>0]=type;HEAP16[pbuf+2>>1]=flags;HEAP64[pbuf+8>>3]=BigInt(rightsBase);HEAP64[pbuf+16>>3]=BigInt(rightsInheriting);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function doReadv(stream,iov,iovcnt,offset){var ret=0;for(var i=0;i<iovcnt;i++){var ptr=HEAPU32[iov>>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.read(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr<len)break;if(typeof offset!=="undefined"){offset+=curr}}return ret}function _fd_read(fd,iov,iovcnt,pnum){try{var stream=SYSCALLS.getStreamFromFD(fd);var num=doReadv(stream,iov,iovcnt);HEAPU32[pnum>>2]=num;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var MAX_INT53=9007199254740992;var MIN_INT53=-9007199254740992;function bigintToI53Checked(num){return num<MIN_INT53||num>MAX_INT53?NaN:Number(num)}function _fd_seek(fd,offset,whence,newOffset){try{offset=bigintToI53Checked(offset);if(isNaN(offset))return 61;var stream=SYSCALLS.getStreamFromFD(fd);FS.llseek(stream,offset,whence);HEAP64[newOffset>>3]=BigInt(stream.position);if(stream.getdents&&offset===0&&whence===0)stream.getdents=null;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function doWritev(stream,iov,iovcnt,offset){var ret=0;for(var i=0;i<iovcnt;i++){var ptr=HEAPU32[iov>>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.write(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(typeof offset!=="undefined"){offset+=curr}}return ret}function _fd_write(fd,iov,iovcnt,pnum){try{var stream=SYSCALLS.getStreamFromFD(fd);var num=doWritev(stream,iov,iovcnt);HEAPU32[pnum>>2]=num;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _getaddrinfo(node,service,hint,out){var addr=0;var port=0;var flags=0;var family=0;var type=0;var proto=0;var ai;function allocaddrinfo(family,type,proto,canon,addr,port){var sa,salen,ai;var errno;salen=family===10?28:16;addr=family===10?inetNtop6(addr):inetNtop4(addr);sa=_malloc(salen);errno=writeSockaddr(sa,family,addr,port);assert(!errno);ai=_malloc(32);HEAP32[ai+4>>2]=family;HEAP32[ai+8>>2]=type;HEAP32[ai+12>>2]=proto;HEAPU32[ai+24>>2]=canon;HEAPU32[ai+20>>2]=sa;if(family===10){HEAP32[ai+16>>2]=28}else{HEAP32[ai+16>>2]=16}HEAP32[ai+28>>2]=0;return ai}if(hint){flags=HEAP32[hint>>2];family=HEAP32[hint+4>>2];type=HEAP32[hint+8>>2];proto=HEAP32[hint+12>>2]}if(type&&!proto){proto=type===2?17:6}if(!type&&proto){type=proto===17?2:1}if(proto===0){proto=6}if(type===0){type=1}if(!node&&!service){return-2}if(flags&~(1|2|4|1024|8|16|32)){return-1}if(hint!==0&&HEAP32[hint>>2]&2&&!node){return-1}if(flags&32){return-2}if(type!==0&&type!==1&&type!==2){return-7}if(family!==0&&family!==2&&family!==10){return-6}if(service){service=UTF8ToString(service);port=parseInt(service,10);if(isNaN(port)){if(flags&1024){return-2}return-8}}if(!node){if(family===0){family=2}if((flags&1)===0){if(family===2){addr=_htonl(2130706433)}else{addr=[0,0,0,1]}}ai=allocaddrinfo(family,type,proto,null,addr,port);HEAPU32[out>>2]=ai;return 0}node=UTF8ToString(node);addr=inetPton4(node);if(addr!==null){if(family===0||family===2){family=2}else if(family===10&&flags&8){addr=[0,0,_htonl(65535),addr];family=10}else{return-2}}else{addr=inetPton6(node);if(addr!==null){if(family===0||family===10){family=10}else{return-2}}}if(addr!=null){ai=allocaddrinfo(family,type,proto,node,addr,port);HEAPU32[out>>2]=ai;return 0}if(flags&4){return-2}node=DNS.lookup_name(node);addr=inetPton4(node);if(family===0){family=2}else if(family===10){addr=[0,0,_htonl(65535),addr]}ai=allocaddrinfo(family,type,proto,null,addr,port);HEAPU32[out>>2]=ai;return 0}function _getnameinfo(sa,salen,node,nodelen,serv,servlen,flags){var info=readSockaddr(sa,salen);if(info.errno){return-6}var port=info.port;var addr=info.addr;var overflowed=false;if(node&&nodelen){var lookup;if(flags&1||!(lookup=DNS.lookup_addr(addr))){if(flags&8){return-2}}else{addr=lookup}var numBytesWrittenExclNull=stringToUTF8(addr,node,nodelen);if(numBytesWrittenExclNull+1>=nodelen){overflowed=true}}if(serv&&servlen){port=""+port;var numBytesWrittenExclNull=stringToUTF8(port,serv,servlen);if(numBytesWrittenExclNull+1>=servlen){overflowed=true}}if(overflowed){return-12}return 0}function arraySum(array,index){var sum=0;for(var i=0;i<=index;sum+=array[i++]){}return sum}var MONTH_DAYS_LEAP=[31,29,31,30,31,30,31,31,30,31,30,31];var MONTH_DAYS_REGULAR=[31,28,31,30,31,30,31,31,30,31,30,31];function addDays(date,days){var newDate=new Date(date.getTime());while(days>0){var leap=isLeapYear(newDate.getFullYear());var currentMonth=newDate.getMonth();var daysInCurrentMonth=(leap?MONTH_DAYS_LEAP:MONTH_DAYS_REGULAR)[currentMonth];if(days>daysInCurrentMonth-newDate.getDate()){days-=daysInCurrentMonth-newDate.getDate()+1;newDate.setDate(1);if(currentMonth<11){newDate.setMonth(currentMonth+1)}else{newDate.setMonth(0);newDate.setFullYear(newDate.getFullYear()+1)}}else{newDate.setDate(newDate.getDate()+days);return newDate}}return newDate}function writeArrayToMemory(array,buffer){HEAP8.set(array,buffer)}function _strftime(s,maxsize,format,tm){var tm_zone=HEAP32[tm+40>>2];var date={tm_sec:HEAP32[tm>>2],tm_min:HEAP32[tm+4>>2],tm_hour:HEAP32[tm+8>>2],tm_mday:HEAP32[tm+12>>2],tm_mon:HEAP32[tm+16>>2],tm_year:HEAP32[tm+20>>2],tm_wday:HEAP32[tm+24>>2],tm_yday:HEAP32[tm+28>>2],tm_isdst:HEAP32[tm+32>>2],tm_gmtoff:HEAP32[tm+36>>2],tm_zone:tm_zone?UTF8ToString(tm_zone):""};var pattern=UTF8ToString(format);var EXPANSION_RULES_1={"%c":"%a %b %d %H:%M:%S %Y","%D":"%m/%d/%y","%F":"%Y-%m-%d","%h":"%b","%r":"%I:%M:%S %p","%R":"%H:%M","%T":"%H:%M:%S","%x":"%m/%d/%y","%X":"%H:%M:%S","%Ec":"%c","%EC":"%C","%Ex":"%m/%d/%y","%EX":"%H:%M:%S","%Ey":"%y","%EY":"%Y","%Od":"%d","%Oe":"%e","%OH":"%H","%OI":"%I","%Om":"%m","%OM":"%M","%OS":"%S","%Ou":"%u","%OU":"%U","%OV":"%V","%Ow":"%w","%OW":"%W","%Oy":"%y"};for(var rule in EXPANSION_RULES_1){pattern=pattern.replace(new RegExp(rule,"g"),EXPANSION_RULES_1[rule])}var WEEKDAYS=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];var MONTHS=["January","February","March","April","May","June","July","August","September","October","November","December"];function leadingSomething(value,digits,character){var str=typeof value=="number"?value.toString():value||"";while(str.length<digits){str=character[0]+str}return str}function leadingNulls(value,digits){return leadingSomething(value,digits,"0")}function compareByDay(date1,date2){function sgn(value){return value<0?-1:value>0?1:0}var compare;if((compare=sgn(date1.getFullYear()-date2.getFullYear()))===0){if((compare=sgn(date1.getMonth()-date2.getMonth()))===0){compare=sgn(date1.getDate()-date2.getDate())}}return compare}function getFirstWeekStartDate(janFourth){switch(janFourth.getDay()){case 0:return new Date(janFourth.getFullYear()-1,11,29);case 1:return janFourth;case 2:return new Date(janFourth.getFullYear(),0,3);case 3:return new Date(janFourth.getFullYear(),0,2);case 4:return new Date(janFourth.getFullYear(),0,1);case 5:return new Date(janFourth.getFullYear()-1,11,31);case 6:return new Date(janFourth.getFullYear()-1,11,30)}}function getWeekBasedYear(date){var thisDate=addDays(new Date(date.tm_year+1900,0,1),date.tm_yday);var janFourthThisYear=new Date(thisDate.getFullYear(),0,4);var janFourthNextYear=new Date(thisDate.getFullYear()+1,0,4);var firstWeekStartThisYear=getFirstWeekStartDate(janFourthThisYear);var firstWeekStartNextYear=getFirstWeekStartDate(janFourthNextYear);if(compareByDay(firstWeekStartThisYear,thisDate)<=0){if(compareByDay(firstWeekStartNextYear,thisDate)<=0){return thisDate.getFullYear()+1}return thisDate.getFullYear()}return thisDate.getFullYear()-1}var EXPANSION_RULES_2={"%a":function(date){return WEEKDAYS[date.tm_wday].substring(0,3)},"%A":function(date){return WEEKDAYS[date.tm_wday]},"%b":function(date){return MONTHS[date.tm_mon].substring(0,3)},"%B":function(date){return MONTHS[date.tm_mon]},"%C":function(date){var year=date.tm_year+1900;return leadingNulls(year/100|0,2)},"%d":function(date){return leadingNulls(date.tm_mday,2)},"%e":function(date){return leadingSomething(date.tm_mday,2," ")},"%g":function(date){return getWeekBasedYear(date).toString().substring(2)},"%G":function(date){return getWeekBasedYear(date)},"%H":function(date){return leadingNulls(date.tm_hour,2)},"%I":function(date){var twelveHour=date.tm_hour;if(twelveHour==0)twelveHour=12;else if(twelveHour>12)twelveHour-=12;return leadingNulls(twelveHour,2)},"%j":function(date){return leadingNulls(date.tm_mday+arraySum(isLeapYear(date.tm_year+1900)?MONTH_DAYS_LEAP:MONTH_DAYS_REGULAR,date.tm_mon-1),3)},"%m":function(date){return leadingNulls(date.tm_mon+1,2)},"%M":function(date){return leadingNulls(date.tm_min,2)},"%n":function(){return"\n"},"%p":function(date){if(date.tm_hour>=0&&date.tm_hour<12){return"AM"}return"PM"},"%S":function(date){return leadingNulls(date.tm_sec,2)},"%t":function(){return"\t"},"%u":function(date){return date.tm_wday||7},"%U":function(date){var days=date.tm_yday+7-date.tm_wday;return leadingNulls(Math.floor(days/7),2)},"%V":function(date){var val=Math.floor((date.tm_yday+7-(date.tm_wday+6)%7)/7);if((date.tm_wday+371-date.tm_yday-2)%7<=2){val++}if(!val){val=52;var dec31=(date.tm_wday+7-date.tm_yday-1)%7;if(dec31==4||dec31==5&&isLeapYear(date.tm_year%400-1)){val++}}else if(val==53){var jan1=(date.tm_wday+371-date.tm_yday)%7;if(jan1!=4&&(jan1!=3||!isLeapYear(date.tm_year)))val=1}return leadingNulls(val,2)},"%w":function(date){return date.tm_wday},"%W":function(date){var days=date.tm_yday+7-(date.tm_wday+6)%7;return leadingNulls(Math.floor(days/7),2)},"%y":function(date){return(date.tm_year+1900).toString().substring(2)},"%Y":function(date){return date.tm_year+1900},"%z":function(date){var off=date.tm_gmtoff;var ahead=off>=0;off=Math.abs(off)/60;off=off/60*100+off%60;return(ahead?"+":"-")+String("0000"+off).slice(-4)},"%Z":function(date){return date.tm_zone},"%%":function(){return"%"}};pattern=pattern.replace(/%%/g,"\0\0");for(var rule in EXPANSION_RULES_2){if(pattern.includes(rule)){pattern=pattern.replace(new RegExp(rule,"g"),EXPANSION_RULES_2[rule](date))}}pattern=pattern.replace(/\0\0/g,"%");var bytes=intArrayFromString(pattern,false);if(bytes.length>maxsize){return 0}writeArrayToMemory(bytes,s);return bytes.length-1}var FSNode=function(parent,name,mode,rdev){if(!parent){parent=this}this.parent=parent;this.mount=parent.mount;this.mounted=null;this.id=FS.nextInode++;this.name=name;this.mode=mode;this.node_ops={};this.stream_ops={};this.rdev=rdev};var readMode=292|73;var writeMode=146;Object.defineProperties(FSNode.prototype,{read:{get:function(){return(this.mode&readMode)===readMode},set:function(val){val?this.mode|=readMode:this.mode&=~readMode}},write:{get:function(){return(this.mode&writeMode)===writeMode},set:function(val){val?this.mode|=writeMode:this.mode&=~writeMode}},isFolder:{get:function(){return FS.isDir(this.mode)}},isDevice:{get:function(){return FS.isChrdev(this.mode)}}});FS.FSNode=FSNode;FS.createPreloadedFile=FS_createPreloadedFile;FS.staticInit();var wasmImports={"b":___assert_fail,"f":___cxa_throw,"ka":___dlsym,"R":___syscall__newselect,"L":___syscall_accept4,"K":___syscall_bind,"J":___syscall_connect,"la":___syscall_faccessat,"g":___syscall_fcntl64,"ha":___syscall_fstat64,"U":___syscall_getdents64,"I":___syscall_getpeername,"H":___syscall_getsockname,"G":___syscall_getsockopt,"y":___syscall_ioctl,"F":___syscall_listen,"ea":___syscall_lstat64,"$":___syscall_mkdirat,"fa":___syscall_newfstatat,"w":___syscall_openat,"V":___syscall_poll,"E":___syscall_recvfrom,"T":___syscall_renameat,"S":___syscall_rmdir,"D":___syscall_sendto,"v":___syscall_socket,"ga":___syscall_stat64,"O":___syscall_unlinkat,"ia":__emscripten_get_now_is_monotonic,"M":__emscripten_throw_longjmp,"Y":__gmtime_js,"Z":__localtime_js,"_":__mktime_js,"W":__mmap_js,"X":__munmap_js,"P":__tzset_js,"a":_abort,"t":_dlopen,"oa":_emscripten_asm_const_int,"m":_emscripten_date_now,"Q":_emscripten_get_heap_max,"p":_emscripten_get_now,"ja":_emscripten_memcpy_big,"N":_emscripten_resize_heap,"ca":_environ_get,"da":_environ_sizes_get,"l":_exit,"n":_fd_close,"ba":_fd_fdstat_get,"x":_fd_read,"aa":_fd_seek,"q":_fd_write,"k":_getaddrinfo,"i":_getnameinfo,"pa":invoke_i,"na":invoke_ii,"c":invoke_iii,"o":invoke_iiii,"s":invoke_iiiii,"z":invoke_iiiiii,"r":invoke_iiiiiiiii,"B":invoke_iiiijj,"qa":invoke_iij,"h":invoke_vi,"j":invoke_vii,"d":invoke_viiii,"ma":invoke_viiiiii,"A":invoke_viiiiiiii,"C":is_timeout,"u":send_progress,"e":_strftime};var asm=createWasm();var ___wasm_call_ctors=function(){return(___wasm_call_ctors=Module["asm"]["sa"]).apply(null,arguments)};var _malloc=Module["_malloc"]=function(){return(_malloc=Module["_malloc"]=Module["asm"]["ta"]).apply(null,arguments)};var ___errno_location=function(){return(___errno_location=Module["asm"]["va"]).apply(null,arguments)};var _ntohs=function(){return(_ntohs=Module["asm"]["wa"]).apply(null,arguments)};var _htons=function(){return(_htons=Module["asm"]["xa"]).apply(null,arguments)};var _ffmpeg=Module["_ffmpeg"]=function(){return(_ffmpeg=Module["_ffmpeg"]=Module["asm"]["ya"]).apply(null,arguments)};var _ffprobe=Module["_ffprobe"]=function(){return(_ffprobe=Module["_ffprobe"]=Module["asm"]["za"]).apply(null,arguments)};var _htonl=function(){return(_htonl=Module["asm"]["Aa"]).apply(null,arguments)};var _emscripten_builtin_memalign=function(){return(_emscripten_builtin_memalign=Module["asm"]["Ba"]).apply(null,arguments)};var _setThrew=function(){return(_setThrew=Module["asm"]["Ca"]).apply(null,arguments)};var stackSave=function(){return(stackSave=Module["asm"]["Da"]).apply(null,arguments)};var stackRestore=function(){return(stackRestore=Module["asm"]["Ea"]).apply(null,arguments)};var ___cxa_is_pointer_type=function(){return(___cxa_is_pointer_type=Module["asm"]["Fa"]).apply(null,arguments)};var _ff_h264_cabac_tables=Module["_ff_h264_cabac_tables"]=1546732;var ___start_em_js=Module["___start_em_js"]=6077485;var ___stop_em_js=Module["___stop_em_js"]=6077662;function invoke_iiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vii(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiijj(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iij(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_i(index){var sp=stackSave();try{return getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_ii(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}Module["setValue"]=setValue;Module["getValue"]=getValue;Module["UTF8ToString"]=UTF8ToString;Module["stringToUTF8"]=stringToUTF8;Module["lengthBytesUTF8"]=lengthBytesUTF8;Module["FS"]=FS;var calledRun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(){if(runDependencies>0){return}preRun();if(runDependencies>0){return}function doRun(){if(calledRun)return;calledRun=true;Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve(Module);if(Module["onRuntimeInitialized"])Module["onRuntimeInitialized"]();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(function(){setTimeout(function(){Module["setStatus"]("")},1);doRun()},1)}else{doRun()}}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run(); + - return function (createFFmpegCore = {}) { - var Module = typeof createFFmpegCore != "undefined" ? createFFmpegCore : {}; // Ensure that createFFmpegCore is defined and available.var readyPromiseResolve,readyPromiseReject;Module["ready"]=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});const NULL=0;const SIZE_I32=Uint32Array.BYTES_PER_ELEMENT;const DEFAULT_ARGS=["./ffmpeg","-nostdin","-y"];const DEFAULT_ARGS_FFPROBE=["./ffprobe"];Module["NULL"]=NULL;Module["SIZE_I32"]=SIZE_I32;Module["DEFAULT_ARGS"]=DEFAULT_ARGS;Module["DEFAULT_ARGS_FFPROBE"]=DEFAULT_ARGS_FFPROBE;Module["ret"]=-1;Module["timeout"]=-1;Module["logger"]=()=>{};Module["progress"]=()=>{};function stringToPtr(str){const len=Module["lengthBytesUTF8"](str)+1;const ptr=Module["_malloc"](len);Module["stringToUTF8"](str,ptr,len);return ptr}function stringsToPtr(strs){const len=strs.length;const ptr=Module["_malloc"](len*SIZE_I32);for(let i=0;i<len;i++){Module["setValue"](ptr+SIZE_I32*i,stringToPtr(strs[i]),"i32")}return ptr}function print(message){Module["logger"]({type:"stdout",message:message})}function printErr(message){if(!message.startsWith("Aborted(native code called abort())"))Module["logger"]({type:"stderr",message:message})}function exec(..._args){const args=[...Module["DEFAULT_ARGS"],..._args];try{Module["_ffmpeg"](args.length,stringsToPtr(args))}catch(e){if(!e.message.startsWith("Aborted")){throw e}}return Module["ret"]}function ffprobe(..._args){const args=[...Module["DEFAULT_ARGS_FFPROBE"],..._args];try{Module["_ffprobe"](args.length,stringsToPtr(args))}catch(e){if(!e.message.startsWith("Aborted")){throw e}}return Module["ret"]}function setLogger(logger){Module["logger"]=logger}function setTimeout(timeout){Module["timeout"]=timeout}function setProgress(handler){Module["progress"]=handler}function receiveProgress(progress,time){Module["progress"]({progress:progress,time:time})}function reset(){Module["ret"]=-1;Module["timeout"]=-1}function _locateFile(path,prefix){const mainScriptUrlOrBlob=Module["mainScriptUrlOrBlob"];if(mainScriptUrlOrBlob){const{wasmURL:wasmURL,workerURL:workerURL}=JSON.parse(atob(mainScriptUrlOrBlob.slice(mainScriptUrlOrBlob.lastIndexOf("#")+1)));if(path.endsWith(".wasm"))return wasmURL;if(path.endsWith(".worker.js"))return workerURL}return prefix+path}Module["stringToPtr"]=stringToPtr;Module["stringsToPtr"]=stringsToPtr;Module["print"]=print;Module["printErr"]=printErr;Module["locateFile"]=_locateFile;Module["exec"]=exec;Module["ffprobe"]=ffprobe;Module["setLogger"]=setLogger;Module["setTimeout"]=setTimeout;Module["setProgress"]=setProgress;Module["reset"]=reset;Module["receiveProgress"]=receiveProgress;var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var ENVIRONMENT_IS_WEB=false;var ENVIRONMENT_IS_WORKER=true;var ENVIRONMENT_IS_NODE=false;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var read_,readAsync,readBinary,setWindowTitle;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptDir){scriptDirectory=_scriptDir}if(scriptDirectory.indexOf("blob:")!==0){scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}else{scriptDirectory=""}{read_=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.send(null);return xhr.responseText};if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=(url,onload,onerror)=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=()=>{if(xhr.status==200||xhr.status==0&&xhr.response){onload(xhr.response);return}onerror()};xhr.onerror=onerror;xhr.send(null)}}setWindowTitle=title=>document.title=title}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["quit"])quit_=Module["quit"];var wasmBinary;if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];var noExitRuntime=Module["noExitRuntime"]||true;if(typeof WebAssembly!="object"){abort("no native wasm support detected")}var wasmMemory;var ABORT=false;var EXITSTATUS;function assert(condition,text){if(!condition){abort(text)}}var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAP64,HEAPU64,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b);Module["HEAP64"]=HEAP64=new BigInt64Array(b);Module["HEAPU64"]=HEAPU64=new BigUint64Array(b)}var wasmTable;var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;var runtimeKeepaliveCounter=0;function keepRuntimeAlive(){return noExitRuntime||runtimeKeepaliveCounter>0}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;if(!Module["noFSInit"]&&!FS.init.initialized)FS.init();FS.ignorePermissions=false;TTY.init();SOCKFS.root=FS.mount(SOCKFS,{},null);callRuntimeCallbacks(__ATINIT__)}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function getUniqueRunDependency(id){return id}function addRunDependency(id){runDependencies++;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}}function removeRunDependency(id){runDependencies--;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){if(Module["onAbort"]){Module["onAbort"](what)}what="Aborted("+what+")";err(what);ABORT=true;EXITSTATUS=1;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";function isDataURI(filename){return filename.startsWith(dataURIPrefix)}var wasmBinaryFile;wasmBinaryFile="ffmpeg-core.wasm";if(!isDataURI(wasmBinaryFile)){wasmBinaryFile=locateFile(wasmBinaryFile)}function getBinary(file){try{if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}catch(err){abort(err)}}function getBinaryPromise(binaryFile){if(!wasmBinary&&(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER)){if(typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{if(!response["ok"]){throw"failed to load wasm binary file at '"+binaryFile+"'"}return response["arrayBuffer"]()}).catch(()=>getBinary(binaryFile))}}return Promise.resolve().then(()=>getBinary(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>{return WebAssembly.instantiate(binary,imports)}).then(instance=>{return instance}).then(receiver,reason=>{err("failed to asynchronously prepare wasm: "+reason);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err("wasm streaming compile failed: "+reason);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}else{return instantiateArrayBuffer(binaryFile,imports,callback)}}function createWasm(){var info={"a":wasmImports};function receiveInstance(instance,module){var exports=instance.exports;Module["asm"]=exports;wasmMemory=Module["asm"]["ra"];updateMemoryViews();wasmTable=Module["asm"]["ua"];addOnInit(Module["asm"]["sa"]);removeRunDependency("wasm-instantiate");return exports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err("Module.instantiateWasm callback failed with error: "+e);readyPromiseReject(e)}}instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}var ASM_CONSTS={6077464:$0=>{Module.ret=$0}};function send_progress(progress,time){Module.receiveProgress(progress,time)}function is_timeout(diff){if(Module.timeout===-1)return 0;else{return Module.timeout<=diff}}function ExitStatus(status){this.name="ExitStatus";this.message=`Program terminated with exit(${status})`;this.status=status}function callRuntimeCallbacks(callbacks){while(callbacks.length>0){callbacks.shift()(Module)}}var wasmTableMirror=[];function getWasmTableEntry(funcPtr){var func=wasmTableMirror[funcPtr];if(!func){if(funcPtr>=wasmTableMirror.length)wasmTableMirror.length=funcPtr+1;wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}return func}function getValue(ptr,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":return HEAP8[ptr>>0];case"i8":return HEAP8[ptr>>0];case"i16":return HEAP16[ptr>>1];case"i32":return HEAP32[ptr>>2];case"i64":return HEAP64[ptr>>3];case"float":return HEAPF32[ptr>>2];case"double":return HEAPF64[ptr>>3];case"*":return HEAPU32[ptr>>2];default:abort(`invalid type for getValue: ${type}`)}}function setValue(ptr,value,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":HEAP8[ptr>>0]=value;break;case"i8":HEAP8[ptr>>0]=value;break;case"i16":HEAP16[ptr>>1]=value;break;case"i32":HEAP32[ptr>>2]=value;break;case"i64":HEAP64[ptr>>3]=BigInt(value);break;case"float":HEAPF32[ptr>>2]=value;break;case"double":HEAPF64[ptr>>3]=value;break;case"*":HEAPU32[ptr>>2]=value;break;default:abort(`invalid type for setValue: ${type}`)}}var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder("utf8"):undefined;function UTF8ArrayToString(heapOrArray,idx,maxBytesToRead){var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx<endPtr){var u0=heapOrArray[idx++];if(!(u0&128)){str+=String.fromCharCode(u0);continue}var u1=heapOrArray[idx++]&63;if((u0&224)==192){str+=String.fromCharCode((u0&31)<<6|u1);continue}var u2=heapOrArray[idx++]&63;if((u0&240)==224){u0=(u0&15)<<12|u1<<6|u2}else{u0=(u0&7)<<18|u1<<12|u2<<6|heapOrArray[idx++]&63}if(u0<65536){str+=String.fromCharCode(u0)}else{var ch=u0-65536;str+=String.fromCharCode(55296|ch>>10,56320|ch&1023)}}return str}function UTF8ToString(ptr,maxBytesToRead){return ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):""}function ___assert_fail(condition,filename,line,func){abort(`Assertion failed: ${UTF8ToString(condition)}, at: `+[filename?UTF8ToString(filename):"unknown filename",line,func?UTF8ToString(func):"unknown function"])}function ExceptionInfo(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24;this.set_type=function(type){HEAPU32[this.ptr+4>>2]=type};this.get_type=function(){return HEAPU32[this.ptr+4>>2]};this.set_destructor=function(destructor){HEAPU32[this.ptr+8>>2]=destructor};this.get_destructor=function(){return HEAPU32[this.ptr+8>>2]};this.set_caught=function(caught){caught=caught?1:0;HEAP8[this.ptr+12>>0]=caught};this.get_caught=function(){return HEAP8[this.ptr+12>>0]!=0};this.set_rethrown=function(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13>>0]=rethrown};this.get_rethrown=function(){return HEAP8[this.ptr+13>>0]!=0};this.init=function(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)};this.set_adjusted_ptr=function(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr};this.get_adjusted_ptr=function(){return HEAPU32[this.ptr+16>>2]};this.get_exception_ptr=function(){var isPointer=___cxa_is_pointer_type(this.get_type());if(isPointer){return HEAPU32[this.excPtr>>2]}var adjusted=this.get_adjusted_ptr();if(adjusted!==0)return adjusted;return this.excPtr}}var exceptionLast=0;var uncaughtExceptionCount=0;function ___cxa_throw(ptr,type,destructor){var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast}var dlopenMissingError="To use dlopen, you need enable dynamic linking, see https://emscripten.org/docs/compiling/Dynamic-Linking.html";function ___dlsym(handle,symbol){abort(dlopenMissingError)}var PATH={isAbs:path=>path.charAt(0)==="/",splitPath:filename=>{var splitPathRe=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;return splitPathRe.exec(filename).slice(1)},normalizeArray:(parts,allowAboveRoot)=>{var up=0;for(var i=parts.length-1;i>=0;i--){var last=parts[i];if(last==="."){parts.splice(i,1)}else if(last===".."){parts.splice(i,1);up++}else if(up){parts.splice(i,1);up--}}if(allowAboveRoot){for(;up;up--){parts.unshift("..")}}return parts},normalize:path=>{var isAbsolute=PATH.isAbs(path),trailingSlash=path.substr(-1)==="/";path=PATH.normalizeArray(path.split("/").filter(p=>!!p),!isAbsolute).join("/");if(!path&&!isAbsolute){path="."}if(path&&trailingSlash){path+="/"}return(isAbsolute?"/":"")+path},dirname:path=>{var result=PATH.splitPath(path),root=result[0],dir=result[1];if(!root&&!dir){return"."}if(dir){dir=dir.substr(0,dir.length-1)}return root+dir},basename:path=>{if(path==="/")return"/";path=PATH.normalize(path);path=path.replace(/\/$/,"");var lastSlash=path.lastIndexOf("/");if(lastSlash===-1)return path;return path.substr(lastSlash+1)},join:function(){var paths=Array.prototype.slice.call(arguments);return PATH.normalize(paths.join("/"))},join2:(l,r)=>{return PATH.normalize(l+"/"+r)}};function initRandomFill(){if(typeof crypto=="object"&&typeof crypto["getRandomValues"]=="function"){return view=>crypto.getRandomValues(view)}else abort("initRandomDevice")}function randomFill(view){return(randomFill=initRandomFill())(view)}var PATH_FS={resolve:function(){var resolvedPath="",resolvedAbsolute=false;for(var i=arguments.length-1;i>=-1&&!resolvedAbsolute;i--){var path=i>=0?arguments[i]:FS.cwd();if(typeof path!="string"){throw new TypeError("Arguments to path.resolve must be strings")}else if(!path){return""}resolvedPath=path+"/"+resolvedPath;resolvedAbsolute=PATH.isAbs(path)}resolvedPath=PATH.normalizeArray(resolvedPath.split("/").filter(p=>!!p),!resolvedAbsolute).join("/");return(resolvedAbsolute?"/":"")+resolvedPath||"."},relative:(from,to)=>{from=PATH_FS.resolve(from).substr(1);to=PATH_FS.resolve(to).substr(1);function trim(arr){var start=0;for(;start<arr.length;start++){if(arr[start]!=="")break}var end=arr.length-1;for(;end>=0;end--){if(arr[end]!=="")break}if(start>end)return[];return arr.slice(start,end-start+1)}var fromParts=trim(from.split("/"));var toParts=trim(to.split("/"));var length=Math.min(fromParts.length,toParts.length);var samePartsLength=length;for(var i=0;i<length;i++){if(fromParts[i]!==toParts[i]){samePartsLength=i;break}}var outputParts=[];for(var i=samePartsLength;i<fromParts.length;i++){outputParts.push("..")}outputParts=outputParts.concat(toParts.slice(samePartsLength));return outputParts.join("/")}};function lengthBytesUTF8(str){var len=0;for(var i=0;i<str.length;++i){var c=str.charCodeAt(i);if(c<=127){len++}else if(c<=2047){len+=2}else if(c>=55296&&c<=57343){len+=4;++i}else{len+=3}}return len}function stringToUTF8Array(str,heap,outIdx,maxBytesToWrite){if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i<str.length;++i){var u=str.charCodeAt(i);if(u>=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx}function intArrayFromString(stringy,dontAddNull,length){var len=length>0?length:lengthBytesUTF8(stringy)+1;var u8array=new Array(len);var numBytesWritten=stringToUTF8Array(stringy,u8array,0,u8array.length);if(dontAddNull)u8array.length=numBytesWritten;return u8array}var TTY={ttys:[],init:function(){},shutdown:function(){},register:function(dev,ops){TTY.ttys[dev]={input:[],output:[],ops:ops};FS.registerDevice(dev,TTY.stream_ops)},stream_ops:{open:function(stream){var tty=TTY.ttys[stream.node.rdev];if(!tty){throw new FS.ErrnoError(43)}stream.tty=tty;stream.seekable=false},close:function(stream){stream.tty.ops.fsync(stream.tty)},fsync:function(stream){stream.tty.ops.fsync(stream.tty)},read:function(stream,buffer,offset,length,pos){if(!stream.tty||!stream.tty.ops.get_char){throw new FS.ErrnoError(60)}var bytesRead=0;for(var i=0;i<length;i++){var result;try{result=stream.tty.ops.get_char(stream.tty)}catch(e){throw new FS.ErrnoError(29)}if(result===undefined&&bytesRead===0){throw new FS.ErrnoError(6)}if(result===null||result===undefined)break;bytesRead++;buffer[offset+i]=result}if(bytesRead){stream.node.timestamp=Date.now()}return bytesRead},write:function(stream,buffer,offset,length,pos){if(!stream.tty||!stream.tty.ops.put_char){throw new FS.ErrnoError(60)}try{for(var i=0;i<length;i++){stream.tty.ops.put_char(stream.tty,buffer[offset+i])}}catch(e){throw new FS.ErrnoError(29)}if(length){stream.node.timestamp=Date.now()}return i}},default_tty_ops:{get_char:function(tty){if(!tty.input.length){var result=null;if(typeof window!="undefined"&&typeof window.prompt=="function"){result=window.prompt("Input: ");if(result!==null){result+="\n"}}else if(typeof readline=="function"){result=readline();if(result!==null){result+="\n"}}if(!result){return null}tty.input=intArrayFromString(result,true)}return tty.input.shift()},put_char:function(tty,val){if(val===null||val===10){out(UTF8ArrayToString(tty.output,0));tty.output=[]}else{if(val!=0)tty.output.push(val)}},fsync:function(tty){if(tty.output&&tty.output.length>0){out(UTF8ArrayToString(tty.output,0));tty.output=[]}}},default_tty1_ops:{put_char:function(tty,val){if(val===null||val===10){err(UTF8ArrayToString(tty.output,0));tty.output=[]}else{if(val!=0)tty.output.push(val)}},fsync:function(tty){if(tty.output&&tty.output.length>0){err(UTF8ArrayToString(tty.output,0));tty.output=[]}}}};function zeroMemory(address,size){HEAPU8.fill(0,address,address+size);return address}function alignMemory(size,alignment){return Math.ceil(size/alignment)*alignment}function mmapAlloc(size){size=alignMemory(size,65536);var ptr=_emscripten_builtin_memalign(65536,size);if(!ptr)return 0;return zeroMemory(ptr,size)}var MEMFS={ops_table:null,mount:function(mount){return MEMFS.createNode(null,"/",16384|511,0)},createNode:function(parent,name,mode,dev){if(FS.isBlkdev(mode)||FS.isFIFO(mode)){throw new FS.ErrnoError(63)}if(!MEMFS.ops_table){MEMFS.ops_table={dir:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,lookup:MEMFS.node_ops.lookup,mknod:MEMFS.node_ops.mknod,rename:MEMFS.node_ops.rename,unlink:MEMFS.node_ops.unlink,rmdir:MEMFS.node_ops.rmdir,readdir:MEMFS.node_ops.readdir,symlink:MEMFS.node_ops.symlink},stream:{llseek:MEMFS.stream_ops.llseek}},file:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:{llseek:MEMFS.stream_ops.llseek,read:MEMFS.stream_ops.read,write:MEMFS.stream_ops.write,allocate:MEMFS.stream_ops.allocate,mmap:MEMFS.stream_ops.mmap,msync:MEMFS.stream_ops.msync}},link:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,readlink:MEMFS.node_ops.readlink},stream:{}},chrdev:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:FS.chrdev_stream_ops}}}var node=FS.createNode(parent,name,mode,dev);if(FS.isDir(node.mode)){node.node_ops=MEMFS.ops_table.dir.node;node.stream_ops=MEMFS.ops_table.dir.stream;node.contents={}}else if(FS.isFile(node.mode)){node.node_ops=MEMFS.ops_table.file.node;node.stream_ops=MEMFS.ops_table.file.stream;node.usedBytes=0;node.contents=null}else if(FS.isLink(node.mode)){node.node_ops=MEMFS.ops_table.link.node;node.stream_ops=MEMFS.ops_table.link.stream}else if(FS.isChrdev(node.mode)){node.node_ops=MEMFS.ops_table.chrdev.node;node.stream_ops=MEMFS.ops_table.chrdev.stream}node.timestamp=Date.now();if(parent){parent.contents[name]=node;parent.timestamp=node.timestamp}return node},getFileDataAsTypedArray:function(node){if(!node.contents)return new Uint8Array(0);if(node.contents.subarray)return node.contents.subarray(0,node.usedBytes);return new Uint8Array(node.contents)},expandFileStorage:function(node,newCapacity){var prevCapacity=node.contents?node.contents.length:0;if(prevCapacity>=newCapacity)return;var CAPACITY_DOUBLING_MAX=1024*1024;newCapacity=Math.max(newCapacity,prevCapacity*(prevCapacity<CAPACITY_DOUBLING_MAX?2:1.125)>>>0);if(prevCapacity!=0)newCapacity=Math.max(newCapacity,256);var oldContents=node.contents;node.contents=new Uint8Array(newCapacity);if(node.usedBytes>0)node.contents.set(oldContents.subarray(0,node.usedBytes),0)},resizeFileStorage:function(node,newSize){if(node.usedBytes==newSize)return;if(newSize==0){node.contents=null;node.usedBytes=0}else{var oldContents=node.contents;node.contents=new Uint8Array(newSize);if(oldContents){node.contents.set(oldContents.subarray(0,Math.min(newSize,node.usedBytes)))}node.usedBytes=newSize}},node_ops:{getattr:function(node){var attr={};attr.dev=FS.isChrdev(node.mode)?node.id:1;attr.ino=node.id;attr.mode=node.mode;attr.nlink=1;attr.uid=0;attr.gid=0;attr.rdev=node.rdev;if(FS.isDir(node.mode)){attr.size=4096}else if(FS.isFile(node.mode)){attr.size=node.usedBytes}else if(FS.isLink(node.mode)){attr.size=node.link.length}else{attr.size=0}attr.atime=new Date(node.timestamp);attr.mtime=new Date(node.timestamp);attr.ctime=new Date(node.timestamp);attr.blksize=4096;attr.blocks=Math.ceil(attr.size/attr.blksize);return attr},setattr:function(node,attr){if(attr.mode!==undefined){node.mode=attr.mode}if(attr.timestamp!==undefined){node.timestamp=attr.timestamp}if(attr.size!==undefined){MEMFS.resizeFileStorage(node,attr.size)}},lookup:function(parent,name){throw FS.genericErrors[44]},mknod:function(parent,name,mode,dev){return MEMFS.createNode(parent,name,mode,dev)},rename:function(old_node,new_dir,new_name){if(FS.isDir(old_node.mode)){var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(new_node){for(var i in new_node.contents){throw new FS.ErrnoError(55)}}}delete old_node.parent.contents[old_node.name];old_node.parent.timestamp=Date.now();old_node.name=new_name;new_dir.contents[new_name]=old_node;new_dir.timestamp=old_node.parent.timestamp;old_node.parent=new_dir},unlink:function(parent,name){delete parent.contents[name];parent.timestamp=Date.now()},rmdir:function(parent,name){var node=FS.lookupNode(parent,name);for(var i in node.contents){throw new FS.ErrnoError(55)}delete parent.contents[name];parent.timestamp=Date.now()},readdir:function(node){var entries=[".",".."];for(var key in node.contents){if(!node.contents.hasOwnProperty(key)){continue}entries.push(key)}return entries},symlink:function(parent,newname,oldpath){var node=MEMFS.createNode(parent,newname,511|40960,0);node.link=oldpath;return node},readlink:function(node){if(!FS.isLink(node.mode)){throw new FS.ErrnoError(28)}return node.link}},stream_ops:{read:function(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=stream.node.usedBytes)return 0;var size=Math.min(stream.node.usedBytes-position,length);if(size>8&&contents.subarray){buffer.set(contents.subarray(position,position+size),offset)}else{for(var i=0;i<size;i++)buffer[offset+i]=contents[position+i]}return size},write:function(stream,buffer,offset,length,position,canOwn){if(buffer.buffer===HEAP8.buffer){canOwn=false}if(!length)return 0;var node=stream.node;node.timestamp=Date.now();if(buffer.subarray&&(!node.contents||node.contents.subarray)){if(canOwn){node.contents=buffer.subarray(offset,offset+length);node.usedBytes=length;return length}else if(node.usedBytes===0&&position===0){node.contents=buffer.slice(offset,offset+length);node.usedBytes=length;return length}else if(position+length<=node.usedBytes){node.contents.set(buffer.subarray(offset,offset+length),position);return length}}MEMFS.expandFileStorage(node,position+length);if(node.contents.subarray&&buffer.subarray){node.contents.set(buffer.subarray(offset,offset+length),position)}else{for(var i=0;i<length;i++){node.contents[position+i]=buffer[offset+i]}}node.usedBytes=Math.max(node.usedBytes,position+length);return length},llseek:function(stream,offset,whence){var position=offset;if(whence===1){position+=stream.position}else if(whence===2){if(FS.isFile(stream.node.mode)){position+=stream.node.usedBytes}}if(position<0){throw new FS.ErrnoError(28)}return position},allocate:function(stream,offset,length){MEMFS.expandFileStorage(stream.node,offset+length);stream.node.usedBytes=Math.max(stream.node.usedBytes,offset+length)},mmap:function(stream,length,position,prot,flags){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}var ptr;var allocated;var contents=stream.node.contents;if(!(flags&2)&&contents.buffer===HEAP8.buffer){allocated=false;ptr=contents.byteOffset}else{if(position>0||position+length<contents.length){if(contents.subarray){contents=contents.subarray(position,position+length)}else{contents=Array.prototype.slice.call(contents,position,position+length)}}allocated=true;ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}HEAP8.set(contents,ptr)}return{ptr:ptr,allocated:allocated}},msync:function(stream,buffer,offset,length,mmapFlags){MEMFS.stream_ops.write(stream,buffer,0,length,offset,false);return 0}}};function asyncLoad(url,onload,onerror,noRunDep){var dep=!noRunDep?getUniqueRunDependency(`al ${url}`):"";readAsync(url,arrayBuffer=>{assert(arrayBuffer,`Loading data file "${url}" failed (no arrayBuffer).`);onload(new Uint8Array(arrayBuffer));if(dep)removeRunDependency(dep)},event=>{if(onerror){onerror()}else{throw`Loading data file "${url}" failed.`}});if(dep)addRunDependency(dep)}var preloadPlugins=Module["preloadPlugins"]||[];function FS_handledByPreloadPlugin(byteArray,fullname,finish,onerror){if(typeof Browser!="undefined")Browser.init();var handled=false;preloadPlugins.forEach(function(plugin){if(handled)return;if(plugin["canHandle"](fullname)){plugin["handle"](byteArray,fullname,finish,onerror);handled=true}});return handled}function FS_createPreloadedFile(parent,name,url,canRead,canWrite,onload,onerror,dontCreateFile,canOwn,preFinish){var fullname=name?PATH_FS.resolve(PATH.join2(parent,name)):parent;var dep=getUniqueRunDependency(`cp ${fullname}`);function processData(byteArray){function finish(byteArray){if(preFinish)preFinish();if(!dontCreateFile){FS.createDataFile(parent,name,byteArray,canRead,canWrite,canOwn)}if(onload)onload();removeRunDependency(dep)}if(FS_handledByPreloadPlugin(byteArray,fullname,finish,()=>{if(onerror)onerror();removeRunDependency(dep)})){return}finish(byteArray)}addRunDependency(dep);if(typeof url=="string"){asyncLoad(url,byteArray=>processData(byteArray),onerror)}else{processData(url)}}function FS_modeStringToFlags(str){var flagModes={"r":0,"r+":2,"w":512|64|1,"w+":512|64|2,"a":1024|64|1,"a+":1024|64|2};var flags=flagModes[str];if(typeof flags=="undefined"){throw new Error(`Unknown file open mode: ${str}`)}return flags}function FS_getMode(canRead,canWrite){var mode=0;if(canRead)mode|=292|73;if(canWrite)mode|=146;return mode}var WORKERFS={DIR_MODE:16895,FILE_MODE:33279,reader:null,mount:function(mount){assert(ENVIRONMENT_IS_WORKER);if(!WORKERFS.reader)WORKERFS.reader=new FileReaderSync;var root=WORKERFS.createNode(null,"/",WORKERFS.DIR_MODE,0);var createdParents={};function ensureParent(path){var parts=path.split("/");var parent=root;for(var i=0;i<parts.length-1;i++){var curr=parts.slice(0,i+1).join("/");if(!createdParents[curr]){createdParents[curr]=WORKERFS.createNode(parent,parts[i],WORKERFS.DIR_MODE,0)}parent=createdParents[curr]}return parent}function base(path){var parts=path.split("/");return parts[parts.length-1]}Array.prototype.forEach.call(mount.opts["files"]||[],function(file){WORKERFS.createNode(ensureParent(file.name),base(file.name),WORKERFS.FILE_MODE,0,file,file.lastModifiedDate)});(mount.opts["blobs"]||[]).forEach(function(obj){WORKERFS.createNode(ensureParent(obj["name"]),base(obj["name"]),WORKERFS.FILE_MODE,0,obj["data"])});(mount.opts["packages"]||[]).forEach(function(pack){pack["metadata"].files.forEach(function(file){var name=file.filename.substr(1);WORKERFS.createNode(ensureParent(name),base(name),WORKERFS.FILE_MODE,0,pack["blob"].slice(file.start,file.end))})});return root},createNode:function(parent,name,mode,dev,contents,mtime){var node=FS.createNode(parent,name,mode);node.mode=mode;node.node_ops=WORKERFS.node_ops;node.stream_ops=WORKERFS.stream_ops;node.timestamp=(mtime||new Date).getTime();assert(WORKERFS.FILE_MODE!==WORKERFS.DIR_MODE);if(mode===WORKERFS.FILE_MODE){node.size=contents.size;node.contents=contents}else{node.size=4096;node.contents={}}if(parent){parent.contents[name]=node}return node},node_ops:{getattr:function(node){return{dev:1,ino:node.id,mode:node.mode,nlink:1,uid:0,gid:0,rdev:undefined,size:node.size,atime:new Date(node.timestamp),mtime:new Date(node.timestamp),ctime:new Date(node.timestamp),blksize:4096,blocks:Math.ceil(node.size/4096)}},setattr:function(node,attr){if(attr.mode!==undefined){node.mode=attr.mode}if(attr.timestamp!==undefined){node.timestamp=attr.timestamp}},lookup:function(parent,name){throw new FS.ErrnoError(44)},mknod:function(parent,name,mode,dev){throw new FS.ErrnoError(63)},rename:function(oldNode,newDir,newName){throw new FS.ErrnoError(63)},unlink:function(parent,name){throw new FS.ErrnoError(63)},rmdir:function(parent,name){throw new FS.ErrnoError(63)},readdir:function(node){var entries=[".",".."];for(var key in node.contents){if(!node.contents.hasOwnProperty(key)){continue}entries.push(key)}return entries},symlink:function(parent,newName,oldPath){throw new FS.ErrnoError(63)}},stream_ops:{read:function(stream,buffer,offset,length,position){if(position>=stream.node.size)return 0;var chunk=stream.node.contents.slice(position,position+length);var ab=WORKERFS.reader.readAsArrayBuffer(chunk);buffer.set(new Uint8Array(ab),offset);return chunk.size},write:function(stream,buffer,offset,length,position){throw new FS.ErrnoError(29)},llseek:function(stream,offset,whence){var position=offset;if(whence===1){position+=stream.position}else if(whence===2){if(FS.isFile(stream.node.mode)){position+=stream.node.size}}if(position<0){throw new FS.ErrnoError(28)}return position}}};var FS={root:null,mounts:[],devices:{},streams:[],nextInode:1,nameTable:null,currentPath:"/",initialized:false,ignorePermissions:true,ErrnoError:null,genericErrors:{},filesystems:null,syncFSRequests:0,lookupPath:(path,opts={})=>{path=PATH_FS.resolve(path);if(!path)return{path:"",node:null};var defaults={follow_mount:true,recurse_count:0};opts=Object.assign(defaults,opts);if(opts.recurse_count>8){throw new FS.ErrnoError(32)}var parts=path.split("/").filter(p=>!!p);var current=FS.root;var current_path="/";for(var i=0;i<parts.length;i++){var islast=i===parts.length-1;if(islast&&opts.parent){break}current=FS.lookupNode(current,parts[i]);current_path=PATH.join2(current_path,parts[i]);if(FS.isMountpoint(current)){if(!islast||islast&&opts.follow_mount){current=current.mounted.root}}if(!islast||opts.follow){var count=0;while(FS.isLink(current.mode)){var link=FS.readlink(current_path);current_path=PATH_FS.resolve(PATH.dirname(current_path),link);var lookup=FS.lookupPath(current_path,{recurse_count:opts.recurse_count+1});current=lookup.node;if(count++>40){throw new FS.ErrnoError(32)}}}}return{path:current_path,node:current}},getPath:node=>{var path;while(true){if(FS.isRoot(node)){var mount=node.mount.mountpoint;if(!path)return mount;return mount[mount.length-1]!=="/"?`${mount}/${path}`:mount+path}path=path?`${node.name}/${path}`:node.name;node=node.parent}},hashName:(parentid,name)=>{var hash=0;for(var i=0;i<name.length;i++){hash=(hash<<5)-hash+name.charCodeAt(i)|0}return(parentid+hash>>>0)%FS.nameTable.length},hashAddNode:node=>{var hash=FS.hashName(node.parent.id,node.name);node.name_next=FS.nameTable[hash];FS.nameTable[hash]=node},hashRemoveNode:node=>{var hash=FS.hashName(node.parent.id,node.name);if(FS.nameTable[hash]===node){FS.nameTable[hash]=node.name_next}else{var current=FS.nameTable[hash];while(current){if(current.name_next===node){current.name_next=node.name_next;break}current=current.name_next}}},lookupNode:(parent,name)=>{var errCode=FS.mayLookup(parent);if(errCode){throw new FS.ErrnoError(errCode,parent)}var hash=FS.hashName(parent.id,name);for(var node=FS.nameTable[hash];node;node=node.name_next){var nodeName=node.name;if(node.parent.id===parent.id&&nodeName===name){return node}}return FS.lookup(parent,name)},createNode:(parent,name,mode,rdev)=>{var node=new FS.FSNode(parent,name,mode,rdev);FS.hashAddNode(node);return node},destroyNode:node=>{FS.hashRemoveNode(node)},isRoot:node=>{return node===node.parent},isMountpoint:node=>{return!!node.mounted},isFile:mode=>{return(mode&61440)===32768},isDir:mode=>{return(mode&61440)===16384},isLink:mode=>{return(mode&61440)===40960},isChrdev:mode=>{return(mode&61440)===8192},isBlkdev:mode=>{return(mode&61440)===24576},isFIFO:mode=>{return(mode&61440)===4096},isSocket:mode=>{return(mode&49152)===49152},flagsToPermissionString:flag=>{var perms=["r","w","rw"][flag&3];if(flag&512){perms+="w"}return perms},nodePermissions:(node,perms)=>{if(FS.ignorePermissions){return 0}if(perms.includes("r")&&!(node.mode&292)){return 2}else if(perms.includes("w")&&!(node.mode&146)){return 2}else if(perms.includes("x")&&!(node.mode&73)){return 2}return 0},mayLookup:dir=>{var errCode=FS.nodePermissions(dir,"x");if(errCode)return errCode;if(!dir.node_ops.lookup)return 2;return 0},mayCreate:(dir,name)=>{try{var node=FS.lookupNode(dir,name);return 20}catch(e){}return FS.nodePermissions(dir,"wx")},mayDelete:(dir,name,isdir)=>{var node;try{node=FS.lookupNode(dir,name)}catch(e){return e.errno}var errCode=FS.nodePermissions(dir,"wx");if(errCode){return errCode}if(isdir){if(!FS.isDir(node.mode)){return 54}if(FS.isRoot(node)||FS.getPath(node)===FS.cwd()){return 10}}else{if(FS.isDir(node.mode)){return 31}}return 0},mayOpen:(node,flags)=>{if(!node){return 44}if(FS.isLink(node.mode)){return 32}else if(FS.isDir(node.mode)){if(FS.flagsToPermissionString(flags)!=="r"||flags&512){return 31}}return FS.nodePermissions(node,FS.flagsToPermissionString(flags))},MAX_OPEN_FDS:4096,nextfd:()=>{for(var fd=0;fd<=FS.MAX_OPEN_FDS;fd++){if(!FS.streams[fd]){return fd}}throw new FS.ErrnoError(33)},getStream:fd=>FS.streams[fd],createStream:(stream,fd=-1)=>{if(!FS.FSStream){FS.FSStream=function(){this.shared={}};FS.FSStream.prototype={};Object.defineProperties(FS.FSStream.prototype,{object:{get:function(){return this.node},set:function(val){this.node=val}},isRead:{get:function(){return(this.flags&2097155)!==1}},isWrite:{get:function(){return(this.flags&2097155)!==0}},isAppend:{get:function(){return this.flags&1024}},flags:{get:function(){return this.shared.flags},set:function(val){this.shared.flags=val}},position:{get:function(){return this.shared.position},set:function(val){this.shared.position=val}}})}stream=Object.assign(new FS.FSStream,stream);if(fd==-1){fd=FS.nextfd()}stream.fd=fd;FS.streams[fd]=stream;return stream},closeStream:fd=>{FS.streams[fd]=null},chrdev_stream_ops:{open:stream=>{var device=FS.getDevice(stream.node.rdev);stream.stream_ops=device.stream_ops;if(stream.stream_ops.open){stream.stream_ops.open(stream)}},llseek:()=>{throw new FS.ErrnoError(70)}},major:dev=>dev>>8,minor:dev=>dev&255,makedev:(ma,mi)=>ma<<8|mi,registerDevice:(dev,ops)=>{FS.devices[dev]={stream_ops:ops}},getDevice:dev=>FS.devices[dev],getMounts:mount=>{var mounts=[];var check=[mount];while(check.length){var m=check.pop();mounts.push(m);check.push.apply(check,m.mounts)}return mounts},syncfs:(populate,callback)=>{if(typeof populate=="function"){callback=populate;populate=false}FS.syncFSRequests++;if(FS.syncFSRequests>1){err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`)}var mounts=FS.getMounts(FS.root.mount);var completed=0;function doCallback(errCode){FS.syncFSRequests--;return callback(errCode)}function done(errCode){if(errCode){if(!done.errored){done.errored=true;return doCallback(errCode)}return}if(++completed>=mounts.length){doCallback(null)}}mounts.forEach(mount=>{if(!mount.type.syncfs){return done(null)}mount.type.syncfs(mount,populate,done)})},mount:(type,opts,mountpoint)=>{var root=mountpoint==="/";var pseudo=!mountpoint;var node;if(root&&FS.root){throw new FS.ErrnoError(10)}else if(!root&&!pseudo){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});mountpoint=lookup.path;node=lookup.node;if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}if(!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}}var mount={type:type,opts:opts,mountpoint:mountpoint,mounts:[]};var mountRoot=type.mount(mount);mountRoot.mount=mount;mount.root=mountRoot;if(root){FS.root=mountRoot}else if(node){node.mounted=mount;if(node.mount){node.mount.mounts.push(mount)}}return mountRoot},unmount:mountpoint=>{var lookup=FS.lookupPath(mountpoint,{follow_mount:false});if(!FS.isMountpoint(lookup.node)){throw new FS.ErrnoError(28)}var node=lookup.node;var mount=node.mounted;var mounts=FS.getMounts(mount);Object.keys(FS.nameTable).forEach(hash=>{var current=FS.nameTable[hash];while(current){var next=current.name_next;if(mounts.includes(current.mount)){FS.destroyNode(current)}current=next}});node.mounted=null;var idx=node.mount.mounts.indexOf(mount);node.mount.mounts.splice(idx,1)},lookup:(parent,name)=>{return parent.node_ops.lookup(parent,name)},mknod:(path,mode,dev)=>{var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);if(!name||name==="."||name===".."){throw new FS.ErrnoError(28)}var errCode=FS.mayCreate(parent,name);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.mknod){throw new FS.ErrnoError(63)}return parent.node_ops.mknod(parent,name,mode,dev)},create:(path,mode)=>{mode=mode!==undefined?mode:438;mode&=4095;mode|=32768;return FS.mknod(path,mode,0)},mkdir:(path,mode)=>{mode=mode!==undefined?mode:511;mode&=511|512;mode|=16384;return FS.mknod(path,mode,0)},mkdirTree:(path,mode)=>{var dirs=path.split("/");var d="";for(var i=0;i<dirs.length;++i){if(!dirs[i])continue;d+="/"+dirs[i];try{FS.mkdir(d,mode)}catch(e){if(e.errno!=20)throw e}}},mkdev:(path,mode,dev)=>{if(typeof dev=="undefined"){dev=mode;mode=438}mode|=8192;return FS.mknod(path,mode,dev)},symlink:(oldpath,newpath)=>{if(!PATH_FS.resolve(oldpath)){throw new FS.ErrnoError(44)}var lookup=FS.lookupPath(newpath,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var newname=PATH.basename(newpath);var errCode=FS.mayCreate(parent,newname);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.symlink){throw new FS.ErrnoError(63)}return parent.node_ops.symlink(parent,newname,oldpath)},rename:(old_path,new_path)=>{var old_dirname=PATH.dirname(old_path);var new_dirname=PATH.dirname(new_path);var old_name=PATH.basename(old_path);var new_name=PATH.basename(new_path);var lookup,old_dir,new_dir;lookup=FS.lookupPath(old_path,{parent:true});old_dir=lookup.node;lookup=FS.lookupPath(new_path,{parent:true});new_dir=lookup.node;if(!old_dir||!new_dir)throw new FS.ErrnoError(44);if(old_dir.mount!==new_dir.mount){throw new FS.ErrnoError(75)}var old_node=FS.lookupNode(old_dir,old_name);var relative=PATH_FS.relative(old_path,new_dirname);if(relative.charAt(0)!=="."){throw new FS.ErrnoError(28)}relative=PATH_FS.relative(new_path,old_dirname);if(relative.charAt(0)!=="."){throw new FS.ErrnoError(55)}var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(old_node===new_node){return}var isdir=FS.isDir(old_node.mode);var errCode=FS.mayDelete(old_dir,old_name,isdir);if(errCode){throw new FS.ErrnoError(errCode)}errCode=new_node?FS.mayDelete(new_dir,new_name,isdir):FS.mayCreate(new_dir,new_name);if(errCode){throw new FS.ErrnoError(errCode)}if(!old_dir.node_ops.rename){throw new FS.ErrnoError(63)}if(FS.isMountpoint(old_node)||new_node&&FS.isMountpoint(new_node)){throw new FS.ErrnoError(10)}if(new_dir!==old_dir){errCode=FS.nodePermissions(old_dir,"w");if(errCode){throw new FS.ErrnoError(errCode)}}FS.hashRemoveNode(old_node);try{old_dir.node_ops.rename(old_node,new_dir,new_name)}catch(e){throw e}finally{FS.hashAddNode(old_node)}},rmdir:path=>{var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,true);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.rmdir){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.rmdir(parent,name);FS.destroyNode(node)},readdir:path=>{var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;if(!node.node_ops.readdir){throw new FS.ErrnoError(54)}return node.node_ops.readdir(node)},unlink:path=>{var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,false);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.unlink){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.unlink(parent,name);FS.destroyNode(node)},readlink:path=>{var lookup=FS.lookupPath(path);var link=lookup.node;if(!link){throw new FS.ErrnoError(44)}if(!link.node_ops.readlink){throw new FS.ErrnoError(28)}return PATH_FS.resolve(FS.getPath(link.parent),link.node_ops.readlink(link))},stat:(path,dontFollow)=>{var lookup=FS.lookupPath(path,{follow:!dontFollow});var node=lookup.node;if(!node){throw new FS.ErrnoError(44)}if(!node.node_ops.getattr){throw new FS.ErrnoError(63)}return node.node_ops.getattr(node)},lstat:path=>{return FS.stat(path,true)},chmod:(path,mode,dontFollow)=>{var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}if(!node.node_ops.setattr){throw new FS.ErrnoError(63)}node.node_ops.setattr(node,{mode:mode&4095|node.mode&~4095,timestamp:Date.now()})},lchmod:(path,mode)=>{FS.chmod(path,mode,true)},fchmod:(fd,mode)=>{var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}FS.chmod(stream.node,mode)},chown:(path,uid,gid,dontFollow)=>{var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}if(!node.node_ops.setattr){throw new FS.ErrnoError(63)}node.node_ops.setattr(node,{timestamp:Date.now()})},lchown:(path,uid,gid)=>{FS.chown(path,uid,gid,true)},fchown:(fd,uid,gid)=>{var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}FS.chown(stream.node,uid,gid)},truncate:(path,len)=>{if(len<0){throw new FS.ErrnoError(28)}var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:true});node=lookup.node}else{node=path}if(!node.node_ops.setattr){throw new FS.ErrnoError(63)}if(FS.isDir(node.mode)){throw new FS.ErrnoError(31)}if(!FS.isFile(node.mode)){throw new FS.ErrnoError(28)}var errCode=FS.nodePermissions(node,"w");if(errCode){throw new FS.ErrnoError(errCode)}node.node_ops.setattr(node,{size:len,timestamp:Date.now()})},ftruncate:(fd,len)=>{var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(28)}FS.truncate(stream.node,len)},utime:(path,atime,mtime)=>{var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;node.node_ops.setattr(node,{timestamp:Math.max(atime,mtime)})},open:(path,flags,mode)=>{if(path===""){throw new FS.ErrnoError(44)}flags=typeof flags=="string"?FS_modeStringToFlags(flags):flags;mode=typeof mode=="undefined"?438:mode;if(flags&64){mode=mode&4095|32768}else{mode=0}var node;if(typeof path=="object"){node=path}else{path=PATH.normalize(path);try{var lookup=FS.lookupPath(path,{follow:!(flags&131072)});node=lookup.node}catch(e){}}var created=false;if(flags&64){if(node){if(flags&128){throw new FS.ErrnoError(20)}}else{node=FS.mknod(path,mode,0);created=true}}if(!node){throw new FS.ErrnoError(44)}if(FS.isChrdev(node.mode)){flags&=~512}if(flags&65536&&!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}if(!created){var errCode=FS.mayOpen(node,flags);if(errCode){throw new FS.ErrnoError(errCode)}}if(flags&512&&!created){FS.truncate(node,0)}flags&=~(128|512|131072);var stream=FS.createStream({node:node,path:FS.getPath(node),flags:flags,seekable:true,position:0,stream_ops:node.stream_ops,ungotten:[],error:false});if(stream.stream_ops.open){stream.stream_ops.open(stream)}if(Module["logReadFiles"]&&!(flags&1)){if(!FS.readFiles)FS.readFiles={};if(!(path in FS.readFiles)){FS.readFiles[path]=1}}return stream},close:stream=>{if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(stream.getdents)stream.getdents=null;try{if(stream.stream_ops.close){stream.stream_ops.close(stream)}}catch(e){throw e}finally{FS.closeStream(stream.fd)}stream.fd=null},isClosed:stream=>{return stream.fd===null},llseek:(stream,offset,whence)=>{if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(!stream.seekable||!stream.stream_ops.llseek){throw new FS.ErrnoError(70)}if(whence!=0&&whence!=1&&whence!=2){throw new FS.ErrnoError(28)}stream.position=stream.stream_ops.llseek(stream,offset,whence);stream.ungotten=[];return stream.position},read:(stream,buffer,offset,length,position)=>{if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.read){throw new FS.ErrnoError(28)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesRead=stream.stream_ops.read(stream,buffer,offset,length,position);if(!seeking)stream.position+=bytesRead;return bytesRead},write:(stream,buffer,offset,length,position,canOwn)=>{if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.write){throw new FS.ErrnoError(28)}if(stream.seekable&&stream.flags&1024){FS.llseek(stream,0,2)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesWritten=stream.stream_ops.write(stream,buffer,offset,length,position,canOwn);if(!seeking)stream.position+=bytesWritten;return bytesWritten},allocate:(stream,offset,length)=>{if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(offset<0||length<=0){throw new FS.ErrnoError(28)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(!FS.isFile(stream.node.mode)&&!FS.isDir(stream.node.mode)){throw new FS.ErrnoError(43)}if(!stream.stream_ops.allocate){throw new FS.ErrnoError(138)}stream.stream_ops.allocate(stream,offset,length)},mmap:(stream,length,position,prot,flags)=>{if((prot&2)!==0&&(flags&2)===0&&(stream.flags&2097155)!==2){throw new FS.ErrnoError(2)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(2)}if(!stream.stream_ops.mmap){throw new FS.ErrnoError(43)}return stream.stream_ops.mmap(stream,length,position,prot,flags)},msync:(stream,buffer,offset,length,mmapFlags)=>{if(!stream.stream_ops.msync){return 0}return stream.stream_ops.msync(stream,buffer,offset,length,mmapFlags)},munmap:stream=>0,ioctl:(stream,cmd,arg)=>{if(!stream.stream_ops.ioctl){throw new FS.ErrnoError(59)}return stream.stream_ops.ioctl(stream,cmd,arg)},readFile:(path,opts={})=>{opts.flags=opts.flags||0;opts.encoding=opts.encoding||"binary";if(opts.encoding!=="utf8"&&opts.encoding!=="binary"){throw new Error(`Invalid encoding type "${opts.encoding}"`)}var ret;var stream=FS.open(path,opts.flags);var stat=FS.stat(path);var length=stat.size;var buf=new Uint8Array(length);FS.read(stream,buf,0,length,0);if(opts.encoding==="utf8"){ret=UTF8ArrayToString(buf,0)}else if(opts.encoding==="binary"){ret=buf}FS.close(stream);return ret},writeFile:(path,data,opts={})=>{opts.flags=opts.flags||577;var stream=FS.open(path,opts.flags,opts.mode);if(typeof data=="string"){var buf=new Uint8Array(lengthBytesUTF8(data)+1);var actualNumBytes=stringToUTF8Array(data,buf,0,buf.length);FS.write(stream,buf,0,actualNumBytes,undefined,opts.canOwn)}else if(ArrayBuffer.isView(data)){FS.write(stream,data,0,data.byteLength,undefined,opts.canOwn)}else{throw new Error("Unsupported data type")}FS.close(stream)},cwd:()=>FS.currentPath,chdir:path=>{var lookup=FS.lookupPath(path,{follow:true});if(lookup.node===null){throw new FS.ErrnoError(44)}if(!FS.isDir(lookup.node.mode)){throw new FS.ErrnoError(54)}var errCode=FS.nodePermissions(lookup.node,"x");if(errCode){throw new FS.ErrnoError(errCode)}FS.currentPath=lookup.path},createDefaultDirectories:()=>{FS.mkdir("/tmp");FS.mkdir("/home");FS.mkdir("/home/web_user")},createDefaultDevices:()=>{FS.mkdir("/dev");FS.registerDevice(FS.makedev(1,3),{read:()=>0,write:(stream,buffer,offset,length,pos)=>length});FS.mkdev("/dev/null",FS.makedev(1,3));TTY.register(FS.makedev(5,0),TTY.default_tty_ops);TTY.register(FS.makedev(6,0),TTY.default_tty1_ops);FS.mkdev("/dev/tty",FS.makedev(5,0));FS.mkdev("/dev/tty1",FS.makedev(6,0));var randomBuffer=new Uint8Array(1024),randomLeft=0;var randomByte=()=>{if(randomLeft===0){randomLeft=randomFill(randomBuffer).byteLength}return randomBuffer[--randomLeft]};FS.createDevice("/dev","random",randomByte);FS.createDevice("/dev","urandom",randomByte);FS.mkdir("/dev/shm");FS.mkdir("/dev/shm/tmp")},createSpecialDirectories:()=>{FS.mkdir("/proc");var proc_self=FS.mkdir("/proc/self");FS.mkdir("/proc/self/fd");FS.mount({mount:()=>{var node=FS.createNode(proc_self,"fd",16384|511,73);node.node_ops={lookup:(parent,name)=>{var fd=+name;var stream=FS.getStream(fd);if(!stream)throw new FS.ErrnoError(8);var ret={parent:null,mount:{mountpoint:"fake"},node_ops:{readlink:()=>stream.path}};ret.parent=ret;return ret}};return node}},{},"/proc/self/fd")},createStandardStreams:()=>{if(Module["stdin"]){FS.createDevice("/dev","stdin",Module["stdin"])}else{FS.symlink("/dev/tty","/dev/stdin")}if(Module["stdout"]){FS.createDevice("/dev","stdout",null,Module["stdout"])}else{FS.symlink("/dev/tty","/dev/stdout")}if(Module["stderr"]){FS.createDevice("/dev","stderr",null,Module["stderr"])}else{FS.symlink("/dev/tty1","/dev/stderr")}var stdin=FS.open("/dev/stdin",0);var stdout=FS.open("/dev/stdout",1);var stderr=FS.open("/dev/stderr",1)},ensureErrnoError:()=>{if(FS.ErrnoError)return;FS.ErrnoError=function ErrnoError(errno,node){this.name="ErrnoError";this.node=node;this.setErrno=function(errno){this.errno=errno};this.setErrno(errno);this.message="FS error"};FS.ErrnoError.prototype=new Error;FS.ErrnoError.prototype.constructor=FS.ErrnoError;[44].forEach(code=>{FS.genericErrors[code]=new FS.ErrnoError(code);FS.genericErrors[code].stack="<generic error, no stack>"})},staticInit:()=>{FS.ensureErrnoError();FS.nameTable=new Array(4096);FS.mount(MEMFS,{},"/");FS.createDefaultDirectories();FS.createDefaultDevices();FS.createSpecialDirectories();FS.filesystems={"MEMFS":MEMFS,"WORKERFS":WORKERFS}},init:(input,output,error)=>{FS.init.initialized=true;FS.ensureErrnoError();Module["stdin"]=input||Module["stdin"];Module["stdout"]=output||Module["stdout"];Module["stderr"]=error||Module["stderr"];FS.createStandardStreams()},quit:()=>{FS.init.initialized=false;for(var i=0;i<FS.streams.length;i++){var stream=FS.streams[i];if(!stream){continue}FS.close(stream)}},findObject:(path,dontResolveLastLink)=>{var ret=FS.analyzePath(path,dontResolveLastLink);if(!ret.exists){return null}return ret.object},analyzePath:(path,dontResolveLastLink)=>{try{var lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});path=lookup.path}catch(e){}var ret={isRoot:false,exists:false,error:0,name:null,path:null,object:null,parentExists:false,parentPath:null,parentObject:null};try{var lookup=FS.lookupPath(path,{parent:true});ret.parentExists=true;ret.parentPath=lookup.path;ret.parentObject=lookup.node;ret.name=PATH.basename(path);lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});ret.exists=true;ret.path=lookup.path;ret.object=lookup.node;ret.name=lookup.node.name;ret.isRoot=lookup.path==="/"}catch(e){ret.error=e.errno}return ret},createPath:(parent,path,canRead,canWrite)=>{parent=typeof parent=="string"?parent:FS.getPath(parent);var parts=path.split("/").reverse();while(parts.length){var part=parts.pop();if(!part)continue;var current=PATH.join2(parent,part);try{FS.mkdir(current)}catch(e){}parent=current}return current},createFile:(parent,name,properties,canRead,canWrite)=>{var path=PATH.join2(typeof parent=="string"?parent:FS.getPath(parent),name);var mode=FS_getMode(canRead,canWrite);return FS.create(path,mode)},createDataFile:(parent,name,data,canRead,canWrite,canOwn)=>{var path=name;if(parent){parent=typeof parent=="string"?parent:FS.getPath(parent);path=name?PATH.join2(parent,name):parent}var mode=FS_getMode(canRead,canWrite);var node=FS.create(path,mode);if(data){if(typeof data=="string"){var arr=new Array(data.length);for(var i=0,len=data.length;i<len;++i)arr[i]=data.charCodeAt(i);data=arr}FS.chmod(node,mode|146);var stream=FS.open(node,577);FS.write(stream,data,0,data.length,0,canOwn);FS.close(stream);FS.chmod(node,mode)}return node},createDevice:(parent,name,input,output)=>{var path=PATH.join2(typeof parent=="string"?parent:FS.getPath(parent),name);var mode=FS_getMode(!!input,!!output);if(!FS.createDevice.major)FS.createDevice.major=64;var dev=FS.makedev(FS.createDevice.major++,0);FS.registerDevice(dev,{open:stream=>{stream.seekable=false},close:stream=>{if(output&&output.buffer&&output.buffer.length){output(10)}},read:(stream,buffer,offset,length,pos)=>{var bytesRead=0;for(var i=0;i<length;i++){var result;try{result=input()}catch(e){throw new FS.ErrnoError(29)}if(result===undefined&&bytesRead===0){throw new FS.ErrnoError(6)}if(result===null||result===undefined)break;bytesRead++;buffer[offset+i]=result}if(bytesRead){stream.node.timestamp=Date.now()}return bytesRead},write:(stream,buffer,offset,length,pos)=>{for(var i=0;i<length;i++){try{output(buffer[offset+i])}catch(e){throw new FS.ErrnoError(29)}}if(length){stream.node.timestamp=Date.now()}return i}});return FS.mkdev(path,mode,dev)},forceLoadFile:obj=>{if(obj.isDevice||obj.isFolder||obj.link||obj.contents)return true;if(typeof XMLHttpRequest!="undefined"){throw new Error("Lazy loading should have been performed (contents set) in createLazyFile, but it was not. Lazy loading only works in web workers. Use --embed-file or --preload-file in emcc on the main thread.")}else if(read_){try{obj.contents=intArrayFromString(read_(obj.url),true);obj.usedBytes=obj.contents.length}catch(e){throw new FS.ErrnoError(29)}}else{throw new Error("Cannot load without read() or XMLHttpRequest.")}},createLazyFile:(parent,name,url,canRead,canWrite)=>{function LazyUint8Array(){this.lengthKnown=false;this.chunks=[]}LazyUint8Array.prototype.get=function LazyUint8Array_get(idx){if(idx>this.length-1||idx<0){return undefined}var chunkOffset=idx%this.chunkSize;var chunkNum=idx/this.chunkSize|0;return this.getter(chunkNum)[chunkOffset]};LazyUint8Array.prototype.setDataGetter=function LazyUint8Array_setDataGetter(getter){this.getter=getter};LazyUint8Array.prototype.cacheLength=function LazyUint8Array_cacheLength(){var xhr=new XMLHttpRequest;xhr.open("HEAD",url,false);xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error("Couldn't load "+url+". Status: "+xhr.status);var datalength=Number(xhr.getResponseHeader("Content-length"));var header;var hasByteServing=(header=xhr.getResponseHeader("Accept-Ranges"))&&header==="bytes";var usesGzip=(header=xhr.getResponseHeader("Content-Encoding"))&&header==="gzip";var chunkSize=1024*1024;if(!hasByteServing)chunkSize=datalength;var doXHR=(from,to)=>{if(from>to)throw new Error("invalid range ("+from+", "+to+") or no bytes requested!");if(to>datalength-1)throw new Error("only "+datalength+" bytes available! programmer error!");var xhr=new XMLHttpRequest;xhr.open("GET",url,false);if(datalength!==chunkSize)xhr.setRequestHeader("Range","bytes="+from+"-"+to);xhr.responseType="arraybuffer";if(xhr.overrideMimeType){xhr.overrideMimeType("text/plain; charset=x-user-defined")}xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))throw new Error("Couldn't load "+url+". Status: "+xhr.status);if(xhr.response!==undefined){return new Uint8Array(xhr.response||[])}return intArrayFromString(xhr.responseText||"",true)};var lazyArray=this;lazyArray.setDataGetter(chunkNum=>{var start=chunkNum*chunkSize;var end=(chunkNum+1)*chunkSize-1;end=Math.min(end,datalength-1);if(typeof lazyArray.chunks[chunkNum]=="undefined"){lazyArray.chunks[chunkNum]=doXHR(start,end)}if(typeof lazyArray.chunks[chunkNum]=="undefined")throw new Error("doXHR failed!");return lazyArray.chunks[chunkNum]});if(usesGzip||!datalength){chunkSize=datalength=1;datalength=this.getter(0).length;chunkSize=datalength;out("LazyFiles on gzip forces download of the whole file when length is accessed")}this._length=datalength;this._chunkSize=chunkSize;this.lengthKnown=true};if(typeof XMLHttpRequest!="undefined"){if(!ENVIRONMENT_IS_WORKER)throw"Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc";var lazyArray=new LazyUint8Array;Object.defineProperties(lazyArray,{length:{get:function(){if(!this.lengthKnown){this.cacheLength()}return this._length}},chunkSize:{get:function(){if(!this.lengthKnown){this.cacheLength()}return this._chunkSize}}});var properties={isDevice:false,contents:lazyArray}}else{var properties={isDevice:false,url:url}}var node=FS.createFile(parent,name,properties,canRead,canWrite);if(properties.contents){node.contents=properties.contents}else if(properties.url){node.contents=null;node.url=properties.url}Object.defineProperties(node,{usedBytes:{get:function(){return this.contents.length}}});var stream_ops={};var keys=Object.keys(node.stream_ops);keys.forEach(key=>{var fn=node.stream_ops[key];stream_ops[key]=function forceLoadLazyFile(){FS.forceLoadFile(node);return fn.apply(null,arguments)}});function writeChunks(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=contents.length)return 0;var size=Math.min(contents.length-position,length);if(contents.slice){for(var i=0;i<size;i++){buffer[offset+i]=contents[position+i]}}else{for(var i=0;i<size;i++){buffer[offset+i]=contents.get(position+i)}}return size}stream_ops.read=(stream,buffer,offset,length,position)=>{FS.forceLoadFile(node);return writeChunks(stream,buffer,offset,length,position)};stream_ops.mmap=(stream,length,position,prot,flags)=>{FS.forceLoadFile(node);var ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}writeChunks(stream,HEAP8,ptr,length,position);return{ptr:ptr,allocated:true}};node.stream_ops=stream_ops;return node}};var SYSCALLS={DEFAULT_POLLMASK:5,calculateAt:function(dirfd,path,allowEmpty){if(PATH.isAbs(path)){return path}var dir;if(dirfd===-100){dir=FS.cwd()}else{var dirstream=SYSCALLS.getStreamFromFD(dirfd);dir=dirstream.path}if(path.length==0){if(!allowEmpty){throw new FS.ErrnoError(44)}return dir}return PATH.join2(dir,path)},doStat:function(func,path,buf){try{var stat=func(path)}catch(e){if(e&&e.node&&PATH.normalize(path)!==PATH.normalize(FS.getPath(e.node))){return-54}throw e}HEAP32[buf>>2]=stat.dev;HEAP32[buf+8>>2]=stat.ino;HEAP32[buf+12>>2]=stat.mode;HEAPU32[buf+16>>2]=stat.nlink;HEAP32[buf+20>>2]=stat.uid;HEAP32[buf+24>>2]=stat.gid;HEAP32[buf+28>>2]=stat.rdev;HEAP64[buf+40>>3]=BigInt(stat.size);HEAP32[buf+48>>2]=4096;HEAP32[buf+52>>2]=stat.blocks;var atime=stat.atime.getTime();var mtime=stat.mtime.getTime();var ctime=stat.ctime.getTime();HEAP64[buf+56>>3]=BigInt(Math.floor(atime/1e3));HEAPU32[buf+64>>2]=atime%1e3*1e3;HEAP64[buf+72>>3]=BigInt(Math.floor(mtime/1e3));HEAPU32[buf+80>>2]=mtime%1e3*1e3;HEAP64[buf+88>>3]=BigInt(Math.floor(ctime/1e3));HEAPU32[buf+96>>2]=ctime%1e3*1e3;HEAP64[buf+104>>3]=BigInt(stat.ino);return 0},doMsync:function(addr,stream,len,flags,offset){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}if(flags&2){return 0}var buffer=HEAPU8.slice(addr,addr+len);FS.msync(stream,buffer,offset,len,flags)},varargs:undefined,get:function(){SYSCALLS.varargs+=4;var ret=HEAP32[SYSCALLS.varargs-4>>2];return ret},getStr:function(ptr){var ret=UTF8ToString(ptr);return ret},getStreamFromFD:function(fd){var stream=FS.getStream(fd);if(!stream)throw new FS.ErrnoError(8);return stream}};function ___syscall__newselect(nfds,readfds,writefds,exceptfds,timeout){try{var total=0;var srcReadLow=readfds?HEAP32[readfds>>2]:0,srcReadHigh=readfds?HEAP32[readfds+4>>2]:0;var srcWriteLow=writefds?HEAP32[writefds>>2]:0,srcWriteHigh=writefds?HEAP32[writefds+4>>2]:0;var srcExceptLow=exceptfds?HEAP32[exceptfds>>2]:0,srcExceptHigh=exceptfds?HEAP32[exceptfds+4>>2]:0;var dstReadLow=0,dstReadHigh=0;var dstWriteLow=0,dstWriteHigh=0;var dstExceptLow=0,dstExceptHigh=0;var allLow=(readfds?HEAP32[readfds>>2]:0)|(writefds?HEAP32[writefds>>2]:0)|(exceptfds?HEAP32[exceptfds>>2]:0);var allHigh=(readfds?HEAP32[readfds+4>>2]:0)|(writefds?HEAP32[writefds+4>>2]:0)|(exceptfds?HEAP32[exceptfds+4>>2]:0);var check=function(fd,low,high,val){return fd<32?low&val:high&val};for(var fd=0;fd<nfds;fd++){var mask=1<<fd%32;if(!check(fd,allLow,allHigh,mask)){continue}var stream=SYSCALLS.getStreamFromFD(fd);var flags=SYSCALLS.DEFAULT_POLLMASK;if(stream.stream_ops.poll){flags=stream.stream_ops.poll(stream)}if(flags&1&&check(fd,srcReadLow,srcReadHigh,mask)){fd<32?dstReadLow=dstReadLow|mask:dstReadHigh=dstReadHigh|mask;total++}if(flags&4&&check(fd,srcWriteLow,srcWriteHigh,mask)){fd<32?dstWriteLow=dstWriteLow|mask:dstWriteHigh=dstWriteHigh|mask;total++}if(flags&2&&check(fd,srcExceptLow,srcExceptHigh,mask)){fd<32?dstExceptLow=dstExceptLow|mask:dstExceptHigh=dstExceptHigh|mask;total++}}if(readfds){HEAP32[readfds>>2]=dstReadLow;HEAP32[readfds+4>>2]=dstReadHigh}if(writefds){HEAP32[writefds>>2]=dstWriteLow;HEAP32[writefds+4>>2]=dstWriteHigh}if(exceptfds){HEAP32[exceptfds>>2]=dstExceptLow;HEAP32[exceptfds+4>>2]=dstExceptHigh}return total}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var SOCKFS={mount:function(mount){Module["websocket"]=Module["websocket"]&&"object"===typeof Module["websocket"]?Module["websocket"]:{};Module["websocket"]._callbacks={};Module["websocket"]["on"]=function(event,callback){if("function"===typeof callback){this._callbacks[event]=callback}return this};Module["websocket"].emit=function(event,param){if("function"===typeof this._callbacks[event]){this._callbacks[event].call(this,param)}};return FS.createNode(null,"/",16384|511,0)},createSocket:function(family,type,protocol){type&=~526336;var streaming=type==1;if(streaming&&protocol&&protocol!=6){throw new FS.ErrnoError(66)}var sock={family:family,type:type,protocol:protocol,server:null,error:null,peers:{},pending:[],recv_queue:[],sock_ops:SOCKFS.websocket_sock_ops};var name=SOCKFS.nextname();var node=FS.createNode(SOCKFS.root,name,49152,0);node.sock=sock;var stream=FS.createStream({path:name,node:node,flags:2,seekable:false,stream_ops:SOCKFS.stream_ops});sock.stream=stream;return sock},getSocket:function(fd){var stream=FS.getStream(fd);if(!stream||!FS.isSocket(stream.node.mode)){return null}return stream.node.sock},stream_ops:{poll:function(stream){var sock=stream.node.sock;return sock.sock_ops.poll(sock)},ioctl:function(stream,request,varargs){var sock=stream.node.sock;return sock.sock_ops.ioctl(sock,request,varargs)},read:function(stream,buffer,offset,length,position){var sock=stream.node.sock;var msg=sock.sock_ops.recvmsg(sock,length);if(!msg){return 0}buffer.set(msg.buffer,offset);return msg.buffer.length},write:function(stream,buffer,offset,length,position){var sock=stream.node.sock;return sock.sock_ops.sendmsg(sock,buffer,offset,length)},close:function(stream){var sock=stream.node.sock;sock.sock_ops.close(sock)}},nextname:function(){if(!SOCKFS.nextname.current){SOCKFS.nextname.current=0}return"socket["+SOCKFS.nextname.current+++"]"},websocket_sock_ops:{createPeer:function(sock,addr,port){var ws;if(typeof addr=="object"){ws=addr;addr=null;port=null}if(ws){if(ws._socket){addr=ws._socket.remoteAddress;port=ws._socket.remotePort}else{var result=/ws[s]?:\/\/([^:]+):(\d+)/.exec(ws.url);if(!result){throw new Error("WebSocket URL must be in the format ws(s)://address:port")}addr=result[1];port=parseInt(result[2],10)}}else{try{var runtimeConfig=Module["websocket"]&&"object"===typeof Module["websocket"];var url="ws:#".replace("#","//");if(runtimeConfig){if("string"===typeof Module["websocket"]["url"]){url=Module["websocket"]["url"]}}if(url==="ws://"||url==="wss://"){var parts=addr.split("/");url=url+parts[0]+":"+port+"/"+parts.slice(1).join("/")}var subProtocols="binary";if(runtimeConfig){if("string"===typeof Module["websocket"]["subprotocol"]){subProtocols=Module["websocket"]["subprotocol"]}}var opts=undefined;if(subProtocols!=="null"){subProtocols=subProtocols.replace(/^ +| +$/g,"").split(/ *, */);opts=subProtocols}if(runtimeConfig&&null===Module["websocket"]["subprotocol"]){subProtocols="null";opts=undefined}var WebSocketConstructor;{WebSocketConstructor=WebSocket}ws=new WebSocketConstructor(url,opts);ws.binaryType="arraybuffer"}catch(e){throw new FS.ErrnoError(23)}}var peer={addr:addr,port:port,socket:ws,dgram_send_queue:[]};SOCKFS.websocket_sock_ops.addPeer(sock,peer);SOCKFS.websocket_sock_ops.handlePeerEvents(sock,peer);if(sock.type===2&&typeof sock.sport!="undefined"){peer.dgram_send_queue.push(new Uint8Array([255,255,255,255,"p".charCodeAt(0),"o".charCodeAt(0),"r".charCodeAt(0),"t".charCodeAt(0),(sock.sport&65280)>>8,sock.sport&255]))}return peer},getPeer:function(sock,addr,port){return sock.peers[addr+":"+port]},addPeer:function(sock,peer){sock.peers[peer.addr+":"+peer.port]=peer},removePeer:function(sock,peer){delete sock.peers[peer.addr+":"+peer.port]},handlePeerEvents:function(sock,peer){var first=true;var handleOpen=function(){Module["websocket"].emit("open",sock.stream.fd);try{var queued=peer.dgram_send_queue.shift();while(queued){peer.socket.send(queued);queued=peer.dgram_send_queue.shift()}}catch(e){peer.socket.close()}};function handleMessage(data){if(typeof data=="string"){var encoder=new TextEncoder;data=encoder.encode(data)}else{assert(data.byteLength!==undefined);if(data.byteLength==0){return}data=new Uint8Array(data)}var wasfirst=first;first=false;if(wasfirst&&data.length===10&&data[0]===255&&data[1]===255&&data[2]===255&&data[3]===255&&data[4]==="p".charCodeAt(0)&&data[5]==="o".charCodeAt(0)&&data[6]==="r".charCodeAt(0)&&data[7]==="t".charCodeAt(0)){var newport=data[8]<<8|data[9];SOCKFS.websocket_sock_ops.removePeer(sock,peer);peer.port=newport;SOCKFS.websocket_sock_ops.addPeer(sock,peer);return}sock.recv_queue.push({addr:peer.addr,port:peer.port,data:data});Module["websocket"].emit("message",sock.stream.fd)}if(ENVIRONMENT_IS_NODE){peer.socket.on("open",handleOpen);peer.socket.on("message",function(data,isBinary){if(!isBinary){return}handleMessage(new Uint8Array(data).buffer)});peer.socket.on("close",function(){Module["websocket"].emit("close",sock.stream.fd)});peer.socket.on("error",function(error){sock.error=14;Module["websocket"].emit("error",[sock.stream.fd,sock.error,"ECONNREFUSED: Connection refused"])})}else{peer.socket.onopen=handleOpen;peer.socket.onclose=function(){Module["websocket"].emit("close",sock.stream.fd)};peer.socket.onmessage=function peer_socket_onmessage(event){handleMessage(event.data)};peer.socket.onerror=function(error){sock.error=14;Module["websocket"].emit("error",[sock.stream.fd,sock.error,"ECONNREFUSED: Connection refused"])}}},poll:function(sock){if(sock.type===1&&sock.server){return sock.pending.length?64|1:0}var mask=0;var dest=sock.type===1?SOCKFS.websocket_sock_ops.getPeer(sock,sock.daddr,sock.dport):null;if(sock.recv_queue.length||!dest||dest&&dest.socket.readyState===dest.socket.CLOSING||dest&&dest.socket.readyState===dest.socket.CLOSED){mask|=64|1}if(!dest||dest&&dest.socket.readyState===dest.socket.OPEN){mask|=4}if(dest&&dest.socket.readyState===dest.socket.CLOSING||dest&&dest.socket.readyState===dest.socket.CLOSED){mask|=16}return mask},ioctl:function(sock,request,arg){switch(request){case 21531:var bytes=0;if(sock.recv_queue.length){bytes=sock.recv_queue[0].data.length}HEAP32[arg>>2]=bytes;return 0;default:return 28}},close:function(sock){if(sock.server){try{sock.server.close()}catch(e){}sock.server=null}var peers=Object.keys(sock.peers);for(var i=0;i<peers.length;i++){var peer=sock.peers[peers[i]];try{peer.socket.close()}catch(e){}SOCKFS.websocket_sock_ops.removePeer(sock,peer)}return 0},bind:function(sock,addr,port){if(typeof sock.saddr!="undefined"||typeof sock.sport!="undefined"){throw new FS.ErrnoError(28)}sock.saddr=addr;sock.sport=port;if(sock.type===2){if(sock.server){sock.server.close();sock.server=null}try{sock.sock_ops.listen(sock,0)}catch(e){if(!(e.name==="ErrnoError"))throw e;if(e.errno!==138)throw e}}},connect:function(sock,addr,port){if(sock.server){throw new FS.ErrnoError(138)}if(typeof sock.daddr!="undefined"&&typeof sock.dport!="undefined"){var dest=SOCKFS.websocket_sock_ops.getPeer(sock,sock.daddr,sock.dport);if(dest){if(dest.socket.readyState===dest.socket.CONNECTING){throw new FS.ErrnoError(7)}else{throw new FS.ErrnoError(30)}}}var peer=SOCKFS.websocket_sock_ops.createPeer(sock,addr,port);sock.daddr=peer.addr;sock.dport=peer.port;throw new FS.ErrnoError(26)},listen:function(sock,backlog){if(!ENVIRONMENT_IS_NODE){throw new FS.ErrnoError(138)}},accept:function(listensock){if(!listensock.server||!listensock.pending.length){throw new FS.ErrnoError(28)}var newsock=listensock.pending.shift();newsock.stream.flags=listensock.stream.flags;return newsock},getname:function(sock,peer){var addr,port;if(peer){if(sock.daddr===undefined||sock.dport===undefined){throw new FS.ErrnoError(53)}addr=sock.daddr;port=sock.dport}else{addr=sock.saddr||0;port=sock.sport||0}return{addr:addr,port:port}},sendmsg:function(sock,buffer,offset,length,addr,port){if(sock.type===2){if(addr===undefined||port===undefined){addr=sock.daddr;port=sock.dport}if(addr===undefined||port===undefined){throw new FS.ErrnoError(17)}}else{addr=sock.daddr;port=sock.dport}var dest=SOCKFS.websocket_sock_ops.getPeer(sock,addr,port);if(sock.type===1){if(!dest||dest.socket.readyState===dest.socket.CLOSING||dest.socket.readyState===dest.socket.CLOSED){throw new FS.ErrnoError(53)}else if(dest.socket.readyState===dest.socket.CONNECTING){throw new FS.ErrnoError(6)}}if(ArrayBuffer.isView(buffer)){offset+=buffer.byteOffset;buffer=buffer.buffer}var data;data=buffer.slice(offset,offset+length);if(sock.type===2){if(!dest||dest.socket.readyState!==dest.socket.OPEN){if(!dest||dest.socket.readyState===dest.socket.CLOSING||dest.socket.readyState===dest.socket.CLOSED){dest=SOCKFS.websocket_sock_ops.createPeer(sock,addr,port)}dest.dgram_send_queue.push(data);return length}}try{dest.socket.send(data);return length}catch(e){throw new FS.ErrnoError(28)}},recvmsg:function(sock,length){if(sock.type===1&&sock.server){throw new FS.ErrnoError(53)}var queued=sock.recv_queue.shift();if(!queued){if(sock.type===1){var dest=SOCKFS.websocket_sock_ops.getPeer(sock,sock.daddr,sock.dport);if(!dest){throw new FS.ErrnoError(53)}if(dest.socket.readyState===dest.socket.CLOSING||dest.socket.readyState===dest.socket.CLOSED){return null}throw new FS.ErrnoError(6)}throw new FS.ErrnoError(6)}var queuedLength=queued.data.byteLength||queued.data.length;var queuedOffset=queued.data.byteOffset||0;var queuedBuffer=queued.data.buffer||queued.data;var bytesRead=Math.min(length,queuedLength);var res={buffer:new Uint8Array(queuedBuffer,queuedOffset,bytesRead),addr:queued.addr,port:queued.port};if(sock.type===1&&bytesRead<queuedLength){var bytesRemaining=queuedLength-bytesRead;queued.data=new Uint8Array(queuedBuffer,queuedOffset+bytesRead,bytesRemaining);sock.recv_queue.unshift(queued)}return res}}};function getSocketFromFD(fd){var socket=SOCKFS.getSocket(fd);if(!socket)throw new FS.ErrnoError(8);return socket}function setErrNo(value){HEAP32[___errno_location()>>2]=value;return value}function inetPton4(str){var b=str.split(".");for(var i=0;i<4;i++){var tmp=Number(b[i]);if(isNaN(tmp))return null;b[i]=tmp}return(b[0]|b[1]<<8|b[2]<<16|b[3]<<24)>>>0}function jstoi_q(str){return parseInt(str)}function inetPton6(str){var words;var w,offset,z;var valid6regx=/^((?=.*::)(?!.*::.+::)(::)?([\dA-F]{1,4}:(:|\b)|){5}|([\dA-F]{1,4}:){6})((([\dA-F]{1,4}((?!\3)::|:\b|$))|(?!\2\3)){2}|(((2[0-4]|1\d|[1-9])?\d|25[0-5])\.?\b){4})$/i;var parts=[];if(!valid6regx.test(str)){return null}if(str==="::"){return[0,0,0,0,0,0,0,0]}if(str.startsWith("::")){str=str.replace("::","Z:")}else{str=str.replace("::",":Z:")}if(str.indexOf(".")>0){str=str.replace(new RegExp("[.]","g"),":");words=str.split(":");words[words.length-4]=jstoi_q(words[words.length-4])+jstoi_q(words[words.length-3])*256;words[words.length-3]=jstoi_q(words[words.length-2])+jstoi_q(words[words.length-1])*256;words=words.slice(0,words.length-2)}else{words=str.split(":")}offset=0;z=0;for(w=0;w<words.length;w++){if(typeof words[w]=="string"){if(words[w]==="Z"){for(z=0;z<8-words.length+1;z++){parts[w+z]=0}offset=z-1}else{parts[w+offset]=_htons(parseInt(words[w],16))}}else{parts[w+offset]=words[w]}}return[parts[1]<<16|parts[0],parts[3]<<16|parts[2],parts[5]<<16|parts[4],parts[7]<<16|parts[6]]}function writeSockaddr(sa,family,addr,port,addrlen){switch(family){case 2:addr=inetPton4(addr);zeroMemory(sa,16);if(addrlen){HEAP32[addrlen>>2]=16}HEAP16[sa>>1]=family;HEAP32[sa+4>>2]=addr;HEAP16[sa+2>>1]=_htons(port);break;case 10:addr=inetPton6(addr);zeroMemory(sa,28);if(addrlen){HEAP32[addrlen>>2]=28}HEAP32[sa>>2]=family;HEAP32[sa+8>>2]=addr[0];HEAP32[sa+12>>2]=addr[1];HEAP32[sa+16>>2]=addr[2];HEAP32[sa+20>>2]=addr[3];HEAP16[sa+2>>1]=_htons(port);break;default:return 5}return 0}var DNS={address_map:{id:1,addrs:{},names:{}},lookup_name:function(name){var res=inetPton4(name);if(res!==null){return name}res=inetPton6(name);if(res!==null){return name}var addr;if(DNS.address_map.addrs[name]){addr=DNS.address_map.addrs[name]}else{var id=DNS.address_map.id++;assert(id<65535,"exceeded max address mappings of 65535");addr="172.29."+(id&255)+"."+(id&65280);DNS.address_map.names[addr]=name;DNS.address_map.addrs[name]=addr}return addr},lookup_addr:function(addr){if(DNS.address_map.names[addr]){return DNS.address_map.names[addr]}return null}};function ___syscall_accept4(fd,addr,addrlen,flags,d1,d2){try{var sock=getSocketFromFD(fd);var newsock=sock.sock_ops.accept(sock);if(addr){var errno=writeSockaddr(addr,newsock.family,DNS.lookup_name(newsock.daddr),newsock.dport,addrlen)}return newsock.stream.fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function inetNtop4(addr){return(addr&255)+"."+(addr>>8&255)+"."+(addr>>16&255)+"."+(addr>>24&255)}function inetNtop6(ints){var str="";var word=0;var longest=0;var lastzero=0;var zstart=0;var len=0;var i=0;var parts=[ints[0]&65535,ints[0]>>16,ints[1]&65535,ints[1]>>16,ints[2]&65535,ints[2]>>16,ints[3]&65535,ints[3]>>16];var hasipv4=true;var v4part="";for(i=0;i<5;i++){if(parts[i]!==0){hasipv4=false;break}}if(hasipv4){v4part=inetNtop4(parts[6]|parts[7]<<16);if(parts[5]===-1){str="::ffff:";str+=v4part;return str}if(parts[5]===0){str="::";if(v4part==="0.0.0.0")v4part="";if(v4part==="0.0.0.1")v4part="1";str+=v4part;return str}}for(word=0;word<8;word++){if(parts[word]===0){if(word-lastzero>1){len=0}lastzero=word;len++}if(len>longest){longest=len;zstart=word-longest+1}}for(word=0;word<8;word++){if(longest>1){if(parts[word]===0&&word>=zstart&&word<zstart+longest){if(word===zstart){str+=":";if(zstart===0)str+=":"}continue}}str+=Number(_ntohs(parts[word]&65535)).toString(16);str+=word<7?":":""}return str}function readSockaddr(sa,salen){var family=HEAP16[sa>>1];var port=_ntohs(HEAPU16[sa+2>>1]);var addr;switch(family){case 2:if(salen!==16){return{errno:28}}addr=HEAP32[sa+4>>2];addr=inetNtop4(addr);break;case 10:if(salen!==28){return{errno:28}}addr=[HEAP32[sa+8>>2],HEAP32[sa+12>>2],HEAP32[sa+16>>2],HEAP32[sa+20>>2]];addr=inetNtop6(addr);break;default:return{errno:5}}return{family:family,addr:addr,port:port}}function getSocketAddress(addrp,addrlen,allowNull){if(allowNull&&addrp===0)return null;var info=readSockaddr(addrp,addrlen);if(info.errno)throw new FS.ErrnoError(info.errno);info.addr=DNS.lookup_addr(info.addr)||info.addr;return info}function ___syscall_bind(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);var info=getSocketAddress(addr,addrlen);sock.sock_ops.bind(sock,info.addr,info.port);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_connect(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);var info=getSocketAddress(addr,addrlen);sock.sock_ops.connect(sock,info.addr,info.port);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_faccessat(dirfd,path,amode,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(amode&~7){return-28}var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;if(!node){return-44}var perms="";if(amode&4)perms+="r";if(amode&2)perms+="w";if(amode&1)perms+="x";if(perms&&FS.nodePermissions(node,perms)){return-2}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_fcntl64(fd,cmd,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(cmd){case 0:{var arg=SYSCALLS.get();if(arg<0){return-28}var newStream;newStream=FS.createStream(stream,arg);return newStream.fd}case 1:case 2:return 0;case 3:return stream.flags;case 4:{var arg=SYSCALLS.get();stream.flags|=arg;return 0}case 5:{var arg=SYSCALLS.get();var offset=0;HEAP16[arg+offset>>1]=2;return 0}case 6:case 7:return 0;case 16:case 8:return-28;case 9:setErrNo(28);return-1;default:{return-28}}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_fstat64(fd,buf){try{var stream=SYSCALLS.getStreamFromFD(fd);return SYSCALLS.doStat(FS.stat,stream.path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function stringToUTF8(str,outPtr,maxBytesToWrite){return stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite)}function ___syscall_getdents64(fd,dirp,count){try{var stream=SYSCALLS.getStreamFromFD(fd);if(!stream.getdents){stream.getdents=FS.readdir(stream.path)}var struct_size=280;var pos=0;var off=FS.llseek(stream,0,1);var idx=Math.floor(off/struct_size);while(idx<stream.getdents.length&&pos+struct_size<=count){var id;var type;var name=stream.getdents[idx];if(name==="."){id=stream.node.id;type=4}else if(name===".."){var lookup=FS.lookupPath(stream.path,{parent:true});id=lookup.node.id;type=4}else{var child=FS.lookupNode(stream.node,name);id=child.id;type=FS.isChrdev(child.mode)?2:FS.isDir(child.mode)?4:FS.isLink(child.mode)?10:8}HEAP64[dirp+pos>>3]=BigInt(id);HEAP64[dirp+pos+8>>3]=BigInt((idx+1)*struct_size);HEAP16[dirp+pos+16>>1]=280;HEAP8[dirp+pos+18>>0]=type;stringToUTF8(name,dirp+pos+19,256);pos+=struct_size;idx+=1}FS.llseek(stream,idx*struct_size,0);return pos}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_getpeername(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);if(!sock.daddr){return-53}var errno=writeSockaddr(addr,sock.family,DNS.lookup_name(sock.daddr),sock.dport,addrlen);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_getsockname(fd,addr,addrlen,d1,d2,d3){try{var sock=getSocketFromFD(fd);var errno=writeSockaddr(addr,sock.family,DNS.lookup_name(sock.saddr||"0.0.0.0"),sock.sport,addrlen);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_getsockopt(fd,level,optname,optval,optlen,d1){try{var sock=getSocketFromFD(fd);if(level===1){if(optname===4){HEAP32[optval>>2]=sock.error;HEAP32[optlen>>2]=4;sock.error=null;return 0}}return-50}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_ioctl(fd,op,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(op){case 21509:case 21505:{if(!stream.tty)return-59;return 0}case 21510:case 21511:case 21512:case 21506:case 21507:case 21508:{if(!stream.tty)return-59;return 0}case 21519:{if(!stream.tty)return-59;var argp=SYSCALLS.get();HEAP32[argp>>2]=0;return 0}case 21520:{if(!stream.tty)return-59;return-28}case 21531:{var argp=SYSCALLS.get();return FS.ioctl(stream,op,argp)}case 21523:{if(!stream.tty)return-59;return 0}case 21524:{if(!stream.tty)return-59;return 0}default:return-28}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_listen(fd,backlog){try{var sock=getSocketFromFD(fd);sock.sock_ops.listen(sock,backlog);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_lstat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.lstat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_mkdirat(dirfd,path,mode){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);path=PATH.normalize(path);if(path[path.length-1]==="/")path=path.substr(0,path.length-1);FS.mkdir(path,mode,0);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_newfstatat(dirfd,path,buf,flags){try{path=SYSCALLS.getStr(path);var nofollow=flags&256;var allowEmpty=flags&4096;flags=flags&~6400;path=SYSCALLS.calculateAt(dirfd,path,allowEmpty);return SYSCALLS.doStat(nofollow?FS.lstat:FS.stat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_openat(dirfd,path,flags,varargs){SYSCALLS.varargs=varargs;try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);var mode=varargs?SYSCALLS.get():0;return FS.open(path,flags,mode).fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_poll(fds,nfds,timeout){try{var nonzero=0;for(var i=0;i<nfds;i++){var pollfd=fds+8*i;var fd=HEAP32[pollfd>>2];var events=HEAP16[pollfd+4>>1];var mask=32;var stream=FS.getStream(fd);if(stream){mask=SYSCALLS.DEFAULT_POLLMASK;if(stream.stream_ops.poll){mask=stream.stream_ops.poll(stream)}}mask&=events|8|16;if(mask)nonzero++;HEAP16[pollfd+6>>1]=mask}return nonzero}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_recvfrom(fd,buf,len,flags,addr,addrlen){try{var sock=getSocketFromFD(fd);var msg=sock.sock_ops.recvmsg(sock,len);if(!msg)return 0;if(addr){var errno=writeSockaddr(addr,sock.family,DNS.lookup_name(msg.addr),msg.port,addrlen)}HEAPU8.set(msg.buffer,buf);return msg.buffer.byteLength}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_renameat(olddirfd,oldpath,newdirfd,newpath){try{oldpath=SYSCALLS.getStr(oldpath);newpath=SYSCALLS.getStr(newpath);oldpath=SYSCALLS.calculateAt(olddirfd,oldpath);newpath=SYSCALLS.calculateAt(newdirfd,newpath);FS.rename(oldpath,newpath);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_rmdir(path){try{path=SYSCALLS.getStr(path);FS.rmdir(path);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_sendto(fd,message,length,flags,addr,addr_len){try{var sock=getSocketFromFD(fd);var dest=getSocketAddress(addr,addr_len,true);if(!dest){return FS.write(sock.stream,HEAP8,message,length)}return sock.sock_ops.sendmsg(sock,HEAP8,message,length,dest.addr,dest.port)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_socket(domain,type,protocol){try{var sock=SOCKFS.createSocket(domain,type,protocol);return sock.stream.fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_stat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.doStat(FS.stat,path,buf)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_unlinkat(dirfd,path,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(flags===0){FS.unlink(path)}else if(flags===512){FS.rmdir(path)}else{abort("Invalid flags passed to unlinkat")}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var nowIsMonotonic=true;function __emscripten_get_now_is_monotonic(){return nowIsMonotonic}function __emscripten_throw_longjmp(){throw Infinity}function readI53FromI64(ptr){return HEAPU32[ptr>>2]+HEAP32[ptr+4>>2]*4294967296}function __gmtime_js(time,tmPtr){var date=new Date(readI53FromI64(time)*1e3);HEAP32[tmPtr>>2]=date.getUTCSeconds();HEAP32[tmPtr+4>>2]=date.getUTCMinutes();HEAP32[tmPtr+8>>2]=date.getUTCHours();HEAP32[tmPtr+12>>2]=date.getUTCDate();HEAP32[tmPtr+16>>2]=date.getUTCMonth();HEAP32[tmPtr+20>>2]=date.getUTCFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getUTCDay();var start=Date.UTC(date.getUTCFullYear(),0,1,0,0,0,0);var yday=(date.getTime()-start)/(1e3*60*60*24)|0;HEAP32[tmPtr+28>>2]=yday}function isLeapYear(year){return year%4===0&&(year%100!==0||year%400===0)}var MONTH_DAYS_LEAP_CUMULATIVE=[0,31,60,91,121,152,182,213,244,274,305,335];var MONTH_DAYS_REGULAR_CUMULATIVE=[0,31,59,90,120,151,181,212,243,273,304,334];function ydayFromDate(date){var leap=isLeapYear(date.getFullYear());var monthDaysCumulative=leap?MONTH_DAYS_LEAP_CUMULATIVE:MONTH_DAYS_REGULAR_CUMULATIVE;var yday=monthDaysCumulative[date.getMonth()]+date.getDate()-1;return yday}function __localtime_js(time,tmPtr){var date=new Date(readI53FromI64(time)*1e3);HEAP32[tmPtr>>2]=date.getSeconds();HEAP32[tmPtr+4>>2]=date.getMinutes();HEAP32[tmPtr+8>>2]=date.getHours();HEAP32[tmPtr+12>>2]=date.getDate();HEAP32[tmPtr+16>>2]=date.getMonth();HEAP32[tmPtr+20>>2]=date.getFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getDay();var yday=ydayFromDate(date)|0;HEAP32[tmPtr+28>>2]=yday;HEAP32[tmPtr+36>>2]=-(date.getTimezoneOffset()*60);var start=new Date(date.getFullYear(),0,1);var summerOffset=new Date(date.getFullYear(),6,1).getTimezoneOffset();var winterOffset=start.getTimezoneOffset();var dst=(summerOffset!=winterOffset&&date.getTimezoneOffset()==Math.min(winterOffset,summerOffset))|0;HEAP32[tmPtr+32>>2]=dst}function __mktime_js(tmPtr){var date=new Date(HEAP32[tmPtr+20>>2]+1900,HEAP32[tmPtr+16>>2],HEAP32[tmPtr+12>>2],HEAP32[tmPtr+8>>2],HEAP32[tmPtr+4>>2],HEAP32[tmPtr>>2],0);var dst=HEAP32[tmPtr+32>>2];var guessedOffset=date.getTimezoneOffset();var start=new Date(date.getFullYear(),0,1);var summerOffset=new Date(date.getFullYear(),6,1).getTimezoneOffset();var winterOffset=start.getTimezoneOffset();var dstOffset=Math.min(winterOffset,summerOffset);if(dst<0){HEAP32[tmPtr+32>>2]=Number(summerOffset!=winterOffset&&dstOffset==guessedOffset)}else if(dst>0!=(dstOffset==guessedOffset)){var nonDstOffset=Math.max(winterOffset,summerOffset);var trueOffset=dst>0?dstOffset:nonDstOffset;date.setTime(date.getTime()+(trueOffset-guessedOffset)*6e4)}HEAP32[tmPtr+24>>2]=date.getDay();var yday=ydayFromDate(date)|0;HEAP32[tmPtr+28>>2]=yday;HEAP32[tmPtr>>2]=date.getSeconds();HEAP32[tmPtr+4>>2]=date.getMinutes();HEAP32[tmPtr+8>>2]=date.getHours();HEAP32[tmPtr+12>>2]=date.getDate();HEAP32[tmPtr+16>>2]=date.getMonth();HEAP32[tmPtr+20>>2]=date.getYear();return date.getTime()/1e3|0}function __mmap_js(len,prot,flags,fd,off,allocated,addr){try{var stream=SYSCALLS.getStreamFromFD(fd);var res=FS.mmap(stream,len,off,prot,flags);var ptr=res.ptr;HEAP32[allocated>>2]=res.allocated;HEAPU32[addr>>2]=ptr;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function __munmap_js(addr,len,prot,flags,fd,offset){try{var stream=SYSCALLS.getStreamFromFD(fd);if(prot&2){SYSCALLS.doMsync(addr,stream,len,flags,offset)}FS.munmap(stream)}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function stringToNewUTF8(str){var size=lengthBytesUTF8(str)+1;var ret=_malloc(size);if(ret)stringToUTF8(str,ret,size);return ret}function __tzset_js(timezone,daylight,tzname){var currentYear=(new Date).getFullYear();var winter=new Date(currentYear,0,1);var summer=new Date(currentYear,6,1);var winterOffset=winter.getTimezoneOffset();var summerOffset=summer.getTimezoneOffset();var stdTimezoneOffset=Math.max(winterOffset,summerOffset);HEAPU32[timezone>>2]=stdTimezoneOffset*60;HEAP32[daylight>>2]=Number(winterOffset!=summerOffset);function extractZone(date){var match=date.toTimeString().match(/\(([A-Za-z ]+)\)$/);return match?match[1]:"GMT"}var winterName=extractZone(winter);var summerName=extractZone(summer);var winterNamePtr=stringToNewUTF8(winterName);var summerNamePtr=stringToNewUTF8(summerName);if(summerOffset<winterOffset){HEAPU32[tzname>>2]=winterNamePtr;HEAPU32[tzname+4>>2]=summerNamePtr}else{HEAPU32[tzname>>2]=summerNamePtr;HEAPU32[tzname+4>>2]=winterNamePtr}}function _abort(){abort("")}Module["_abort"]=_abort;function _dlopen(handle){abort(dlopenMissingError)}var readEmAsmArgsArray=[];function readEmAsmArgs(sigPtr,buf){readEmAsmArgsArray.length=0;var ch;buf>>=2;while(ch=HEAPU8[sigPtr++]){buf+=ch!=105&buf;readEmAsmArgsArray.push(ch==105?HEAP32[buf]:(ch==106?HEAP64:HEAPF64)[buf++>>1]);++buf}return readEmAsmArgsArray}function runEmAsmFunction(code,sigPtr,argbuf){var args=readEmAsmArgs(sigPtr,argbuf);return ASM_CONSTS[code].apply(null,args)}function _emscripten_asm_const_int(code,sigPtr,argbuf){return runEmAsmFunction(code,sigPtr,argbuf)}function _emscripten_date_now(){return Date.now()}function getHeapMax(){return 2147483648}function _emscripten_get_heap_max(){return getHeapMax()}var _emscripten_get_now;_emscripten_get_now=()=>performance.now();function _emscripten_memcpy_big(dest,src,num){HEAPU8.copyWithin(dest,src,src+num)}function emscripten_realloc_buffer(size){var b=wasmMemory.buffer;try{wasmMemory.grow(size-b.byteLength+65535>>>16);updateMemoryViews();return 1}catch(e){}}function _emscripten_resize_heap(requestedSize){var oldSize=HEAPU8.length;requestedSize=requestedSize>>>0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}var alignUp=(x,multiple)=>x+(multiple-x%multiple)%multiple;for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignUp(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=emscripten_realloc_buffer(newSize);if(replacement){return true}}return false}var ENV={};function getExecutableName(){return thisProgram||"./this.program"}function getEnvStrings(){if(!getEnvStrings.strings){var lang=(typeof navigator=="object"&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8";var env={"USER":"web_user","LOGNAME":"web_user","PATH":"/","PWD":"/","HOME":"/home/web_user","LANG":lang,"_":getExecutableName()};for(var x in ENV){if(ENV[x]===undefined)delete env[x];else env[x]=ENV[x]}var strings=[];for(var x in env){strings.push(`${x}=${env[x]}`)}getEnvStrings.strings=strings}return getEnvStrings.strings}function stringToAscii(str,buffer){for(var i=0;i<str.length;++i){HEAP8[buffer++>>0]=str.charCodeAt(i)}HEAP8[buffer>>0]=0}function _environ_get(__environ,environ_buf){var bufSize=0;getEnvStrings().forEach(function(string,i){var ptr=environ_buf+bufSize;HEAPU32[__environ+i*4>>2]=ptr;stringToAscii(string,ptr);bufSize+=string.length+1});return 0}function _environ_sizes_get(penviron_count,penviron_buf_size){var strings=getEnvStrings();HEAPU32[penviron_count>>2]=strings.length;var bufSize=0;strings.forEach(function(string){bufSize+=string.length+1});HEAPU32[penviron_buf_size>>2]=bufSize;return 0}function _proc_exit(code){EXITSTATUS=code;if(!keepRuntimeAlive()){if(Module["onExit"])Module["onExit"](code);ABORT=true}quit_(code,new ExitStatus(code))}function exitJS(status,implicit){EXITSTATUS=status;_proc_exit(status)}var _exit=exitJS;function _fd_close(fd){try{var stream=SYSCALLS.getStreamFromFD(fd);FS.close(stream);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_fdstat_get(fd,pbuf){try{var rightsBase=0;var rightsInheriting=0;var flags=0;{var stream=SYSCALLS.getStreamFromFD(fd);var type=stream.tty?2:FS.isDir(stream.mode)?3:FS.isLink(stream.mode)?7:4}HEAP8[pbuf>>0]=type;HEAP16[pbuf+2>>1]=flags;HEAP64[pbuf+8>>3]=BigInt(rightsBase);HEAP64[pbuf+16>>3]=BigInt(rightsInheriting);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function doReadv(stream,iov,iovcnt,offset){var ret=0;for(var i=0;i<iovcnt;i++){var ptr=HEAPU32[iov>>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.read(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr<len)break;if(typeof offset!=="undefined"){offset+=curr}}return ret}function _fd_read(fd,iov,iovcnt,pnum){try{var stream=SYSCALLS.getStreamFromFD(fd);var num=doReadv(stream,iov,iovcnt);HEAPU32[pnum>>2]=num;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var MAX_INT53=9007199254740992;var MIN_INT53=-9007199254740992;function bigintToI53Checked(num){return num<MIN_INT53||num>MAX_INT53?NaN:Number(num)}function _fd_seek(fd,offset,whence,newOffset){try{offset=bigintToI53Checked(offset);if(isNaN(offset))return 61;var stream=SYSCALLS.getStreamFromFD(fd);FS.llseek(stream,offset,whence);HEAP64[newOffset>>3]=BigInt(stream.position);if(stream.getdents&&offset===0&&whence===0)stream.getdents=null;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function doWritev(stream,iov,iovcnt,offset){var ret=0;for(var i=0;i<iovcnt;i++){var ptr=HEAPU32[iov>>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.write(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(typeof offset!=="undefined"){offset+=curr}}return ret}function _fd_write(fd,iov,iovcnt,pnum){try{var stream=SYSCALLS.getStreamFromFD(fd);var num=doWritev(stream,iov,iovcnt);HEAPU32[pnum>>2]=num;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _getaddrinfo(node,service,hint,out){var addr=0;var port=0;var flags=0;var family=0;var type=0;var proto=0;var ai;function allocaddrinfo(family,type,proto,canon,addr,port){var sa,salen,ai;var errno;salen=family===10?28:16;addr=family===10?inetNtop6(addr):inetNtop4(addr);sa=_malloc(salen);errno=writeSockaddr(sa,family,addr,port);assert(!errno);ai=_malloc(32);HEAP32[ai+4>>2]=family;HEAP32[ai+8>>2]=type;HEAP32[ai+12>>2]=proto;HEAPU32[ai+24>>2]=canon;HEAPU32[ai+20>>2]=sa;if(family===10){HEAP32[ai+16>>2]=28}else{HEAP32[ai+16>>2]=16}HEAP32[ai+28>>2]=0;return ai}if(hint){flags=HEAP32[hint>>2];family=HEAP32[hint+4>>2];type=HEAP32[hint+8>>2];proto=HEAP32[hint+12>>2]}if(type&&!proto){proto=type===2?17:6}if(!type&&proto){type=proto===17?2:1}if(proto===0){proto=6}if(type===0){type=1}if(!node&&!service){return-2}if(flags&~(1|2|4|1024|8|16|32)){return-1}if(hint!==0&&HEAP32[hint>>2]&2&&!node){return-1}if(flags&32){return-2}if(type!==0&&type!==1&&type!==2){return-7}if(family!==0&&family!==2&&family!==10){return-6}if(service){service=UTF8ToString(service);port=parseInt(service,10);if(isNaN(port)){if(flags&1024){return-2}return-8}}if(!node){if(family===0){family=2}if((flags&1)===0){if(family===2){addr=_htonl(2130706433)}else{addr=[0,0,0,1]}}ai=allocaddrinfo(family,type,proto,null,addr,port);HEAPU32[out>>2]=ai;return 0}node=UTF8ToString(node);addr=inetPton4(node);if(addr!==null){if(family===0||family===2){family=2}else if(family===10&&flags&8){addr=[0,0,_htonl(65535),addr];family=10}else{return-2}}else{addr=inetPton6(node);if(addr!==null){if(family===0||family===10){family=10}else{return-2}}}if(addr!=null){ai=allocaddrinfo(family,type,proto,node,addr,port);HEAPU32[out>>2]=ai;return 0}if(flags&4){return-2}node=DNS.lookup_name(node);addr=inetPton4(node);if(family===0){family=2}else if(family===10){addr=[0,0,_htonl(65535),addr]}ai=allocaddrinfo(family,type,proto,null,addr,port);HEAPU32[out>>2]=ai;return 0}function _getnameinfo(sa,salen,node,nodelen,serv,servlen,flags){var info=readSockaddr(sa,salen);if(info.errno){return-6}var port=info.port;var addr=info.addr;var overflowed=false;if(node&&nodelen){var lookup;if(flags&1||!(lookup=DNS.lookup_addr(addr))){if(flags&8){return-2}}else{addr=lookup}var numBytesWrittenExclNull=stringToUTF8(addr,node,nodelen);if(numBytesWrittenExclNull+1>=nodelen){overflowed=true}}if(serv&&servlen){port=""+port;var numBytesWrittenExclNull=stringToUTF8(port,serv,servlen);if(numBytesWrittenExclNull+1>=servlen){overflowed=true}}if(overflowed){return-12}return 0}function arraySum(array,index){var sum=0;for(var i=0;i<=index;sum+=array[i++]){}return sum}var MONTH_DAYS_LEAP=[31,29,31,30,31,30,31,31,30,31,30,31];var MONTH_DAYS_REGULAR=[31,28,31,30,31,30,31,31,30,31,30,31];function addDays(date,days){var newDate=new Date(date.getTime());while(days>0){var leap=isLeapYear(newDate.getFullYear());var currentMonth=newDate.getMonth();var daysInCurrentMonth=(leap?MONTH_DAYS_LEAP:MONTH_DAYS_REGULAR)[currentMonth];if(days>daysInCurrentMonth-newDate.getDate()){days-=daysInCurrentMonth-newDate.getDate()+1;newDate.setDate(1);if(currentMonth<11){newDate.setMonth(currentMonth+1)}else{newDate.setMonth(0);newDate.setFullYear(newDate.getFullYear()+1)}}else{newDate.setDate(newDate.getDate()+days);return newDate}}return newDate}function writeArrayToMemory(array,buffer){HEAP8.set(array,buffer)}function _strftime(s,maxsize,format,tm){var tm_zone=HEAP32[tm+40>>2];var date={tm_sec:HEAP32[tm>>2],tm_min:HEAP32[tm+4>>2],tm_hour:HEAP32[tm+8>>2],tm_mday:HEAP32[tm+12>>2],tm_mon:HEAP32[tm+16>>2],tm_year:HEAP32[tm+20>>2],tm_wday:HEAP32[tm+24>>2],tm_yday:HEAP32[tm+28>>2],tm_isdst:HEAP32[tm+32>>2],tm_gmtoff:HEAP32[tm+36>>2],tm_zone:tm_zone?UTF8ToString(tm_zone):""};var pattern=UTF8ToString(format);var EXPANSION_RULES_1={"%c":"%a %b %d %H:%M:%S %Y","%D":"%m/%d/%y","%F":"%Y-%m-%d","%h":"%b","%r":"%I:%M:%S %p","%R":"%H:%M","%T":"%H:%M:%S","%x":"%m/%d/%y","%X":"%H:%M:%S","%Ec":"%c","%EC":"%C","%Ex":"%m/%d/%y","%EX":"%H:%M:%S","%Ey":"%y","%EY":"%Y","%Od":"%d","%Oe":"%e","%OH":"%H","%OI":"%I","%Om":"%m","%OM":"%M","%OS":"%S","%Ou":"%u","%OU":"%U","%OV":"%V","%Ow":"%w","%OW":"%W","%Oy":"%y"};for(var rule in EXPANSION_RULES_1){pattern=pattern.replace(new RegExp(rule,"g"),EXPANSION_RULES_1[rule])}var WEEKDAYS=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];var MONTHS=["January","February","March","April","May","June","July","August","September","October","November","December"];function leadingSomething(value,digits,character){var str=typeof value=="number"?value.toString():value||"";while(str.length<digits){str=character[0]+str}return str}function leadingNulls(value,digits){return leadingSomething(value,digits,"0")}function compareByDay(date1,date2){function sgn(value){return value<0?-1:value>0?1:0}var compare;if((compare=sgn(date1.getFullYear()-date2.getFullYear()))===0){if((compare=sgn(date1.getMonth()-date2.getMonth()))===0){compare=sgn(date1.getDate()-date2.getDate())}}return compare}function getFirstWeekStartDate(janFourth){switch(janFourth.getDay()){case 0:return new Date(janFourth.getFullYear()-1,11,29);case 1:return janFourth;case 2:return new Date(janFourth.getFullYear(),0,3);case 3:return new Date(janFourth.getFullYear(),0,2);case 4:return new Date(janFourth.getFullYear(),0,1);case 5:return new Date(janFourth.getFullYear()-1,11,31);case 6:return new Date(janFourth.getFullYear()-1,11,30)}}function getWeekBasedYear(date){var thisDate=addDays(new Date(date.tm_year+1900,0,1),date.tm_yday);var janFourthThisYear=new Date(thisDate.getFullYear(),0,4);var janFourthNextYear=new Date(thisDate.getFullYear()+1,0,4);var firstWeekStartThisYear=getFirstWeekStartDate(janFourthThisYear);var firstWeekStartNextYear=getFirstWeekStartDate(janFourthNextYear);if(compareByDay(firstWeekStartThisYear,thisDate)<=0){if(compareByDay(firstWeekStartNextYear,thisDate)<=0){return thisDate.getFullYear()+1}return thisDate.getFullYear()}return thisDate.getFullYear()-1}var EXPANSION_RULES_2={"%a":function(date){return WEEKDAYS[date.tm_wday].substring(0,3)},"%A":function(date){return WEEKDAYS[date.tm_wday]},"%b":function(date){return MONTHS[date.tm_mon].substring(0,3)},"%B":function(date){return MONTHS[date.tm_mon]},"%C":function(date){var year=date.tm_year+1900;return leadingNulls(year/100|0,2)},"%d":function(date){return leadingNulls(date.tm_mday,2)},"%e":function(date){return leadingSomething(date.tm_mday,2," ")},"%g":function(date){return getWeekBasedYear(date).toString().substring(2)},"%G":function(date){return getWeekBasedYear(date)},"%H":function(date){return leadingNulls(date.tm_hour,2)},"%I":function(date){var twelveHour=date.tm_hour;if(twelveHour==0)twelveHour=12;else if(twelveHour>12)twelveHour-=12;return leadingNulls(twelveHour,2)},"%j":function(date){return leadingNulls(date.tm_mday+arraySum(isLeapYear(date.tm_year+1900)?MONTH_DAYS_LEAP:MONTH_DAYS_REGULAR,date.tm_mon-1),3)},"%m":function(date){return leadingNulls(date.tm_mon+1,2)},"%M":function(date){return leadingNulls(date.tm_min,2)},"%n":function(){return"\n"},"%p":function(date){if(date.tm_hour>=0&&date.tm_hour<12){return"AM"}return"PM"},"%S":function(date){return leadingNulls(date.tm_sec,2)},"%t":function(){return"\t"},"%u":function(date){return date.tm_wday||7},"%U":function(date){var days=date.tm_yday+7-date.tm_wday;return leadingNulls(Math.floor(days/7),2)},"%V":function(date){var val=Math.floor((date.tm_yday+7-(date.tm_wday+6)%7)/7);if((date.tm_wday+371-date.tm_yday-2)%7<=2){val++}if(!val){val=52;var dec31=(date.tm_wday+7-date.tm_yday-1)%7;if(dec31==4||dec31==5&&isLeapYear(date.tm_year%400-1)){val++}}else if(val==53){var jan1=(date.tm_wday+371-date.tm_yday)%7;if(jan1!=4&&(jan1!=3||!isLeapYear(date.tm_year)))val=1}return leadingNulls(val,2)},"%w":function(date){return date.tm_wday},"%W":function(date){var days=date.tm_yday+7-(date.tm_wday+6)%7;return leadingNulls(Math.floor(days/7),2)},"%y":function(date){return(date.tm_year+1900).toString().substring(2)},"%Y":function(date){return date.tm_year+1900},"%z":function(date){var off=date.tm_gmtoff;var ahead=off>=0;off=Math.abs(off)/60;off=off/60*100+off%60;return(ahead?"+":"-")+String("0000"+off).slice(-4)},"%Z":function(date){return date.tm_zone},"%%":function(){return"%"}};pattern=pattern.replace(/%%/g,"\0\0");for(var rule in EXPANSION_RULES_2){if(pattern.includes(rule)){pattern=pattern.replace(new RegExp(rule,"g"),EXPANSION_RULES_2[rule](date))}}pattern=pattern.replace(/\0\0/g,"%");var bytes=intArrayFromString(pattern,false);if(bytes.length>maxsize){return 0}writeArrayToMemory(bytes,s);return bytes.length-1}var FSNode=function(parent,name,mode,rdev){if(!parent){parent=this}this.parent=parent;this.mount=parent.mount;this.mounted=null;this.id=FS.nextInode++;this.name=name;this.mode=mode;this.node_ops={};this.stream_ops={};this.rdev=rdev};var readMode=292|73;var writeMode=146;Object.defineProperties(FSNode.prototype,{read:{get:function(){return(this.mode&readMode)===readMode},set:function(val){val?this.mode|=readMode:this.mode&=~readMode}},write:{get:function(){return(this.mode&writeMode)===writeMode},set:function(val){val?this.mode|=writeMode:this.mode&=~writeMode}},isFolder:{get:function(){return FS.isDir(this.mode)}},isDevice:{get:function(){return FS.isChrdev(this.mode)}}});FS.FSNode=FSNode;FS.createPreloadedFile=FS_createPreloadedFile;FS.staticInit();var wasmImports={"b":___assert_fail,"f":___cxa_throw,"ka":___dlsym,"R":___syscall__newselect,"L":___syscall_accept4,"K":___syscall_bind,"J":___syscall_connect,"la":___syscall_faccessat,"g":___syscall_fcntl64,"ha":___syscall_fstat64,"U":___syscall_getdents64,"I":___syscall_getpeername,"H":___syscall_getsockname,"G":___syscall_getsockopt,"y":___syscall_ioctl,"F":___syscall_listen,"ea":___syscall_lstat64,"$":___syscall_mkdirat,"fa":___syscall_newfstatat,"w":___syscall_openat,"V":___syscall_poll,"E":___syscall_recvfrom,"T":___syscall_renameat,"S":___syscall_rmdir,"D":___syscall_sendto,"v":___syscall_socket,"ga":___syscall_stat64,"O":___syscall_unlinkat,"ia":__emscripten_get_now_is_monotonic,"M":__emscripten_throw_longjmp,"Y":__gmtime_js,"Z":__localtime_js,"_":__mktime_js,"W":__mmap_js,"X":__munmap_js,"P":__tzset_js,"a":_abort,"t":_dlopen,"oa":_emscripten_asm_const_int,"m":_emscripten_date_now,"Q":_emscripten_get_heap_max,"p":_emscripten_get_now,"ja":_emscripten_memcpy_big,"N":_emscripten_resize_heap,"ca":_environ_get,"da":_environ_sizes_get,"l":_exit,"n":_fd_close,"ba":_fd_fdstat_get,"x":_fd_read,"aa":_fd_seek,"q":_fd_write,"k":_getaddrinfo,"i":_getnameinfo,"pa":invoke_i,"na":invoke_ii,"c":invoke_iii,"o":invoke_iiii,"s":invoke_iiiii,"z":invoke_iiiiii,"r":invoke_iiiiiiiii,"B":invoke_iiiijj,"qa":invoke_iij,"h":invoke_vi,"j":invoke_vii,"d":invoke_viiii,"ma":invoke_viiiiii,"A":invoke_viiiiiiii,"C":is_timeout,"u":send_progress,"e":_strftime};var asm=createWasm();var ___wasm_call_ctors=function(){return(___wasm_call_ctors=Module["asm"]["sa"]).apply(null,arguments)};var _malloc=Module["_malloc"]=function(){return(_malloc=Module["_malloc"]=Module["asm"]["ta"]).apply(null,arguments)};var ___errno_location=function(){return(___errno_location=Module["asm"]["va"]).apply(null,arguments)};var _ntohs=function(){return(_ntohs=Module["asm"]["wa"]).apply(null,arguments)};var _htons=function(){return(_htons=Module["asm"]["xa"]).apply(null,arguments)};var _ffmpeg=Module["_ffmpeg"]=function(){return(_ffmpeg=Module["_ffmpeg"]=Module["asm"]["ya"]).apply(null,arguments)};var _ffprobe=Module["_ffprobe"]=function(){return(_ffprobe=Module["_ffprobe"]=Module["asm"]["za"]).apply(null,arguments)};var _htonl=function(){return(_htonl=Module["asm"]["Aa"]).apply(null,arguments)};var _emscripten_builtin_memalign=function(){return(_emscripten_builtin_memalign=Module["asm"]["Ba"]).apply(null,arguments)};var _setThrew=function(){return(_setThrew=Module["asm"]["Ca"]).apply(null,arguments)};var stackSave=function(){return(stackSave=Module["asm"]["Da"]).apply(null,arguments)};var stackRestore=function(){return(stackRestore=Module["asm"]["Ea"]).apply(null,arguments)};var ___cxa_is_pointer_type=function(){return(___cxa_is_pointer_type=Module["asm"]["Fa"]).apply(null,arguments)};var _ff_h264_cabac_tables=Module["_ff_h264_cabac_tables"]=1546732;var ___start_em_js=Module["___start_em_js"]=6077485;var ___stop_em_js=Module["___stop_em_js"]=6077662;function invoke_iiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vii(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiijj(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iij(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_i(index){var sp=stackSave();try{return getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_ii(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_iiiiii(index,a1,a2,a3,a4,a5){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}function invoke_viiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(e!==e+0)throw e;_setThrew(1,0)}}Module["setValue"]=setValue;Module["getValue"]=getValue;Module["UTF8ToString"]=UTF8ToString;Module["stringToUTF8"]=stringToUTF8;Module["lengthBytesUTF8"]=lengthBytesUTF8;Module["FS"]=FS;var calledRun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(){if(runDependencies>0){return}preRun();if(runDependencies>0){return}function doRun(){if(calledRun)return;calledRun=true;Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve(Module);if(Module["onRuntimeInitialized"])Module["onRuntimeInitialized"]();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(function(){setTimeout(function(){Module["setStatus"]("")},1);doRun()},1)}else{doRun()}}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run(); + return createFFmpegCore.ready +} - return createFFmpegCore.ready; - }; +); })(); -if (typeof exports === "object" && typeof module === "object") - module.exports = createFFmpegCore; -else if (typeof define === "function" && define["amd"]) - define([], function () { - return createFFmpegCore; - }); -else if (typeof exports === "object") - exports["createFFmpegCore"] = createFFmpegCore; +if (typeof exports === 'object' && typeof module === 'object') + module.exports = createFFmpegCore; +else if (typeof define === 'function' && define['amd']) + define([], function() { return createFFmpegCore; }); +else if (typeof exports === 'object') + exports["createFFmpegCore"] = createFFmpegCore; diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json index 2f00e1e9..fcbfad59 100644 --- a/apps/web/public/manifest.json +++ b/apps/web/public/manifest.json @@ -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" + } + ] +}
\ No newline at end of file diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 170be83f..f28cd191 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -3,11 +3,11 @@ import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, } from "@/components/ui/card"; import { memo, Suspense } from "react"; import { Input } from "@/components/ui/input"; @@ -20,127 +20,127 @@ import { GoogleIcon } from "@/components/icons"; import { useLogin } from "@/hooks/auth/useLogin"; const LoginPage = () => { - const router = useRouter(); - const { - email, - setEmail, - password, - setPassword, - error, - isAnyLoading, - isEmailLoading, - isGoogleLoading, - handleLogin, - handleGoogleLogin, - } = useLogin(); + const router = useRouter(); + const { + email, + setEmail, + password, + setPassword, + error, + isAnyLoading, + isEmailLoading, + isGoogleLoading, + handleLogin, + handleGoogleLogin, + } = useLogin(); - return ( - <div className="flex h-screen items-center justify-center relative"> - <Button - variant="text" - onClick={() => router.back()} - className="absolute top-6 left-6" - > - <ArrowLeft className="h-5 w-5" /> Back - </Button> - <Card className="w-[400px] shadow-lg border-0"> - <CardHeader className="text-center pb-4"> - <CardTitle className="text-2xl font-semibold">Welcome back</CardTitle> - <CardDescription className="text-base"> - Sign in to your account to continue - </CardDescription> - </CardHeader> - <CardContent className="pt-0"> - <Suspense - fallback={ - <div className="text-center"> - <Loader2 className="animate-spin" /> - </div> - } - > - <div className="flex flex-col space-y-6"> - {error && ( - <Alert variant="destructive"> - <AlertTitle>Error</AlertTitle> - <AlertDescription>{error}</AlertDescription> - </Alert> - )} + return ( + <div className="flex h-screen items-center justify-center relative"> + <Button + variant="text" + onClick={() => router.back()} + className="absolute top-6 left-6" + > + <ArrowLeft className="h-5 w-5" /> Back + </Button> + <Card className="w-[400px] shadow-lg border-0"> + <CardHeader className="text-center pb-4"> + <CardTitle className="text-2xl font-semibold">Welcome back</CardTitle> + <CardDescription className="text-base"> + Sign in to your account to continue + </CardDescription> + </CardHeader> + <CardContent className="pt-0"> + <Suspense + fallback={ + <div className="text-center"> + <Loader2 className="animate-spin" /> + </div> + } + > + <div className="flex flex-col space-y-6"> + {error && ( + <Alert variant="destructive"> + <AlertTitle>Error</AlertTitle> + <AlertDescription>{error}</AlertDescription> + </Alert> + )} - <Button - onClick={handleGoogleLogin} - variant="outline" - size="lg" - disabled={isAnyLoading} - > - {isGoogleLoading ? ( - <Loader2 className="animate-spin" /> - ) : ( - <GoogleIcon /> - )}{" "} - Continue with Google - </Button> - <div className="relative"> - <div className="absolute inset-0 flex items-center"> - <Separator className="w-full" /> - </div> - <div className="relative flex justify-center text-xs uppercase"> - <span className="bg-background px-2 text-muted-foreground"> - Or continue with - </span> - </div> - </div> - <div className="space-y-4"> - <div className="space-y-2"> - <Label htmlFor="email">Email</Label> - <Input - id="email" - type="email" - placeholder="m@example.com" - value={email} - onChange={(e) => setEmail(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="password">Password</Label> - <Input - id="password" - type="password" - value={password} - onChange={(e) => setPassword(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> - </div> - <Button - onClick={handleLogin} - disabled={isAnyLoading || !email || !password} - className="w-full h-11" - size="lg" - > - {isEmailLoading ? ( - <Loader2 className="animate-spin" /> - ) : ( - "Sign in" - )} - </Button> - </div> - </div> - <div className="mt-6 text-center text-sm"> - Don't have an account?{" "} - <Link - href="/signup" - className="font-medium text-primary underline-offset-4 hover:underline" - > - Sign up - </Link> - </div> - </Suspense> - </CardContent> - </Card> - </div> - ); + <Button + onClick={handleGoogleLogin} + variant="outline" + size="lg" + disabled={isAnyLoading} + > + {isGoogleLoading ? ( + <Loader2 className="animate-spin" /> + ) : ( + <GoogleIcon /> + )}{" "} + Continue with Google + </Button> + <div className="relative"> + <div className="absolute inset-0 flex items-center"> + <Separator className="w-full" /> + </div> + <div className="relative flex justify-center text-xs uppercase"> + <span className="bg-background px-2 text-muted-foreground"> + Or continue with + </span> + </div> + </div> + <div className="space-y-4"> + <div className="space-y-2"> + <Label htmlFor="email">Email</Label> + <Input + id="email" + type="email" + placeholder="m@example.com" + value={email} + onChange={(e) => setEmail(e.target.value)} + disabled={isAnyLoading} + className="h-11" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="password">Password</Label> + <Input + id="password" + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + disabled={isAnyLoading} + className="h-11" + /> + </div> + <Button + onClick={handleLogin} + disabled={isAnyLoading || !email || !password} + className="w-full h-11" + size="lg" + > + {isEmailLoading ? ( + <Loader2 className="animate-spin" /> + ) : ( + "Sign in" + )} + </Button> + </div> + </div> + <div className="mt-6 text-center text-sm"> + Don't have an account?{" "} + <Link + href="/signup" + className="font-medium text-primary underline-offset-4 hover:underline" + > + Sign up + </Link> + </div> + </Suspense> + </CardContent> + </Card> + </div> + ); }; export default memo(LoginPage); diff --git a/apps/web/src/app/(auth)/signup/page.tsx b/apps/web/src/app/(auth)/signup/page.tsx index a3cd1faf..c3a0f899 100644 --- a/apps/web/src/app/(auth)/signup/page.tsx +++ b/apps/web/src/app/(auth)/signup/page.tsx @@ -3,11 +3,11 @@ import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, } from "@/components/ui/card"; import { memo, Suspense } from "react"; import { Input } from "@/components/ui/input"; @@ -20,143 +20,143 @@ import { GoogleIcon } from "@/components/icons"; import { useSignUp } from "@/hooks/auth/useSignUp"; const SignUpPage = () => { - const router = useRouter(); - const { - name, - setName, - email, - setEmail, - password, - setPassword, - error, - isAnyLoading, - isEmailLoading, - isGoogleLoading, - handleSignUp, - handleGoogleSignUp, - } = useSignUp(); + const router = useRouter(); + const { + name, + setName, + email, + setEmail, + password, + setPassword, + error, + isAnyLoading, + isEmailLoading, + isGoogleLoading, + handleSignUp, + handleGoogleSignUp, + } = useSignUp(); - return ( - <div className="flex h-screen items-center justify-center relative"> - <Button - variant="text" - onClick={() => router.back()} - className="absolute top-6 left-6" - > - <ArrowLeft className="h-5 w-5" /> Back - </Button> - <Card className="w-[400px] shadow-lg border-0"> - <CardHeader className="text-center pb-4"> - <CardTitle className="text-2xl font-semibold"> - Create your account - </CardTitle> - <CardDescription className="text-base"> - Get started with your free account today - </CardDescription> - </CardHeader> - <CardContent className="pt-0"> - <Suspense - fallback={ - <div className="text-center"> - <Loader2 className="animate-spin" /> - </div> - } - > - <div className="flex flex-col space-y-6"> - {error && ( - <Alert variant="destructive"> - <AlertTitle>Error</AlertTitle> - <AlertDescription>{error}</AlertDescription> - </Alert> - )} - <Button - onClick={handleGoogleSignUp} - variant="outline" - size="lg" - disabled={isAnyLoading} - > - {isGoogleLoading ? ( - <Loader2 className="animate-spin" /> - ) : ( - <GoogleIcon /> - )}{" "} - Continue with Google - </Button> - <div className="relative"> - <div className="absolute inset-0 flex items-center"> - <Separator className="w-full" /> - </div> - <div className="relative flex justify-center text-xs uppercase"> - <span className="bg-background px-2 text-muted-foreground"> - Or continue with - </span> - </div> - </div> - <div className="space-y-4"> - <div className="space-y-2"> - <Label htmlFor="name">Full Name</Label> - <Input - id="name" - type="text" - placeholder="John Doe" - value={name} - onChange={(e) => setName(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="email">Email</Label> - <Input - id="email" - type="email" - placeholder="m@example.com" - value={email} - onChange={(e) => setEmail(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="password">Password</Label> - <Input - id="password" - type="password" - placeholder="Create a strong password" - value={password} - onChange={(e) => setPassword(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> - </div> - <Button - onClick={handleSignUp} - disabled={isAnyLoading || !name || !email || !password} - className="w-full h-11" - size="lg" - > - {isEmailLoading ? ( - <Loader2 className="animate-spin" /> - ) : ( - "Create account" - )} - </Button> - </div> - </div> - <div className="mt-6 text-center text-sm"> - Already have an account?{" "} - <Link - href="/login" - className="font-medium text-primary underline-offset-4 hover:underline" - > - Sign in - </Link> - </div> - </Suspense> - </CardContent> - </Card> - </div> - ); + return ( + <div className="flex h-screen items-center justify-center relative"> + <Button + variant="text" + onClick={() => router.back()} + className="absolute top-6 left-6" + > + <ArrowLeft className="h-5 w-5" /> Back + </Button> + <Card className="w-[400px] shadow-lg border-0"> + <CardHeader className="text-center pb-4"> + <CardTitle className="text-2xl font-semibold"> + Create your account + </CardTitle> + <CardDescription className="text-base"> + Get started with your free account today + </CardDescription> + </CardHeader> + <CardContent className="pt-0"> + <Suspense + fallback={ + <div className="text-center"> + <Loader2 className="animate-spin" /> + </div> + } + > + <div className="flex flex-col space-y-6"> + {error && ( + <Alert variant="destructive"> + <AlertTitle>Error</AlertTitle> + <AlertDescription>{error}</AlertDescription> + </Alert> + )} + <Button + onClick={handleGoogleSignUp} + variant="outline" + size="lg" + disabled={isAnyLoading} + > + {isGoogleLoading ? ( + <Loader2 className="animate-spin" /> + ) : ( + <GoogleIcon /> + )}{" "} + Continue with Google + </Button> + <div className="relative"> + <div className="absolute inset-0 flex items-center"> + <Separator className="w-full" /> + </div> + <div className="relative flex justify-center text-xs uppercase"> + <span className="bg-background px-2 text-muted-foreground"> + Or continue with + </span> + </div> + </div> + <div className="space-y-4"> + <div className="space-y-2"> + <Label htmlFor="name">Full Name</Label> + <Input + id="name" + type="text" + placeholder="John Doe" + value={name} + onChange={(e) => setName(e.target.value)} + disabled={isAnyLoading} + className="h-11" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="email">Email</Label> + <Input + id="email" + type="email" + placeholder="m@example.com" + value={email} + onChange={(e) => setEmail(e.target.value)} + disabled={isAnyLoading} + className="h-11" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="password">Password</Label> + <Input + id="password" + type="password" + placeholder="Create a strong password" + value={password} + onChange={(e) => setPassword(e.target.value)} + disabled={isAnyLoading} + className="h-11" + /> + </div> + <Button + onClick={handleSignUp} + disabled={isAnyLoading || !name || !email || !password} + className="w-full h-11" + size="lg" + > + {isEmailLoading ? ( + <Loader2 className="animate-spin" /> + ) : ( + "Create account" + )} + </Button> + </div> + </div> + <div className="mt-6 text-center text-sm"> + Already have an account?{" "} + <Link + href="/login" + className="font-medium text-primary underline-offset-4 hover:underline" + > + Sign in + </Link> + </div> + </Suspense> + </CardContent> + </Card> + </div> + ); }; export default memo(SignUpPage); diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts index 5deef204..55510dcb 100644 --- a/apps/web/src/app/api/auth/[...all]/route.ts +++ b/apps/web/src/app/api/auth/[...all]/route.ts @@ -1,4 +1,4 @@ -import { auth } from "@opencut/auth"; -import { toNextJsHandler } from "better-auth/next-js"; - -export const { POST, GET } = toNextJsHandler(auth); +import { auth } from "@opencut/auth";
+import { toNextJsHandler } from "better-auth/next-js";
+
+export const { POST, GET } = toNextJsHandler(auth);
\ No newline at end of file diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts index f8c951fe..ecb9ce04 100644 --- a/apps/web/src/app/api/health/route.ts +++ b/apps/web/src/app/api/health/route.ts @@ -1,5 +1,5 @@ import { NextRequest } from "next/server"; export async function GET(request: NextRequest) { - return new Response("OK", { status: 200 }); + return new Response("OK", { status: 200 }); } diff --git a/apps/web/src/app/api/waitlist/route.ts b/apps/web/src/app/api/waitlist/route.ts index 98232e49..e5c9fd94 100644 --- a/apps/web/src/app/api/waitlist/route.ts +++ b/apps/web/src/app/api/waitlist/route.ts @@ -1,105 +1,83 @@ -import { NextRequest, NextResponse } from "next/server"; -import { db, eq, waitlist } from "@opencut/db"; -import { checkBotId } from "botid/server"; -import { nanoid } from "nanoid"; -import { waitlistRateLimit } from "@/lib/rate-limit"; -import { z } from "zod"; -import { env } from "@/env"; -import { cookies } from "next/headers"; -import crypto from "crypto"; - -const waitlistSchema = z.object({ - email: z.string().email("Invalid email format").min(1, "Email is required"), -}); - -const CSRF_TOKEN_NAME = "waitlist-csrf"; -const TOKEN_EXPIRY = 60 * 60 * 1000; - -async function validateCSRFToken(request: NextRequest): Promise<boolean> { - const clientToken = request.headers.get("x-csrf-token"); - if (!clientToken) return false; - - const cookieStore = await cookies(); - const cookieValue = cookieStore.get(CSRF_TOKEN_NAME)?.value; - if (!cookieValue) return false; - - const [token, timestamp, signature] = cookieValue.split(":"); - if (!token || !timestamp || !signature) return false; - - if (clientToken !== token) return false; - - const now = Date.now(); - const tokenTime = parseInt(timestamp); - if (now - tokenTime > TOKEN_EXPIRY) return false; - - const expectedSignature = crypto - .createHmac("sha256", env.BETTER_AUTH_SECRET) - .update(`${token}:${timestamp}`) - .digest("hex"); - - return signature === expectedSignature; -} - -export async function POST(request: NextRequest) { - const verification = await checkBotId(); - - if (verification.isBot) { - return NextResponse.json({ error: "Access denied" }, { status: 403 }); - } - - const identifier = request.headers.get("x-forwarded-for") ?? "127.0.0.1"; - const { success } = await waitlistRateLimit.limit(identifier); - - if (!success) { - return NextResponse.json( - { error: "Too many requests. Please try again later." }, - { status: 429 }, - ); - } - const isValidToken = await validateCSRFToken(request); - if (!isValidToken) { - return NextResponse.json( - { error: "Invalid security token" }, - { status: 403 }, - ); - } - - try { - const body = await request.json(); - const { email } = waitlistSchema.parse(body); - - const existingEmail = await db - .select() - .from(waitlist) - .where(eq(waitlist.email, email.toLowerCase())) - .limit(1); - - if (existingEmail.length > 0) { - return NextResponse.json( - { error: "Email already registered" }, - { status: 409 }, - ); - } - - await db.insert(waitlist).values({ - id: nanoid(), - email: email.toLowerCase(), - }); - - return NextResponse.json( - { message: "Successfully joined waitlist!" }, - { status: 201 }, - ); - } catch (error) { - if (error instanceof z.ZodError) { - const firstError = error.errors[0]; - return NextResponse.json({ error: firstError.message }, { status: 400 }); - } - - console.error("Waitlist signup error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); - } -} +import { NextRequest, NextResponse } from "next/server";
+import { db, eq, waitlist } from "@opencut/db";
+import { checkBotId } from "botid/server";
+import { nanoid } from "nanoid";
+import { waitlistRateLimit } from "@/lib/rate-limit";
+import { z } from "zod";
+import { env } from "@/env";
+import { cookies } from "next/headers";
+import crypto from "crypto";
+
+const waitlistSchema = z.object({
+ email: z.string().email("Invalid email format").min(1, "Email is required"),
+});
+
+const CSRF_TOKEN_NAME = "waitlist-csrf";
+const TOKEN_EXPIRY = 60 * 60 * 1000;
+
+async function validateCSRFToken(request: NextRequest): Promise<boolean> {
+ const clientToken = request.headers.get("x-csrf-token");
+ if (!clientToken) return false;
+
+ const cookieStore = await cookies();
+ const cookieValue = cookieStore.get(CSRF_TOKEN_NAME)?.value;
+ if (!cookieValue) return false;
+
+ const [token, timestamp, signature] = cookieValue.split(":");
+ if (!token || !timestamp || !signature) return false;
+
+ if (clientToken !== token) return false;
+
+ const now = Date.now();
+ const tokenTime = parseInt(timestamp);
+ if (now - tokenTime > TOKEN_EXPIRY) return false;
+
+ const expectedSignature = crypto.createHmac("sha256", env.BETTER_AUTH_SECRET).update(`${token}:${timestamp}`).digest("hex");
+
+ return signature === expectedSignature;
+}
+
+export async function POST(request: NextRequest) {
+ const verification = await checkBotId();
+
+ if (verification.isBot) {
+ return NextResponse.json({ error: "Access denied" }, { status: 403 });
+ }
+
+ const identifier = request.headers.get("x-forwarded-for") ?? "127.0.0.1";
+ const { success } = await waitlistRateLimit.limit(identifier);
+
+ if (!success) {
+ return NextResponse.json({ error: "Too many requests. Please try again later." }, { status: 429 });
+ }
+ const isValidToken = await validateCSRFToken(request);
+ if (!isValidToken) {
+ return NextResponse.json({ error: "Invalid security token" }, { status: 403 });
+ }
+
+ try {
+ const body = await request.json();
+ const { email } = waitlistSchema.parse(body);
+
+ const existingEmail = await db.select().from(waitlist).where(eq(waitlist.email, email.toLowerCase())).limit(1);
+
+ if (existingEmail.length > 0) {
+ return NextResponse.json({ error: "Email already registered" }, { status: 409 });
+ }
+
+ await db.insert(waitlist).values({
+ id: nanoid(),
+ email: email.toLowerCase(),
+ });
+
+ return NextResponse.json({ message: "Successfully joined waitlist!" }, { status: 201 });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ const firstError = error.errors[0];
+ return NextResponse.json({ error: firstError.message }, { status: 400 });
+ }
+
+ console.error("Waitlist signup error:", error);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/apps/web/src/app/api/waitlist/token/route.ts b/apps/web/src/app/api/waitlist/token/route.ts index 6f5ee18f..4c7695fa 100644 --- a/apps/web/src/app/api/waitlist/token/route.ts +++ b/apps/web/src/app/api/waitlist/token/route.ts @@ -5,57 +5,42 @@ import { env } from "@/env"; const CSRF_TOKEN_NAME = "waitlist-csrf"; const TOKEN_EXPIRY = 60 * 60 * 1000; -const allowedHosts = - env.NODE_ENV === "development" - ? ["localhost:3000", "127.0.0.1:3000"] - : ["opencut.app", "www.opencut.app"]; +const allowedHosts = env.NODE_ENV === "development" ? ["localhost:3000", "127.0.0.1:3000"] : ["opencut.app", "www.opencut.app"]; export async function GET(request: NextRequest) { - const referer = request.headers.get("referer"); - const host = request.headers.get("host"); + const referer = request.headers.get("referer"); + const host = request.headers.get("host"); - if (referer) { - const refererUrl = new URL(referer); + if (referer) { + const refererUrl = new URL(referer); - if ( - !allowedHosts.some( - (allowed) => - refererUrl.host === allowed || refererUrl.host.endsWith(allowed), - ) - ) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - } else if (host) { - if ( - !allowedHosts.some( - (allowed) => host === allowed || host.endsWith(allowed), - ) - ) { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - } else { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } + if (!allowedHosts.some((allowed) => refererUrl.host === allowed || refererUrl.host.endsWith(allowed))) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + } else if (host) { + if (!allowedHosts.some((allowed) => host === allowed || host.endsWith(allowed))) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + } else { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } - if (!env.BETTER_AUTH_SECRET) { - throw new Error("BETTER_AUTH_SECRET must be configured"); - } + if (!env.BETTER_AUTH_SECRET) { + throw new Error("BETTER_AUTH_SECRET must be configured"); + } - const token = crypto.randomBytes(32).toString("hex"); - const timestamp = Date.now(); - const signature = crypto - .createHmac("sha256", env.BETTER_AUTH_SECRET) - .update(`${token}:${timestamp}`) - .digest("hex"); + const token = crypto.randomBytes(32).toString("hex"); + const timestamp = Date.now(); + const signature = crypto.createHmac("sha256", env.BETTER_AUTH_SECRET).update(`${token}:${timestamp}`).digest("hex"); - const cookieStore = await cookies(); - cookieStore.set(CSRF_TOKEN_NAME, `${token}:${timestamp}:${signature}`, { - httpOnly: true, - secure: env.NODE_ENV === "production", - sameSite: "strict", - maxAge: TOKEN_EXPIRY / 1000, - path: "/", - }); + const cookieStore = await cookies(); + cookieStore.set(CSRF_TOKEN_NAME, `${token}:${timestamp}:${signature}`, { + httpOnly: true, + secure: env.NODE_ENV === "production", + sameSite: "strict", + maxAge: TOKEN_EXPIRY / 1000, + path: "/", + }); - return NextResponse.json({ token }); + return NextResponse.json({ token }); } diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx index 2c602e25..5613f1f9 100644 --- a/apps/web/src/app/contributors/page.tsx +++ b/apps/web/src/app/contributors/page.tsx @@ -1,277 +1,277 @@ -import { Metadata } from "next"; -import { Header } from "@/components/header"; -import { Card, CardContent } from "@/components/ui/card"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Button } from "@/components/ui/button"; -import { ExternalLink } from "lucide-react"; -import Link from "next/link"; -import { GithubIcon } from "@/components/icons"; -import { Badge } from "@/components/ui/badge"; - -export const metadata: Metadata = { - title: "Contributors - OpenCut", - description: - "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.", - openGraph: { - title: "Contributors - OpenCut", - description: - "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.", - type: "website", - }, -}; - -interface Contributor { - id: number; - login: string; - avatar_url: string; - html_url: string; - contributions: number; - type: string; -} - -async function getContributors(): Promise<Contributor[]> { - try { - const response = await fetch( - "https://api.github.com/repos/OpenCut-app/OpenCut/contributors?per_page=100", - { - headers: { - Accept: "application/vnd.github.v3+json", - "User-Agent": "OpenCut-Web-App", - }, - next: { revalidate: 600 }, // 10 minutes - }, - ); - - if (!response.ok) { - console.error("Failed to fetch contributors"); - return []; - } - - const contributors = (await response.json()) as Contributor[]; - - const filteredContributors = contributors.filter( - (contributor: Contributor) => contributor.type === "User", - ); - - return filteredContributors; - } catch (error) { - console.error("Error fetching contributors:", error); - return []; - } -} - -export default async function ContributorsPage() { - const contributors = await getContributors(); - const topContributors = contributors.slice(0, 2); - const otherContributors = contributors.slice(2); - - return ( - <div className="min-h-screen bg-background"> - <Header /> - - <main className="relative"> - <div className="absolute inset-0 overflow-hidden pointer-events-none"> - <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" /> - <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" /> - </div> - - <div className="relative container mx-auto px-4 py-16"> - <div className="max-w-6xl mx-auto"> - <div className="text-center mb-20"> - <Link - href={"https://github.com/OpenCut-app/OpenCut"} - target="_blank" - > - <Badge variant="secondary" className="gap-2 mb-6"> - <GithubIcon className="h-3 w-3" /> - Open Source - </Badge> - </Link> - <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6"> - Contributors - </h1> - <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed"> - Meet the amazing developers who are building the future of video - editing - </p> - - <div className="flex items-center justify-center gap-8 text-sm"> - <div className="flex items-center gap-2"> - <div className="w-2 h-2 bg-foreground rounded-full" /> - <span className="font-medium">{contributors.length}</span> - <span className="text-muted-foreground">contributors</span> - </div> - <div className="flex items-center gap-2"> - <div className="w-2 h-2 bg-foreground rounded-full" /> - <span className="font-medium"> - {contributors.reduce((sum, c) => sum + c.contributions, 0)} - </span> - <span className="text-muted-foreground">contributions</span> - </div> - </div> - </div> - - {topContributors.length > 0 && ( - <div className="mb-20"> - <div className="text-center mb-12"> - <h2 className="text-2xl font-semibold mb-2"> - Top Contributors - </h2> - <p className="text-muted-foreground"> - Leading the way in contributions - </p> - </div> - - <div className="flex flex-col md:flex-row gap-6 justify-center max-w-4xl mx-auto"> - {topContributors.map((contributor, index) => ( - <Link - key={contributor.id} - href={contributor.html_url} - target="_blank" - rel="noopener noreferrer" - className="group block flex-1" - > - <div className="relative mx-auto max-w-md"> - <div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" /> - <Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl"> - <CardContent className="p-8 text-center"> - <div className="relative mb-6"> - <Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl"> - <AvatarImage - src={contributor.avatar_url} - alt={`${contributor.login}'s avatar`} - /> - <AvatarFallback className="text-lg font-semibold"> - {contributor.login.charAt(0).toUpperCase()} - </AvatarFallback> - </Avatar> - </div> - <h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors"> - {contributor.login} - </h3> - <div className="flex items-center justify-center gap-2 text-muted-foreground"> - <span className="font-medium text-foreground"> - {contributor.contributions} - </span> - <span>contributions</span> - </div> - </CardContent> - </Card> - </div> - </Link> - ))} - </div> - </div> - )} - - {otherContributors.length > 0 && ( - <div> - <div className="text-center mb-12"> - <h2 className="text-2xl font-semibold mb-2"> - All Contributors - </h2> - <p className="text-muted-foreground"> - Everyone who makes OpenCut better - </p> - </div> - - <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6"> - {otherContributors.map((contributor, index) => ( - <Link - key={contributor.id} - href={contributor.html_url} - target="_blank" - rel="noopener noreferrer" - className="group block" - style={{ - animationDelay: `${index * 50}ms`, - }} - > - <div className="text-center p-2 rounded-xl transition-all duration-300 hover:opacity-50"> - <Avatar className="h-16 w-16 mx-auto mb-3"> - <AvatarImage - src={contributor.avatar_url} - alt={`${contributor.login}'s avatar`} - /> - <AvatarFallback className="font-medium"> - {contributor.login.charAt(0).toUpperCase()} - </AvatarFallback> - </Avatar> - <h3 className="font-medium text-sm truncate group-hover:text-foreground transition-colors mb-1"> - {contributor.login} - </h3> - <p className="text-xs text-muted-foreground"> - {contributor.contributions} - </p> - </div> - </Link> - ))} - </div> - </div> - )} - - {contributors.length === 0 && ( - <div className="text-center py-20"> - <div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted/50 flex items-center justify-center"> - <GithubIcon className="h-8 w-8 text-muted-foreground" /> - </div> - <h3 className="text-xl font-medium mb-3"> - No contributors found - </h3> - <p className="text-muted-foreground mb-8 max-w-md mx-auto"> - Unable to load contributors at the moment. Check back later or - view on GitHub. - </p> - <Link - href="https://github.com/OpenCut-app/OpenCut/graphs/contributors" - target="_blank" - rel="noopener noreferrer" - > - <Button variant="outline" className="gap-2"> - <GithubIcon className="h-4 w-4" /> - View on GitHub - <ExternalLink className="h-4 w-4" /> - </Button> - </Link> - </div> - )} - - <div className="mt-32 text-center"> - <div className="max-w-2xl mx-auto"> - <h2 className="text-3xl font-bold mb-4">Join the community</h2> - <p className="text-lg text-muted-foreground mb-10 leading-relaxed"> - OpenCut is built by developers like you. Every contribution, - no matter how small, helps make video editing more accessible - for everyone. - </p> - - <div className="flex flex-col sm:flex-row gap-4 justify-center"> - <Link - href="https://github.com/OpenCut-app/OpenCut/blob/main/.github/CONTRIBUTING.md" - target="_blank" - rel="noopener noreferrer" - > - <Button size="lg" className="gap-2 group"> - <GithubIcon className="h-4 w-4 group-hover:scale-110 transition-transform" /> - Start Contributing - </Button> - </Link> - <Link - href="https://github.com/OpenCut-app/OpenCut/issues" - target="_blank" - rel="noopener noreferrer" - > - <Button variant="outline" size="lg" className="gap-2 group"> - Browse Issues - <ExternalLink className="h-4 w-4 group-hover:scale-110 transition-transform" /> - </Button> - </Link> - </div> - </div> - </div> - </div> - </div> - </main> - </div> - ); -} +import { Metadata } from "next";
+import { Header } from "@/components/header";
+import { Card, CardContent } from "@/components/ui/card";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import { ExternalLink } from "lucide-react";
+import Link from "next/link";
+import { GithubIcon } from "@/components/icons";
+import { Badge } from "@/components/ui/badge";
+
+export const metadata: Metadata = {
+ title: "Contributors - OpenCut",
+ description:
+ "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
+ openGraph: {
+ title: "Contributors - OpenCut",
+ description:
+ "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
+ type: "website",
+ },
+};
+
+interface Contributor {
+ id: number;
+ login: string;
+ avatar_url: string;
+ html_url: string;
+ contributions: number;
+ type: string;
+}
+
+async function getContributors(): Promise<Contributor[]> {
+ try {
+ const response = await fetch(
+ "https://api.github.com/repos/OpenCut-app/OpenCut/contributors?per_page=100",
+ {
+ headers: {
+ Accept: "application/vnd.github.v3+json",
+ "User-Agent": "OpenCut-Web-App",
+ },
+ next: { revalidate: 600 }, // 10 minutes
+ }
+ );
+
+ if (!response.ok) {
+ console.error("Failed to fetch contributors");
+ return [];
+ }
+
+ const contributors = (await response.json()) as Contributor[];
+
+ const filteredContributors = contributors.filter(
+ (contributor: Contributor) => contributor.type === "User"
+ );
+
+ return filteredContributors;
+ } catch (error) {
+ console.error("Error fetching contributors:", error);
+ return [];
+ }
+}
+
+export default async function ContributorsPage() {
+ const contributors = await getContributors();
+ const topContributors = contributors.slice(0, 2);
+ const otherContributors = contributors.slice(2);
+
+ return (
+ <div className="min-h-screen bg-background">
+ <Header />
+
+ <main className="relative">
+ <div className="absolute inset-0 overflow-hidden pointer-events-none">
+ <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
+ <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
+ </div>
+
+ <div className="relative container mx-auto px-4 py-16">
+ <div className="max-w-6xl mx-auto">
+ <div className="text-center mb-20">
+ <Link
+ href={"https://github.com/OpenCut-app/OpenCut"}
+ target="_blank"
+ >
+ <Badge variant="secondary" className="gap-2 mb-6">
+ <GithubIcon className="h-3 w-3" />
+ Open Source
+ </Badge>
+ </Link>
+ <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
+ Contributors
+ </h1>
+ <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
+ Meet the amazing developers who are building the future of video
+ editing
+ </p>
+
+ <div className="flex items-center justify-center gap-8 text-sm">
+ <div className="flex items-center gap-2">
+ <div className="w-2 h-2 bg-foreground rounded-full" />
+ <span className="font-medium">{contributors.length}</span>
+ <span className="text-muted-foreground">contributors</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <div className="w-2 h-2 bg-foreground rounded-full" />
+ <span className="font-medium">
+ {contributors.reduce((sum, c) => sum + c.contributions, 0)}
+ </span>
+ <span className="text-muted-foreground">contributions</span>
+ </div>
+ </div>
+ </div>
+
+ {topContributors.length > 0 && (
+ <div className="mb-20">
+ <div className="text-center mb-12">
+ <h2 className="text-2xl font-semibold mb-2">
+ Top Contributors
+ </h2>
+ <p className="text-muted-foreground">
+ Leading the way in contributions
+ </p>
+ </div>
+
+ <div className="flex flex-col md:flex-row gap-6 justify-center max-w-4xl mx-auto">
+ {topContributors.map((contributor, index) => (
+ <Link
+ key={contributor.id}
+ href={contributor.html_url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="group block flex-1"
+ >
+ <div className="relative mx-auto max-w-md">
+ <div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" />
+ <Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
+ <CardContent className="p-8 text-center">
+ <div className="relative mb-6">
+ <Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
+ <AvatarImage
+ src={contributor.avatar_url}
+ alt={`${contributor.login}'s avatar`}
+ />
+ <AvatarFallback className="text-lg font-semibold">
+ {contributor.login.charAt(0).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ </div>
+ <h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
+ {contributor.login}
+ </h3>
+ <div className="flex items-center justify-center gap-2 text-muted-foreground">
+ <span className="font-medium text-foreground">
+ {contributor.contributions}
+ </span>
+ <span>contributions</span>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ </Link>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {otherContributors.length > 0 && (
+ <div>
+ <div className="text-center mb-12">
+ <h2 className="text-2xl font-semibold mb-2">
+ All Contributors
+ </h2>
+ <p className="text-muted-foreground">
+ Everyone who makes OpenCut better
+ </p>
+ </div>
+
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
+ {otherContributors.map((contributor, index) => (
+ <Link
+ key={contributor.id}
+ href={contributor.html_url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="group block"
+ style={{
+ animationDelay: `${index * 50}ms`,
+ }}
+ >
+ <div className="text-center p-2 rounded-xl transition-all duration-300 hover:opacity-50">
+ <Avatar className="h-16 w-16 mx-auto mb-3">
+ <AvatarImage
+ src={contributor.avatar_url}
+ alt={`${contributor.login}'s avatar`}
+ />
+ <AvatarFallback className="font-medium">
+ {contributor.login.charAt(0).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ <h3 className="font-medium text-sm truncate group-hover:text-foreground transition-colors mb-1">
+ {contributor.login}
+ </h3>
+ <p className="text-xs text-muted-foreground">
+ {contributor.contributions}
+ </p>
+ </div>
+ </Link>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {contributors.length === 0 && (
+ <div className="text-center py-20">
+ <div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted/50 flex items-center justify-center">
+ <GithubIcon className="h-8 w-8 text-muted-foreground" />
+ </div>
+ <h3 className="text-xl font-medium mb-3">
+ No contributors found
+ </h3>
+ <p className="text-muted-foreground mb-8 max-w-md mx-auto">
+ Unable to load contributors at the moment. Check back later or
+ view on GitHub.
+ </p>
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut/graphs/contributors"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <Button variant="outline" className="gap-2">
+ <GithubIcon className="h-4 w-4" />
+ View on GitHub
+ <ExternalLink className="h-4 w-4" />
+ </Button>
+ </Link>
+ </div>
+ )}
+
+ <div className="mt-32 text-center">
+ <div className="max-w-2xl mx-auto">
+ <h2 className="text-3xl font-bold mb-4">Join the community</h2>
+ <p className="text-lg text-muted-foreground mb-10 leading-relaxed">
+ OpenCut is built by developers like you. Every contribution,
+ no matter how small, helps make video editing more accessible
+ for everyone.
+ </p>
+
+ <div className="flex flex-col sm:flex-row gap-4 justify-center">
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut/blob/main/.github/CONTRIBUTING.md"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <Button size="lg" className="gap-2 group">
+ <GithubIcon className="h-4 w-4 group-hover:scale-110 transition-transform" />
+ Start Contributing
+ </Button>
+ </Link>
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut/issues"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <Button variant="outline" size="lg" className="gap-2 group">
+ Browse Issues
+ <ExternalLink className="h-4 w-4 group-hover:scale-110 transition-transform" />
+ </Button>
+ </Link>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 6c7186bc..aa5b7315 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -3,9 +3,9 @@ import { useEffect, useRef, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { - ResizablePanelGroup, - ResizablePanel, - ResizableHandle, + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, } from "../../../components/ui/resizable"; import { MediaPanel } from "../../../components/editor/media-panel"; import { PropertiesPanel } from "../../../components/editor/properties-panel"; @@ -19,127 +19,127 @@ import { usePlaybackControls } from "@/hooks/use-playback-controls"; import { Onboarding } from "@/components/onboarding"; export default function Editor() { - const { - toolsPanel, - previewPanel, - mainContent, - timeline, - setToolsPanel, - setPreviewPanel, - setMainContent, - setTimeline, - propertiesPanel, - setPropertiesPanel, - } = usePanelStore(); + const { + toolsPanel, + previewPanel, + mainContent, + timeline, + setToolsPanel, + setPreviewPanel, + setMainContent, + setTimeline, + propertiesPanel, + setPropertiesPanel, + } = usePanelStore(); - const { activeProject, loadProject, createNewProject } = useProjectStore(); - const params = useParams(); - const router = useRouter(); - const projectId = params.project_id as string; - const handledProjectIds = useRef<Set<string>>(new Set()); - const [isOnboardingOpen, setIsOnboardingOpen] = useState(true); + const { activeProject, loadProject, createNewProject } = useProjectStore(); + const params = useParams(); + const router = useRouter(); + const projectId = params.project_id as string; + const handledProjectIds = useRef<Set<string>>(new Set()); + const [isOnboardingOpen, setIsOnboardingOpen] = useState(true); - usePlaybackControls(); + usePlaybackControls(); - useEffect(() => { - const initProject = async () => { - if (!projectId) return; + useEffect(() => { + const initProject = async () => { + if (!projectId) return; - if (activeProject?.id === projectId) { - return; - } + if (activeProject?.id === projectId) { + return; + } - if (handledProjectIds.current.has(projectId)) { - return; - } + if (handledProjectIds.current.has(projectId)) { + return; + } - try { - await loadProject(projectId); - } catch (error) { - handledProjectIds.current.add(projectId); + try { + await loadProject(projectId); + } catch (error) { + handledProjectIds.current.add(projectId); - const newProjectId = await createNewProject("Untitled Project"); - router.replace(`/editor/${newProjectId}`); - return; - } - }; + const newProjectId = await createNewProject("Untitled Project"); + router.replace(`/editor/${newProjectId}`); + return; + } + }; - initProject(); - }, [projectId, activeProject?.id, loadProject, createNewProject, router]); + initProject(); + }, [projectId, activeProject?.id, loadProject, createNewProject, router]); - return ( - <EditorProvider> - <div className="h-screen w-screen flex flex-col bg-background overflow-hidden"> - <EditorHeader /> - <div className="flex-1 min-h-0 min-w-0"> - <ResizablePanelGroup - direction="vertical" - className="h-full w-full gap-[0.18rem]" - > - <ResizablePanel - defaultSize={mainContent} - minSize={30} - maxSize={85} - onResize={setMainContent} - className="min-h-0" - > - {/* Main content area */} - <ResizablePanelGroup - direction="horizontal" - className="h-full w-full gap-[0.19rem] px-2" - > - {/* Tools Panel */} - <ResizablePanel - defaultSize={toolsPanel} - minSize={15} - maxSize={40} - onResize={setToolsPanel} - className="min-w-0" - > - <MediaPanel /> - </ResizablePanel> + return ( + <EditorProvider> + <div className="h-screen w-screen flex flex-col bg-background overflow-hidden"> + <EditorHeader /> + <div className="flex-1 min-h-0 min-w-0"> + <ResizablePanelGroup + direction="vertical" + className="h-full w-full gap-[0.18rem]" + > + <ResizablePanel + defaultSize={mainContent} + minSize={30} + maxSize={85} + onResize={setMainContent} + className="min-h-0" + > + {/* Main content area */} + <ResizablePanelGroup + direction="horizontal" + className="h-full w-full gap-[0.19rem] px-2" + > + {/* Tools Panel */} + <ResizablePanel + defaultSize={toolsPanel} + minSize={15} + maxSize={40} + onResize={setToolsPanel} + className="min-w-0" + > + <MediaPanel /> + </ResizablePanel> - <ResizableHandle withHandle /> + <ResizableHandle withHandle /> - {/* Preview Area */} - <ResizablePanel - defaultSize={previewPanel} - minSize={30} - onResize={setPreviewPanel} - className="min-w-0 min-h-0 flex-1" - > - <PreviewPanel /> - </ResizablePanel> + {/* Preview Area */} + <ResizablePanel + defaultSize={previewPanel} + minSize={30} + onResize={setPreviewPanel} + className="min-w-0 min-h-0 flex-1" + > + <PreviewPanel /> + </ResizablePanel> - <ResizableHandle withHandle /> + <ResizableHandle withHandle /> - <ResizablePanel - defaultSize={propertiesPanel} - minSize={15} - maxSize={40} - onResize={setPropertiesPanel} - className="min-w-0" - > - <PropertiesPanel /> - </ResizablePanel> - </ResizablePanelGroup> - </ResizablePanel> + <ResizablePanel + defaultSize={propertiesPanel} + minSize={15} + maxSize={40} + onResize={setPropertiesPanel} + className="min-w-0" + > + <PropertiesPanel /> + </ResizablePanel> + </ResizablePanelGroup> + </ResizablePanel> - <ResizableHandle withHandle /> + <ResizableHandle withHandle /> - {/* Timeline */} - <ResizablePanel - defaultSize={timeline} - minSize={15} - maxSize={70} - onResize={setTimeline} - className="min-h-0 px-2 pb-2" - > - <Timeline /> - </ResizablePanel> - </ResizablePanelGroup> - </div> - </div> - </EditorProvider> - ); + {/* Timeline */} + <ResizablePanel + defaultSize={timeline} + minSize={15} + maxSize={70} + onResize={setTimeline} + className="min-h-0 px-2 pb-2" + > + <Timeline /> + </ResizablePanel> + </ResizablePanelGroup> + </div> + </div> + </EditorProvider> + ); } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index e7c575a1..2f7158cc 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -3,86 +3,86 @@ @tailwind utilities; @layer base { - :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - --radius: 1rem; - --sidebar-background: 0 0% 100%; - --sidebar-foreground: 0 0% 3.9%; - --sidebar-primary: 0 0% 9%; - --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 0 0% 96.1%; - --sidebar-accent-foreground: 0 0% 9%; - --sidebar-border: 0 0% 89.8%; - --sidebar-ring: 0 0% 3.9%; - } - .dark { - --background: 0 0% 4%; - --foreground: 0 0% 89%; - --card: 0 0% 14.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 14.9%; - --popover-foreground: 0 0% 98%; - --primary: 180 95% 40%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 100% 60%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 17%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - --sidebar-background: 0 0% 3.9%; - --sidebar-foreground: 0 0% 98%; - --sidebar-primary: 0 0% 98%; - --sidebar-primary-foreground: 0 0% 9%; - --sidebar-accent: 0 0% 14.9%; - --sidebar-accent-foreground: 0 0% 98%; - --sidebar-border: 0 0% 14.9%; - --sidebar-ring: 0 0% 83.1%; - --panel-background: 0 0% 11%; - --panel-accent: 0 0% 15%; - } + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 1rem; + --sidebar-background: 0 0% 100%; + --sidebar-foreground: 0 0% 3.9%; + --sidebar-primary: 0 0% 9%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 0 0% 96.1%; + --sidebar-accent-foreground: 0 0% 9%; + --sidebar-border: 0 0% 89.8%; + --sidebar-ring: 0 0% 3.9%; + } + .dark { + --background: 0 0% 4%; + --foreground: 0 0% 89%; + --card: 0 0% 14.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 14.9%; + --popover-foreground: 0 0% 98%; + --primary: 180 95% 40%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 100% 60%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 17%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + --sidebar-background: 0 0% 3.9%; + --sidebar-foreground: 0 0% 98%; + --sidebar-primary: 0 0% 98%; + --sidebar-primary-foreground: 0 0% 9%; + --sidebar-accent: 0 0% 14.9%; + --sidebar-accent-foreground: 0 0% 98%; + --sidebar-border: 0 0% 14.9%; + --sidebar-ring: 0 0% 83.1%; + --panel-background: 0 0% 11%; + --panel-accent: 0 0% 15%; + } } @layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - /* Prevent back/forward swipe */ - overscroll-behavior-x: contain; - } + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + /* Prevent back/forward swipe */ + overscroll-behavior-x: contain; + } } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 41c84304..5c1d7520 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -13,43 +13,43 @@ import { env } from "@/env"; export const metadata = baseMetaData; const protectedRoutes = [ - { - path: "/api/waitlist", - method: "POST", - }, + { + path: "/api/waitlist", + method: "POST", + }, ]; export default function RootLayout({ - children, + children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode; }>) { - return ( - <html lang="en" suppressHydrationWarning> - <head> - <BotIdClient protect={protectedRoutes} /> - </head> - <body className={`${defaultFont.className} font-sans antialiased`}> - <ThemeProvider attribute="class" forcedTheme="dark"> - <TooltipProvider> - <StorageProvider>{children}</StorageProvider> - <Analytics /> - <Toaster /> - <Script - src="https://cdn.databuddy.cc/databuddy.js" - strategy="afterInteractive" - async - data-client-id="UP-Wcoy5arxFeK7oyjMMZ" - data-disabled={env.NODE_ENV === "development"} - data-track-attributes={false} - data-track-errors={true} - data-track-outgoing-links={false} - data-track-web-vitals={false} - data-track-sessions={false} - /> - </TooltipProvider> - </ThemeProvider> - </body> - </html> - ); + return ( + <html lang="en" suppressHydrationWarning> + <head> + <BotIdClient protect={protectedRoutes} /> + </head> + <body className={`${defaultFont.className} font-sans antialiased`}> + <ThemeProvider attribute="class" forcedTheme="dark"> + <TooltipProvider> + <StorageProvider>{children}</StorageProvider> + <Analytics /> + <Toaster /> + <Script + src="https://cdn.databuddy.cc/databuddy.js" + strategy="afterInteractive" + async + data-client-id="UP-Wcoy5arxFeK7oyjMMZ" + data-disabled={env.NODE_ENV === "development"} + data-track-attributes={false} + data-track-errors={true} + data-track-outgoing-links={false} + data-track-web-vitals={false} + data-track-sessions={false} + /> + </TooltipProvider> + </ThemeProvider> + </body> + </html> + ); } diff --git a/apps/web/src/app/metadata.ts b/apps/web/src/app/metadata.ts index adff976f..47f0020b 100644 --- a/apps/web/src/app/metadata.ts +++ b/apps/web/src/app/metadata.ts @@ -2,87 +2,87 @@ import { Metadata } from "next"; const title = "OpenCut"; const description = - "A simple but powerful video editor that gets the job done. In your browser."; + "A simple but powerful video editor that gets the job done. In your browser."; const openGraphImageUrl = "https://opencut.app/open-graph/default.jpg"; const twitterImageUrl = "/open-graph/default.jpg"; export const baseMetaData: Metadata = { - metadataBase: new URL("https://opencut.app"), - title: title, - description: description, - openGraph: { - title: title, - description: description, - url: "https://opencut.app", - siteName: "OpenCut", - locale: "en_US", - type: "website", - images: [ - { - url: openGraphImageUrl, - width: 1200, - height: 630, - alt: "OpenCut", - }, - ], - }, - twitter: { - card: "summary_large_image", - title: title, - description: description, - creator: "@opencutapp", - images: [twitterImageUrl], - }, - robots: { - index: true, - follow: true, - }, - icons: { - icon: [ - { url: "/favicon.ico" }, - { url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" }, - { url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" }, - { url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" }, - ], - apple: [ - { url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" }, - { url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" }, - { url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" }, - { url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" }, - { - url: "/icons/apple-icon-114x114.png", - sizes: "114x114", - type: "image/png", - }, - { - url: "/icons/apple-icon-120x120.png", - sizes: "120x120", - type: "image/png", - }, - { - url: "/icons/apple-icon-144x144.png", - sizes: "144x144", - type: "image/png", - }, - { - url: "/icons/apple-icon-152x152.png", - sizes: "152x152", - type: "image/png", - }, - { - url: "/icons/apple-icon-180x180.png", - sizes: "180x180", - type: "image/png", - }, - ], - shortcut: ["/favicon.ico"], - }, - appleWebApp: { - capable: true, - title: title, - }, - manifest: "/manifest.json", - other: { - "msapplication-config": "/browserconfig.xml", - }, + metadataBase: new URL("https://opencut.app"), + title: title, + description: description, + openGraph: { + title: title, + description: description, + url: "https://opencut.app", + siteName: "OpenCut", + locale: "en_US", + type: "website", + images: [ + { + url: openGraphImageUrl, + width: 1200, + height: 630, + alt: "OpenCut", + }, + ], + }, + twitter: { + card: "summary_large_image", + title: title, + description: description, + creator: "@opencutapp", + images: [twitterImageUrl], + }, + robots: { + index: true, + follow: true, + }, + icons: { + icon: [ + { url: "/favicon.ico" }, + { url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" }, + { url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" }, + { url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" }, + ], + apple: [ + { url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" }, + { url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" }, + { url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" }, + { url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" }, + { + url: "/icons/apple-icon-114x114.png", + sizes: "114x114", + type: "image/png", + }, + { + url: "/icons/apple-icon-120x120.png", + sizes: "120x120", + type: "image/png", + }, + { + url: "/icons/apple-icon-144x144.png", + sizes: "144x144", + type: "image/png", + }, + { + url: "/icons/apple-icon-152x152.png", + sizes: "152x152", + type: "image/png", + }, + { + url: "/icons/apple-icon-180x180.png", + sizes: "180x180", + type: "image/png", + }, + ], + shortcut: ["/favicon.ico"], + }, + appleWebApp: { + capable: true, + title: title, + }, + manifest: "/manifest.json", + other: { + "msapplication-config": "/browserconfig.xml", + }, }; diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index f1c78a01..74961549 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -3,11 +3,11 @@ import { Header } from "@/components/header"; import { Footer } from "@/components/footer"; export default async function Home() { - return ( - <div> - <Header /> - <Hero /> - <Footer /> - </div> - ); + return ( + <div> + <Header /> + <Hero /> + <Footer /> + </div> + ); } diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index 94a7fa77..42dd0f9a 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -1,253 +1,253 @@ -import { Metadata } from "next"; -import { Card, CardContent } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { GithubIcon } from "@/components/icons"; -import Link from "next/link"; -import { Footer } from "@/components/footer"; -import { Header } from "@/components/header"; - -export const metadata: Metadata = { - title: "Privacy Policy - OpenCut", - description: - "Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.", - openGraph: { - title: "Privacy Policy - OpenCut", - description: - "Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.", - type: "website", - }, -}; - -export default function PrivacyPage() { - return ( - <div className="min-h-screen bg-background"> - <Header /> - <main className="relative"> - <div className="absolute inset-0 overflow-hidden pointer-events-none"> - <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" /> - <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" /> - </div> - <div className="relative container mx-auto px-4 py-16"> - <div className="max-w-4xl mx-auto"> - <div className="text-center mb-10"> - <Link - href="https://github.com/OpenCut-app/OpenCut" - target="_blank" - > - <Badge variant="secondary" className="gap-2 mb-6"> - <GithubIcon className="h-3 w-3" /> - Open Source - </Badge> - </Link> - <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6"> - Privacy Policy - </h1> - <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed"> - Learn how we handle your data and privacy. Contact us if you - have any questions. - </p> - </div> - <Card className="bg-background/80 backdrop-blur-sm border-2 border-muted/30"> - <CardContent className="p-8 text-base leading-relaxed space-y-8"> - <section> - <h2 className="text-2xl font-semibold mb-4"> - Your Videos Stay Private - </h2> - <p className="mb-4"> - <strong> - OpenCut processes all videos locally on your device. - </strong>{" "} - We never upload, store, or have access to your video files. - Your content remains completely private and under your - control at all times. - </p> - <p> - All video editing, rendering, and processing happens in your - browser using WebAssembly and local storage. No video data - is transmitted to our servers. - </p> - </section> - - <section> - <h2 className="text-2xl font-semibold mb-4"> - Account Information - </h2> - <p className="mb-4"> - When you create an account, we only collect: - </p> - <ul className="list-disc pl-6 mb-4 space-y-2"> - <li>Email address (for account access)</li> - <li> - Profile information from Google OAuth (if you choose to - sign in with Google) - </li> - </ul> - <p className="mb-4"> - <strong> - We do NOT store your projects on our servers. - </strong>{" "} - All project data, including names, thumbnails, and creation - dates, is stored locally in your browser using IndexedDB. - </p> - <p> - We use{" "} - <a - href="https://www.better-auth.com" - target="_blank" - rel="noopener" - className="text-primary hover:underline" - > - Better Auth - </a>{" "} - for secure authentication and follow industry-standard - security practices. - </p> - </section> - - <section> - <h2 className="text-2xl font-semibold mb-4">Analytics</h2> - <p className="mb-4"> - We use{" "} - <a - href="https://www.databuddy.cc" - target="_blank" - rel="noopener" - className="text-primary hover:underline" - > - Databuddy - </a>{" "} - for completely anonymized and non-invasive analytics to - understand how people use OpenCut. - </p> - <p> - This helps us improve the editor, but we never collect - personal information, track individual users, or store any - data that could identify you. - </p> - </section> - - <section> - <h2 className="text-2xl font-semibold mb-4"> - Local Storage & Cookies - </h2> - <p className="mb-4"> - We use browser local storage and IndexedDB to: - </p> - <ul className="list-disc pl-6 mb-4 space-y-2"> - <li>Save your projects locally on your device</li> - <li>Remember your editor preferences and settings</li> - <li>Keep you logged in across browser sessions</li> - </ul> - <p> - All data stays on your device and can be cleared at any time - through your browser settings. - </p> - </section> - - <section> - <h2 className="text-2xl font-semibold mb-4"> - Third-Party Services - </h2> - <p className="mb-4"> - OpenCut integrates with these services: - </p> - <ul className="list-disc pl-6 mb-4 space-y-2"> - <li> - <strong>Google OAuth:</strong> For optional Google sign-in - (governed by Google's privacy policy) - </li> - <li> - <strong>Vercel:</strong> For hosting and content delivery - </li> - <li> - <strong>Databuddy:</strong> For anonymized analytics - </li> - </ul> - </section> - - <section> - <h2 className="text-2xl font-semibold mb-4">Your Rights</h2> - <p className="mb-4"> - You have complete control over your data: - </p> - <ul className="list-disc pl-6 mb-4 space-y-2"> - <li> - Delete your account and all associated data at any time - </li> - <li>Export your project data</li> - <li>Clear local storage to remove all saved projects</li> - <li>Contact us with any privacy concerns</li> - </ul> - </section> - - <section> - <h2 className="text-2xl font-semibold mb-4"> - Open Source Transparency - </h2> - <p className="mb-4"> - OpenCut is completely open source. You can review our code, - see exactly how we handle data, and even self-host the - application if you prefer. - </p> - <p> - View our source code on{" "} - <a - href="https://github.com/OpenCut-app/OpenCut" - target="_blank" - rel="noopener" - className="text-primary hover:underline" - > - GitHub - </a> - . - </p> - </section> - - <section> - <h2 className="text-2xl font-semibold mb-4">Contact Us</h2> - <p className="mb-4"> - Questions about this privacy policy or how we handle your - data? - </p> - <p> - Open an issue on our{" "} - <a - href="https://github.com/OpenCut-app/OpenCut/issues" - target="_blank" - rel="noopener" - className="text-primary hover:underline" - > - GitHub repository - </a> - , email us at{" "} - <a - href="mailto:oss@opencut.app" - className="text-primary hover:underline" - > - oss@opencut.app - </a> - , or reach out on{" "} - <a - href="https://x.com/opencutapp" - target="_blank" - rel="noopener" - className="text-primary hover:underline" - > - X (Twitter) - </a> - . - </p> - </section> - - <p className="text-sm text-muted-foreground mt-8 pt-8 border-t border-muted/20"> - Last updated: July 14, 2025 - </p> - </CardContent> - </Card> - </div> - </div> - </main> - <Footer /> - </div> - ); -} +import { Metadata } from "next";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { GithubIcon } from "@/components/icons";
+import Link from "next/link";
+import { Footer } from "@/components/footer";
+import { Header } from "@/components/header";
+
+export const metadata: Metadata = {
+ title: "Privacy Policy - OpenCut",
+ description:
+ "Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.",
+ openGraph: {
+ title: "Privacy Policy - OpenCut",
+ description:
+ "Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.",
+ type: "website",
+ },
+};
+
+export default function PrivacyPage() {
+ return (
+ <div className="min-h-screen bg-background">
+ <Header />
+ <main className="relative">
+ <div className="absolute inset-0 overflow-hidden pointer-events-none">
+ <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
+ <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
+ </div>
+ <div className="relative container mx-auto px-4 py-16">
+ <div className="max-w-4xl mx-auto">
+ <div className="text-center mb-10">
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut"
+ target="_blank"
+ >
+ <Badge variant="secondary" className="gap-2 mb-6">
+ <GithubIcon className="h-3 w-3" />
+ Open Source
+ </Badge>
+ </Link>
+ <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
+ Privacy Policy
+ </h1>
+ <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
+ Learn how we handle your data and privacy. Contact us if you
+ have any questions.
+ </p>
+ </div>
+ <Card className="bg-background/80 backdrop-blur-sm border-2 border-muted/30">
+ <CardContent className="p-8 text-base leading-relaxed space-y-8">
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Your Videos Stay Private
+ </h2>
+ <p className="mb-4">
+ <strong>
+ OpenCut processes all videos locally on your device.
+ </strong>{" "}
+ We never upload, store, or have access to your video files.
+ Your content remains completely private and under your
+ control at all times.
+ </p>
+ <p>
+ All video editing, rendering, and processing happens in your
+ browser using WebAssembly and local storage. No video data
+ is transmitted to our servers.
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Account Information
+ </h2>
+ <p className="mb-4">
+ When you create an account, we only collect:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>Email address (for account access)</li>
+ <li>
+ Profile information from Google OAuth (if you choose to
+ sign in with Google)
+ </li>
+ </ul>
+ <p className="mb-4">
+ <strong>
+ We do NOT store your projects on our servers.
+ </strong>{" "}
+ All project data, including names, thumbnails, and creation
+ dates, is stored locally in your browser using IndexedDB.
+ </p>
+ <p>
+ We use{" "}
+ <a
+ href="https://www.better-auth.com"
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ Better Auth
+ </a>{" "}
+ for secure authentication and follow industry-standard
+ security practices.
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">Analytics</h2>
+ <p className="mb-4">
+ We use{" "}
+ <a
+ href="https://www.databuddy.cc"
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ Databuddy
+ </a>{" "}
+ for completely anonymized and non-invasive analytics to
+ understand how people use OpenCut.
+ </p>
+ <p>
+ This helps us improve the editor, but we never collect
+ personal information, track individual users, or store any
+ data that could identify you.
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Local Storage & Cookies
+ </h2>
+ <p className="mb-4">
+ We use browser local storage and IndexedDB to:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>Save your projects locally on your device</li>
+ <li>Remember your editor preferences and settings</li>
+ <li>Keep you logged in across browser sessions</li>
+ </ul>
+ <p>
+ All data stays on your device and can be cleared at any time
+ through your browser settings.
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Third-Party Services
+ </h2>
+ <p className="mb-4">
+ OpenCut integrates with these services:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>
+ <strong>Google OAuth:</strong> For optional Google sign-in
+ (governed by Google's privacy policy)
+ </li>
+ <li>
+ <strong>Vercel:</strong> For hosting and content delivery
+ </li>
+ <li>
+ <strong>Databuddy:</strong> For anonymized analytics
+ </li>
+ </ul>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">Your Rights</h2>
+ <p className="mb-4">
+ You have complete control over your data:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>
+ Delete your account and all associated data at any time
+ </li>
+ <li>Export your project data</li>
+ <li>Clear local storage to remove all saved projects</li>
+ <li>Contact us with any privacy concerns</li>
+ </ul>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Open Source Transparency
+ </h2>
+ <p className="mb-4">
+ OpenCut is completely open source. You can review our code,
+ see exactly how we handle data, and even self-host the
+ application if you prefer.
+ </p>
+ <p>
+ View our source code on{" "}
+ <a
+ href="https://github.com/OpenCut-app/OpenCut"
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ GitHub
+ </a>
+ .
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">Contact Us</h2>
+ <p className="mb-4">
+ Questions about this privacy policy or how we handle your
+ data?
+ </p>
+ <p>
+ Open an issue on our{" "}
+ <a
+ href="https://github.com/OpenCut-app/OpenCut/issues"
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ GitHub repository
+ </a>
+ , email us at{" "}
+ <a
+ href="mailto:oss@opencut.app"
+ className="text-primary hover:underline"
+ >
+ oss@opencut.app
+ </a>
+ , or reach out on{" "}
+ <a
+ href="https://x.com/opencutapp"
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ X (Twitter)
+ </a>
+ .
+ </p>
+ </section>
+
+ <p className="text-sm text-muted-foreground mt-8 pt-8 border-t border-muted/20">
+ Last updated: July 14, 2025
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+ </main>
+ <Footer />
+ </div>
+ );
+}
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index f4df5882..a1a61d14 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -1,36 +1,33 @@ "use client"; -import Link from "next/link"; -import { useState, useEffect, useCallback } from "react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Input } from "@/components/ui/input"; import { - ChevronLeft, - Plus, Calendar, + ChevronLeft, + Loader2, MoreHorizontal, + Plus, + Search, + Trash2, Video, - Loader2, X, - Trash2, - Search, } from "lucide-react"; -import { TProject } from "@/types/project"; import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { DeleteProjectDialog } from "@/components/delete-project-dialog"; +import { RenameProjectDialog } from "@/components/rename-project-dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import { DropdownMenu, - DropdownMenuItem, DropdownMenuContent, - DropdownMenuTrigger, + DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { useProjectStore } from "@/stores/project-store"; -import { useRouter } from "next/navigation"; -import { DeleteProjectDialog } from "@/components/delete-project-dialog"; -import { RenameProjectDialog } from "@/components/rename-project-dialog"; -import { useTimelineStore } from "@/stores/timeline-store"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -39,6 +36,9 @@ import { SelectValue, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; +import { useProjectStore } from "@/stores/project-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import type { TProject } from "@/types/project"; export default function ProjectsPage() { const { @@ -52,13 +52,9 @@ export default function ProjectsPage() { const [thumbnailCache, setThumbnailCache] = useState< Record<string, string | null> >({}); - const [loadingThumbnails, setLoadingThumbnails] = useState<Set<string>>( + const [_loadingThumbnails, setLoadingThumbnails] = useState<Set<string>>( new Set(), ); - const [selectedProject, setSelectedProject] = useState<TProject | null>(null); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); - const [sortBy, setSortBy] = useState<"name" | "date">("date"); const [isSelectionMode, setIsSelectionMode] = useState(false); const [selectedProjects, setSelectedProjects] = useState<Set<string>>( new Set(), @@ -246,9 +242,17 @@ export default function ProjectsPage() { </div> {isSelectionMode && sortedProjects.length > 0 && ( - <div + <button + type="button" onClick={() => handleSelectAll(!allSelected)} - className="hover:cursor-pointer gap-2 mb-6 p-4 bg-muted/30 rounded-lg border items-center flex" + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelectAll(!allSelected); + } + }} + className="w-full hover:cursor-pointer gap-2 mb-6 p-4 bg-muted/30 rounded-lg border items-center flex" + tabIndex={0} > <Checkbox checked={someSelected ? "indeterminate" : allSelected} /> <span className="text-sm font-medium"> @@ -257,14 +261,14 @@ export default function ProjectsPage() { <span className="text-sm text-muted-foreground"> ({selectedProjects.size} of {sortedProjects.length} selected) </span> - </div> + </button> )} {isLoading || !isInitialized ? ( <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6"> - {Array.from({ length: 8 }).map((_, index) => ( + {Array.from({ length: 8 }, (_, index) => ( <div - key={index} + key={`skeleton-${index}-${Date.now()}`} className="overflow-hidden bg-background border-none p-0" > <Skeleton className="aspect-square w-full bg-muted/50" /> @@ -343,7 +347,7 @@ function ProjectCard({ } }; loadThumbnail(); - }, [project.id]); + }, [project.id, getProjectThumbnail]); const formatDate = (date: Date): string => { return date.toLocaleDateString("en-US", { @@ -375,6 +379,13 @@ function ProjectCard({ } }; + const handleCardKeyDown = (e: React.KeyboardEvent) => { + if (isSelectionMode && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onSelect?.(project.id, !isSelected); + } + }; + const cardContent = ( <Card className={`overflow-hidden bg-background border-none p-0 transition-all ${ @@ -501,9 +512,14 @@ function ProjectCard({ return ( <> {isSelectionMode ? ( - <div onClick={handleCardClick} className="block group cursor-pointer"> + <button + type="button" + onClick={handleCardClick} + onKeyDown={handleCardKeyDown} + className="block group cursor-pointer w-full text-left" + > {cardContent} - </div> + </button> ) : ( <Link href={`/editor/${project.id}`} className="block group"> {cardContent} diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx index a3b98908..c62dce3a 100644 --- a/apps/web/src/app/roadmap/page.tsx +++ b/apps/web/src/app/roadmap/page.tsx @@ -1,247 +1,247 @@ -import { Metadata } from "next"; -import { Badge } from "@/components/ui/badge"; -import { GithubIcon } from "@/components/icons"; -import Link from "next/link"; -import { Footer } from "@/components/footer"; -import { Header } from "@/components/header"; -import ReactMarkdown from "react-markdown"; -import { cn } from "@/lib/utils"; - -const roadmapItems: { - title: string; - description: string; - status: { - text: string; - type: "complete" | "pending" | "default" | "info"; - }; -}[] = [ - { - title: "Start", - description: - "This is where it all started. Repository created, initial project structure, and the vision for a free, open-source video editor. [Check out the first tweet](https://x.com/mazeincoding/status/1936706642512388188) to see where it started.", - status: { - text: "Completed", - type: "complete", - }, - }, - { - title: "Core UI", - description: - "Built the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.", - status: { - text: "Completed", - type: "complete", - }, - }, - { - title: "Basic Functionality", - description: - "The heart of any video editor. Timeline zoom in/out, making clips longer/shorter, dragging elements around, selection, playhead scrubbing. **This part has to be fucking perfect** because it's what users interact with 99% of the time.", - status: { - text: "In Progress", - type: "pending", - }, - }, - { - title: "Export/Preview Logic", - description: - "The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.", - status: { - text: "In Progress", - type: "pending", - }, - }, - { - title: "Text", - description: - "After media, text is the next most important thing. Font selection with custom font imports, text stroke, colors. All the text essential text properties.", - status: { - text: "Not Started", - type: "default", - }, - }, - { - title: "Effects", - description: - "Adding visual effects to both text and media. Blur, brightness, contrast, saturation, filters, and all the creative tools that make videos pop. This is where the magic happens.", - status: { - text: "Not Started", - type: "default", - }, - }, - { - title: "Transitions", - description: - "Smooth transitions between clips. Fade in/out, slide, zoom, dissolve, and custom transition effects.", - status: { - text: "Not Started", - type: "default", - }, - }, - { - title: "Refine from Here", - description: - "Once we nail the above, we have a **solid foundation** to build anything. Advanced features, performance optimizations, mobile support, desktop app.", - status: { - text: "Future", - type: "info", - }, - }, -]; - -export const metadata: Metadata = { - title: "Roadmap - OpenCut", - description: - "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.", - openGraph: { - title: "OpenCut Roadmap - What's Coming Next", - description: - "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.", - type: "website", - images: [ - { - url: "/open-graph/roadmap.jpg", - width: 1200, - height: 630, - alt: "OpenCut Roadmap", - }, - ], - }, - twitter: { - card: "summary_large_image", - title: "OpenCut Roadmap - What's Coming Next", - description: - "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.", - images: ["/open-graph/roadmap.jpg"], - }, -}; - -export default function RoadmapPage() { - return ( - <div className="min-h-screen bg-background"> - <Header /> - <main className="relative"> - <div className="absolute inset-0 overflow-hidden pointer-events-none"> - <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" /> - <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" /> - </div> - <div className="relative container mx-auto px-4 py-16"> - <div className="max-w-4xl mx-auto"> - <div className="text-center mb-10"> - <Link - href="https://github.com/OpenCut-app/OpenCut" - target="_blank" - > - <Badge variant="secondary" className="gap-2 mb-6"> - <GithubIcon className="h-3 w-3" /> - Open Source - </Badge> - </Link> - <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6"> - Roadmap - </h1> - <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed"> - What's coming next for OpenCut (last updated: July 14, 2025) - </p> - </div> - <div className="space-y-6"> - {roadmapItems.map((item, index) => ( - <div key={index} className="relative"> - <div className="flex items-start gap-2"> - <span className="text-lg font-medium text-muted-foreground select-none leading-[1.5]"> - {index + 1}. - </span> - <div className="flex-1 pt-[2px]"> - <div className="flex items-center gap-2 mb-2"> - <h3 className="font-medium text-lg">{item.title}</h3> - <Badge - className={cn("shadow-none", { - "!bg-green-500 text-white": - item.status.type === "complete", - "!bg-yellow-500 text-white": - item.status.type === "pending", - "!bg-blue-500 text-white": - item.status.type === "info", - "!bg-foreground/10 text-accent-foreground": - item.status.type === "default", - })} - > - {item.status.text} - </Badge> - </div> - <div className="text-foreground/70 leading-relaxed"> - <ReactMarkdown - components={{ - a: ({ className, children, ...props }) => ( - <a - className={cn( - "text-primary hover:underline", - className, - )} - target="_blank" - rel="noopener noreferrer" - {...props} - > - {children} - </a> - ), - strong: ({ children }) => ( - <strong className="font-semibold text-foreground"> - {children} - </strong> - ), - }} - > - {item.description} - </ReactMarkdown> - </div> - </div> - </div> - </div> - ))} - </div> - - <div className="mt-12 pt-8 border-t border-muted/20"> - <div className="text-center space-y-4"> - <h3 className="text-xl font-semibold">Want to Help?</h3> - <p className="text-muted-foreground max-w-2xl mx-auto"> - OpenCut is open source and built by the community. Every - contribution, no matter how small, helps us build the best - free video editor possible. - </p> - <div className="flex flex-col sm:flex-row gap-4 justify-center mt-6"> - <Link - href="https://github.com/OpenCut-app/OpenCut/blob/main/.github/CONTRIBUTING.md" - target="_blank" - rel="noopener noreferrer" - > - <Badge - variant="outline" - className="text-sm px-4 py-2 hover:bg-muted/50 transition-colors" - > - <GithubIcon className="h-4 w-4 mr-2" /> - Start Contributing - </Badge> - </Link> - <Link - href="https://github.com/OpenCut-app/OpenCut/issues" - target="_blank" - rel="noopener noreferrer" - > - <Badge - variant="outline" - className="text-sm px-4 py-2 hover:bg-muted/50 transition-colors" - > - Report Issues - </Badge> - </Link> - </div> - </div> - </div> - </div> - </div> - </main> - <Footer /> - </div> - ); -} +import { Metadata } from "next";
+import { Badge } from "@/components/ui/badge";
+import { GithubIcon } from "@/components/icons";
+import Link from "next/link";
+import { Footer } from "@/components/footer";
+import { Header } from "@/components/header";
+import ReactMarkdown from "react-markdown";
+import { cn } from "@/lib/utils";
+
+const roadmapItems: {
+ title: string;
+ description: string;
+ status: {
+ text: string;
+ type: "complete" | "pending" | "default" | "info";
+ };
+}[] = [
+ {
+ title: "Start",
+ description:
+ "This is where it all started. Repository created, initial project structure, and the vision for a free, open-source video editor. [Check out the first tweet](https://x.com/mazeincoding/status/1936706642512388188) to see where it started.",
+ status: {
+ text: "Completed",
+ type: "complete",
+ },
+ },
+ {
+ title: "Core UI",
+ description:
+ "Built the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.",
+ status: {
+ text: "Completed",
+ type: "complete",
+ },
+ },
+ {
+ title: "Basic Functionality",
+ description:
+ "The heart of any video editor. Timeline zoom in/out, making clips longer/shorter, dragging elements around, selection, playhead scrubbing. **This part has to be fucking perfect** because it's what users interact with 99% of the time.",
+ status: {
+ text: "In Progress",
+ type: "pending",
+ },
+ },
+ {
+ title: "Export/Preview Logic",
+ description:
+ "The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.",
+ status: {
+ text: "In Progress",
+ type: "pending",
+ },
+ },
+ {
+ title: "Text",
+ description:
+ "After media, text is the next most important thing. Font selection with custom font imports, text stroke, colors. All the text essential text properties.",
+ status: {
+ text: "Not Started",
+ type: "default",
+ },
+ },
+ {
+ title: "Effects",
+ description:
+ "Adding visual effects to both text and media. Blur, brightness, contrast, saturation, filters, and all the creative tools that make videos pop. This is where the magic happens.",
+ status: {
+ text: "Not Started",
+ type: "default",
+ },
+ },
+ {
+ title: "Transitions",
+ description:
+ "Smooth transitions between clips. Fade in/out, slide, zoom, dissolve, and custom transition effects.",
+ status: {
+ text: "Not Started",
+ type: "default",
+ },
+ },
+ {
+ title: "Refine from Here",
+ description:
+ "Once we nail the above, we have a **solid foundation** to build anything. Advanced features, performance optimizations, mobile support, desktop app.",
+ status: {
+ text: "Future",
+ type: "info",
+ },
+ },
+];
+
+export const metadata: Metadata = {
+ title: "Roadmap - OpenCut",
+ description:
+ "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
+ openGraph: {
+ title: "OpenCut Roadmap - What's Coming Next",
+ description:
+ "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
+ type: "website",
+ images: [
+ {
+ url: "/open-graph/roadmap.jpg",
+ width: 1200,
+ height: 630,
+ alt: "OpenCut Roadmap",
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: "OpenCut Roadmap - What's Coming Next",
+ description:
+ "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
+ images: ["/open-graph/roadmap.jpg"],
+ },
+};
+
+export default function RoadmapPage() {
+ return (
+ <div className="min-h-screen bg-background">
+ <Header />
+ <main className="relative">
+ <div className="absolute inset-0 overflow-hidden pointer-events-none">
+ <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
+ <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
+ </div>
+ <div className="relative container mx-auto px-4 py-16">
+ <div className="max-w-4xl mx-auto">
+ <div className="text-center mb-10">
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut"
+ target="_blank"
+ >
+ <Badge variant="secondary" className="gap-2 mb-6">
+ <GithubIcon className="h-3 w-3" />
+ Open Source
+ </Badge>
+ </Link>
+ <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
+ Roadmap
+ </h1>
+ <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
+ What's coming next for OpenCut (last updated: July 14, 2025)
+ </p>
+ </div>
+ <div className="space-y-6">
+ {roadmapItems.map((item, index) => (
+ <div key={index} className="relative">
+ <div className="flex items-start gap-2">
+ <span className="text-lg font-medium text-muted-foreground select-none leading-[1.5]">
+ {index + 1}.
+ </span>
+ <div className="flex-1 pt-[2px]">
+ <div className="flex items-center gap-2 mb-2">
+ <h3 className="font-medium text-lg">{item.title}</h3>
+ <Badge
+ className={cn("shadow-none", {
+ "!bg-green-500 text-white":
+ item.status.type === "complete",
+ "!bg-yellow-500 text-white":
+ item.status.type === "pending",
+ "!bg-blue-500 text-white":
+ item.status.type === "info",
+ "!bg-foreground/10 text-accent-foreground":
+ item.status.type === "default",
+ })}
+ >
+ {item.status.text}
+ </Badge>
+ </div>
+ <div className="text-foreground/70 leading-relaxed">
+ <ReactMarkdown
+ components={{
+ a: ({ className, children, ...props }) => (
+ <a
+ className={cn(
+ "text-primary hover:underline",
+ className
+ )}
+ target="_blank"
+ rel="noopener noreferrer"
+ {...props}
+ >
+ {children}
+ </a>
+ ),
+ strong: ({ children }) => (
+ <strong className="font-semibold text-foreground">
+ {children}
+ </strong>
+ ),
+ }}
+ >
+ {item.description}
+ </ReactMarkdown>
+ </div>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+
+ <div className="mt-12 pt-8 border-t border-muted/20">
+ <div className="text-center space-y-4">
+ <h3 className="text-xl font-semibold">Want to Help?</h3>
+ <p className="text-muted-foreground max-w-2xl mx-auto">
+ OpenCut is open source and built by the community. Every
+ contribution, no matter how small, helps us build the best
+ free video editor possible.
+ </p>
+ <div className="flex flex-col sm:flex-row gap-4 justify-center mt-6">
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut/blob/main/.github/CONTRIBUTING.md"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <Badge
+ variant="outline"
+ className="text-sm px-4 py-2 hover:bg-muted/50 transition-colors"
+ >
+ <GithubIcon className="h-4 w-4 mr-2" />
+ Start Contributing
+ </Badge>
+ </Link>
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut/issues"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <Badge
+ variant="outline"
+ className="text-sm px-4 py-2 hover:bg-muted/50 transition-colors"
+ >
+ Report Issues
+ </Badge>
+ </Link>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </main>
+ <Footer />
+ </div>
+ );
+}
diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx index 34d75833..c56e8fc1 100644 --- a/apps/web/src/app/terms/page.tsx +++ b/apps/web/src/app/terms/page.tsx @@ -1,317 +1,317 @@ -import { Metadata } from "next"; -import { Card, CardContent } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { GithubIcon } from "@/components/icons"; -import Link from "next/link"; -import { Footer } from "@/components/footer"; -import { Header } from "@/components/header"; - -export const metadata: Metadata = { - title: "Terms of Service - OpenCut", - description: - "OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.", - openGraph: { - title: "Terms of Service - OpenCut", - description: - "OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.", - type: "website", - }, -}; - -export default function TermsPage() { - return ( - <div className="min-h-screen bg-background"> - <Header /> - <main className="relative"> - <div className="absolute inset-0 overflow-hidden pointer-events-none"> - <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" /> - <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" /> - </div> - <div className="relative container mx-auto px-4 py-16"> - <div className="max-w-4xl mx-auto"> - <div className="text-center mb-10"> - <Link - href="https://github.com/OpenCut-app/OpenCut" - target="_blank" - > - <Badge variant="secondary" className="gap-2 mb-6"> - <GithubIcon className="h-3 w-3" /> - Open Source - </Badge> - </Link> - <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6"> - Terms of Service - </h1> - <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed"> - Fair and transparent terms for our free, open-source video - editor. - </p> - </div> - <Card className="bg-background/80 backdrop-blur-sm border-2 border-muted/30"> - <CardContent className="p-8 text-base leading-relaxed space-y-8"> - <section> - <h2 className="text-2xl font-semibold mb-4"> - Welcome to OpenCut - </h2> - <p className="mb-4"> - OpenCut is a free, open-source video editor that runs in - your browser. By using our service, you agree to these - terms. We've designed these terms to be fair and protect - both you and our project. - </p> - <p> - <strong>Key principle:</strong> Your content stays on your - device. We never claim ownership of your videos or projects. - </p> - </section> - - <section> - <h2 className="text-2xl font-semibold mb-4"> - Your Content, Your Rights - </h2> - <p className="mb-4"> - <strong>You own everything you create.</strong> OpenCut - processes your videos locally on your device, so we never - have access to your content. We make no claims to ownership, - licensing, or rights over your videos, projects, or any - content you create using OpenCut. - </p> - <ul className="list-disc pl-6 mb-4 space-y-2"> - <li> - Your videos remain completely private and under your - control - </li> - <li> - You retain all intellectual property rights to your - content - </li> - <li> - You can export and use your content however you choose - </li> - <li> - No watermarks, no licensing restrictions from OpenCut - </li> - </ul> - </section> - - <section> - <h2 className="text-2xl font-semibold mb-4"> - How You Can Use OpenCut - </h2> - <p className="mb-4"> - OpenCut is free for personal and commercial use. You can: - </p> - <ul className="list-disc pl-6 mb-4 space-y-2"> - <li> - Create videos for personal, educational, or commercial - purposes - </li> - <li>Use OpenCut for client work and paid projects</li> - <li>Share and distribute videos created with OpenCut</li> - <li> - Modify and distribute the OpenCut software (under MIT - license) - </li> - </ul> - <p> - <strong>What we ask:</strong> Don't use OpenCut for illegal - activities, harassment, or creating harmful content. Be - respectful of others and follow applicable laws. - </p> - </section> - - <section> - <h2 className="text-2xl font-semibold mb-4"> - Account and Service - </h2> - <p className="mb-4"> - To use certain features, you may create an account: - </p> - <ul className="list-disc pl-6 mb-4 space-y-2"> - <li>Provide accurate information when signing up</li> - <li> - Keep your account secure and don't share credentials - </li> - <li>You're responsible for activity under your account</li> - <li>You can delete your account at any time</li> - </ul> - <p> - OpenCut is provided "as is" without warranties. While we - strive for reliability, we can't guarantee uninterrupted - service. - </p> - </section> - - <section> - <h2 className="text-2xl font-semibold mb-4"> - Open Source Benefits - </h2> - <p className="mb-4"> - Because OpenCut is open source, you have additional rights: - </p> - <ul className="list-disc pl-6 mb-4 space-y-2"> - <li> - Review our code to see exactly how we handle your data - </li> - <li>Self-host OpenCut on your own servers</li> - <li>Modify the software to suit your needs</li> - <li>Contribute improvements back to the community</li> - </ul> - <p> - View our source code and license on{" "} - <a - href="https://github.com/OpenCut-app/OpenCut" - target="_blank" - rel="noopener" - className="text-primary hover:underline" - > - GitHub - </a> - . - </p> - </section> - - <section> - <h2 className="text-2xl font-semibold mb-4"> - Third-Party Content - </h2> - <p className="mb-4"> - When using OpenCut, make sure you have the right to use any - content you import: - </p> - <ul className="list-disc pl-6 mb-4 space-y-2"> - <li> - Only upload content you own or have permission to use - </li> - <li> - Respect copyright, trademarks, and other intellectual - property - </li> - <li> - Don't use copyrighted music, images, or videos without - permission - </li> - <li> - You're responsible for any claims related to your content - </li> - </ul> - </section> - - <section> - <h2 className="text-2xl font-semibold mb-4"> - Limitations and Liability - </h2> - <p className="mb-4"> - OpenCut is provided free of charge. To the extent permitted - by law: - </p> - <ul className="list-disc pl-6 mb-4 space-y-2"> - <li>We're not liable for any loss of data or content</li> - <li> - Projects are stored in your browser and may be lost if you - clear browser data - </li> - <li>We're not responsible for how you use the service</li> - <li> - Our liability is limited to the maximum extent allowed by - law - </li> - </ul> - <p> - Since your content stays on your device, we have no way to - recover lost projects. Consider exporting important videos - when finished editing. - </p> - </section> - - <section> - <h2 className="text-2xl font-semibold mb-4"> - Service Changes - </h2> - <p className="mb-4">We may update OpenCut and these terms:</p> - <ul className="list-disc pl-6 mb-4 space-y-2"> - <li> - We'll notify you of significant changes to these terms - </li> - <li>Continued use means you accept any updates</li> - <li> - You can always self-host an older version if you prefer - </li> - <li> - Major changes will be discussed with the community on - GitHub - </li> - </ul> - </section> - - <section> - <h2 className="text-2xl font-semibold mb-4">Termination</h2> - <p className="mb-4"> - You can stop using OpenCut at any time: - </p> - <ul className="list-disc pl-6 mb-4 space-y-2"> - <li>Delete your account through your profile settings</li> - <li>Clear your browser data to remove local projects</li> - <li> - Your content remains yours even if you stop using OpenCut - </li> - <li> - We may suspend accounts for violations of these terms - </li> - </ul> - </section> - - <section> - <h2 className="text-2xl font-semibold mb-4"> - Contact and Disputes - </h2> - <p className="mb-4"> - Questions about these terms or need to report an issue? - </p> - <p className="mb-4"> - Contact us through our{" "} - <a - href="https://github.com/OpenCut-app/OpenCut/issues" - target="_blank" - rel="noopener" - className="text-primary hover:underline" - > - GitHub repository - </a> - , email us at{" "} - <a - href="mailto:oss@opencut.app" - className="text-primary hover:underline" - > - oss@opencut.app - </a> - , or reach out on{" "} - <a - href="https://x.com/opencutapp" - target="_blank" - rel="noopener" - className="text-primary hover:underline" - > - X (Twitter) - </a> - . - </p> - <p> - These terms are governed by applicable law in your - jurisdiction. We prefer to resolve disputes through friendly - discussion in our open-source community. - </p> - </section> - - <p className="text-sm text-muted-foreground mt-8 pt-8 border-t border-muted/20"> - Last updated: July 14, 2025 - </p> - </CardContent> - </Card> - </div> - </div> - </main> - <Footer /> - </div> - ); -} +import { Metadata } from "next";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { GithubIcon } from "@/components/icons";
+import Link from "next/link";
+import { Footer } from "@/components/footer";
+import { Header } from "@/components/header";
+
+export const metadata: Metadata = {
+ title: "Terms of Service - OpenCut",
+ description:
+ "OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.",
+ openGraph: {
+ title: "Terms of Service - OpenCut",
+ description:
+ "OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.",
+ type: "website",
+ },
+};
+
+export default function TermsPage() {
+ return (
+ <div className="min-h-screen bg-background">
+ <Header />
+ <main className="relative">
+ <div className="absolute inset-0 overflow-hidden pointer-events-none">
+ <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
+ <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
+ </div>
+ <div className="relative container mx-auto px-4 py-16">
+ <div className="max-w-4xl mx-auto">
+ <div className="text-center mb-10">
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut"
+ target="_blank"
+ >
+ <Badge variant="secondary" className="gap-2 mb-6">
+ <GithubIcon className="h-3 w-3" />
+ Open Source
+ </Badge>
+ </Link>
+ <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
+ Terms of Service
+ </h1>
+ <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
+ Fair and transparent terms for our free, open-source video
+ editor.
+ </p>
+ </div>
+ <Card className="bg-background/80 backdrop-blur-sm border-2 border-muted/30">
+ <CardContent className="p-8 text-base leading-relaxed space-y-8">
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Welcome to OpenCut
+ </h2>
+ <p className="mb-4">
+ OpenCut is a free, open-source video editor that runs in
+ your browser. By using our service, you agree to these
+ terms. We've designed these terms to be fair and protect
+ both you and our project.
+ </p>
+ <p>
+ <strong>Key principle:</strong> Your content stays on your
+ device. We never claim ownership of your videos or projects.
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Your Content, Your Rights
+ </h2>
+ <p className="mb-4">
+ <strong>You own everything you create.</strong> OpenCut
+ processes your videos locally on your device, so we never
+ have access to your content. We make no claims to ownership,
+ licensing, or rights over your videos, projects, or any
+ content you create using OpenCut.
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>
+ Your videos remain completely private and under your
+ control
+ </li>
+ <li>
+ You retain all intellectual property rights to your
+ content
+ </li>
+ <li>
+ You can export and use your content however you choose
+ </li>
+ <li>
+ No watermarks, no licensing restrictions from OpenCut
+ </li>
+ </ul>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ How You Can Use OpenCut
+ </h2>
+ <p className="mb-4">
+ OpenCut is free for personal and commercial use. You can:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>
+ Create videos for personal, educational, or commercial
+ purposes
+ </li>
+ <li>Use OpenCut for client work and paid projects</li>
+ <li>Share and distribute videos created with OpenCut</li>
+ <li>
+ Modify and distribute the OpenCut software (under MIT
+ license)
+ </li>
+ </ul>
+ <p>
+ <strong>What we ask:</strong> Don't use OpenCut for illegal
+ activities, harassment, or creating harmful content. Be
+ respectful of others and follow applicable laws.
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Account and Service
+ </h2>
+ <p className="mb-4">
+ To use certain features, you may create an account:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>Provide accurate information when signing up</li>
+ <li>
+ Keep your account secure and don't share credentials
+ </li>
+ <li>You're responsible for activity under your account</li>
+ <li>You can delete your account at any time</li>
+ </ul>
+ <p>
+ OpenCut is provided "as is" without warranties. While we
+ strive for reliability, we can't guarantee uninterrupted
+ service.
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Open Source Benefits
+ </h2>
+ <p className="mb-4">
+ Because OpenCut is open source, you have additional rights:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>
+ Review our code to see exactly how we handle your data
+ </li>
+ <li>Self-host OpenCut on your own servers</li>
+ <li>Modify the software to suit your needs</li>
+ <li>Contribute improvements back to the community</li>
+ </ul>
+ <p>
+ View our source code and license on{" "}
+ <a
+ href="https://github.com/OpenCut-app/OpenCut"
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ GitHub
+ </a>
+ .
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Third-Party Content
+ </h2>
+ <p className="mb-4">
+ When using OpenCut, make sure you have the right to use any
+ content you import:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>
+ Only upload content you own or have permission to use
+ </li>
+ <li>
+ Respect copyright, trademarks, and other intellectual
+ property
+ </li>
+ <li>
+ Don't use copyrighted music, images, or videos without
+ permission
+ </li>
+ <li>
+ You're responsible for any claims related to your content
+ </li>
+ </ul>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Limitations and Liability
+ </h2>
+ <p className="mb-4">
+ OpenCut is provided free of charge. To the extent permitted
+ by law:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>We're not liable for any loss of data or content</li>
+ <li>
+ Projects are stored in your browser and may be lost if you
+ clear browser data
+ </li>
+ <li>We're not responsible for how you use the service</li>
+ <li>
+ Our liability is limited to the maximum extent allowed by
+ law
+ </li>
+ </ul>
+ <p>
+ Since your content stays on your device, we have no way to
+ recover lost projects. Consider exporting important videos
+ when finished editing.
+ </p>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Service Changes
+ </h2>
+ <p className="mb-4">We may update OpenCut and these terms:</p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>
+ We'll notify you of significant changes to these terms
+ </li>
+ <li>Continued use means you accept any updates</li>
+ <li>
+ You can always self-host an older version if you prefer
+ </li>
+ <li>
+ Major changes will be discussed with the community on
+ GitHub
+ </li>
+ </ul>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">Termination</h2>
+ <p className="mb-4">
+ You can stop using OpenCut at any time:
+ </p>
+ <ul className="list-disc pl-6 mb-4 space-y-2">
+ <li>Delete your account through your profile settings</li>
+ <li>Clear your browser data to remove local projects</li>
+ <li>
+ Your content remains yours even if you stop using OpenCut
+ </li>
+ <li>
+ We may suspend accounts for violations of these terms
+ </li>
+ </ul>
+ </section>
+
+ <section>
+ <h2 className="text-2xl font-semibold mb-4">
+ Contact and Disputes
+ </h2>
+ <p className="mb-4">
+ Questions about these terms or need to report an issue?
+ </p>
+ <p className="mb-4">
+ Contact us through our{" "}
+ <a
+ href="https://github.com/OpenCut-app/OpenCut/issues"
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ GitHub repository
+ </a>
+ , email us at{" "}
+ <a
+ href="mailto:oss@opencut.app"
+ className="text-primary hover:underline"
+ >
+ oss@opencut.app
+ </a>
+ , or reach out on{" "}
+ <a
+ href="https://x.com/opencutapp"
+ target="_blank"
+ rel="noopener"
+ className="text-primary hover:underline"
+ >
+ X (Twitter)
+ </a>
+ .
+ </p>
+ <p>
+ These terms are governed by applicable law in your
+ jurisdiction. We prefer to resolve disputes through friendly
+ discussion in our open-source community.
+ </p>
+ </section>
+
+ <p className="text-sm text-muted-foreground mt-8 pt-8 border-t border-muted/20">
+ Last updated: July 14, 2025
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+ </main>
+ <Footer />
+ </div>
+ );
+}
diff --git a/apps/web/src/app/why-not-capcut/page.tsx b/apps/web/src/app/why-not-capcut/page.tsx index 0e54ce0c..56b3d27a 100644 --- a/apps/web/src/app/why-not-capcut/page.tsx +++ b/apps/web/src/app/why-not-capcut/page.tsx @@ -1,191 +1,191 @@ -import { Header } from "@/components/header"; - -export default function WhyNotCapcut() { - return ( - <div className="min-h-screen bg-background px-5"> - <Header /> - - <main className="relative mt-12"> - <div className="absolute inset-0 overflow-hidden pointer-events-none"> - <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" /> - <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" /> - </div> - - <div className="relative container mx-auto px-4 py-16"> - <div className="max-w-6xl mx-auto"> - <div className="text-center mb-20"> - <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6"> - Fuck CapCut - </h1> - <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed"> - Roasting time, so get ready motherfucker. - </p> - </div> - - <div className="max-w-4xl mx-auto space-y-12"> - <div> - <h2 className="text-3xl font-bold mb-6"> - Seriously, what the fuck else do you want? - </h2> - <p className="text-lg mb-6"> - You probably use CapCut and think your video editing is - special. You think your fucking TikTok with 47 transitions and - 12 different fonts is going to get you some viral fame. You - think loading up every goddamn effect in their library makes - your content better. Wrong, motherfucker. Let me describe what - CapCut actually gives you: - </p> - <ul className="text-lg space-y-2 mb-6 list-disc list-inside"> - <li>A paywall every time you breathe</li> - <li>Terms of service that steal your shit</li> - <li> - More "Get Pro" dialogs than a Windows 95 error message - </li> - <li> - Features that disappear behind paywalls while you're fucking - using them - </li> - <li>Bugs disguised as "premium features"</li> - </ul> - <p className="text-lg mb-6"> - <strong>Well guess what, motherfucker:</strong> - </p> - <p className="text-lg mb-6"> - You. Are. Getting. Scammed. Look at this shit. It's a fucking - video editor. Why the fuck do you need to pay $20/month just - to remove a goddamn watermark? You spent hours editing your - video and they slap their logo on it like they fucking made - it. - </p> - </div> - - <div> - <h2 className="text-3xl font-bold mb-6"> - The "Get Pro" dialog is everywhere - </h2> - <p className="text-lg mb-6"> - This motherfucking dialog pops up more than ads on a pirated - movie site. Want to add a transition? Get Pro. Want to export - without their watermark? Get Pro. Want to use more than 2 - fonts? Get fucking Pro, peasant. - </p> - <p className="text-lg mb-6"> - Did you seriously think you could edit a video without seeing - this dialog 47 times? You click one button and BAM - there it - is again, asking for your credit card like a desperate ex - asking for money. - </p> - </div> - - <div> - <h2 className="text-3xl font-bold mb-6"> - Everything costs money now - </h2> - <p className="text-lg mb-6"> - You dumbass. You thought CapCut was free, but no. Free means - they let you open the app. Everything else costs money. Basic - shake effect? That'll be $20/month. A decent transition that - isn't "fade"? Pay up, motherfucker. - </p> - <p className="text-lg mb-6"> - Here's my favorite piece of bullshit: You import an MP3 file - - you know, AUDIO - and try to export. "Sorry, can't export - because you're using our premium extract audio feature!" - </p> - <p className="text-lg mb-6"> - <strong> - My MP3 was already fucking audio, you absolute morons. - </strong> - </p> - <p className="text-lg mb-6"> - But wait, there's more! If you drag that same MP3 to their - media panel first, then to the timeline, it magically works. - This isn't a bug, it's a fucking scam disguised as software - engineering. - </p> - </div> - - <div> - <h2 className="text-3xl font-bold mb-6"> - Their Terms of Service are insane - </h2> - <p className="text-lg mb-6"> - Look at this shit. You upload your content and they basically - say "thanks for the free content, we own it now, but if Disney - sues anyone, that's your problem." - </p> - <p className="text-lg mb-6"> - <strong>CapCut's Terms of Service:</strong> We get full rights - to use, modify, distribute, and monetize everything you upload - - permanently and without paying you shit. But you're still - responsible if anything goes wrong. - </p> - <p className="text-lg mb-6"> - Translation: "We'll make money off your viral video, you - handle the lawsuits." Brilliant legal strategy, you fucks. - </p> - </div> - - <div> - <h2 className="text-3xl font-bold mb-6"> - The editor is actually good - </h2> - <p className="text-lg mb-6"> - Here's the thing that makes me want to punch my monitor: the - actual video editor is fucking good. It's intuitive, powerful, - and anyone can figure it out. When it's not begging for money - every 30 seconds, it actually works well. - </p> - <p className="text-lg mb-6"> - Which makes everything else so much worse. They built - something people want to use, then turned it into a digital - slot machine. Every click might trigger a payment request. - </p> - </div> - - <div> - <h2 className="text-3xl font-bold mb-6"> - This is a video editor. Look at it. You've never seen one - before. - </h2> - <p className="text-lg mb-6"> - Like the person who's never used software that doesn't - constantly beg for money, you have no fucking idea what a - video editor should be. All you've ever seen are predatory - apps disguised as creative tools. - </p> - <p className="text-lg mb-6"> - A real video editor lets you edit videos. It doesn't steal - your content. It doesn't pop up payment dialogs every 5 - seconds. It doesn't charge you separately for basic features - that should be free. - </p> - </div> - - <div> - <h2 className="text-3xl font-bold mb-6"> - Yes, this is fucking satire, you fuck - </h2> - <p className="text-lg mb-6"> - I'm not actually saying all video editors should be basic as - shit. What I'm saying is that all the problems we have with - video editing apps are{" "} - <strong>ones they create themselves</strong>. Video editors - aren't broken by default - they edit videos, export them, and - let you use basic features without constantly begging for - money. CapCut breaks them. They turn them into payment - processors with video editing as a side feature. - </p> - <p className="text-lg"> - <em>"Good software gets out of your way."</em> - <br />- Some smart motherfucker who definitely wasn't working - at CapCut - </p> - </div> - </div> - </div> - </div> - </main> - </div> - ); -} +import { Header } from "@/components/header";
+
+export default function WhyNotCapcut() {
+ return (
+ <div className="min-h-screen bg-background px-5">
+ <Header />
+
+ <main className="relative mt-12">
+ <div className="absolute inset-0 overflow-hidden pointer-events-none">
+ <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
+ <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
+ </div>
+
+ <div className="relative container mx-auto px-4 py-16">
+ <div className="max-w-6xl mx-auto">
+ <div className="text-center mb-20">
+ <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
+ Fuck CapCut
+ </h1>
+ <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
+ Roasting time, so get ready motherfucker.
+ </p>
+ </div>
+
+ <div className="max-w-4xl mx-auto space-y-12">
+ <div>
+ <h2 className="text-3xl font-bold mb-6">
+ Seriously, what the fuck else do you want?
+ </h2>
+ <p className="text-lg mb-6">
+ You probably use CapCut and think your video editing is
+ special. You think your fucking TikTok with 47 transitions and
+ 12 different fonts is going to get you some viral fame. You
+ think loading up every goddamn effect in their library makes
+ your content better. Wrong, motherfucker. Let me describe what
+ CapCut actually gives you:
+ </p>
+ <ul className="text-lg space-y-2 mb-6 list-disc list-inside">
+ <li>A paywall every time you breathe</li>
+ <li>Terms of service that steal your shit</li>
+ <li>
+ More "Get Pro" dialogs than a Windows 95 error message
+ </li>
+ <li>
+ Features that disappear behind paywalls while you're fucking
+ using them
+ </li>
+ <li>Bugs disguised as "premium features"</li>
+ </ul>
+ <p className="text-lg mb-6">
+ <strong>Well guess what, motherfucker:</strong>
+ </p>
+ <p className="text-lg mb-6">
+ You. Are. Getting. Scammed. Look at this shit. It's a fucking
+ video editor. Why the fuck do you need to pay $20/month just
+ to remove a goddamn watermark? You spent hours editing your
+ video and they slap their logo on it like they fucking made
+ it.
+ </p>
+ </div>
+
+ <div>
+ <h2 className="text-3xl font-bold mb-6">
+ The "Get Pro" dialog is everywhere
+ </h2>
+ <p className="text-lg mb-6">
+ This motherfucking dialog pops up more than ads on a pirated
+ movie site. Want to add a transition? Get Pro. Want to export
+ without their watermark? Get Pro. Want to use more than 2
+ fonts? Get fucking Pro, peasant.
+ </p>
+ <p className="text-lg mb-6">
+ Did you seriously think you could edit a video without seeing
+ this dialog 47 times? You click one button and BAM - there it
+ is again, asking for your credit card like a desperate ex
+ asking for money.
+ </p>
+ </div>
+
+ <div>
+ <h2 className="text-3xl font-bold mb-6">
+ Everything costs money now
+ </h2>
+ <p className="text-lg mb-6">
+ You dumbass. You thought CapCut was free, but no. Free means
+ they let you open the app. Everything else costs money. Basic
+ shake effect? That'll be $20/month. A decent transition that isn't
+ "fade"? Pay up, motherfucker.
+ </p>
+ <p className="text-lg mb-6">
+ Here's my favorite piece of bullshit: You import an MP3 file -
+ you know, AUDIO - and try to export. "Sorry, can't export
+ because you're using our premium extract audio feature!"
+ </p>
+ <p className="text-lg mb-6">
+ <strong>
+ My MP3 was already fucking audio, you absolute morons.
+ </strong>
+ </p>
+ <p className="text-lg mb-6">
+ But wait, there's more! If you drag that same MP3 to their
+ media panel first, then to the timeline, it magically works.
+ This isn't a bug, it's a fucking scam disguised as software
+ engineering.
+ </p>
+ </div>
+
+ <div>
+ <h2 className="text-3xl font-bold mb-6">
+ Their Terms of Service are insane
+ </h2>
+ <p className="text-lg mb-6">
+ Look at this shit. You upload your content and they basically
+ say "thanks for the free content, we own it now, but if Disney
+ sues anyone, that's your problem."
+ </p>
+ <p className="text-lg mb-6">
+ <strong>CapCut's Terms of Service:</strong> We get full rights
+ to use, modify, distribute, and monetize everything you upload
+ - permanently and without paying you shit. But you're still
+ responsible if anything goes wrong.
+ </p>
+ <p className="text-lg mb-6">
+ Translation: "We'll make money off your viral video, you
+ handle the lawsuits." Brilliant legal strategy, you fucks.
+ </p>
+ </div>
+
+ <div>
+ <h2 className="text-3xl font-bold mb-6">
+ The editor is actually good
+ </h2>
+ <p className="text-lg mb-6">
+ Here's the thing that makes me want to punch my monitor: the
+ actual video editor is fucking good. It's intuitive, powerful,
+ and anyone can figure it out. When it's not begging for money
+ every 30 seconds, it actually works well.
+ </p>
+ <p className="text-lg mb-6">
+ Which makes everything else so much worse. They built
+ something people want to use, then turned it into a digital
+ slot machine. Every click might trigger a payment request.
+ </p>
+ </div>
+
+ <div>
+ <h2 className="text-3xl font-bold mb-6">
+ This is a video editor. Look at it. You've never seen one
+ before.
+ </h2>
+ <p className="text-lg mb-6">
+ Like the person who's never used software that doesn't
+ constantly beg for money, you have no fucking idea what a
+ video editor should be. All you've ever seen are predatory
+ apps disguised as creative tools.
+ </p>
+ <p className="text-lg mb-6">
+ A real video editor lets you edit videos. It doesn't steal
+ your content. It doesn't pop up payment dialogs every 5
+ seconds. It doesn't charge you separately for basic features
+ that should be free.
+ </p>
+ </div>
+
+ <div>
+ <h2 className="text-3xl font-bold mb-6">
+ Yes, this is fucking satire, you fuck
+ </h2>
+ <p className="text-lg mb-6">
+ I'm not actually saying all video editors should be basic as
+ shit. What I'm saying is that all the problems we have with
+ video editing apps are{" "}
+ <strong>ones they create themselves</strong>. Video editors
+ aren't broken by default - they edit videos, export them, and
+ let you use basic features without constantly begging for
+ money. CapCut breaks them. They turn them into payment
+ processors with video editing as a side feature.
+ </p>
+ <p className="text-lg">
+ <em>"Good software gets out of your way."</em>
+ <br />- Some smart motherfucker who definitely wasn't working
+ at CapCut
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/background-settings.tsx b/apps/web/src/components/background-settings.tsx index 1d80465b..721564ea 100644 --- a/apps/web/src/components/background-settings.tsx +++ b/apps/web/src/components/background-settings.tsx @@ -1,184 +1,184 @@ -import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; -import { Button } from "./ui/button"; -import { BackgroundIcon } from "./icons"; -import { cn } from "@/lib/utils"; -import Image from "next/image"; -import { colors } from "@/data/colors"; -import { useProjectStore } from "@/stores/project-store"; -import { PipetteIcon } from "lucide-react"; - -type BackgroundTab = "color" | "blur"; - -export function BackgroundSettings() { - const { activeProject, updateBackgroundType } = useProjectStore(); - - // ✅ Good: derive activeTab from activeProject during rendering - const activeTab = activeProject?.backgroundType || "color"; - - const handleColorSelect = (color: string) => { - updateBackgroundType("color", { backgroundColor: color }); - }; - - const handleBlurSelect = (blurIntensity: number) => { - updateBackgroundType("blur", { blurIntensity }); - }; - - const tabs = [ - { - label: "Color", - value: "color", - }, - { - label: "Blur", - value: "blur", - }, - ]; - - return ( - <Popover> - <PopoverTrigger asChild> - <Button - variant="text" - size="icon" - className="!size-4 border border-muted-foreground" - > - <BackgroundIcon className="!size-3" /> - </Button> - </PopoverTrigger> - <PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0"> - <div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3"> - <h2 className="text-sm">Background</h2> - <div className="flex items-center gap-2 text-sm"> - {tabs.map((tab) => ( - <span - key={tab.value} - onClick={() => { - // Switch to the background type when clicking tabs - if (tab.value === "color") { - updateBackgroundType("color", { - backgroundColor: - activeProject?.backgroundColor || "#000000", - }); - } else { - updateBackgroundType("blur", { - blurIntensity: activeProject?.blurIntensity || 8, - }); - } - }} - className={cn( - "text-muted-foreground cursor-pointer", - activeTab === tab.value && "text-foreground", - )} - > - {tab.label} - </span> - ))} - </div> - </div> - {activeTab === "color" ? ( - <ColorView - selectedColor={activeProject?.backgroundColor || "#000000"} - onColorSelect={handleColorSelect} - /> - ) : ( - <BlurView - selectedBlur={activeProject?.blurIntensity || 8} - onBlurSelect={handleBlurSelect} - /> - )} - </PopoverContent> - </Popover> - ); -} - -function ColorView({ - selectedColor, - onColorSelect, -}: { - selectedColor: string; - onColorSelect: (color: string) => void; -}) { - return ( - <div className="w-full h-full"> - <div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-gradient-to-b from-popover to-transparent pointer-events-none"></div> - <div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto"> - <div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center"> - <PipetteIcon className="size-4" /> - </div> - {colors.map((color) => ( - <ColorItem - key={color} - color={color} - isSelected={color === selectedColor} - onClick={() => onColorSelect(color)} - /> - ))} - </div> - </div> - ); -} - -function ColorItem({ - color, - isSelected, - onClick, -}: { - color: string; - isSelected: boolean; - onClick: () => void; -}) { - return ( - <div - className={cn( - "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary", - isSelected && "border-2 border-primary", - )} - style={{ backgroundColor: color }} - onClick={onClick} - /> - ); -} - -function BlurView({ - selectedBlur, - onBlurSelect, -}: { - selectedBlur: number; - onBlurSelect: (blurIntensity: number) => void; -}) { - const blurLevels = [ - { label: "Light", value: 4 }, - { label: "Medium", value: 8 }, - { label: "Heavy", value: 18 }, - ]; - const blurImage = - "https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"; - - return ( - <div className="grid grid-cols-3 gap-2 w-full p-3 pt-0"> - {blurLevels.map((blur) => ( - <div - key={blur.value} - className={cn( - "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden", - selectedBlur === blur.value && "border-2 border-primary", - )} - onClick={() => onBlurSelect(blur.value)} - > - <Image - src={blurImage} - alt={`Blur preview ${blur.label}`} - fill - className="object-cover" - style={{ filter: `blur(${blur.value}px)` }} - /> - <div className="absolute bottom-1 left-1 right-1 text-center"> - <span className="text-xs text-white bg-black/50 px-1 rounded"> - {blur.label} - </span> - </div> - </div> - ))} - </div> - ); -} +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
+import { Button } from "./ui/button";
+import { BackgroundIcon } from "./icons";
+import { cn } from "@/lib/utils";
+import Image from "next/image";
+import { colors } from "@/data/colors";
+import { useProjectStore } from "@/stores/project-store";
+import { PipetteIcon } from "lucide-react";
+
+type BackgroundTab = "color" | "blur";
+
+export function BackgroundSettings() {
+ const { activeProject, updateBackgroundType } = useProjectStore();
+
+ // ✅ Good: derive activeTab from activeProject during rendering
+ const activeTab = activeProject?.backgroundType || "color";
+
+ const handleColorSelect = (color: string) => {
+ updateBackgroundType("color", { backgroundColor: color });
+ };
+
+ const handleBlurSelect = (blurIntensity: number) => {
+ updateBackgroundType("blur", { blurIntensity });
+ };
+
+ const tabs = [
+ {
+ label: "Color",
+ value: "color",
+ },
+ {
+ label: "Blur",
+ value: "blur",
+ },
+ ];
+
+ return (
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ className="!size-4 border border-muted-foreground"
+ >
+ <BackgroundIcon className="!size-3" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0">
+ <div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3">
+ <h2 className="text-sm">Background</h2>
+ <div className="flex items-center gap-2 text-sm">
+ {tabs.map((tab) => (
+ <span
+ key={tab.value}
+ onClick={() => {
+ // Switch to the background type when clicking tabs
+ if (tab.value === "color") {
+ updateBackgroundType("color", {
+ backgroundColor:
+ activeProject?.backgroundColor || "#000000",
+ });
+ } else {
+ updateBackgroundType("blur", {
+ blurIntensity: activeProject?.blurIntensity || 8,
+ });
+ }
+ }}
+ className={cn(
+ "text-muted-foreground cursor-pointer",
+ activeTab === tab.value && "text-foreground"
+ )}
+ >
+ {tab.label}
+ </span>
+ ))}
+ </div>
+ </div>
+ {activeTab === "color" ? (
+ <ColorView
+ selectedColor={activeProject?.backgroundColor || "#000000"}
+ onColorSelect={handleColorSelect}
+ />
+ ) : (
+ <BlurView
+ selectedBlur={activeProject?.blurIntensity || 8}
+ onBlurSelect={handleBlurSelect}
+ />
+ )}
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function ColorView({
+ selectedColor,
+ onColorSelect,
+}: {
+ selectedColor: string;
+ onColorSelect: (color: string) => void;
+}) {
+ return (
+ <div className="w-full h-full">
+ <div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-gradient-to-b from-popover to-transparent pointer-events-none"></div>
+ <div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto">
+ <div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
+ <PipetteIcon className="size-4" />
+ </div>
+ {colors.map((color) => (
+ <ColorItem
+ key={color}
+ color={color}
+ isSelected={color === selectedColor}
+ onClick={() => onColorSelect(color)}
+ />
+ ))}
+ </div>
+ </div>
+ );
+}
+
+function ColorItem({
+ color,
+ isSelected,
+ onClick,
+}: {
+ color: string;
+ isSelected: boolean;
+ onClick: () => void;
+}) {
+ return (
+ <div
+ className={cn(
+ "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary",
+ isSelected && "border-2 border-primary"
+ )}
+ style={{ backgroundColor: color }}
+ onClick={onClick}
+ />
+ );
+}
+
+function BlurView({
+ selectedBlur,
+ onBlurSelect,
+}: {
+ selectedBlur: number;
+ onBlurSelect: (blurIntensity: number) => void;
+}) {
+ const blurLevels = [
+ { label: "Light", value: 4 },
+ { label: "Medium", value: 8 },
+ { label: "Heavy", value: 18 },
+ ];
+ const blurImage =
+ "https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";
+
+ return (
+ <div className="grid grid-cols-3 gap-2 w-full p-3 pt-0">
+ {blurLevels.map((blur) => (
+ <div
+ key={blur.value}
+ className={cn(
+ "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden",
+ selectedBlur === blur.value && "border-2 border-primary"
+ )}
+ onClick={() => onBlurSelect(blur.value)}
+ >
+ <Image
+ src={blurImage}
+ alt={`Blur preview ${blur.label}`}
+ fill
+ className="object-cover"
+ style={{ filter: `blur(${blur.value}px)` }}
+ />
+ <div className="absolute bottom-1 left-1 right-1 text-center">
+ <span className="text-xs text-white bg-black/50 px-1 rounded">
+ {blur.label}
+ </span>
+ </div>
+ </div>
+ ))}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/delete-project-dialog.tsx b/apps/web/src/components/delete-project-dialog.tsx index f16eacf4..e2b32dda 100644 --- a/apps/web/src/components/delete-project-dialog.tsx +++ b/apps/web/src/components/delete-project-dialog.tsx @@ -1,68 +1,68 @@ import { Button } from "@/components/ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; export function DeleteProjectDialog({ - isOpen, - onOpenChange, - onConfirm, - projectName, + isOpen, + onOpenChange, + onConfirm, + projectName, }: { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: () => void; - projectName?: string; + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + projectName?: string; }) { - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent - onOpenAutoFocus={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - > - <DialogHeader> - <DialogTitle> - {projectName ? ( - <> - {"Delete '"} - <span className="inline-block max-w-[300px] truncate align-bottom"> - {projectName} - </span> - {"'?"} - </> - ) : ( - "Delete Project?" - )} - </DialogTitle> - <DialogDescription> - Are you sure you want to delete this project? This action cannot be - undone. - </DialogDescription> - </DialogHeader> - <DialogFooter> - <Button - variant="outline" - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - onOpenChange(false); - }} - > - Cancel - </Button> - <Button variant="destructive" onClick={onConfirm}> - Delete - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogContent + onOpenAutoFocus={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + > + <DialogHeader> + <DialogTitle> + {projectName ? ( + <> + {"Delete '"} + <span className="inline-block max-w-[300px] truncate align-bottom"> + {projectName} + </span> + {"'?"} + </> + ) : ( + "Delete Project?" + )} + </DialogTitle> + <DialogDescription> + Are you sure you want to delete this project? This action cannot be + undone. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button + variant="outline" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onOpenChange(false); + }} + > + Cancel + </Button> + <Button variant="destructive" onClick={onConfirm}> + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); } diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index 99b9d8e3..3b51e182 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -1,68 +1,68 @@ -"use client"; - -import Link from "next/link"; -import { Button } from "./ui/button"; -import { ChevronLeft, Download } from "lucide-react"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { HeaderBase } from "./header-base"; -import { formatTimeCode } from "@/lib/time"; -import { useProjectStore } from "@/stores/project-store"; -import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help"; - -export function EditorHeader() { - const { getTotalDuration } = useTimelineStore(); - const { activeProject } = useProjectStore(); - - const handleExport = () => { - // TODO: Implement export functionality - console.log("Export project"); - }; - - const leftContent = ( - <div className="flex items-center gap-2"> - <Link - href="/projects" - className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity" - > - <ChevronLeft className="h-4 w-4" /> - <span className="text-sm">{activeProject?.name}</span> - </Link> - </div> - ); - - const centerContent = ( - <div className="flex items-center gap-2 text-xs"> - <span> - {formatTimeCode( - getTotalDuration(), - "HH:MM:SS:FF", - activeProject?.fps || 30, - )} - </span> - </div> - ); - - const rightContent = ( - <nav className="flex items-center gap-2"> - <KeyboardShortcutsHelp /> - <Button - size="sm" - variant="primary" - className="h-7 text-xs" - onClick={handleExport} - > - <Download className="h-4 w-4" /> - <span className="text-sm">Export</span> - </Button> - </nav> - ); - - return ( - <HeaderBase - leftContent={leftContent} - centerContent={centerContent} - rightContent={rightContent} - className="bg-background h-[3.2rem] px-4" - /> - ); -} +"use client";
+
+import Link from "next/link";
+import { Button } from "./ui/button";
+import { ChevronLeft, Download } from "lucide-react";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { HeaderBase } from "./header-base";
+import { formatTimeCode } from "@/lib/time";
+import { useProjectStore } from "@/stores/project-store";
+import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help";
+
+export function EditorHeader() {
+ const { getTotalDuration } = useTimelineStore();
+ const { activeProject } = useProjectStore();
+
+ const handleExport = () => {
+ // TODO: Implement export functionality
+ console.log("Export project");
+ };
+
+ const leftContent = (
+ <div className="flex items-center gap-2">
+ <Link
+ href="/projects"
+ className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
+ >
+ <ChevronLeft className="h-4 w-4" />
+ <span className="text-sm">{activeProject?.name}</span>
+ </Link>
+ </div>
+ );
+
+ const centerContent = (
+ <div className="flex items-center gap-2 text-xs">
+ <span>
+ {formatTimeCode(
+ getTotalDuration(),
+ "HH:MM:SS:FF",
+ activeProject?.fps || 30
+ )}
+ </span>
+ </div>
+ );
+
+ const rightContent = (
+ <nav className="flex items-center gap-2">
+ <KeyboardShortcutsHelp />
+ <Button
+ size="sm"
+ variant="primary"
+ className="h-7 text-xs"
+ onClick={handleExport}
+ >
+ <Download className="h-4 w-4" />
+ <span className="text-sm">Export</span>
+ </Button>
+ </nav>
+ );
+
+ return (
+ <HeaderBase
+ leftContent={leftContent}
+ centerContent={centerContent}
+ rightContent={rightContent}
+ className="bg-background h-[3.2rem] px-4"
+ />
+ );
+}
diff --git a/apps/web/src/components/editor-provider.tsx b/apps/web/src/components/editor-provider.tsx index c036b507..25d040db 100644 --- a/apps/web/src/components/editor-provider.tsx +++ b/apps/web/src/components/editor-provider.tsx @@ -1,53 +1,53 @@ -"use client"; - -import { useEffect } from "react"; -import { Loader2 } from "lucide-react"; -import { useEditorStore } from "@/stores/editor-store"; -import { - useKeybindingsListener, - useKeybindingDisabler, -} from "@/hooks/use-keybindings"; -import { useEditorActions } from "@/hooks/use-editor-actions"; - -interface EditorProviderProps { - children: React.ReactNode; -} - -export function EditorProvider({ children }: EditorProviderProps) { - const { isInitializing, isPanelsReady, initializeApp } = useEditorStore(); - const { disableKeybindings, enableKeybindings } = useKeybindingDisabler(); - - // Set up action handlers - useEditorActions(); - - // Set up keybinding listener - useKeybindingsListener(); - - // Disable keybindings when initializing - useEffect(() => { - if (isInitializing || !isPanelsReady) { - disableKeybindings(); - } else { - enableKeybindings(); - } - }, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]); - - useEffect(() => { - initializeApp(); - }, [initializeApp]); - - // Show loading screen while initializing - if (isInitializing || !isPanelsReady) { - return ( - <div className="h-screen w-screen flex items-center justify-center bg-background"> - <div className="flex flex-col items-center gap-4"> - <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> - <p className="text-sm text-muted-foreground">Loading editor...</p> - </div> - </div> - ); - } - - // App is ready, render children - return <>{children}</>; -} +"use client";
+
+import { useEffect } from "react";
+import { Loader2 } from "lucide-react";
+import { useEditorStore } from "@/stores/editor-store";
+import {
+ useKeybindingsListener,
+ useKeybindingDisabler,
+} from "@/hooks/use-keybindings";
+import { useEditorActions } from "@/hooks/use-editor-actions";
+
+interface EditorProviderProps {
+ children: React.ReactNode;
+}
+
+export function EditorProvider({ children }: EditorProviderProps) {
+ const { isInitializing, isPanelsReady, initializeApp } = useEditorStore();
+ const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
+
+ // Set up action handlers
+ useEditorActions();
+
+ // Set up keybinding listener
+ useKeybindingsListener();
+
+ // Disable keybindings when initializing
+ useEffect(() => {
+ if (isInitializing || !isPanelsReady) {
+ disableKeybindings();
+ } else {
+ enableKeybindings();
+ }
+ }, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]);
+
+ useEffect(() => {
+ initializeApp();
+ }, [initializeApp]);
+
+ // Show loading screen while initializing
+ if (isInitializing || !isPanelsReady) {
+ return (
+ <div className="h-screen w-screen flex items-center justify-center bg-background">
+ <div className="flex flex-col items-center gap-4">
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+ <p className="text-sm text-muted-foreground">Loading editor...</p>
+ </div>
+ </div>
+ );
+ }
+
+ // App is ready, render children
+ return <>{children}</>;
+}
diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx index cae2359b..75a5ae06 100644 --- a/apps/web/src/components/editor/audio-waveform.tsx +++ b/apps/web/src/components/editor/audio-waveform.tsx @@ -1,117 +1,115 @@ -import React, { useEffect, useRef, useState } from "react"; -import WaveSurfer from "wavesurfer.js"; +import React, { useEffect, useRef, useState } from 'react'; +import WaveSurfer from 'wavesurfer.js'; interface AudioWaveformProps { - audioUrl: string; - height?: number; - className?: string; + audioUrl: string; + height?: number; + className?: string; } -const AudioWaveform: React.FC<AudioWaveformProps> = ({ - audioUrl, - height = 32, - className = "", +const AudioWaveform: React.FC<AudioWaveformProps> = ({ + audioUrl, + height = 32, + className = '' }) => { - const waveformRef = useRef<HTMLDivElement>(null); - const wavesurfer = useRef<WaveSurfer | null>(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(false); + const waveformRef = useRef<HTMLDivElement>(null); + const wavesurfer = useRef<WaveSurfer | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(false); - useEffect(() => { - let mounted = true; + useEffect(() => { + let mounted = true; - const initWaveSurfer = async () => { - if (!waveformRef.current || !audioUrl) return; + const initWaveSurfer = async () => { + if (!waveformRef.current || !audioUrl) return; - try { - // Clean up any existing instance - if (wavesurfer.current) { - try { - wavesurfer.current.destroy(); - } catch (e) { - // Silently ignore destroy errors - } - wavesurfer.current = null; - } + try { + // Clean up any existing instance + if (wavesurfer.current) { + try { + wavesurfer.current.destroy(); + } catch (e) { + // Silently ignore destroy errors + } + wavesurfer.current = null; + } - wavesurfer.current = WaveSurfer.create({ - container: waveformRef.current, - waveColor: "rgba(255, 255, 255, 0.6)", - progressColor: "rgba(255, 255, 255, 0.9)", - cursorColor: "transparent", - barWidth: 2, - barGap: 1, - height: height, - normalize: true, - interact: false, - }); + wavesurfer.current = WaveSurfer.create({ + container: waveformRef.current, + waveColor: 'rgba(255, 255, 255, 0.6)', + progressColor: 'rgba(255, 255, 255, 0.9)', + cursorColor: 'transparent', + barWidth: 2, + barGap: 1, + height: height, + normalize: true, + interact: false, + }); - // Event listeners - wavesurfer.current.on("ready", () => { - if (mounted) { - setIsLoading(false); - setError(false); - } - }); + // Event listeners + wavesurfer.current.on('ready', () => { + if (mounted) { + setIsLoading(false); + setError(false); + } + }); - wavesurfer.current.on("error", (err) => { - console.error("WaveSurfer error:", err); - if (mounted) { - setError(true); - setIsLoading(false); - } - }); + wavesurfer.current.on('error', (err) => { + console.error('WaveSurfer error:', err); + if (mounted) { + setError(true); + setIsLoading(false); + } + }); - await wavesurfer.current.load(audioUrl); - } catch (err) { - console.error("Failed to initialize WaveSurfer:", err); - if (mounted) { - setError(true); - setIsLoading(false); - } - } - }; + await wavesurfer.current.load(audioUrl); - initWaveSurfer(); + } catch (err) { + console.error('Failed to initialize WaveSurfer:', err); + if (mounted) { + setError(true); + setIsLoading(false); + } + } + }; - return () => { - mounted = false; - if (wavesurfer.current) { - try { - wavesurfer.current.destroy(); - } catch (e) { - // Silently ignore destroy errors - } - wavesurfer.current = null; - } - }; - }, [audioUrl, height]); + initWaveSurfer(); - if (error) { - return ( - <div - className={`flex items-center justify-center ${className}`} - style={{ height }} - > - <span className="text-xs text-foreground/60">Audio unavailable</span> - </div> - ); - } + return () => { + mounted = false; + if (wavesurfer.current) { + try { + wavesurfer.current.destroy(); + } catch (e) { + // Silently ignore destroy errors + } + wavesurfer.current = null; + } + }; + }, [audioUrl, height]); - return ( - <div className={`relative ${className}`}> - {isLoading && ( - <div className="absolute inset-0 flex items-center justify-center"> - <span className="text-xs text-foreground/60">Loading...</span> - </div> - )} - <div - ref={waveformRef} - className={`w-full transition-opacity duration-200 ${isLoading ? "opacity-0" : "opacity-100"}`} - style={{ height }} - /> - </div> - ); + if (error) { + return ( + <div className={`flex items-center justify-center ${className}`} style={{ height }}> + <span className="text-xs text-foreground/60">Audio unavailable</span> + </div> + ); + } + + return ( + <div className={`relative ${className}`}> + {isLoading && ( + <div className="absolute inset-0 flex items-center justify-center"> + <span className="text-xs text-foreground/60">Loading...</span> + </div> + )} + <div + ref={waveformRef} + className={`w-full transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`} + style={{ height }} + /> + </div> + ); }; -export default AudioWaveform; +export default AudioWaveform;
\ No newline at end of file diff --git a/apps/web/src/components/editor/media-panel/drag-overlay.tsx b/apps/web/src/components/editor/media-panel/drag-overlay.tsx index 0c75d146..3f3820db 100644 --- a/apps/web/src/components/editor/media-panel/drag-overlay.tsx +++ b/apps/web/src/components/editor/media-panel/drag-overlay.tsx @@ -1,57 +1,57 @@ -import { Upload, Plus, Image } from "lucide-react"; -import { Button } from "@/components/ui/button"; - -interface MediaDragOverlayProps { - isVisible: boolean; - isProcessing?: boolean; - progress?: number; - onClick?: () => void; - isEmptyState?: boolean; -} - -export function MediaDragOverlay({ - isVisible, - isProcessing = false, - progress = 0, - onClick, - isEmptyState = false, -}: MediaDragOverlayProps) { - if (!isVisible) return null; - - const handleClick = (e: React.MouseEvent) => { - if (isProcessing || !onClick) return; - e.preventDefault(); - e.stopPropagation(); - onClick(); - }; - - return ( - <div - className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8" - onClick={handleClick} - > - <div className="flex items-center justify-center"> - <Upload className="h-10 w-10 text-foreground" /> - </div> - - <div className="space-y-2"> - <p className="text-xs text-muted-foreground max-w-sm"> - {isProcessing - ? `Processing your files (${progress}%)` - : "Drag and drop videos, photos, and audio files here"} - </p> - </div> - - {isProcessing && ( - <div className="w-full max-w-xs"> - <div className="w-full bg-muted/50 rounded-full h-2"> - <div - className="bg-primary h-2 rounded-full transition-all duration-300" - style={{ width: `${progress}%` }} - /> - </div> - </div> - )} - </div> - ); -} +import { Upload, Plus, Image } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+interface MediaDragOverlayProps {
+ isVisible: boolean;
+ isProcessing?: boolean;
+ progress?: number;
+ onClick?: () => void;
+ isEmptyState?: boolean;
+}
+
+export function MediaDragOverlay({
+ isVisible,
+ isProcessing = false,
+ progress = 0,
+ onClick,
+ isEmptyState = false,
+}: MediaDragOverlayProps) {
+ if (!isVisible) return null;
+
+ const handleClick = (e: React.MouseEvent) => {
+ if (isProcessing || !onClick) return;
+ e.preventDefault();
+ e.stopPropagation();
+ onClick();
+ };
+
+ return (
+ <div
+ className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8"
+ onClick={handleClick}
+ >
+ <div className="flex items-center justify-center">
+ <Upload className="h-10 w-10 text-foreground" />
+ </div>
+
+ <div className="space-y-2">
+ <p className="text-xs text-muted-foreground max-w-sm">
+ {isProcessing
+ ? `Processing your files (${progress}%)`
+ : "Drag and drop videos, photos, and audio files here"}
+ </p>
+ </div>
+
+ {isProcessing && (
+ <div className="w-full max-w-xs">
+ <div className="w-full bg-muted/50 rounded-full h-2">
+ <div
+ className="bg-primary h-2 rounded-full transition-all duration-300"
+ style={{ width: `${progress}%` }}
+ />
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 50fee601..9f7322c7 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -1,55 +1,55 @@ -"use client"; - -import { TabBar } from "./tabbar"; -import { MediaView } from "./views/media"; -import { useMediaPanelStore, Tab } from "./store"; -import { TextView } from "./views/text"; - -export function MediaPanel() { - const { activeTab } = useMediaPanelStore(); - - const viewMap: Record<Tab, React.ReactNode> = { - media: <MediaView />, - audio: ( - <div className="p-4 text-muted-foreground">Audio view coming soon...</div> - ), - text: <TextView />, - stickers: ( - <div className="p-4 text-muted-foreground"> - Stickers view coming soon... - </div> - ), - effects: ( - <div className="p-4 text-muted-foreground"> - Effects view coming soon... - </div> - ), - transitions: ( - <div className="p-4 text-muted-foreground"> - Transitions view coming soon... - </div> - ), - captions: ( - <div className="p-4 text-muted-foreground"> - Captions view coming soon... - </div> - ), - filters: ( - <div className="p-4 text-muted-foreground"> - Filters view coming soon... - </div> - ), - adjustment: ( - <div className="p-4 text-muted-foreground"> - Adjustment view coming soon... - </div> - ), - }; - - return ( - <div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden"> - <TabBar /> - <div className="flex-1">{viewMap[activeTab]}</div> - </div> - ); -} +"use client";
+
+import { TabBar } from "./tabbar";
+import { MediaView } from "./views/media";
+import { useMediaPanelStore, Tab } from "./store";
+import { TextView } from "./views/text";
+
+export function MediaPanel() {
+ const { activeTab } = useMediaPanelStore();
+
+ const viewMap: Record<Tab, React.ReactNode> = {
+ media: <MediaView />,
+ audio: (
+ <div className="p-4 text-muted-foreground">Audio view coming soon...</div>
+ ),
+ text: <TextView />,
+ stickers: (
+ <div className="p-4 text-muted-foreground">
+ Stickers view coming soon...
+ </div>
+ ),
+ effects: (
+ <div className="p-4 text-muted-foreground">
+ Effects view coming soon...
+ </div>
+ ),
+ transitions: (
+ <div className="p-4 text-muted-foreground">
+ Transitions view coming soon...
+ </div>
+ ),
+ captions: (
+ <div className="p-4 text-muted-foreground">
+ Captions view coming soon...
+ </div>
+ ),
+ filters: (
+ <div className="p-4 text-muted-foreground">
+ Filters view coming soon...
+ </div>
+ ),
+ adjustment: (
+ <div className="p-4 text-muted-foreground">
+ Adjustment view coming soon...
+ </div>
+ ),
+ };
+
+ return (
+ <div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden">
+ <TabBar />
+ <div className="flex-1">{viewMap[activeTab]}</div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts index 0150c39a..9668f285 100644 --- a/apps/web/src/components/editor/media-panel/store.ts +++ b/apps/web/src/components/editor/media-panel/store.ts @@ -1,73 +1,73 @@ -import { - CaptionsIcon, - ArrowLeftRightIcon, - SparklesIcon, - StickerIcon, - MusicIcon, - VideoIcon, - BlendIcon, - SlidersHorizontalIcon, - LucideIcon, - TypeIcon, -} from "lucide-react"; -import { create } from "zustand"; - -export type Tab = - | "media" - | "audio" - | "text" - | "stickers" - | "effects" - | "transitions" - | "captions" - | "filters" - | "adjustment"; - -export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = { - media: { - icon: VideoIcon, - label: "Media", - }, - audio: { - icon: MusicIcon, - label: "Audio", - }, - text: { - icon: TypeIcon, - label: "Text", - }, - stickers: { - icon: StickerIcon, - label: "Stickers", - }, - effects: { - icon: SparklesIcon, - label: "Effects", - }, - transitions: { - icon: ArrowLeftRightIcon, - label: "Transitions", - }, - captions: { - icon: CaptionsIcon, - label: "Captions", - }, - filters: { - icon: BlendIcon, - label: "Filters", - }, - adjustment: { - icon: SlidersHorizontalIcon, - label: "Adjustment", - }, -}; - -interface MediaPanelStore { - activeTab: Tab; - setActiveTab: (tab: Tab) => void; -} - -export const useMediaPanelStore = create<MediaPanelStore>((set) => ({ - activeTab: "media", - setActiveTab: (tab) => set({ activeTab: tab }), -})); +import {
+ CaptionsIcon,
+ ArrowLeftRightIcon,
+ SparklesIcon,
+ StickerIcon,
+ MusicIcon,
+ VideoIcon,
+ BlendIcon,
+ SlidersHorizontalIcon,
+ LucideIcon,
+ TypeIcon,
+} from "lucide-react";
+import { create } from "zustand";
+
+export type Tab =
+ | "media"
+ | "audio"
+ | "text"
+ | "stickers"
+ | "effects"
+ | "transitions"
+ | "captions"
+ | "filters"
+ | "adjustment";
+
+export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
+ media: {
+ icon: VideoIcon,
+ label: "Media",
+ },
+ audio: {
+ icon: MusicIcon,
+ label: "Audio",
+ },
+ text: {
+ icon: TypeIcon,
+ label: "Text",
+ },
+ stickers: {
+ icon: StickerIcon,
+ label: "Stickers",
+ },
+ effects: {
+ icon: SparklesIcon,
+ label: "Effects",
+ },
+ transitions: {
+ icon: ArrowLeftRightIcon,
+ label: "Transitions",
+ },
+ captions: {
+ icon: CaptionsIcon,
+ label: "Captions",
+ },
+ filters: {
+ icon: BlendIcon,
+ label: "Filters",
+ },
+ adjustment: {
+ icon: SlidersHorizontalIcon,
+ label: "Adjustment",
+ },
+};
+
+interface MediaPanelStore {
+ activeTab: Tab;
+ setActiveTab: (tab: Tab) => void;
+}
+
+export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
+ activeTab: "media",
+ setActiveTab: (tab) => set({ activeTab: tab }),
+}));
diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index 50793243..b05bee7e 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -1,124 +1,124 @@ -"use client"; - -import { cn } from "@/lib/utils"; -import { Tab, tabs, useMediaPanelStore } from "./store"; -import { Button } from "@/components/ui/button"; -import { ChevronRight, ChevronLeft } from "lucide-react"; -import { useRef, useState, useEffect } from "react"; - -export function TabBar() { - const { activeTab, setActiveTab } = useMediaPanelStore(); - const scrollContainerRef = useRef<HTMLDivElement>(null); - const [isAtEnd, setIsAtEnd] = useState(false); - const [isAtStart, setIsAtStart] = useState(true); - - const scrollToEnd = () => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTo({ - left: scrollContainerRef.current.scrollWidth, - }); - setIsAtEnd(true); - setIsAtStart(false); - } - }; - - const scrollToStart = () => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTo({ - left: 0, - }); - setIsAtStart(true); - setIsAtEnd(false); - } - }; - - const checkScrollPosition = () => { - if (scrollContainerRef.current) { - const { scrollLeft, scrollWidth, clientWidth } = - scrollContainerRef.current; - const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1; - const isAtStartNow = scrollLeft <= 1; - setIsAtEnd(isAtEndNow); - setIsAtStart(isAtStartNow); - } - }; - - // We're using useEffect because we need to sync with external DOM scroll events - useEffect(() => { - const container = scrollContainerRef.current; - if (!container) return; - - checkScrollPosition(); - container.addEventListener("scroll", checkScrollPosition); - - const resizeObserver = new ResizeObserver(checkScrollPosition); - resizeObserver.observe(container); - - return () => { - container.removeEventListener("scroll", checkScrollPosition); - resizeObserver.disconnect(); - }; - }, []); - - return ( - <div className="flex"> - <ScrollButton - direction="left" - onClick={scrollToStart} - isVisible={!isAtStart} - /> - <div - ref={scrollContainerRef} - className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full" - > - {(Object.keys(tabs) as Tab[]).map((tabKey) => { - const tab = tabs[tabKey]; - return ( - <div - className={cn( - "flex flex-col gap-0.5 items-center cursor-pointer", - activeTab === tabKey ? "text-primary" : "text-muted-foreground", - )} - onClick={() => setActiveTab(tabKey)} - key={tabKey} - > - <tab.icon className="!size-[1.1rem]" /> - <span className="text-[0.65rem]">{tab.label}</span> - </div> - ); - })} - </div> - <ScrollButton - direction="right" - onClick={scrollToEnd} - isVisible={!isAtEnd} - /> - </div> - ); -} - -function ScrollButton({ - direction, - onClick, - isVisible, -}: { - direction: "left" | "right"; - onClick: () => void; - isVisible: boolean; -}) { - if (!isVisible) return null; - - const Icon = direction === "left" ? ChevronLeft : ChevronRight; - - return ( - <div className="bg-panel-accent w-12 h-full flex items-center justify-center"> - <Button - size="icon" - className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10" - onClick={onClick} - > - <Icon className="!size-4 text-foreground" /> - </Button> - </div> - ); -} +"use client";
+
+import { cn } from "@/lib/utils";
+import { Tab, tabs, useMediaPanelStore } from "./store";
+import { Button } from "@/components/ui/button";
+import { ChevronRight, ChevronLeft } from "lucide-react";
+import { useRef, useState, useEffect } from "react";
+
+export function TabBar() {
+ const { activeTab, setActiveTab } = useMediaPanelStore();
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
+ const [isAtEnd, setIsAtEnd] = useState(false);
+ const [isAtStart, setIsAtStart] = useState(true);
+
+ const scrollToEnd = () => {
+ if (scrollContainerRef.current) {
+ scrollContainerRef.current.scrollTo({
+ left: scrollContainerRef.current.scrollWidth,
+ });
+ setIsAtEnd(true);
+ setIsAtStart(false);
+ }
+ };
+
+ const scrollToStart = () => {
+ if (scrollContainerRef.current) {
+ scrollContainerRef.current.scrollTo({
+ left: 0,
+ });
+ setIsAtStart(true);
+ setIsAtEnd(false);
+ }
+ };
+
+ const checkScrollPosition = () => {
+ if (scrollContainerRef.current) {
+ const { scrollLeft, scrollWidth, clientWidth } =
+ scrollContainerRef.current;
+ const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1;
+ const isAtStartNow = scrollLeft <= 1;
+ setIsAtEnd(isAtEndNow);
+ setIsAtStart(isAtStartNow);
+ }
+ };
+
+ // We're using useEffect because we need to sync with external DOM scroll events
+ useEffect(() => {
+ const container = scrollContainerRef.current;
+ if (!container) return;
+
+ checkScrollPosition();
+ container.addEventListener("scroll", checkScrollPosition);
+
+ const resizeObserver = new ResizeObserver(checkScrollPosition);
+ resizeObserver.observe(container);
+
+ return () => {
+ container.removeEventListener("scroll", checkScrollPosition);
+ resizeObserver.disconnect();
+ };
+ }, []);
+
+ return (
+ <div className="flex">
+ <ScrollButton
+ direction="left"
+ onClick={scrollToStart}
+ isVisible={!isAtStart}
+ />
+ <div
+ ref={scrollContainerRef}
+ className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full"
+ >
+ {(Object.keys(tabs) as Tab[]).map((tabKey) => {
+ const tab = tabs[tabKey];
+ return (
+ <div
+ className={cn(
+ "flex flex-col gap-0.5 items-center cursor-pointer",
+ activeTab === tabKey ? "text-primary" : "text-muted-foreground"
+ )}
+ onClick={() => setActiveTab(tabKey)}
+ key={tabKey}
+ >
+ <tab.icon className="!size-[1.1rem]" />
+ <span className="text-[0.65rem]">{tab.label}</span>
+ </div>
+ );
+ })}
+ </div>
+ <ScrollButton
+ direction="right"
+ onClick={scrollToEnd}
+ isVisible={!isAtEnd}
+ />
+ </div>
+ );
+}
+
+function ScrollButton({
+ direction,
+ onClick,
+ isVisible,
+}: {
+ direction: "left" | "right";
+ onClick: () => void;
+ isVisible: boolean;
+}) {
+ if (!isVisible) return null;
+
+ const Icon = direction === "left" ? ChevronLeft : ChevronRight;
+
+ return (
+ <div className="bg-panel-accent w-12 h-full flex items-center justify-center">
+ <Button
+ size="icon"
+ className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
+ onClick={onClick}
+ >
+ <Icon className="!size-4 text-foreground" />
+ </Button>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index 712dd922..631fe39a 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -1,298 +1,298 @@ -"use client"; - -import { useDragDrop } from "@/hooks/use-drag-drop"; -import { processMediaFiles } from "@/lib/media-processing"; -import { useMediaStore, type MediaItem } from "@/stores/media-store"; -import { Image, Loader2, Music, Plus, Video } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { DraggableMediaItem } from "@/components/ui/draggable-item"; -import { useProjectStore } from "@/stores/project-store"; -import { useTimelineStore } from "@/stores/timeline-store"; - -export function MediaView() { - const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); - const { activeProject } = useProjectStore(); - const fileInputRef = useRef<HTMLInputElement>(null); - const [isProcessing, setIsProcessing] = useState(false); - const [progress, setProgress] = useState(0); - const [searchQuery, setSearchQuery] = useState(""); - const [mediaFilter, setMediaFilter] = useState("all"); - - const processFiles = async (files: FileList | File[]) => { - if (!files || files.length === 0) return; - if (!activeProject) { - toast.error("No active project"); - return; - } - - setIsProcessing(true); - setProgress(0); - try { - // Process files (extract metadata, generate thumbnails, etc.) - const processedItems = await processMediaFiles(files, (p) => - setProgress(p), - ); - // Add each processed media item to the store - for (const item of processedItems) { - await addMediaItem(activeProject.id, item); - } - } catch (error) { - // Show error toast if processing fails - console.error("Error processing files:", error); - toast.error("Failed to process files"); - } finally { - setIsProcessing(false); - setProgress(0); - } - }; - - const { isDragOver, dragProps } = useDragDrop({ - // When files are dropped, process them - onDrop: processFiles, - }); - - const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker - - const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { - // When files are selected via file picker, process them - if (e.target.files) processFiles(e.target.files); - e.target.value = ""; // Reset input - }; - - const handleRemove = async (e: React.MouseEvent, id: string) => { - // Remove a media item from the store - e.stopPropagation(); - - if (!activeProject) { - toast.error("No active project"); - return; - } - - // Media store now handles cascade deletion automatically - await removeMediaItem(activeProject.id, id); - }; - - const formatDuration = (duration: number) => { - // Format seconds as mm:ss - const min = Math.floor(duration / 60); - const sec = Math.floor(duration % 60); - return `${min}:${sec.toString().padStart(2, "0")}`; - }; - - const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); - - useEffect(() => { - const filtered = mediaItems.filter((item) => { - if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { - return false; - } - - if ( - searchQuery && - !item.name.toLowerCase().includes(searchQuery.toLowerCase()) - ) { - return false; - } - - return true; - }); - - setFilteredMediaItems(filtered); - }, [mediaItems, mediaFilter, searchQuery]); - - const renderPreview = (item: MediaItem) => { - // Render a preview for each media type (image, video, audio, unknown) - if (item.type === "image") { - return ( - <div className="w-full h-full flex items-center justify-center"> - <img - src={item.url} - alt={item.name} - className="max-w-full max-h-full object-contain" - loading="lazy" - /> - </div> - ); - } - - if (item.type === "video") { - if (item.thumbnailUrl) { - return ( - <div className="relative w-full h-full"> - <img - src={item.thumbnailUrl} - alt={item.name} - className="w-full h-full object-cover rounded" - loading="lazy" - /> - <div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded"> - <Video className="h-6 w-6 text-white drop-shadow-md" /> - </div> - {item.duration && ( - <div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded"> - {formatDuration(item.duration)} - </div> - )} - </div> - ); - } - return ( - <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> - <Video className="h-6 w-6 mb-1" /> - <span className="text-xs">Video</span> - {item.duration && ( - <span className="text-xs opacity-70"> - {formatDuration(item.duration)} - </span> - )} - </div> - ); - } - - if (item.type === "audio") { - return ( - <div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20"> - <Music className="h-6 w-6 mb-1" /> - <span className="text-xs">Audio</span> - {item.duration && ( - <span className="text-xs opacity-70"> - {formatDuration(item.duration)} - </span> - )} - </div> - ); - } - - return ( - <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> - <Image className="h-6 w-6" /> - <span className="text-xs mt-1">Unknown</span> - </div> - ); - }; - - return ( - <> - {/* Hidden file input for uploading media */} - <input - ref={fileInputRef} - type="file" - accept="image/*,video/*,audio/*" - multiple - className="hidden" - onChange={handleFileChange} - /> - - <div - className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`} - {...dragProps} - > - <div className="p-3 pb-2"> - {/* Search and filter controls */} - <div className="flex gap-2"> - <Select value={mediaFilter} onValueChange={setMediaFilter}> - <SelectTrigger className="w-[80px] h-full text-xs"> - <SelectValue /> - </SelectTrigger> - <SelectContent className=""> - <SelectItem value="all">All</SelectItem> - <SelectItem value="video">Video</SelectItem> - <SelectItem value="audio">Audio</SelectItem> - <SelectItem value="image">Image</SelectItem> - </SelectContent> - </Select> - <Input - type="text" - placeholder="Search media..." - className="min-w-[60px] flex-1 h-full text-xs" - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - /> - <Button - variant="outline" - size="lg" - onClick={handleFileSelect} - disabled={isProcessing} - className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center" - > - {isProcessing ? ( - <Loader2 className="h-4 w-4 animate-spin" /> - ) : ( - <Plus className="h-4 w-4" /> - )} - </Button> - </div> - </div> - - <div className="flex-1 overflow-y-auto p-3 pt-0"> - {isDragOver || filteredMediaItems.length === 0 ? ( - <MediaDragOverlay - isVisible={true} - isProcessing={isProcessing} - progress={progress} - onClick={handleFileSelect} - isEmptyState={filteredMediaItems.length === 0 && !isDragOver} - /> - ) : ( - <div - className="grid gap-2" - style={{ - gridTemplateColumns: "repeat(auto-fill, 160px)", - }} - > - {/* Render each media item as a draggable button */} - {filteredMediaItems.map((item) => ( - <ContextMenu key={item.id}> - <ContextMenuTrigger> - <DraggableMediaItem - name={item.name} - preview={renderPreview(item)} - dragData={{ - id: item.id, - type: item.type, - name: item.name, - }} - showPlusOnDrag={false} - onAddToTimeline={(currentTime) => - useTimelineStore - .getState() - .addMediaAtTime(item, currentTime) - } - rounded={false} - /> - </ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuItem>Export clips</ContextMenuItem> - <ContextMenuItem - variant="destructive" - onClick={(e) => handleRemove(e, item.id)} - > - Delete - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - ))} - </div> - )} - </div> - </div> - </> - ); -} +"use client";
+
+import { useDragDrop } from "@/hooks/use-drag-drop";
+import { processMediaFiles } from "@/lib/media-processing";
+import { useMediaStore, type MediaItem } from "@/stores/media-store";
+import { Image, Loader2, Music, Plus, Video } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { DraggableMediaItem } from "@/components/ui/draggable-item";
+import { useProjectStore } from "@/stores/project-store";
+import { useTimelineStore } from "@/stores/timeline-store";
+
+export function MediaView() {
+ const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
+ const { activeProject } = useProjectStore();
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [mediaFilter, setMediaFilter] = useState("all");
+
+ const processFiles = async (files: FileList | File[]) => {
+ if (!files || files.length === 0) return;
+ if (!activeProject) {
+ toast.error("No active project");
+ return;
+ }
+
+ setIsProcessing(true);
+ setProgress(0);
+ try {
+ // Process files (extract metadata, generate thumbnails, etc.)
+ const processedItems = await processMediaFiles(files, (p) =>
+ setProgress(p)
+ );
+ // Add each processed media item to the store
+ for (const item of processedItems) {
+ await addMediaItem(activeProject.id, item);
+ }
+ } catch (error) {
+ // Show error toast if processing fails
+ console.error("Error processing files:", error);
+ toast.error("Failed to process files");
+ } finally {
+ setIsProcessing(false);
+ setProgress(0);
+ }
+ };
+
+ const { isDragOver, dragProps } = useDragDrop({
+ // When files are dropped, process them
+ onDrop: processFiles,
+ });
+
+ const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
+
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ // When files are selected via file picker, process them
+ if (e.target.files) processFiles(e.target.files);
+ e.target.value = ""; // Reset input
+ };
+
+ const handleRemove = async (e: React.MouseEvent, id: string) => {
+ // Remove a media item from the store
+ e.stopPropagation();
+
+ if (!activeProject) {
+ toast.error("No active project");
+ return;
+ }
+
+ // Media store now handles cascade deletion automatically
+ await removeMediaItem(activeProject.id, id);
+ };
+
+ const formatDuration = (duration: number) => {
+ // Format seconds as mm:ss
+ const min = Math.floor(duration / 60);
+ const sec = Math.floor(duration % 60);
+ return `${min}:${sec.toString().padStart(2, "0")}`;
+ };
+
+ const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
+
+ useEffect(() => {
+ const filtered = mediaItems.filter((item) => {
+ if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
+ return false;
+ }
+
+ if (
+ searchQuery &&
+ !item.name.toLowerCase().includes(searchQuery.toLowerCase())
+ ) {
+ return false;
+ }
+
+ return true;
+ });
+
+ setFilteredMediaItems(filtered);
+ }, [mediaItems, mediaFilter, searchQuery]);
+
+ const renderPreview = (item: MediaItem) => {
+ // Render a preview for each media type (image, video, audio, unknown)
+ if (item.type === "image") {
+ return (
+ <div className="w-full h-full flex items-center justify-center">
+ <img
+ src={item.url}
+ alt={item.name}
+ className="max-w-full max-h-full object-contain"
+ loading="lazy"
+ />
+ </div>
+ );
+ }
+
+ if (item.type === "video") {
+ if (item.thumbnailUrl) {
+ return (
+ <div className="relative w-full h-full">
+ <img
+ src={item.thumbnailUrl}
+ alt={item.name}
+ className="w-full h-full object-cover rounded"
+ loading="lazy"
+ />
+ <div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
+ <Video className="h-6 w-6 text-white drop-shadow-md" />
+ </div>
+ {item.duration && (
+ <div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
+ {formatDuration(item.duration)}
+ </div>
+ )}
+ </div>
+ );
+ }
+ return (
+ <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
+ <Video className="h-6 w-6 mb-1" />
+ <span className="text-xs">Video</span>
+ {item.duration && (
+ <span className="text-xs opacity-70">
+ {formatDuration(item.duration)}
+ </span>
+ )}
+ </div>
+ );
+ }
+
+ if (item.type === "audio") {
+ return (
+ <div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
+ <Music className="h-6 w-6 mb-1" />
+ <span className="text-xs">Audio</span>
+ {item.duration && (
+ <span className="text-xs opacity-70">
+ {formatDuration(item.duration)}
+ </span>
+ )}
+ </div>
+ );
+ }
+
+ return (
+ <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
+ <Image className="h-6 w-6" />
+ <span className="text-xs mt-1">Unknown</span>
+ </div>
+ );
+ };
+
+ return (
+ <>
+ {/* Hidden file input for uploading media */}
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept="image/*,video/*,audio/*"
+ multiple
+ className="hidden"
+ onChange={handleFileChange}
+ />
+
+ <div
+ className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
+ {...dragProps}
+ >
+ <div className="p-3 pb-2">
+ {/* Search and filter controls */}
+ <div className="flex gap-2">
+ <Select value={mediaFilter} onValueChange={setMediaFilter}>
+ <SelectTrigger className="w-[80px] h-full text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent className="">
+ <SelectItem value="all">All</SelectItem>
+ <SelectItem value="video">Video</SelectItem>
+ <SelectItem value="audio">Audio</SelectItem>
+ <SelectItem value="image">Image</SelectItem>
+ </SelectContent>
+ </Select>
+ <Input
+ type="text"
+ placeholder="Search media..."
+ className="min-w-[60px] flex-1 h-full text-xs"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ <Button
+ variant="outline"
+ size="lg"
+ onClick={handleFileSelect}
+ disabled={isProcessing}
+ className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
+ >
+ {isProcessing ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <Plus className="h-4 w-4" />
+ )}
+ </Button>
+ </div>
+ </div>
+
+ <div className="flex-1 overflow-y-auto p-3 pt-0">
+ {isDragOver || filteredMediaItems.length === 0 ? (
+ <MediaDragOverlay
+ isVisible={true}
+ isProcessing={isProcessing}
+ progress={progress}
+ onClick={handleFileSelect}
+ isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
+ />
+ ) : (
+ <div
+ className="grid gap-2"
+ style={{
+ gridTemplateColumns: "repeat(auto-fill, 160px)",
+ }}
+ >
+ {/* Render each media item as a draggable button */}
+ {filteredMediaItems.map((item) => (
+ <ContextMenu key={item.id}>
+ <ContextMenuTrigger>
+ <DraggableMediaItem
+ name={item.name}
+ preview={renderPreview(item)}
+ dragData={{
+ id: item.id,
+ type: item.type,
+ name: item.name,
+ }}
+ showPlusOnDrag={false}
+ onAddToTimeline={(currentTime) =>
+ useTimelineStore
+ .getState()
+ .addMediaAtTime(item, currentTime)
+ }
+ rounded={false}
+ />
+ </ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem>Export clips</ContextMenuItem>
+ <ContextMenuItem
+ variant="destructive"
+ onClick={(e) => handleRemove(e, item.id)}
+ >
+ Delete
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ </>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index b47edd8d..6d0d310b 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -1,53 +1,53 @@ -import { DraggableMediaItem } from "@/components/ui/draggable-item"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { type TextElement } from "@/types/timeline"; - -let textData: TextElement = { - id: "default-text", - type: "text", - name: "Default text", - content: "Default text", - fontSize: 48, - fontFamily: "Arial", - color: "#ffffff", - backgroundColor: "transparent", - textAlign: "center" as const, - fontWeight: "normal" as const, - fontStyle: "normal" as const, - textDecoration: "none" as const, - x: 0, - y: 0, - rotation: 0, - opacity: 1, - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, -}; - -export function TextView() { - return ( - <div className="p-4"> - <DraggableMediaItem - name="Default text" - preview={ - <div className="flex items-center justify-center w-full h-full bg-accent rounded"> - <span className="text-xs select-none">Default text</span> - </div> - } - dragData={{ - id: textData.id, - type: textData.type, - name: textData.name, - content: textData.content, - }} - aspectRatio={1} - onAddToTimeline={(currentTime) => - useTimelineStore.getState().addTextAtTime(textData, currentTime) - } - showLabel={false} - /> - </div> - ); -} +import { DraggableMediaItem } from "@/components/ui/draggable-item";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { type TextElement } from "@/types/timeline";
+
+let textData: TextElement = {
+ id: "default-text",
+ type: "text",
+ name: "Default text",
+ content: "Default text",
+ fontSize: 48,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ backgroundColor: "transparent",
+ textAlign: "center" as const,
+ fontWeight: "normal" as const,
+ fontStyle: "normal" as const,
+ textDecoration: "none" as const,
+ x: 0,
+ y: 0,
+ rotation: 0,
+ opacity: 1,
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+};
+
+export function TextView() {
+ return (
+ <div className="p-4">
+ <DraggableMediaItem
+ name="Default text"
+ preview={
+ <div className="flex items-center justify-center w-full h-full bg-accent rounded">
+ <span className="text-xs select-none">Default text</span>
+ </div>
+ }
+ dragData={{
+ id: textData.id,
+ type: textData.type,
+ name: textData.name,
+ content: textData.content,
+ }}
+ aspectRatio={1}
+ onAddToTimeline={(currentTime) =>
+ useTimelineStore.getState().addTextAtTime(textData, currentTime)
+ }
+ showLabel={false}
+ />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index ea66de74..2ecee391 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -10,11 +10,11 @@ import { VideoPlayer } from "@/components/ui/video-player"; import { AudioPlayer } from "@/components/ui/audio-player"; import { Button } from "@/components/ui/button"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuSeparator, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { Play, Pause, Expand } from "lucide-react"; import { useState, useRef, useEffect } from "react"; @@ -25,473 +25,473 @@ import { BackgroundSettings } from "../background-settings"; import { useProjectStore } from "@/stores/project-store"; interface ActiveElement { - element: TimelineElement; - track: TimelineTrack; - mediaItem: MediaItem | null; + element: TimelineElement; + track: TimelineTrack; + mediaItem: MediaItem | null; } export function PreviewPanel() { - const { tracks } = useTimelineStore(); - const { mediaItems } = useMediaStore(); - const { currentTime } = usePlaybackStore(); - const { canvasSize } = useEditorStore(); - const previewRef = useRef<HTMLDivElement>(null); - const containerRef = useRef<HTMLDivElement>(null); - const [previewDimensions, setPreviewDimensions] = useState({ - width: 0, - height: 0, - }); - const { activeProject } = useProjectStore(); + const { tracks } = useTimelineStore(); + const { mediaItems } = useMediaStore(); + const { currentTime } = usePlaybackStore(); + const { canvasSize } = useEditorStore(); + const previewRef = useRef<HTMLDivElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const [previewDimensions, setPreviewDimensions] = useState({ + width: 0, + height: 0, + }); + const { activeProject } = useProjectStore(); - // Calculate optimal preview size that fits in container while maintaining aspect ratio - useEffect(() => { - const updatePreviewSize = () => { - if (!containerRef.current) return; + // Calculate optimal preview size that fits in container while maintaining aspect ratio + useEffect(() => { + const updatePreviewSize = () => { + if (!containerRef.current) return; - const container = containerRef.current.getBoundingClientRect(); - const computedStyle = getComputedStyle(containerRef.current); + const container = containerRef.current.getBoundingClientRect(); + const computedStyle = getComputedStyle(containerRef.current); - // Get padding values - const paddingTop = parseFloat(computedStyle.paddingTop); - const paddingBottom = parseFloat(computedStyle.paddingBottom); - const paddingLeft = parseFloat(computedStyle.paddingLeft); - const paddingRight = parseFloat(computedStyle.paddingRight); + // Get padding values + const paddingTop = parseFloat(computedStyle.paddingTop); + const paddingBottom = parseFloat(computedStyle.paddingBottom); + const paddingLeft = parseFloat(computedStyle.paddingLeft); + const paddingRight = parseFloat(computedStyle.paddingRight); - // Get gap value (gap-4 = 1rem = 16px) - const gap = parseFloat(computedStyle.gap) || 16; + // Get gap value (gap-4 = 1rem = 16px) + const gap = parseFloat(computedStyle.gap) || 16; - // Get toolbar height if it exists - const toolbar = containerRef.current.querySelector("[data-toolbar]"); - const toolbarHeight = toolbar - ? toolbar.getBoundingClientRect().height - : 0; + // Get toolbar height if it exists + const toolbar = containerRef.current.querySelector("[data-toolbar]"); + const toolbarHeight = toolbar + ? toolbar.getBoundingClientRect().height + : 0; - // Calculate available space after accounting for padding, gap, and toolbar - const availableWidth = container.width - paddingLeft - paddingRight; - const availableHeight = - container.height - - paddingTop - - paddingBottom - - toolbarHeight - - (toolbarHeight > 0 ? gap : 0); + // Calculate available space after accounting for padding, gap, and toolbar + const availableWidth = container.width - paddingLeft - paddingRight; + const availableHeight = + container.height - + paddingTop - + paddingBottom - + toolbarHeight - + (toolbarHeight > 0 ? gap : 0); - const targetRatio = canvasSize.width / canvasSize.height; - const containerRatio = availableWidth / availableHeight; + const targetRatio = canvasSize.width / canvasSize.height; + const containerRatio = availableWidth / availableHeight; - let width, height; + let width, height; - if (containerRatio > targetRatio) { - // Container is wider - constrain by height - height = availableHeight; - width = height * targetRatio; - } else { - // Container is taller - constrain by width - width = availableWidth; - height = width / targetRatio; - } + if (containerRatio > targetRatio) { + // Container is wider - constrain by height + height = availableHeight; + width = height * targetRatio; + } else { + // Container is taller - constrain by width + width = availableWidth; + height = width / targetRatio; + } - setPreviewDimensions({ width, height }); - }; + setPreviewDimensions({ width, height }); + }; - updatePreviewSize(); + updatePreviewSize(); - const resizeObserver = new ResizeObserver(updatePreviewSize); - if (containerRef.current) { - resizeObserver.observe(containerRef.current); - } + const resizeObserver = new ResizeObserver(updatePreviewSize); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } - return () => resizeObserver.disconnect(); - }, [canvasSize.width, canvasSize.height]); + return () => resizeObserver.disconnect(); + }, [canvasSize.width, canvasSize.height]); - // Get active elements at current time - const getActiveElements = (): ActiveElement[] => { - const activeElements: ActiveElement[] = []; + // Get active elements at current time + const getActiveElements = (): ActiveElement[] => { + const activeElements: ActiveElement[] = []; - tracks.forEach((track) => { - track.elements.forEach((element) => { - const elementStart = element.startTime; - const elementEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + tracks.forEach((track) => { + track.elements.forEach((element) => { + const elementStart = element.startTime; + const elementEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); - if (currentTime >= elementStart && currentTime < elementEnd) { - let mediaItem = null; + if (currentTime >= elementStart && currentTime < elementEnd) { + let mediaItem = null; - // Only get media item for media elements - if (element.type === "media") { - mediaItem = - element.mediaId === "test" - ? null // Test elements don't have a real media item - : mediaItems.find((item) => item.id === element.mediaId) || - null; - } + // Only get media item for media elements + if (element.type === "media") { + mediaItem = + element.mediaId === "test" + ? null // Test elements don't have a real media item + : mediaItems.find((item) => item.id === element.mediaId) || + null; + } - activeElements.push({ element, track, mediaItem }); - } - }); - }); + activeElements.push({ element, track, mediaItem }); + } + }); + }); - return activeElements; - }; + return activeElements; + }; - const activeElements = getActiveElements(); + const activeElements = getActiveElements(); - // Check if there are any elements in the timeline at all - const hasAnyElements = tracks.some((track) => track.elements.length > 0); + // Check if there are any elements in the timeline at all + const hasAnyElements = tracks.some((track) => track.elements.length > 0); - // Get media elements for blur background (video/image only) - const getBlurBackgroundElements = (): ActiveElement[] => { - return activeElements.filter( - ({ element, mediaItem }) => - element.type === "media" && - mediaItem && - (mediaItem.type === "video" || mediaItem.type === "image") && - element.mediaId !== "test", // Exclude test elements - ); - }; + // Get media elements for blur background (video/image only) + const getBlurBackgroundElements = (): ActiveElement[] => { + return activeElements.filter( + ({ element, mediaItem }) => + element.type === "media" && + mediaItem && + (mediaItem.type === "video" || mediaItem.type === "image") && + element.mediaId !== "test" // Exclude test elements + ); + }; - const blurBackgroundElements = getBlurBackgroundElements(); + const blurBackgroundElements = getBlurBackgroundElements(); - // Render blur background layer - const renderBlurBackground = () => { - if ( - !activeProject?.backgroundType || - activeProject.backgroundType !== "blur" || - blurBackgroundElements.length === 0 - ) { - return null; - } + // Render blur background layer + const renderBlurBackground = () => { + if ( + !activeProject?.backgroundType || + activeProject.backgroundType !== "blur" || + blurBackgroundElements.length === 0 + ) { + return null; + } - // Use the first media element for background (could be enhanced to use primary/focused element) - const backgroundElement = blurBackgroundElements[0]; - const { element, mediaItem } = backgroundElement; + // Use the first media element for background (could be enhanced to use primary/focused element) + const backgroundElement = blurBackgroundElements[0]; + const { element, mediaItem } = backgroundElement; - if (!mediaItem) return null; + if (!mediaItem) return null; - const blurIntensity = activeProject.blurIntensity || 8; + const blurIntensity = activeProject.blurIntensity || 8; - if (mediaItem.type === "video") { - return ( - <div - key={`blur-${element.id}`} - className="absolute inset-0 overflow-hidden" - style={{ - filter: `blur(${blurIntensity}px)`, - transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts - transformOrigin: "center", - }} - > - <VideoPlayer - src={mediaItem.url!} - poster={mediaItem.thumbnailUrl} - clipStartTime={element.startTime} - trimStart={element.trimStart} - trimEnd={element.trimEnd} - clipDuration={element.duration} - className="w-full h-full object-cover" - /> - </div> - ); - } + if (mediaItem.type === "video") { + return ( + <div + key={`blur-${element.id}`} + className="absolute inset-0 overflow-hidden" + style={{ + filter: `blur(${blurIntensity}px)`, + transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts + transformOrigin: "center", + }} + > + <VideoPlayer + src={mediaItem.url!} + poster={mediaItem.thumbnailUrl} + clipStartTime={element.startTime} + trimStart={element.trimStart} + trimEnd={element.trimEnd} + clipDuration={element.duration} + className="w-full h-full object-cover" + /> + </div> + ); + } - if (mediaItem.type === "image") { - return ( - <div - key={`blur-${element.id}`} - className="absolute inset-0 overflow-hidden" - style={{ - filter: `blur(${blurIntensity}px)`, - transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts - transformOrigin: "center", - }} - > - <img - src={mediaItem.url!} - alt={mediaItem.name} - className="w-full h-full object-cover" - draggable={false} - /> - </div> - ); - } + if (mediaItem.type === "image") { + return ( + <div + key={`blur-${element.id}`} + className="absolute inset-0 overflow-hidden" + style={{ + filter: `blur(${blurIntensity}px)`, + transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts + transformOrigin: "center", + }} + > + <img + src={mediaItem.url!} + alt={mediaItem.name} + className="w-full h-full object-cover" + draggable={false} + /> + </div> + ); + } - return null; - }; + return null; + }; - // Render an element - const renderElement = (elementData: ActiveElement, index: number) => { - const { element, mediaItem } = elementData; + // Render an element + const renderElement = (elementData: ActiveElement, index: number) => { + const { element, mediaItem } = elementData; - // Text elements - if (element.type === "text") { - const fontClassName = - FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || ""; + // Text elements + if (element.type === "text") { + const fontClassName = + FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || ""; - const scaleRatio = previewDimensions.width / canvasSize.width; + const scaleRatio = previewDimensions.width / canvasSize.width; - return ( - <div - key={element.id} - className="absolute flex items-center justify-center" - style={{ - left: `${50 + (element.x / canvasSize.width) * 100}%`, - top: `${50 + (element.y / canvasSize.height) * 100}%`, - transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`, - opacity: element.opacity, - zIndex: 100 + index, // Text elements on top - }} - > - <div - className={fontClassName} - style={{ - fontSize: `${element.fontSize}px`, - color: element.color, - backgroundColor: element.backgroundColor, - textAlign: element.textAlign, - fontWeight: element.fontWeight, - fontStyle: element.fontStyle, - textDecoration: element.textDecoration, - padding: "4px 8px", - borderRadius: "2px", - whiteSpace: "nowrap", - // Fallback for system fonts that don't have classes - ...(fontClassName === "" && { fontFamily: element.fontFamily }), - }} - > - {element.content} - </div> - </div> - ); - } + return ( + <div + key={element.id} + className="absolute flex items-center justify-center" + style={{ + left: `${50 + (element.x / canvasSize.width) * 100}%`, + top: `${50 + (element.y / canvasSize.height) * 100}%`, + transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`, + opacity: element.opacity, + zIndex: 100 + index, // Text elements on top + }} + > + <div + className={fontClassName} + style={{ + fontSize: `${element.fontSize}px`, + color: element.color, + backgroundColor: element.backgroundColor, + textAlign: element.textAlign, + fontWeight: element.fontWeight, + fontStyle: element.fontStyle, + textDecoration: element.textDecoration, + padding: "4px 8px", + borderRadius: "2px", + whiteSpace: "nowrap", + // Fallback for system fonts that don't have classes + ...(fontClassName === "" && { fontFamily: element.fontFamily }), + }} + > + {element.content} + </div> + </div> + ); + } - // Media elements - if (element.type === "media") { - // Test elements - if (!mediaItem || element.mediaId === "test") { - return ( - <div - key={element.id} - className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center" - > - <div className="text-center"> - <div className="text-2xl mb-2">🎬</div> - <p className="text-xs text-white">{element.name}</p> - </div> - </div> - ); - } + // Media elements + if (element.type === "media") { + // Test elements + if (!mediaItem || element.mediaId === "test") { + return ( + <div + key={element.id} + className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center" + > + <div className="text-center"> + <div className="text-2xl mb-2">🎬</div> + <p className="text-xs text-white">{element.name}</p> + </div> + </div> + ); + } - // Video elements - if (mediaItem.type === "video") { - return ( - <div - key={element.id} - className="absolute inset-0 flex items-center justify-center" - > - <VideoPlayer - src={mediaItem.url!} - poster={mediaItem.thumbnailUrl} - clipStartTime={element.startTime} - trimStart={element.trimStart} - trimEnd={element.trimEnd} - clipDuration={element.duration} - /> - </div> - ); - } + // Video elements + if (mediaItem.type === "video") { + return ( + <div + key={element.id} + className="absolute inset-0 flex items-center justify-center" + > + <VideoPlayer + src={mediaItem.url!} + poster={mediaItem.thumbnailUrl} + clipStartTime={element.startTime} + trimStart={element.trimStart} + trimEnd={element.trimEnd} + clipDuration={element.duration} + /> + </div> + ); + } - // Image elements - if (mediaItem.type === "image") { - return ( - <div - key={element.id} - className="absolute inset-0 flex items-center justify-center" - > - <img - src={mediaItem.url!} - alt={mediaItem.name} - className="max-w-full max-h-full object-contain" - draggable={false} - /> - </div> - ); - } + // Image elements + if (mediaItem.type === "image") { + return ( + <div + key={element.id} + className="absolute inset-0 flex items-center justify-center" + > + <img + src={mediaItem.url!} + alt={mediaItem.name} + className="max-w-full max-h-full object-contain" + draggable={false} + /> + </div> + ); + } - // Audio elements (no visual representation) - if (mediaItem.type === "audio") { - return ( - <div key={element.id} className="absolute inset-0"> - <AudioPlayer - src={mediaItem.url!} - clipStartTime={element.startTime} - trimStart={element.trimStart} - trimEnd={element.trimEnd} - clipDuration={element.duration} - trackMuted={elementData.track.muted} - /> - </div> - ); - } - } + // Audio elements (no visual representation) + if (mediaItem.type === "audio") { + return ( + <div key={element.id} className="absolute inset-0"> + <AudioPlayer + src={mediaItem.url!} + clipStartTime={element.startTime} + trimStart={element.trimStart} + trimEnd={element.trimEnd} + clipDuration={element.duration} + trackMuted={elementData.track.muted} + /> + </div> + ); + } + } - return null; - }; + return null; + }; - return ( - <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm"> - <div - ref={containerRef} - className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0" - > - <div className="flex-1"></div> - {hasAnyElements ? ( - <div - ref={previewRef} - className="relative overflow-hidden border" - style={{ - width: previewDimensions.width, - height: previewDimensions.height, - backgroundColor: - activeProject?.backgroundType === "blur" - ? "transparent" - : activeProject?.backgroundColor || "#000000", - }} - > - {renderBlurBackground()} - {activeElements.length === 0 ? ( - <div className="absolute inset-0 flex items-center justify-center text-muted-foreground"> - No elements at current time - </div> - ) : ( - activeElements.map((elementData, index) => - renderElement(elementData, index), - ) - )} - {/* Show message when blur is selected but no media available */} - {activeProject?.backgroundType === "blur" && - blurBackgroundElements.length === 0 && - activeElements.length > 0 && ( - <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded"> - Add a video or image to use blur background - </div> - )} - </div> - ) : null} + return ( + <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm"> + <div + ref={containerRef} + className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0" + > + <div className="flex-1"></div> + {hasAnyElements ? ( + <div + ref={previewRef} + className="relative overflow-hidden border" + style={{ + width: previewDimensions.width, + height: previewDimensions.height, + backgroundColor: + activeProject?.backgroundType === "blur" + ? "transparent" + : activeProject?.backgroundColor || "#000000", + }} + > + {renderBlurBackground()} + {activeElements.length === 0 ? ( + <div className="absolute inset-0 flex items-center justify-center text-muted-foreground"> + No elements at current time + </div> + ) : ( + activeElements.map((elementData, index) => + renderElement(elementData, index) + ) + )} + {/* Show message when blur is selected but no media available */} + {activeProject?.backgroundType === "blur" && + blurBackgroundElements.length === 0 && + activeElements.length > 0 && ( + <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded"> + Add a video or image to use blur background + </div> + )} + </div> + ) : null} - <div className="flex-1"></div> + <div className="flex-1"></div> - <PreviewToolbar hasAnyElements={hasAnyElements} /> - </div> - </div> - ); + <PreviewToolbar hasAnyElements={hasAnyElements} /> + </div> + </div> + ); } function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { - const { isPlaying, toggle, currentTime } = usePlaybackStore(); - const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); - const { getTotalDuration } = useTimelineStore(); - const { activeProject } = useProjectStore(); - const { - currentPreset, - isOriginal, - getOriginalAspectRatio, - getDisplayName, - canvasPresets, - } = useAspectRatio(); + const { isPlaying, toggle, currentTime } = usePlaybackStore(); + const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); + const { getTotalDuration } = useTimelineStore(); + const { activeProject } = useProjectStore(); + const { + currentPreset, + isOriginal, + getOriginalAspectRatio, + getDisplayName, + canvasPresets, + } = useAspectRatio(); - const handlePresetSelect = (preset: { width: number; height: number }) => { - setCanvasSize({ width: preset.width, height: preset.height }); - }; + const handlePresetSelect = (preset: { width: number; height: number }) => { + setCanvasSize({ width: preset.width, height: preset.height }); + }; - const handleOriginalSelect = () => { - const aspectRatio = getOriginalAspectRatio(); - setCanvasSizeToOriginal(aspectRatio); - }; + const handleOriginalSelect = () => { + const aspectRatio = getOriginalAspectRatio(); + setCanvasSizeToOriginal(aspectRatio); + }; - return ( - <div - data-toolbar - className="flex items-end justify-between gap-2 p-1 pt-2 w-full" - > - <div> - <p - className={cn( - "text-[0.75rem] text-muted-foreground flex items-center gap-1", - !hasAnyElements && "opacity-50", - )} - > - <span className="text-primary tabular-nums"> - {formatTimeCode( - currentTime, - "HH:MM:SS:FF", - activeProject?.fps || 30, - )} - </span> - <span className="opacity-50">/</span> - <span className="tabular-nums"> - {formatTimeCode( - getTotalDuration(), - "HH:MM:SS:FF", - activeProject?.fps || 30, - )} - </span> - </p> - </div> - <Button - variant="text" - size="icon" - onClick={toggle} - disabled={!hasAnyElements} - className="h-auto p-0" - > - {isPlaying ? ( - <Pause className="h-3 w-3" /> - ) : ( - <Play className="h-3 w-3" /> - )} - </Button> - <div className="flex items-center gap-3"> - <BackgroundSettings /> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - size="sm" - className="!bg-panel-accent text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light" - disabled={!hasAnyElements} - > - {getDisplayName()} - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem - onClick={handleOriginalSelect} - className={cn("text-xs", isOriginal && "font-semibold")} - > - Original - </DropdownMenuItem> - <DropdownMenuSeparator /> - {canvasPresets.map((preset) => ( - <DropdownMenuItem - key={preset.name} - onClick={() => handlePresetSelect(preset)} - className={cn( - "text-xs", - currentPreset?.name === preset.name && "font-semibold", - )} - > - {preset.name} - </DropdownMenuItem> - ))} - </DropdownMenuContent> - </DropdownMenu> - <Button - variant="text" - size="icon" - className="!size-4 text-muted-foreground" - > - <Expand className="!size-4" /> - </Button> - </div> - </div> - ); + return ( + <div + data-toolbar + className="flex items-end justify-between gap-2 p-1 pt-2 w-full" + > + <div> + <p + className={cn( + "text-[0.75rem] text-muted-foreground flex items-center gap-1", + !hasAnyElements && "opacity-50" + )} + > + <span className="text-primary tabular-nums"> + {formatTimeCode( + currentTime, + "HH:MM:SS:FF", + activeProject?.fps || 30 + )} + </span> + <span className="opacity-50">/</span> + <span className="tabular-nums"> + {formatTimeCode( + getTotalDuration(), + "HH:MM:SS:FF", + activeProject?.fps || 30 + )} + </span> + </p> + </div> + <Button + variant="text" + size="icon" + onClick={toggle} + disabled={!hasAnyElements} + className="h-auto p-0" + > + {isPlaying ? ( + <Pause className="h-3 w-3" /> + ) : ( + <Play className="h-3 w-3" /> + )} + </Button> + <div className="flex items-center gap-3"> + <BackgroundSettings /> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + size="sm" + className="!bg-panel-accent text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light" + disabled={!hasAnyElements} + > + {getDisplayName()} + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={handleOriginalSelect} + className={cn("text-xs", isOriginal && "font-semibold")} + > + Original + </DropdownMenuItem> + <DropdownMenuSeparator /> + {canvasPresets.map((preset) => ( + <DropdownMenuItem + key={preset.name} + onClick={() => handlePresetSelect(preset)} + className={cn( + "text-xs", + currentPreset?.name === preset.name && "font-semibold" + )} + > + {preset.name} + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + <Button + variant="text" + size="icon" + className="!size-4 text-muted-foreground" + > + <Expand className="!size-4" /> + </Button> + </div> + </div> + ); } diff --git a/apps/web/src/components/editor/properties-panel/audio-properties.tsx b/apps/web/src/components/editor/properties-panel/audio-properties.tsx index 5e2a781f..384f0c5e 100644 --- a/apps/web/src/components/editor/properties-panel/audio-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/audio-properties.tsx @@ -1,5 +1,5 @@ -import { MediaElement } from "@/types/timeline"; - -export function AudioProperties({ element }: { element: MediaElement }) { - return <div className="space-y-4 p-5">Audio properties</div>; -} +import { MediaElement } from "@/types/timeline";
+
+export function AudioProperties({ element }: { element: MediaElement }) {
+ return <div className="space-y-4 p-5">Audio properties</div>;
+}
\ No newline at end of file diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index 7d2845be..9e28850f 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -10,100 +10,100 @@ import { AudioProperties } from "./audio-properties"; import { MediaProperties } from "./media-properties"; import { TextProperties } from "./text-properties"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "../../ui/select"; import { FPS_PRESETS } from "@/constants/timeline-constants"; export function PropertiesPanel() { - const { activeProject, updateProjectFps } = useProjectStore(); - const { getDisplayName, canvasSize } = useAspectRatio(); - const { selectedElements, tracks } = useTimelineStore(); - const { mediaItems } = useMediaStore(); + const { activeProject, updateProjectFps } = useProjectStore(); + const { getDisplayName, canvasSize } = useAspectRatio(); + const { selectedElements, tracks } = useTimelineStore(); + const { mediaItems } = useMediaStore(); - const handleFpsChange = (value: string) => { - const fps = parseFloat(value); - if (!isNaN(fps) && fps > 0) { - updateProjectFps(fps); - } - }; + const handleFpsChange = (value: string) => { + const fps = parseFloat(value); + if (!isNaN(fps) && fps > 0) { + updateProjectFps(fps); + } + }; - const emptyView = ( - <div className="space-y-4 p-5"> - {/* Media Properties */} - <div className="flex flex-col gap-3"> - <PropertyItem label="Name:" value={activeProject?.name || ""} /> - <PropertyItem label="Aspect ratio:" value={getDisplayName()} /> - <PropertyItem - label="Resolution:" - value={`${canvasSize.width} × ${canvasSize.height}`} - /> - <div className="flex justify-between items-center"> - <Label className="text-xs text-muted-foreground">Frame rate:</Label> - <Select - value={(activeProject?.fps || 30).toString()} - onValueChange={handleFpsChange} - > - <SelectTrigger className="w-32 h-6 text-xs"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {FPS_PRESETS.map(({ value, label }) => ( - <SelectItem key={value} value={value} className="text-xs"> - {label} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - </div> - </div> - ); + const emptyView = ( + <div className="space-y-4 p-5"> + {/* Media Properties */} + <div className="flex flex-col gap-3"> + <PropertyItem label="Name:" value={activeProject?.name || ""} /> + <PropertyItem label="Aspect ratio:" value={getDisplayName()} /> + <PropertyItem + label="Resolution:" + value={`${canvasSize.width} × ${canvasSize.height}`} + /> + <div className="flex justify-between items-center"> + <Label className="text-xs text-muted-foreground">Frame rate:</Label> + <Select + value={(activeProject?.fps || 30).toString()} + onValueChange={handleFpsChange} + > + <SelectTrigger className="w-32 h-6 text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {FPS_PRESETS.map(({ value, label }) => ( + <SelectItem key={value} value={value} className="text-xs"> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + </div> + ); - return ( - <ScrollArea className="h-full bg-panel rounded-sm"> - {selectedElements.length > 0 - ? selectedElements.map(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); + return ( + <ScrollArea className="h-full bg-panel rounded-sm"> + {selectedElements.length > 0 + ? selectedElements.map(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((e) => e.id === elementId); - if (element?.type === "text") { - return ( - <div key={elementId}> - <TextProperties element={element} trackId={trackId} /> - </div> - ); - } - if (element?.type === "media") { - const mediaItem = mediaItems.find( - (item) => item.id === element.mediaId, - ); + if (element?.type === "text") { + return ( + <div key={elementId}> + <TextProperties element={element} trackId={trackId} /> + </div> + ); + } + if (element?.type === "media") { + const mediaItem = mediaItems.find( + (item) => item.id === element.mediaId + ); - if (mediaItem?.type === "audio") { - return <AudioProperties element={element} />; - } + if (mediaItem?.type === "audio") { + return <AudioProperties element={element} />; + } - return ( - <div key={elementId}> - <MediaProperties element={element} /> - </div> - ); - } - return null; - }) - : emptyView} - </ScrollArea> - ); + return ( + <div key={elementId}> + <MediaProperties element={element} /> + </div> + ); + } + return null; + }) + : emptyView} + </ScrollArea> + ); } function PropertyItem({ label, value }: { label: string; value: string }) { - return ( - <div className="flex justify-between"> - <Label className="text-xs text-muted-foreground">{label}</Label> - <span className="text-xs text-right">{value}</span> - </div> - ); + return ( + <div className="flex justify-between"> + <Label className="text-xs text-muted-foreground">{label}</Label> + <span className="text-xs text-right">{value}</span> + </div> + ); } diff --git a/apps/web/src/components/editor/properties-panel/media-properties.tsx b/apps/web/src/components/editor/properties-panel/media-properties.tsx index fe2d448e..c2c514cf 100644 --- a/apps/web/src/components/editor/properties-panel/media-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/media-properties.tsx @@ -1,5 +1,5 @@ -import { MediaElement } from "@/types/timeline"; - -export function MediaProperties({ element }: { element: MediaElement }) { - return <div className="space-y-4 p-5">Media properties</div>; -} +import { MediaElement } from "@/types/timeline";
+
+export function MediaProperties({ element }: { element: MediaElement }) {
+ return <div className="space-y-4 p-5">Media properties</div>;
+}
diff --git a/apps/web/src/components/editor/properties-panel/property-item.tsx b/apps/web/src/components/editor/properties-panel/property-item.tsx index 30f1b40c..beb8c799 100644 --- a/apps/web/src/components/editor/properties-panel/property-item.tsx +++ b/apps/web/src/components/editor/properties-panel/property-item.tsx @@ -1,47 +1,47 @@ -import { cn } from "@/lib/utils"; - -interface PropertyItemProps { - direction?: "row" | "column"; - children: React.ReactNode; - className?: string; -} - -export function PropertyItem({ - direction = "row", - children, - className, -}: PropertyItemProps) { - return ( - <div - className={cn( - "flex gap-2", - direction === "row" - ? "items-center justify-between gap-6" - : "flex-col gap-1", - className, - )} - > - {children} - </div> - ); -} - -export function PropertyItemLabel({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return <label className={cn("text-xs", className)}>{children}</label>; -} - -export function PropertyItemValue({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return <div className={cn("flex-1", className)}>{children}</div>; -} +import { cn } from "@/lib/utils";
+
+interface PropertyItemProps {
+ direction?: "row" | "column";
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function PropertyItem({
+ direction = "row",
+ children,
+ className,
+}: PropertyItemProps) {
+ return (
+ <div
+ className={cn(
+ "flex gap-2",
+ direction === "row"
+ ? "items-center justify-between gap-6"
+ : "flex-col gap-1",
+ className
+ )}
+ >
+ {children}
+ </div>
+ );
+}
+
+export function PropertyItemLabel({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return <label className={cn("text-xs", className)}>{children}</label>;
+}
+
+export function PropertyItemValue({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return <div className={cn("flex-1", className)}>{children}</div>;
+}
diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx index 4d58752b..e13dabc8 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -1,76 +1,76 @@ -import { Textarea } from "@/components/ui/textarea"; -import { FontPicker } from "@/components/ui/font-picker"; -import { FontFamily } from "@/constants/font-constants"; -import { TextElement } from "@/types/timeline"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { Slider } from "@/components/ui/slider"; -import { Input } from "@/components/ui/input"; -import { - PropertyItem, - PropertyItemLabel, - PropertyItemValue, -} from "./property-item"; - -export function TextProperties({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const { updateTextElement } = useTimelineStore(); - - return ( - <div className="space-y-6 p-5"> - <Textarea - placeholder="Name" - defaultValue={element.content} - className="min-h-[4.5rem] resize-none bg-background/50" - onChange={(e) => - updateTextElement(trackId, element.id, { content: e.target.value }) - } - /> - <PropertyItem direction="row"> - <PropertyItemLabel>Font</PropertyItemLabel> - <PropertyItemValue> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value: FontFamily) => - updateTextElement(trackId, element.id, { fontFamily: value }) - } - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Font size</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Slider - defaultValue={[element.fontSize]} - min={8} - max={300} - step={1} - onValueChange={([value]) => - updateTextElement(trackId, element.id, { fontSize: value }) - } - className="w-full" - /> - <Input - type="number" - value={element.fontSize} - onChange={(e) => - updateTextElement(trackId, element.id, { - fontSize: parseInt(e.target.value), - }) - } - className="w-12 !text-xs h-7 rounded-sm text-center - [appearance:textfield] - [&::-webkit-outer-spin-button]:appearance-none - [&::-webkit-inner-spin-button]:appearance-none" - /> - </div> - </PropertyItemValue> - </PropertyItem> - </div> - ); -} +import { Textarea } from "@/components/ui/textarea";
+import { FontPicker } from "@/components/ui/font-picker";
+import { FontFamily } from "@/constants/font-constants";
+import { TextElement } from "@/types/timeline";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { Slider } from "@/components/ui/slider";
+import { Input } from "@/components/ui/input";
+import {
+ PropertyItem,
+ PropertyItemLabel,
+ PropertyItemValue,
+} from "./property-item";
+
+export function TextProperties({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const { updateTextElement } = useTimelineStore();
+
+ return (
+ <div className="space-y-6 p-5">
+ <Textarea
+ placeholder="Name"
+ defaultValue={element.content}
+ className="min-h-[4.5rem] resize-none bg-background/50"
+ onChange={(e) =>
+ updateTextElement(trackId, element.id, { content: e.target.value })
+ }
+ />
+ <PropertyItem direction="row">
+ <PropertyItemLabel>Font</PropertyItemLabel>
+ <PropertyItemValue>
+ <FontPicker
+ defaultValue={element.fontFamily}
+ onValueChange={(value: FontFamily) =>
+ updateTextElement(trackId, element.id, { fontFamily: value })
+ }
+ />
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Font size</PropertyItemLabel>
+ <PropertyItemValue>
+ <div className="flex items-center gap-2">
+ <Slider
+ defaultValue={[element.fontSize]}
+ min={8}
+ max={300}
+ step={1}
+ onValueChange={([value]) =>
+ updateTextElement(trackId, element.id, { fontSize: value })
+ }
+ className="w-full"
+ />
+ <Input
+ type="number"
+ value={element.fontSize}
+ onChange={(e) =>
+ updateTextElement(trackId, element.id, {
+ fontSize: parseInt(e.target.value),
+ })
+ }
+ className="w-12 !text-xs h-7 rounded-sm text-center
+ [appearance:textfield]
+ [&::-webkit-outer-spin-button]:appearance-none
+ [&::-webkit-inner-spin-button]:appearance-none"
+ />
+ </div>
+ </PropertyItemValue>
+ </PropertyItem>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/selection-box.tsx b/apps/web/src/components/editor/selection-box.tsx index 69b753f4..5c452f7c 100644 --- a/apps/web/src/components/editor/selection-box.tsx +++ b/apps/web/src/components/editor/selection-box.tsx @@ -1,58 +1,58 @@ -"use client"; - -import { useEffect, useRef } from "react"; - -interface SelectionBoxProps { - startPos: { x: number; y: number } | null; - currentPos: { x: number; y: number } | null; - containerRef: React.RefObject<HTMLElement>; - isActive: boolean; -} - -export function SelectionBox({ - startPos, - currentPos, - containerRef, - isActive, -}: SelectionBoxProps) { - const selectionBoxRef = useRef<HTMLDivElement>(null); - - useEffect(() => { - if (!isActive || !startPos || !currentPos || !containerRef.current) return; - - const container = containerRef.current; - const containerRect = container.getBoundingClientRect(); - - // Calculate relative positions within the container - const startX = startPos.x - containerRect.left; - const startY = startPos.y - containerRect.top; - const currentX = currentPos.x - containerRect.left; - const currentY = currentPos.y - containerRect.top; - - // Calculate the selection rectangle bounds - const left = Math.min(startX, currentX); - const top = Math.min(startY, currentY); - const width = Math.abs(currentX - startX); - const height = Math.abs(currentY - startY); - - // Update the selection box position and size - if (selectionBoxRef.current) { - selectionBoxRef.current.style.left = `${left}px`; - selectionBoxRef.current.style.top = `${top}px`; - selectionBoxRef.current.style.width = `${width}px`; - selectionBoxRef.current.style.height = `${height}px`; - } - }, [startPos, currentPos, isActive, containerRef]); - - if (!isActive || !startPos || !currentPos) return null; - - return ( - <div - ref={selectionBoxRef} - className="absolute pointer-events-none z-50" - style={{ - backgroundColor: "hsl(var(--foreground) / 0.1)", - }} - /> - ); -} +"use client";
+
+import { useEffect, useRef } from "react";
+
+interface SelectionBoxProps {
+ startPos: { x: number; y: number } | null;
+ currentPos: { x: number; y: number } | null;
+ containerRef: React.RefObject<HTMLElement>;
+ isActive: boolean;
+}
+
+export function SelectionBox({
+ startPos,
+ currentPos,
+ containerRef,
+ isActive,
+}: SelectionBoxProps) {
+ const selectionBoxRef = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ if (!isActive || !startPos || !currentPos || !containerRef.current) return;
+
+ const container = containerRef.current;
+ const containerRect = container.getBoundingClientRect();
+
+ // Calculate relative positions within the container
+ const startX = startPos.x - containerRect.left;
+ const startY = startPos.y - containerRect.top;
+ const currentX = currentPos.x - containerRect.left;
+ const currentY = currentPos.y - containerRect.top;
+
+ // Calculate the selection rectangle bounds
+ const left = Math.min(startX, currentX);
+ const top = Math.min(startY, currentY);
+ const width = Math.abs(currentX - startX);
+ const height = Math.abs(currentY - startY);
+
+ // Update the selection box position and size
+ if (selectionBoxRef.current) {
+ selectionBoxRef.current.style.left = `${left}px`;
+ selectionBoxRef.current.style.top = `${top}px`;
+ selectionBoxRef.current.style.width = `${width}px`;
+ selectionBoxRef.current.style.height = `${height}px`;
+ }
+ }, [startPos, currentPos, isActive, containerRef]);
+
+ if (!isActive || !startPos || !currentPos) return null;
+
+ return (
+ <div
+ ref={selectionBoxRef}
+ className="absolute pointer-events-none z-50"
+ style={{
+ backgroundColor: "hsl(var(--foreground) / 0.1)",
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/snap-indicator.tsx index 2bbe605d..83a1f678 100644 --- a/apps/web/src/components/editor/snap-indicator.tsx +++ b/apps/web/src/components/editor/snap-indicator.tsx @@ -5,50 +5,50 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import type { TimelineTrack } from "@/types/timeline"; interface SnapIndicatorProps { - snapPoint: SnapPoint | null; - zoomLevel: number; - isVisible: boolean; - tracks: TimelineTrack[]; - timelineRef: React.RefObject<HTMLDivElement>; - trackLabelsRef?: React.RefObject<HTMLDivElement>; + snapPoint: SnapPoint | null; + zoomLevel: number; + isVisible: boolean; + tracks: TimelineTrack[]; + timelineRef: React.RefObject<HTMLDivElement>; + trackLabelsRef?: React.RefObject<HTMLDivElement>; } export function SnapIndicator({ - snapPoint, - zoomLevel, - isVisible, - tracks, - timelineRef, - trackLabelsRef, + snapPoint, + zoomLevel, + isVisible, + tracks, + timelineRef, + trackLabelsRef, }: SnapIndicatorProps) { - if (!isVisible || !snapPoint) { - return null; - } + if (!isVisible || !snapPoint) { + return null; + } - const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; - const totalHeight = timelineContainerHeight - 8; // 8px padding from edges + const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; + const totalHeight = timelineContainerHeight - 8; // 8px padding from edges - // Get dynamic track labels width, fallback to 0 if no tracks or no ref - const trackLabelsWidth = - tracks.length > 0 && trackLabelsRef?.current - ? trackLabelsRef.current.offsetWidth - : 0; + // Get dynamic track labels width, fallback to 0 if no tracks or no ref + const trackLabelsWidth = + tracks.length > 0 && trackLabelsRef?.current + ? trackLabelsRef.current.offsetWidth + : 0; - const leftPosition = - trackLabelsWidth + - snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const leftPosition = + trackLabelsWidth + + snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - return ( - <div - className="absolute pointer-events-none z-[90]" - style={{ - left: `${leftPosition}px`, - top: 0, - height: `${totalHeight}px`, - width: "2px", - }} - > - <div className={`w-0.5 h-full bg-primary/40 opacity-80`} /> - </div> - ); + return ( + <div + className="absolute pointer-events-none z-[90]" + style={{ + left: `${leftPosition}px`, + top: 0, + height: `${totalHeight}px`, + width: "2px", + }} + > + <div className={`w-0.5 h-full bg-primary/40 opacity-80`} /> + </div> + ); } diff --git a/apps/web/src/components/editor/speed-control.tsx b/apps/web/src/components/editor/speed-control.tsx index ba5a4cc6..39281a9a 100644 --- a/apps/web/src/components/editor/speed-control.tsx +++ b/apps/web/src/components/editor/speed-control.tsx @@ -4,43 +4,43 @@ import { Button } from "../ui/button"; import { usePlaybackStore } from "@/stores/playback-store"; const SPEED_PRESETS = [ - { label: "0.5x", value: 0.5 }, - { label: "1x", value: 1.0 }, - { label: "1.5x", value: 1.5 }, - { label: "2x", value: 2.0 }, + { label: "0.5x", value: 0.5 }, + { label: "1x", value: 1.0 }, + { label: "1.5x", value: 1.5 }, + { label: "2x", value: 2.0 }, ]; export function SpeedControl() { - const { speed, setSpeed } = usePlaybackStore(); + const { speed, setSpeed } = usePlaybackStore(); - return ( - <div className="space-y-4"> - <h3 className="text-sm font-medium">Playback Speed</h3> - <div className="space-y-4"> - <div className="flex gap-2"> - {SPEED_PRESETS.map((preset) => ( - <Button - key={preset.value} - variant={speed === preset.value ? "default" : "outline"} - className="flex-1" - onClick={() => setSpeed(preset.value)} - > - {preset.label} - </Button> - ))} - </div> - <div className="space-y-1"> - <Label>Custom ({speed.toFixed(1)}x)</Label> - <Slider - value={[speed]} - min={0.1} - max={2.0} - step={0.1} - onValueChange={(value) => setSpeed(value[0])} - className="mt-2" - /> - </div> - </div> - </div> - ); -} + return ( + <div className="space-y-4"> + <h3 className="text-sm font-medium">Playback Speed</h3> + <div className="space-y-4"> + <div className="flex gap-2"> + {SPEED_PRESETS.map((preset) => ( + <Button + key={preset.value} + variant={speed === preset.value ? "default" : "outline"} + className="flex-1" + onClick={() => setSpeed(preset.value)} + > + {preset.label} + </Button> + ))} + </div> + <div className="space-y-1"> + <Label>Custom ({speed.toFixed(1)}x)</Label> + <Slider + value={[speed]} + min={0.1} + max={2.0} + step={0.1} + onValueChange={(value) => setSpeed(value[0])} + className="mt-2" + /> + </div> + </div> + </div> + ); +}
\ No newline at end of file diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx index c0acef6a..eb9f1c01 100644 --- a/apps/web/src/components/editor/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -3,16 +3,16 @@ import { useState } from "react"; import { Button } from "../ui/button"; import { - MoreVertical, - Scissors, - Trash2, - SplitSquareHorizontal, - Music, - ChevronRight, - ChevronLeft, - Type, - Copy, - RefreshCw, + MoreVertical, + Scissors, + Trash2, + SplitSquareHorizontal, + Music, + ChevronRight, + ChevronLeft, + Type, + Copy, + RefreshCw, } from "lucide-react"; import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; @@ -22,371 +22,371 @@ import { toast } from "sonner"; import { TimelineElementProps, TrackType } from "@/types/timeline"; import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; import { - getTrackElementClasses, - TIMELINE_CONSTANTS, - getTrackHeight, + getTrackElementClasses, + TIMELINE_CONSTANTS, + getTrackHeight, } from "@/constants/timeline-constants"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, } from "../ui/dropdown-menu"; import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, } from "../ui/context-menu"; export function TimelineElement({ - element, - track, - zoomLevel, - isSelected, - onElementMouseDown, - onElementClick, + element, + track, + zoomLevel, + isSelected, + onElementMouseDown, + onElementClick, }: TimelineElementProps) { - const { mediaItems } = useMediaStore(); - const { - updateElementTrim, - updateElementDuration, - removeElementFromTrack, - removeElementFromTrackWithRipple, - dragState, - splitElement, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, - addElementToTrack, - replaceElementMedia, - rippleEditingEnabled, - } = useTimelineStore(); - const { currentTime } = usePlaybackStore(); + const { mediaItems } = useMediaStore(); + const { + updateElementTrim, + updateElementDuration, + removeElementFromTrack, + removeElementFromTrackWithRipple, + dragState, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + addElementToTrack, + replaceElementMedia, + rippleEditingEnabled, + } = useTimelineStore(); + const { currentTime } = usePlaybackStore(); - const [elementMenuOpen, setElementMenuOpen] = useState(false); + const [elementMenuOpen, setElementMenuOpen] = useState(false); - const { - resizing, - isResizing, - handleResizeStart, - handleResizeMove, - handleResizeEnd, - } = useTimelineElementResize({ - element, - track, - zoomLevel, - onUpdateTrim: updateElementTrim, - onUpdateDuration: updateElementDuration, - }); + const { + resizing, + isResizing, + handleResizeStart, + handleResizeMove, + handleResizeEnd, + } = useTimelineElementResize({ + element, + track, + zoomLevel, + onUpdateTrim: updateElementTrim, + onUpdateDuration: updateElementDuration, + }); - const effectiveDuration = - element.duration - element.trimStart - element.trimEnd; - const elementWidth = Math.max( - TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH, - effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, - ); + const effectiveDuration = + element.duration - element.trimStart - element.trimEnd; + const elementWidth = Math.max( + TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH, + effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel + ); - // Use real-time position during drag, otherwise use stored position - const isBeingDragged = dragState.elementId === element.id; - const elementStartTime = - isBeingDragged && dragState.isDragging - ? dragState.currentTime - : element.startTime; + // Use real-time position during drag, otherwise use stored position + const isBeingDragged = dragState.elementId === element.id; + const elementStartTime = + isBeingDragged && dragState.isDragging + ? dragState.currentTime + : element.startTime; - // Element should always be positioned at startTime - trimStart only affects content, not position - const elementLeft = elementStartTime * 50 * zoomLevel; + // Element should always be positioned at startTime - trimStart only affects content, not position + const elementLeft = elementStartTime * 50 * zoomLevel; - const handleElementSplitContext = () => { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + const handleElementSplitContext = () => { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const secondElementId = splitElement(track.id, element.id, currentTime); - if (!secondElementId) { - toast.error("Failed to split element"); - } - } else { - toast.error("Playhead must be within element to split"); - } - }; + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + const secondElementId = splitElement(track.id, element.id, currentTime); + if (!secondElementId) { + toast.error("Failed to split element"); + } + } else { + toast.error("Playhead must be within element to split"); + } + }; - const handleElementDuplicateContext = () => { - const { id, ...elementWithoutId } = element; - addElementToTrack(track.id, { - ...elementWithoutId, - name: element.name + " (copy)", - startTime: - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1, - }); - }; + const handleElementDuplicateContext = () => { + const { id, ...elementWithoutId } = element; + addElementToTrack(track.id, { + ...elementWithoutId, + name: element.name + " (copy)", + startTime: + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1, + }); + }; - const handleElementDeleteContext = () => { - if (rippleEditingEnabled) { - removeElementFromTrackWithRipple(track.id, element.id); - } else { - removeElementFromTrack(track.id, element.id); - } - }; + const handleElementDeleteContext = () => { + if (rippleEditingEnabled) { + removeElementFromTrackWithRipple(track.id, element.id); + } else { + removeElementFromTrack(track.id, element.id); + } + }; - const handleReplaceClip = () => { - if (element.type !== "media") { - toast.error("Replace is only available for media clips"); - return; - } + const handleReplaceClip = () => { + if (element.type !== "media") { + toast.error("Replace is only available for media clips"); + return; + } - // Create a file input to select replacement media - const input = document.createElement("input"); - input.type = "file"; - input.accept = "video/*,audio/*,image/*"; - input.onchange = async (e) => { - const file = (e.target as HTMLInputElement).files?.[0]; - if (!file) return; + // Create a file input to select replacement media + const input = document.createElement("input"); + input.type = "file"; + input.accept = "video/*,audio/*,image/*"; + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; - try { - const success = await replaceElementMedia(track.id, element.id, file); - if (success) { - toast.success("Clip replaced successfully"); - } else { - toast.error("Failed to replace clip"); - } - } catch (error) { - toast.error("Failed to replace clip"); - console.log( - JSON.stringify({ error: "Failed to replace clip", details: error }), - ); - } - }; - input.click(); - }; + try { + const success = await replaceElementMedia(track.id, element.id, file); + if (success) { + toast.success("Clip replaced successfully"); + } else { + toast.error("Failed to replace clip"); + } + } catch (error) { + toast.error("Failed to replace clip"); + console.log( + JSON.stringify({ error: "Failed to replace clip", details: error }) + ); + } + }; + input.click(); + }; - const renderElementContent = () => { - if (element.type === "text") { - return ( - <div className="w-full h-full flex items-center justify-start pl-2"> - <span className="text-xs text-foreground/80 truncate"> - {element.content} - </span> - </div> - ); - } + const renderElementContent = () => { + if (element.type === "text") { + return ( + <div className="w-full h-full flex items-center justify-start pl-2"> + <span className="text-xs text-foreground/80 truncate"> + {element.content} + </span> + </div> + ); + } - // Render media element -> - const mediaItem = mediaItems.find((item) => item.id === element.mediaId); - if (!mediaItem) { - return ( - <span className="text-xs text-foreground/80 truncate"> - {element.name} - </span> - ); - } + // Render media element -> + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + if (!mediaItem) { + return ( + <span className="text-xs text-foreground/80 truncate"> + {element.name} + </span> + ); + } - const TILE_ASPECT_RATIO = 16 / 9; + const TILE_ASPECT_RATIO = 16 / 9; - if (mediaItem.type === "image") { - // Calculate tile size based on 16:9 aspect ratio - const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight - 8; // Account for padding - const tileWidth = tileHeight * TILE_ASPECT_RATIO; + if (mediaItem.type === "image") { + // Calculate tile size based on 16:9 aspect ratio + const trackHeight = getTrackHeight(track.type); + const tileHeight = trackHeight - 8; // Account for padding + const tileWidth = tileHeight * TILE_ASPECT_RATIO; - return ( - <div className="w-full h-full flex items-center justify-center"> - <div className="bg-[#004D52] py-3 w-full h-full relative"> - {/* Background with tiled images */} - <div - className="absolute top-3 bottom-3 left-0 right-0" - style={{ - backgroundImage: mediaItem.url - ? `url(${mediaItem.url})` - : "none", - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${tileHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - }} - aria-label={`Tiled background of ${mediaItem.name}`} - /> - {/* Overlay with vertical borders */} - <div - className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none" - style={{ - backgroundImage: `repeating-linear-gradient( + return ( + <div className="w-full h-full flex items-center justify-center"> + <div className="bg-[#004D52] py-3 w-full h-full relative"> + {/* Background with tiled images */} + <div + className="absolute top-3 bottom-3 left-0 right-0" + style={{ + backgroundImage: mediaItem.url + ? `url(${mediaItem.url})` + : "none", + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${tileHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + aria-label={`Tiled background of ${mediaItem.name}`} + /> + {/* Overlay with vertical borders */} + <div + className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none" + style={{ + backgroundImage: `repeating-linear-gradient( to right, transparent 0px, transparent ${tileWidth - 1}px, rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, rgba(255, 255, 255, 0.6) ${tileWidth}px )`, - backgroundPosition: "left center", - }} - /> - </div> - </div> - ); - } + backgroundPosition: "left center", + }} + /> + </div> + </div> + ); + } - const VIDEO_TILE_PADDING = 16; - const OVERLAY_SPACE_MULTIPLIER = 1.5; + const VIDEO_TILE_PADDING = 16; + const OVERLAY_SPACE_MULTIPLIER = 1.5; - if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { - const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight - VIDEO_TILE_PADDING; - const tileWidth = tileHeight * TILE_ASPECT_RATIO; + if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { + const trackHeight = getTrackHeight(track.type); + const tileHeight = trackHeight - VIDEO_TILE_PADDING; + const tileWidth = tileHeight * TILE_ASPECT_RATIO; - return ( - <div className="w-full h-full flex items-center gap-2"> - <div className="flex-1 h-full relative overflow-hidden"> - {/* Background with tiled thumbnails */} - <div - className="absolute inset-0" - style={{ - backgroundImage: mediaItem.thumbnailUrl - ? `url(${mediaItem.thumbnailUrl})` - : "none", - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${tileHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - }} - aria-label={`Tiled thumbnail of ${mediaItem.name}`} - /> - {/* Overlay with vertical borders */} - <div - className="absolute inset-0 pointer-events-none" - style={{ - backgroundImage: `repeating-linear-gradient( + return ( + <div className="w-full h-full flex items-center gap-2"> + <div className="flex-1 h-full relative overflow-hidden"> + {/* Background with tiled thumbnails */} + <div + className="absolute inset-0" + style={{ + backgroundImage: mediaItem.thumbnailUrl + ? `url(${mediaItem.thumbnailUrl})` + : "none", + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${tileHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + aria-label={`Tiled thumbnail of ${mediaItem.name}`} + /> + {/* Overlay with vertical borders */} + <div + className="absolute inset-0 pointer-events-none" + style={{ + backgroundImage: `repeating-linear-gradient( to right, transparent 0px, transparent ${tileWidth - 1}px, rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, rgba(255, 255, 255, 0.6) ${tileWidth}px )`, - backgroundPosition: "left center", - }} - /> - </div> - {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? ( - <div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate"> - {element.name} - </div> - ) : ( - <span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]"> - {element.name} - </span> - )} - </div> - ); - } + backgroundPosition: "left center", + }} + /> + </div> + {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? ( + <div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate"> + {element.name} + </div> + ) : ( + <span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]"> + {element.name} + </span> + )} + </div> + ); + } - // Render audio element -> - if (mediaItem.type === "audio") { - return ( - <div className="w-full h-full flex items-center gap-2"> - <div className="flex-1 min-w-0"> - <AudioWaveform - audioUrl={mediaItem.url || ""} - height={24} - className="w-full" - /> - </div> - </div> - ); - } + // Render audio element -> + if (mediaItem.type === "audio") { + return ( + <div className="w-full h-full flex items-center gap-2"> + <div className="flex-1 min-w-0"> + <AudioWaveform + audioUrl={mediaItem.url || ""} + height={24} + className="w-full" + /> + </div> + </div> + ); + } - return ( - <span className="text-xs text-foreground/80 truncate"> - {element.name} - </span> - ); - }; + return ( + <span className="text-xs text-foreground/80 truncate"> + {element.name} + </span> + ); + }; - const handleElementMouseDown = (e: React.MouseEvent) => { - if (onElementMouseDown) { - onElementMouseDown(e, element); - } - }; + const handleElementMouseDown = (e: React.MouseEvent) => { + if (onElementMouseDown) { + onElementMouseDown(e, element); + } + }; - return ( - <ContextMenu> - <ContextMenuTrigger asChild> - <div - className={`absolute top-0 h-full select-none timeline-element ${ - isBeingDragged ? "z-50" : "z-10" - }`} - style={{ - left: `${elementLeft}px`, - width: `${elementWidth}px`, - }} - data-element-id={element.id} - data-track-id={track.id} - onMouseMove={resizing ? handleResizeMove : undefined} - onMouseUp={resizing ? handleResizeEnd : undefined} - onMouseLeave={resizing ? handleResizeEnd : undefined} - > - <div - className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses( - track.type, - )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${ - isBeingDragged ? "z-50" : "z-10" - }`} - onClick={(e) => onElementClick && onElementClick(e, element)} - onMouseDown={handleElementMouseDown} - onContextMenu={(e) => - onElementMouseDown && onElementMouseDown(e, element) - } - > - <div className="absolute inset-0 flex items-center h-full"> - {renderElementContent()} - </div> + return ( + <ContextMenu> + <ContextMenuTrigger asChild> + <div + className={`absolute top-0 h-full select-none timeline-element ${ + isBeingDragged ? "z-50" : "z-10" + }`} + style={{ + left: `${elementLeft}px`, + width: `${elementWidth}px`, + }} + data-element-id={element.id} + data-track-id={track.id} + onMouseMove={resizing ? handleResizeMove : undefined} + onMouseUp={resizing ? handleResizeEnd : undefined} + onMouseLeave={resizing ? handleResizeEnd : undefined} + > + <div + className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses( + track.type + )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${ + isBeingDragged ? "z-50" : "z-10" + }`} + onClick={(e) => onElementClick && onElementClick(e, element)} + onMouseDown={handleElementMouseDown} + onContextMenu={(e) => + onElementMouseDown && onElementMouseDown(e, element) + } + > + <div className="absolute inset-0 flex items-center h-full"> + {renderElementContent()} + </div> - {isSelected && ( - <> - <div - className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50" - onMouseDown={(e) => handleResizeStart(e, element.id, "left")} - /> - <div - className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50" - onMouseDown={(e) => handleResizeStart(e, element.id, "right")} - /> - </> - )} - </div> - </div> - </ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuItem onClick={handleElementSplitContext}> - <Scissors className="h-4 w-4 mr-2" /> - Split at playhead - </ContextMenuItem> - <ContextMenuItem onClick={handleElementDuplicateContext}> - <Copy className="h-4 w-4 mr-2" /> - Duplicate {element.type === "text" ? "text" : "clip"} - </ContextMenuItem> - {element.type === "media" && ( - <ContextMenuItem onClick={handleReplaceClip}> - <RefreshCw className="h-4 w-4 mr-2" /> - Replace clip - </ContextMenuItem> - )} - <ContextMenuSeparator /> - <ContextMenuItem - onClick={handleElementDeleteContext} - className="text-destructive focus:text-destructive" - > - <Trash2 className="h-4 w-4 mr-2" /> - Delete {element.type === "text" ? "text" : "clip"} - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - ); + {isSelected && ( + <> + <div + className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50" + onMouseDown={(e) => handleResizeStart(e, element.id, "left")} + /> + <div + className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50" + onMouseDown={(e) => handleResizeStart(e, element.id, "right")} + /> + </> + )} + </div> + </div> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem onClick={handleElementSplitContext}> + <Scissors className="h-4 w-4 mr-2" /> + Split at playhead + </ContextMenuItem> + <ContextMenuItem onClick={handleElementDuplicateContext}> + <Copy className="h-4 w-4 mr-2" /> + Duplicate {element.type === "text" ? "text" : "clip"} + </ContextMenuItem> + {element.type === "media" && ( + <ContextMenuItem onClick={handleReplaceClip}> + <RefreshCw className="h-4 w-4 mr-2" /> + Replace clip + </ContextMenuItem> + )} + <ContextMenuSeparator /> + <ContextMenuItem + onClick={handleElementDeleteContext} + className="text-destructive focus:text-destructive" + > + <Trash2 className="h-4 w-4 mr-2" /> + Delete {element.type === "text" ? "text" : "clip"} + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ); } diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline-playhead.tsx index 6e2e1cfa..7a357067 100644 --- a/apps/web/src/components/editor/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline-playhead.tsx @@ -1,116 +1,116 @@ -"use client"; - -import { useRef } from "react"; -import { TimelineTrack } from "@/types/timeline"; -import { - TIMELINE_CONSTANTS, - getTotalTracksHeight, -} from "@/constants/timeline-constants"; -import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead"; - -interface TimelinePlayheadProps { - currentTime: number; - duration: number; - zoomLevel: number; - tracks: TimelineTrack[]; - seek: (time: number) => void; - rulerRef: React.RefObject<HTMLDivElement>; - rulerScrollRef: React.RefObject<HTMLDivElement>; - tracksScrollRef: React.RefObject<HTMLDivElement>; - trackLabelsRef?: React.RefObject<HTMLDivElement>; - timelineRef: React.RefObject<HTMLDivElement>; - playheadRef?: React.RefObject<HTMLDivElement>; - isSnappingToPlayhead?: boolean; -} - -export function TimelinePlayhead({ - currentTime, - duration, - zoomLevel, - tracks, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - trackLabelsRef, - timelineRef, - playheadRef: externalPlayheadRef, - isSnappingToPlayhead = false, -}: TimelinePlayheadProps) { - const internalPlayheadRef = useRef<HTMLDivElement>(null); - const playheadRef = externalPlayheadRef || internalPlayheadRef; - const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); - - // Use timeline container height minus a few pixels for breathing room - const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; - const totalHeight = timelineContainerHeight - 8; // 8px padding from edges - - // Get dynamic track labels width, fallback to 0 if no tracks or no ref - const trackLabelsWidth = - tracks.length > 0 && trackLabelsRef?.current - ? trackLabelsRef.current.offsetWidth - : 0; - const leftPosition = - trackLabelsWidth + - playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - - return ( - <div - ref={playheadRef} - className="absolute pointer-events-auto z-[150]" - style={{ - left: `${leftPosition}px`, - top: 0, - height: `${totalHeight}px`, - width: "2px", // Slightly wider for better click target - }} - onMouseDown={handlePlayheadMouseDown} - > - {/* The playhead line spanning full height */} - <div - className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`} - /> - - {/* Playhead dot indicator at the top (in ruler area) */} - <div - className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`} - /> - </div> - ); -} - -// Also export a hook for getting ruler handlers -export function useTimelinePlayheadRuler({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, -}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) { - const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); - - return { handleRulerMouseDown, isDraggingRuler }; -} - -export { TimelinePlayhead as default }; +"use client";
+
+import { useRef } from "react";
+import { TimelineTrack } from "@/types/timeline";
+import {
+ TIMELINE_CONSTANTS,
+ getTotalTracksHeight,
+} from "@/constants/timeline-constants";
+import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
+
+interface TimelinePlayheadProps {
+ currentTime: number;
+ duration: number;
+ zoomLevel: number;
+ tracks: TimelineTrack[];
+ seek: (time: number) => void;
+ rulerRef: React.RefObject<HTMLDivElement>;
+ rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+ trackLabelsRef?: React.RefObject<HTMLDivElement>;
+ timelineRef: React.RefObject<HTMLDivElement>;
+ playheadRef?: React.RefObject<HTMLDivElement>;
+ isSnappingToPlayhead?: boolean;
+}
+
+export function TimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ tracks,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ trackLabelsRef,
+ timelineRef,
+ playheadRef: externalPlayheadRef,
+ isSnappingToPlayhead = false,
+}: TimelinePlayheadProps) {
+ const internalPlayheadRef = useRef<HTMLDivElement>(null);
+ const playheadRef = externalPlayheadRef || internalPlayheadRef;
+ const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ // Use timeline container height minus a few pixels for breathing room
+ const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
+ const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
+
+ // Get dynamic track labels width, fallback to 0 if no tracks or no ref
+ const trackLabelsWidth =
+ tracks.length > 0 && trackLabelsRef?.current
+ ? trackLabelsRef.current.offsetWidth
+ : 0;
+ const leftPosition =
+ trackLabelsWidth +
+ playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+
+ return (
+ <div
+ ref={playheadRef}
+ className="absolute pointer-events-auto z-[150]"
+ style={{
+ left: `${leftPosition}px`,
+ top: 0,
+ height: `${totalHeight}px`,
+ width: "2px", // Slightly wider for better click target
+ }}
+ onMouseDown={handlePlayheadMouseDown}
+ >
+ {/* The playhead line spanning full height */}
+ <div
+ className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
+ />
+
+ {/* Playhead dot indicator at the top (in ruler area) */}
+ <div
+ className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
+ />
+ </div>
+ );
+}
+
+// Also export a hook for getting ruler handlers
+export function useTimelinePlayheadRuler({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
+ const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ return { handleRulerMouseDown, isDraggingRuler };
+}
+
+export { TimelinePlayhead as default };
diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx index 6c5a5033..82ca0b98 100644 --- a/apps/web/src/components/editor/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline-track.tsx @@ -1,1151 +1,1151 @@ -"use client"; - -import { useRef, useState, useEffect } from "react"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { useMediaStore } from "@/stores/media-store"; -import { toast } from "sonner"; -import { TimelineElement } from "./timeline-element"; -import { - TimelineTrack, - sortTracksByOrder, - ensureMainTrack, - getMainTrack, - canElementGoOnTrack, -} from "@/types/timeline"; -import { usePlaybackStore } from "@/stores/playback-store"; -import type { - TimelineElement as TimelineElementType, - DragData, -} from "@/types/timeline"; -import { - snapTimeToFrame, - TIMELINE_CONSTANTS, -} from "@/constants/timeline-constants"; -import { useProjectStore } from "@/stores/project-store"; -import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping"; - -export function TimelineTrackContent({ - track, - zoomLevel, - onSnapPointChange, -}: { - track: TimelineTrack; - zoomLevel: number; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -}) { - const { mediaItems } = useMediaStore(); - const { - tracks, - addTrack, - moveElementToTrack, - updateElementStartTime, - updateElementStartTimeWithRipple, - addElementToTrack, - selectedElements, - selectElement, - dragState, - startDrag: startDragAction, - updateDragTime, - endDrag: endDragAction, - clearSelectedElements, - insertTrackAt, - snappingEnabled, - rippleEditingEnabled, - } = useTimelineStore(); - - const { currentTime } = usePlaybackStore(); - - // Initialize snapping hook - const { snapElementPosition, snapElementEdge } = useTimelineSnapping({ - snapThreshold: 10, - enableElementSnapping: snappingEnabled, - enablePlayheadSnapping: snappingEnabled, - }); - - // Helper function for drop snapping that tries both edges - const getDropSnappedTime = ( - dropTime: number, - elementDuration: number, - excludeElementId?: string, - ) => { - if (!snappingEnabled) { - // Use frame snapping if project has FPS, otherwise use decimal snapping - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - return snapTimeToFrame(dropTime, projectFps); - } - - // Try snapping both start and end edges for drops - const startSnapResult = snapElementEdge( - dropTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - excludeElementId, - true, // snap to start edge - ); - - const endSnapResult = snapElementEdge( - dropTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - excludeElementId, - false, // snap to end edge - ); - - // Choose the snap result with the smaller distance (closer snap) - let bestSnapResult = startSnapResult; - if ( - endSnapResult.snapPoint && - (!startSnapResult.snapPoint || - endSnapResult.snapDistance < startSnapResult.snapDistance) - ) { - bestSnapResult = endSnapResult; - } - - return bestSnapResult.snappedTime; - }; - - const timelineRef = useRef<HTMLDivElement>(null); - const [isDropping, setIsDropping] = useState(false); - const [dropPosition, setDropPosition] = useState<number | null>(null); - const [wouldOverlap, setWouldOverlap] = useState(false); - const dragCounterRef = useRef(0); - const [mouseDownLocation, setMouseDownLocation] = useState<{ - x: number; - y: number; - } | null>(null); - - // Set up mouse event listeners for drag - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseMove = (e: MouseEvent) => { - if (!timelineRef.current) return; - - // On first mouse move during drag, ensure the element is selected - if (dragState.elementId && dragState.trackId) { - const isSelected = selectedElements.some( - (c) => - c.trackId === dragState.trackId && - c.elementId === dragState.elementId, - ); - - if (!isSelected) { - // Select this element (replacing other selections) since we're dragging it - selectElement(dragState.trackId, dragState.elementId, false); - } - } - - const timelineRect = timelineRef.current.getBoundingClientRect(); - const mouseX = e.clientX - timelineRect.left; - const mouseTime = Math.max( - 0, - mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), - ); - const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - - // Apply snapping if enabled - let finalTime = adjustedTime; - let snapPoint = null; - if (snappingEnabled) { - // Find the element being dragged to get its duration - let elementDuration = 5; // fallback duration - if (dragState.elementId && dragState.trackId) { - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const element = sourceTrack?.elements.find( - (e) => e.id === dragState.elementId, - ); - if (element) { - elementDuration = - element.duration - element.trimStart - element.trimEnd; - } - } - - // Try snapping both start and end edges - const startSnapResult = snapElementEdge( - adjustedTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - dragState.elementId || undefined, - true, // snap to start edge - ); - - const endSnapResult = snapElementEdge( - adjustedTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - dragState.elementId || undefined, - false, // snap to end edge - ); - - // Choose the snap result with the smaller distance (closer snap) - let bestSnapResult = startSnapResult; - if ( - endSnapResult.snapPoint && - (!startSnapResult.snapPoint || - endSnapResult.snapDistance < startSnapResult.snapDistance) - ) { - bestSnapResult = endSnapResult; - } - - finalTime = bestSnapResult.snappedTime; - snapPoint = bestSnapResult.snapPoint; - - // Notify parent component about snap point change - onSnapPointChange?.(snapPoint); - } else { - // Use frame snapping if project has FPS, otherwise use decimal snapping - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - finalTime = snapTimeToFrame(adjustedTime, projectFps); - - // Clear snap point when not snapping - onSnapPointChange?.(null); - } - - updateDragTime(finalTime); - }; - - const handleMouseUp = (e: MouseEvent) => { - if (!dragState.elementId || !dragState.trackId) return; - - // If this track initiated the drag, we should handle the mouse up regardless of where it occurs - const isTrackThatStartedDrag = dragState.trackId === track.id; - - const timelineRect = timelineRef.current?.getBoundingClientRect(); - if (!timelineRect) { - if (isTrackThatStartedDrag) { - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - dragState.elementId, - dragState.currentTime, - ); - } else { - updateElementStartTime( - track.id, - dragState.elementId, - dragState.currentTime, - ); - } - endDragAction(); - // Clear snap point when drag ends - onSnapPointChange?.(null); - } - return; - } - - const isMouseOverThisTrack = - e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom; - - if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return; - - const finalTime = dragState.currentTime; - - if (isMouseOverThisTrack) { - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const movingElement = sourceTrack?.elements.find( - (c) => c.id === dragState.elementId, - ); - - if (movingElement) { - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - const movingElementEnd = finalTime + movingElementDuration; - - const targetTrack = tracks.find((t) => t.id === track.id); - const hasOverlap = targetTrack?.elements.some((existingElement) => { - if ( - dragState.trackId === track.id && - existingElement.id === dragState.elementId - ) { - return false; - } - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return finalTime < existingEnd && movingElementEnd > existingStart; - }); - - if (!hasOverlap) { - if (dragState.trackId === track.id) { - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - dragState.elementId, - finalTime, - ); - } else { - updateElementStartTime( - track.id, - dragState.elementId, - finalTime, - ); - } - } else { - moveElementToTrack( - dragState.trackId, - track.id, - dragState.elementId, - ); - requestAnimationFrame(() => { - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - dragState.elementId!, - finalTime, - ); - } else { - updateElementStartTime( - track.id, - dragState.elementId!, - finalTime, - ); - } - }); - } - } - } - } else if (isTrackThatStartedDrag) { - // Mouse is not over this track, but this track started the drag - // This means user released over ruler/outside - update position within same track - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const movingElement = sourceTrack?.elements.find( - (c) => c.id === dragState.elementId, - ); - - if (movingElement) { - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - const movingElementEnd = finalTime + movingElementDuration; - - const hasOverlap = track.elements.some((existingElement) => { - if (existingElement.id === dragState.elementId) { - return false; - } - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return finalTime < existingEnd && movingElementEnd > existingStart; - }); - - if (!hasOverlap) { - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - dragState.elementId, - finalTime, - ); - } else { - updateElementStartTime(track.id, dragState.elementId, finalTime); - } - } - } - } - - if (isTrackThatStartedDrag) { - endDragAction(); - // Clear snap point when drag ends - onSnapPointChange?.(null); - } - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [ - dragState.isDragging, - dragState.clickOffsetTime, - dragState.elementId, - dragState.trackId, - dragState.currentTime, - zoomLevel, - tracks, - track.id, - updateDragTime, - updateElementStartTime, - moveElementToTrack, - endDragAction, - selectedElements, - selectElement, - onSnapPointChange, - ]); - - const handleElementMouseDown = ( - e: React.MouseEvent, - element: TimelineElementType, - ) => { - setMouseDownLocation({ x: e.clientX, y: e.clientY }); - - // Detect right-click (button 2) and handle selection without starting drag - const isRightClick = e.button === 2; - const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey; - - if (isRightClick) { - // Handle right-click selection - const isSelected = selectedElements.some( - (c) => c.trackId === track.id && c.elementId === element.id, - ); - - // If element is not selected, select it (keep other selections if multi-select) - if (!isSelected) { - selectElement(track.id, element.id, isMultiSelect); - } - // If element is already selected, keep it selected - - // Don't start drag action for right-clicks - return; - } - - // Handle multi-selection for left-click with modifiers - if (isMultiSelect) { - selectElement(track.id, element.id, true); - } - - // Calculate the offset from the left edge of the element to where the user clicked - const elementElement = e.currentTarget as HTMLElement; - const elementRect = elementElement.getBoundingClientRect(); - const clickOffsetX = e.clientX - elementRect.left; - const clickOffsetTime = - clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); - - startDragAction( - element.id, - track.id, - e.clientX, - element.startTime, - clickOffsetTime, - ); - }; - - const handleElementClick = ( - e: React.MouseEvent, - element: TimelineElementType, - ) => { - e.stopPropagation(); - - // Check if mouse moved significantly - if (mouseDownLocation) { - const deltaX = Math.abs(e.clientX - mouseDownLocation.x); - const deltaY = Math.abs(e.clientY - mouseDownLocation.y); - // If it moved more than a few pixels, consider it a drag and not a click. - if (deltaX > 5 || deltaY > 5) { - setMouseDownLocation(null); // Reset for next interaction - return; - } - } - - // Skip selection logic for multi-selection (handled in mousedown) - if (e.metaKey || e.ctrlKey || e.shiftKey) { - return; - } - - // Handle single selection - const isSelected = selectedElements.some( - (c) => c.trackId === track.id && c.elementId === element.id, - ); - - if (!isSelected) { - // If element is not selected, select it (replacing other selections) - selectElement(track.id, element.id, false); - } - // If element is already selected, keep it selected (do nothing) - }; - - const handleTrackDragOver = (e: React.DragEvent) => { - e.preventDefault(); - - // Handle both timeline elements and media items - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element", - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item", - ); - - if (!hasTimelineElement && !hasMediaItem) return; - - // Calculate drop position for overlap checking - const trackContainer = e.currentTarget.querySelector( - ".track-elements-container", - ) as HTMLElement; - let dropTime = 0; - if (trackContainer) { - const rect = trackContainer.getBoundingClientRect(); - const mouseX = Math.max(0, e.clientX - rect.left); - dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); - } - - // Check for potential overlaps and show appropriate feedback - let wouldOverlap = false; - - if (hasMediaItem) { - try { - const mediaItemData = e.dataTransfer.getData( - "application/x-media-item", - ); - if (mediaItemData) { - const dragData: DragData = JSON.parse(mediaItemData); - - if (dragData.type === "text") { - // Text elements have default duration of 5 seconds - const newElementDuration = 5; - const snappedTime = getDropSnappedTime( - dropTime, - newElementDuration, - ); - const newElementEnd = snappedTime + newElementDuration; - - wouldOverlap = track.elements.some((existingElement) => { - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return snappedTime < existingEnd && newElementEnd > existingStart; - }); - } else { - // Media elements - const mediaItem = mediaItems.find( - (item) => item.id === dragData.id, - ); - if (mediaItem) { - const newElementDuration = mediaItem.duration || 5; - const snappedTime = getDropSnappedTime( - dropTime, - newElementDuration, - ); - const newElementEnd = snappedTime + newElementDuration; - - wouldOverlap = track.elements.some((existingElement) => { - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return ( - snappedTime < existingEnd && newElementEnd > existingStart - ); - }); - } - } - } - } catch (error) { - // Continue with default behavior - } - } else if (hasTimelineElement) { - try { - const timelineElementData = e.dataTransfer.getData( - "application/x-timeline-element", - ); - if (timelineElementData) { - const { elementId, trackId: fromTrackId } = - JSON.parse(timelineElementData); - const sourceTrack = tracks.find( - (t: TimelineTrack) => t.id === fromTrackId, - ); - const movingElement = sourceTrack?.elements.find( - (c: any) => c.id === elementId, - ); - - if (movingElement) { - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - const snappedTime = getDropSnappedTime( - dropTime, - movingElementDuration, - elementId, - ); - const movingElementEnd = snappedTime + movingElementDuration; - - wouldOverlap = track.elements.some((existingElement) => { - if (fromTrackId === track.id && existingElement.id === elementId) - return false; - - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - return ( - snappedTime < existingEnd && movingElementEnd > existingStart - ); - }); - } - } - } catch (error) { - // Continue with default behavior - } - } - - if (wouldOverlap) { - e.dataTransfer.dropEffect = "none"; - setWouldOverlap(true); - // Use default duration for position indicator - setDropPosition(getDropSnappedTime(dropTime, 5)); - return; - } - - e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy"; - setWouldOverlap(false); - // Use default duration for position indicator - setDropPosition(getDropSnappedTime(dropTime, 5)); - }; - - const handleTrackDragEnter = (e: React.DragEvent) => { - e.preventDefault(); - - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element", - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item", - ); - - if (!hasTimelineElement && !hasMediaItem) return; - - dragCounterRef.current++; - setIsDropping(true); - }; - - const handleTrackDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element", - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item", - ); - - if (!hasTimelineElement && !hasMediaItem) return; - - dragCounterRef.current--; - - if (dragCounterRef.current === 0) { - setIsDropping(false); - setWouldOverlap(false); - setDropPosition(null); - } - }; - - const handleTrackDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - - // Debug logging - console.log( - JSON.stringify({ - message: "Drop event started in timeline track", - dataTransferTypes: Array.from(e.dataTransfer.types), - trackId: track.id, - trackType: track.type, - }), - ); - - // Reset all drag states - dragCounterRef.current = 0; - setIsDropping(false); - setWouldOverlap(false); - - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element", - ); - const hasMediaItem = e.dataTransfer.types.includes( - "application/x-media-item", - ); - - if (!hasTimelineElement && !hasMediaItem) return; - - const trackContainer = e.currentTarget.querySelector( - ".track-elements-container", - ) as HTMLElement; - if (!trackContainer) return; - - const rect = trackContainer.getBoundingClientRect(); - const mouseX = Math.max(0, e.clientX - rect.left); - const mouseY = e.clientY - rect.top; // Get Y position relative to this track - const newStartTime = - mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - const snappedTime = snapTimeToFrame(newStartTime, projectFps); - - // Calculate drop position relative to tracks - const currentTrackIndex = tracks.findIndex((t) => t.id === track.id); - - // Determine drop zone within the track (top 20px, middle 20px, bottom 20px) - let dropPosition: "above" | "on" | "below"; - if (mouseY < 20) { - dropPosition = "above"; - } else if (mouseY > 40) { - dropPosition = "below"; - } else { - dropPosition = "on"; - } - - try { - if (hasTimelineElement) { - // Handle timeline element movement - const timelineElementData = e.dataTransfer.getData( - "application/x-timeline-element", - ); - if (!timelineElementData) return; - - const { - elementId, - trackId: fromTrackId, - clickOffsetTime = 0, - } = JSON.parse(timelineElementData); - - // Find the element being moved - const sourceTrack = tracks.find( - (t: TimelineTrack) => t.id === fromTrackId, - ); - const movingElement = sourceTrack?.elements.find( - (c: TimelineElementType) => c.id === elementId, - ); - - if (!movingElement) { - toast.error("Element not found"); - return; - } - - // Check for overlaps with existing elements (excluding the moving element itself) - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - - // Adjust position based on where user clicked on the element - const adjustedStartTime = newStartTime - clickOffsetTime; - const snappedStartTime = getDropSnappedTime( - adjustedStartTime, - movingElementDuration, - elementId, - ); - const finalStartTime = Math.max(0, snappedStartTime); - const movingElementEnd = finalStartTime + movingElementDuration; - - const hasOverlap = track.elements.some((existingElement) => { - // Skip the element being moved if it's on the same track - if (fromTrackId === track.id && existingElement.id === elementId) - return false; - - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - - // Check if elements overlap - return ( - finalStartTime < existingEnd && movingElementEnd > existingStart - ); - }); - - if (hasOverlap) { - toast.error( - "Cannot move element here - it would overlap with existing elements", - ); - return; - } - - if (fromTrackId === track.id) { - // Moving within same track - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - elementId, - finalStartTime, - ); - } else { - updateElementStartTime(track.id, elementId, finalStartTime); - } - } else { - // Moving to different track - moveElementToTrack(fromTrackId, track.id, elementId); - requestAnimationFrame(() => { - if (rippleEditingEnabled) { - updateElementStartTimeWithRipple( - track.id, - elementId, - finalStartTime, - ); - } else { - updateElementStartTime(track.id, elementId, finalStartTime); - } - }); - } - } else if (hasMediaItem) { - // Handle media item drop - const mediaItemData = e.dataTransfer.getData( - "application/x-media-item", - ); - if (!mediaItemData) return; - - const dragData: DragData = JSON.parse(mediaItemData); - - if (dragData.type === "text") { - let targetTrackId = track.id; - let targetTrack = track; - - // Handle position-aware track creation for text - if (track.type !== "text" || dropPosition !== "on") { - // Text tracks should go above the main track - const mainTrack = getMainTrack(tracks); - let insertIndex: number; - - if (dropPosition === "above") { - insertIndex = currentTrackIndex; - } else if (dropPosition === "below") { - insertIndex = currentTrackIndex + 1; - } else { - // dropPosition === "on" but track is not text type - // Insert above main track if main track exists, otherwise at top - if (mainTrack) { - const mainTrackIndex = tracks.findIndex( - (t) => t.id === mainTrack.id, - ); - insertIndex = mainTrackIndex; - } else { - insertIndex = 0; // Top of timeline - } - } - - targetTrackId = insertTrackAt("text", insertIndex); - // Get the updated tracks array after creating the new track - const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find( - (t) => t.id === targetTrackId, - ); - if (!newTargetTrack) return; - targetTrack = newTargetTrack; - } - - // Check for overlaps with existing elements in target track - const newElementDuration = 5; // Default text duration - const textSnappedTime = getDropSnappedTime( - newStartTime, - newElementDuration, - ); - const newElementEnd = textSnappedTime + newElementDuration; - - const hasOverlap = targetTrack.elements.some((existingElement) => { - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - - // Check if elements overlap - return ( - textSnappedTime < existingEnd && newElementEnd > existingStart - ); - }); - - if (hasOverlap) { - toast.error( - "Cannot place element here - it would overlap with existing elements", - ); - return; - } - - addElementToTrack(targetTrackId, { - type: "text", - name: dragData.name || "Text", - content: dragData.content || "Default Text", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: textSnappedTime, - trimStart: 0, - trimEnd: 0, - fontSize: 48, - fontFamily: "Arial", - color: "#ffffff", - backgroundColor: "transparent", - textAlign: "center", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - x: 0, - y: 0, - rotation: 0, - opacity: 1, - }); - } else { - // Handle media items - const mediaItem = mediaItems.find((item) => item.id === dragData.id); - - if (!mediaItem) { - toast.error("Media item not found"); - return; - } - - let targetTrackId = track.id; - - // Check if track type is compatible - const isVideoOrImage = - dragData.type === "video" || dragData.type === "image"; - const isAudio = dragData.type === "audio"; - const isCompatible = isVideoOrImage - ? canElementGoOnTrack("media", track.type) - : isAudio - ? canElementGoOnTrack("media", track.type) - : false; - - let targetTrack = tracks.find((t) => t.id === targetTrackId); - - // Handle position-aware track creation for media elements - if (!isCompatible || dropPosition !== "on") { - if (isVideoOrImage) { - // For video/image, check if we need a main track or additional media track - const mainTrack = getMainTrack(tracks); - - if (!mainTrack) { - // No main track exists, create it - targetTrackId = addTrack("media"); - const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find( - (t) => t.id === targetTrackId, - ); - if (!newTargetTrack) return; - targetTrack = newTargetTrack; - } else if ( - mainTrack.elements.length === 0 && - dropPosition === "on" - ) { - // Main track exists and is empty, use it - targetTrackId = mainTrack.id; - targetTrack = mainTrack; - } else { - // Create new media track - let insertIndex: number; - - if (dropPosition === "above") { - insertIndex = currentTrackIndex; - } else if (dropPosition === "below") { - insertIndex = currentTrackIndex + 1; - } else { - // Insert above main track - const mainTrackIndex = tracks.findIndex( - (t) => t.id === mainTrack.id, - ); - insertIndex = mainTrackIndex; - } - - targetTrackId = insertTrackAt("media", insertIndex); - const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find( - (t) => t.id === targetTrackId, - ); - if (!newTargetTrack) return; - targetTrack = newTargetTrack; - } - } else if (isAudio) { - // Audio tracks go at the bottom - const mainTrack = getMainTrack(tracks); - let insertIndex: number; - - if (dropPosition === "above") { - insertIndex = currentTrackIndex; - } else if (dropPosition === "below") { - insertIndex = currentTrackIndex + 1; - } else { - // Insert after main track (bottom area) - if (mainTrack) { - const mainTrackIndex = tracks.findIndex( - (t) => t.id === mainTrack.id, - ); - insertIndex = mainTrackIndex + 1; - } else { - insertIndex = tracks.length; // Bottom of timeline - } - } - - targetTrackId = insertTrackAt("audio", insertIndex); - const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find( - (t) => t.id === targetTrackId, - ); - if (!newTargetTrack) return; - targetTrack = newTargetTrack; - } - } - - if (!targetTrack) return; - - // Check for overlaps with existing elements in target track - const newElementDuration = mediaItem.duration || 5; - const mediaSnappedTime = getDropSnappedTime( - newStartTime, - newElementDuration, - ); - const newElementEnd = mediaSnappedTime + newElementDuration; - - const hasOverlap = targetTrack.elements.some((existingElement) => { - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - - // Check if elements overlap - return ( - mediaSnappedTime < existingEnd && newElementEnd > existingStart - ); - }); - - if (hasOverlap) { - toast.error( - "Cannot place element here - it would overlap with existing elements", - ); - return; - } - - addElementToTrack(targetTrackId, { - type: "media", - mediaId: mediaItem.id, - name: mediaItem.name, - duration: mediaItem.duration || 5, - startTime: mediaSnappedTime, - trimStart: 0, - trimEnd: 0, - }); - } - } - } catch (error) { - console.error("Error handling drop:", error); - toast.error("Failed to add media to track"); - } - }; - - return ( - <div - className="w-full h-full hover:bg-muted/20" - onClick={(e) => { - // If clicking empty area (not on an element), deselect all elements - if (!(e.target as HTMLElement).closest(".timeline-element")) { - clearSelectedElements(); - } - }} - onDragOver={handleTrackDragOver} - onDragEnter={handleTrackDragEnter} - onDragLeave={handleTrackDragLeave} - onDrop={handleTrackDrop} - > - <div - ref={timelineRef} - className="h-full relative track-elements-container min-w-full" - > - {track.elements.length === 0 ? ( - <div - className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${ - isDropping - ? wouldOverlap - ? "border-red-500 bg-red-500/10 text-red-600" - : "border-blue-500 bg-blue-500/10 text-blue-600" - : "border-muted/30" - }`} - > - {isDropping - ? wouldOverlap - ? "Cannot drop - would overlap" - : "Drop element here" - : ""} - </div> - ) : ( - <> - {track.elements.map((element) => { - const isSelected = selectedElements.some( - (c) => c.trackId === track.id && c.elementId === element.id, - ); - - const handleElementSplit = () => { - const { currentTime } = usePlaybackStore(); - const { splitElement } = useTimelineStore(); - const splitTime = currentTime; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (splitTime > effectiveStart && splitTime < effectiveEnd) { - const secondElementId = splitElement( - track.id, - element.id, - splitTime, - ); - if (!secondElementId) { - toast.error("Failed to split element"); - } - } else { - toast.error("Playhead must be within element to split"); - } - }; - - const handleElementDuplicate = () => { - const { addElementToTrack } = useTimelineStore.getState(); - const { id, ...elementWithoutId } = element; - addElementToTrack(track.id, { - ...elementWithoutId, - name: element.name + " (copy)", - startTime: - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1, - }); - }; - - const handleElementDelete = () => { - const { removeElementFromTrack } = useTimelineStore.getState(); - removeElementFromTrack(track.id, element.id); - }; - - return ( - <TimelineElement - key={element.id} - element={element} - track={track} - zoomLevel={zoomLevel} - isSelected={isSelected} - onElementMouseDown={handleElementMouseDown} - onElementClick={handleElementClick} - /> - ); - })} - </> - )} - </div> - </div> - ); -} +"use client";
+
+import { useRef, useState, useEffect } from "react";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useMediaStore } from "@/stores/media-store";
+import { toast } from "sonner";
+import { TimelineElement } from "./timeline-element";
+import {
+ TimelineTrack,
+ sortTracksByOrder,
+ ensureMainTrack,
+ getMainTrack,
+ canElementGoOnTrack,
+} from "@/types/timeline";
+import { usePlaybackStore } from "@/stores/playback-store";
+import type {
+ TimelineElement as TimelineElementType,
+ DragData,
+} from "@/types/timeline";
+import {
+ snapTimeToFrame,
+ TIMELINE_CONSTANTS,
+} from "@/constants/timeline-constants";
+import { useProjectStore } from "@/stores/project-store";
+import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
+
+export function TimelineTrackContent({
+ track,
+ zoomLevel,
+ onSnapPointChange,
+}: {
+ track: TimelineTrack;
+ zoomLevel: number;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+}) {
+ const { mediaItems } = useMediaStore();
+ const {
+ tracks,
+ addTrack,
+ moveElementToTrack,
+ updateElementStartTime,
+ updateElementStartTimeWithRipple,
+ addElementToTrack,
+ selectedElements,
+ selectElement,
+ dragState,
+ startDrag: startDragAction,
+ updateDragTime,
+ endDrag: endDragAction,
+ clearSelectedElements,
+ insertTrackAt,
+ snappingEnabled,
+ rippleEditingEnabled,
+ } = useTimelineStore();
+
+ const { currentTime } = usePlaybackStore();
+
+ // Initialize snapping hook
+ const { snapElementPosition, snapElementEdge } = useTimelineSnapping({
+ snapThreshold: 10,
+ enableElementSnapping: snappingEnabled,
+ enablePlayheadSnapping: snappingEnabled,
+ });
+
+ // Helper function for drop snapping that tries both edges
+ const getDropSnappedTime = (
+ dropTime: number,
+ elementDuration: number,
+ excludeElementId?: string
+ ) => {
+ if (!snappingEnabled) {
+ // Use frame snapping if project has FPS, otherwise use decimal snapping
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ return snapTimeToFrame(dropTime, projectFps);
+ }
+
+ // Try snapping both start and end edges for drops
+ const startSnapResult = snapElementEdge(
+ dropTime,
+ elementDuration,
+ tracks,
+ currentTime,
+ zoomLevel,
+ excludeElementId,
+ true // snap to start edge
+ );
+
+ const endSnapResult = snapElementEdge(
+ dropTime,
+ elementDuration,
+ tracks,
+ currentTime,
+ zoomLevel,
+ excludeElementId,
+ false // snap to end edge
+ );
+
+ // Choose the snap result with the smaller distance (closer snap)
+ let bestSnapResult = startSnapResult;
+ if (
+ endSnapResult.snapPoint &&
+ (!startSnapResult.snapPoint ||
+ endSnapResult.snapDistance < startSnapResult.snapDistance)
+ ) {
+ bestSnapResult = endSnapResult;
+ }
+
+ return bestSnapResult.snappedTime;
+ };
+
+ const timelineRef = useRef<HTMLDivElement>(null);
+ const [isDropping, setIsDropping] = useState(false);
+ const [dropPosition, setDropPosition] = useState<number | null>(null);
+ const [wouldOverlap, setWouldOverlap] = useState(false);
+ const dragCounterRef = useRef(0);
+ const [mouseDownLocation, setMouseDownLocation] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
+
+ // Set up mouse event listeners for drag
+ useEffect(() => {
+ if (!dragState.isDragging) return;
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!timelineRef.current) return;
+
+ // On first mouse move during drag, ensure the element is selected
+ if (dragState.elementId && dragState.trackId) {
+ const isSelected = selectedElements.some(
+ (c) =>
+ c.trackId === dragState.trackId &&
+ c.elementId === dragState.elementId
+ );
+
+ if (!isSelected) {
+ // Select this element (replacing other selections) since we're dragging it
+ selectElement(dragState.trackId, dragState.elementId, false);
+ }
+ }
+
+ const timelineRect = timelineRef.current.getBoundingClientRect();
+ const mouseX = e.clientX - timelineRect.left;
+ const mouseTime = Math.max(
+ 0,
+ mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
+ );
+ const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
+
+ // Apply snapping if enabled
+ let finalTime = adjustedTime;
+ let snapPoint = null;
+ if (snappingEnabled) {
+ // Find the element being dragged to get its duration
+ let elementDuration = 5; // fallback duration
+ if (dragState.elementId && dragState.trackId) {
+ const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
+ const element = sourceTrack?.elements.find(
+ (e) => e.id === dragState.elementId
+ );
+ if (element) {
+ elementDuration =
+ element.duration - element.trimStart - element.trimEnd;
+ }
+ }
+
+ // Try snapping both start and end edges
+ const startSnapResult = snapElementEdge(
+ adjustedTime,
+ elementDuration,
+ tracks,
+ currentTime,
+ zoomLevel,
+ dragState.elementId || undefined,
+ true // snap to start edge
+ );
+
+ const endSnapResult = snapElementEdge(
+ adjustedTime,
+ elementDuration,
+ tracks,
+ currentTime,
+ zoomLevel,
+ dragState.elementId || undefined,
+ false // snap to end edge
+ );
+
+ // Choose the snap result with the smaller distance (closer snap)
+ let bestSnapResult = startSnapResult;
+ if (
+ endSnapResult.snapPoint &&
+ (!startSnapResult.snapPoint ||
+ endSnapResult.snapDistance < startSnapResult.snapDistance)
+ ) {
+ bestSnapResult = endSnapResult;
+ }
+
+ finalTime = bestSnapResult.snappedTime;
+ snapPoint = bestSnapResult.snapPoint;
+
+ // Notify parent component about snap point change
+ onSnapPointChange?.(snapPoint);
+ } else {
+ // Use frame snapping if project has FPS, otherwise use decimal snapping
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ finalTime = snapTimeToFrame(adjustedTime, projectFps);
+
+ // Clear snap point when not snapping
+ onSnapPointChange?.(null);
+ }
+
+ updateDragTime(finalTime);
+ };
+
+ const handleMouseUp = (e: MouseEvent) => {
+ if (!dragState.elementId || !dragState.trackId) return;
+
+ // If this track initiated the drag, we should handle the mouse up regardless of where it occurs
+ const isTrackThatStartedDrag = dragState.trackId === track.id;
+
+ const timelineRect = timelineRef.current?.getBoundingClientRect();
+ if (!timelineRect) {
+ if (isTrackThatStartedDrag) {
+ if (rippleEditingEnabled) {
+ updateElementStartTimeWithRipple(
+ track.id,
+ dragState.elementId,
+ dragState.currentTime
+ );
+ } else {
+ updateElementStartTime(
+ track.id,
+ dragState.elementId,
+ dragState.currentTime
+ );
+ }
+ endDragAction();
+ // Clear snap point when drag ends
+ onSnapPointChange?.(null);
+ }
+ return;
+ }
+
+ const isMouseOverThisTrack =
+ e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom;
+
+ if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return;
+
+ const finalTime = dragState.currentTime;
+
+ if (isMouseOverThisTrack) {
+ const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
+ const movingElement = sourceTrack?.elements.find(
+ (c) => c.id === dragState.elementId
+ );
+
+ if (movingElement) {
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+ const movingElementEnd = finalTime + movingElementDuration;
+
+ const targetTrack = tracks.find((t) => t.id === track.id);
+ const hasOverlap = targetTrack?.elements.some((existingElement) => {
+ if (
+ dragState.trackId === track.id &&
+ existingElement.id === dragState.elementId
+ ) {
+ return false;
+ }
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return finalTime < existingEnd && movingElementEnd > existingStart;
+ });
+
+ if (!hasOverlap) {
+ if (dragState.trackId === track.id) {
+ if (rippleEditingEnabled) {
+ updateElementStartTimeWithRipple(
+ track.id,
+ dragState.elementId,
+ finalTime
+ );
+ } else {
+ updateElementStartTime(
+ track.id,
+ dragState.elementId,
+ finalTime
+ );
+ }
+ } else {
+ moveElementToTrack(
+ dragState.trackId,
+ track.id,
+ dragState.elementId
+ );
+ requestAnimationFrame(() => {
+ if (rippleEditingEnabled) {
+ updateElementStartTimeWithRipple(
+ track.id,
+ dragState.elementId!,
+ finalTime
+ );
+ } else {
+ updateElementStartTime(
+ track.id,
+ dragState.elementId!,
+ finalTime
+ );
+ }
+ });
+ }
+ }
+ }
+ } else if (isTrackThatStartedDrag) {
+ // Mouse is not over this track, but this track started the drag
+ // This means user released over ruler/outside - update position within same track
+ const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
+ const movingElement = sourceTrack?.elements.find(
+ (c) => c.id === dragState.elementId
+ );
+
+ if (movingElement) {
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+ const movingElementEnd = finalTime + movingElementDuration;
+
+ const hasOverlap = track.elements.some((existingElement) => {
+ if (existingElement.id === dragState.elementId) {
+ return false;
+ }
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return finalTime < existingEnd && movingElementEnd > existingStart;
+ });
+
+ if (!hasOverlap) {
+ if (rippleEditingEnabled) {
+ updateElementStartTimeWithRipple(
+ track.id,
+ dragState.elementId,
+ finalTime
+ );
+ } else {
+ updateElementStartTime(track.id, dragState.elementId, finalTime);
+ }
+ }
+ }
+ }
+
+ if (isTrackThatStartedDrag) {
+ endDragAction();
+ // Clear snap point when drag ends
+ onSnapPointChange?.(null);
+ }
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [
+ dragState.isDragging,
+ dragState.clickOffsetTime,
+ dragState.elementId,
+ dragState.trackId,
+ dragState.currentTime,
+ zoomLevel,
+ tracks,
+ track.id,
+ updateDragTime,
+ updateElementStartTime,
+ moveElementToTrack,
+ endDragAction,
+ selectedElements,
+ selectElement,
+ onSnapPointChange,
+ ]);
+
+ const handleElementMouseDown = (
+ e: React.MouseEvent,
+ element: TimelineElementType
+ ) => {
+ setMouseDownLocation({ x: e.clientX, y: e.clientY });
+
+ // Detect right-click (button 2) and handle selection without starting drag
+ const isRightClick = e.button === 2;
+ const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
+
+ if (isRightClick) {
+ // Handle right-click selection
+ const isSelected = selectedElements.some(
+ (c) => c.trackId === track.id && c.elementId === element.id
+ );
+
+ // If element is not selected, select it (keep other selections if multi-select)
+ if (!isSelected) {
+ selectElement(track.id, element.id, isMultiSelect);
+ }
+ // If element is already selected, keep it selected
+
+ // Don't start drag action for right-clicks
+ return;
+ }
+
+ // Handle multi-selection for left-click with modifiers
+ if (isMultiSelect) {
+ selectElement(track.id, element.id, true);
+ }
+
+ // Calculate the offset from the left edge of the element to where the user clicked
+ const elementElement = e.currentTarget as HTMLElement;
+ const elementRect = elementElement.getBoundingClientRect();
+ const clickOffsetX = e.clientX - elementRect.left;
+ const clickOffsetTime =
+ clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
+
+ startDragAction(
+ element.id,
+ track.id,
+ e.clientX,
+ element.startTime,
+ clickOffsetTime
+ );
+ };
+
+ const handleElementClick = (
+ e: React.MouseEvent,
+ element: TimelineElementType
+ ) => {
+ e.stopPropagation();
+
+ // Check if mouse moved significantly
+ if (mouseDownLocation) {
+ const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
+ const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
+ // If it moved more than a few pixels, consider it a drag and not a click.
+ if (deltaX > 5 || deltaY > 5) {
+ setMouseDownLocation(null); // Reset for next interaction
+ return;
+ }
+ }
+
+ // Skip selection logic for multi-selection (handled in mousedown)
+ if (e.metaKey || e.ctrlKey || e.shiftKey) {
+ return;
+ }
+
+ // Handle single selection
+ const isSelected = selectedElements.some(
+ (c) => c.trackId === track.id && c.elementId === element.id
+ );
+
+ if (!isSelected) {
+ // If element is not selected, select it (replacing other selections)
+ selectElement(track.id, element.id, false);
+ }
+ // If element is already selected, keep it selected (do nothing)
+ };
+
+ const handleTrackDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ // Handle both timeline elements and media items
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineElement && !hasMediaItem) return;
+
+ // Calculate drop position for overlap checking
+ const trackContainer = e.currentTarget.querySelector(
+ ".track-elements-container"
+ ) as HTMLElement;
+ let dropTime = 0;
+ if (trackContainer) {
+ const rect = trackContainer.getBoundingClientRect();
+ const mouseX = Math.max(0, e.clientX - rect.left);
+ dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
+ }
+
+ // Check for potential overlaps and show appropriate feedback
+ let wouldOverlap = false;
+
+ if (hasMediaItem) {
+ try {
+ const mediaItemData = e.dataTransfer.getData(
+ "application/x-media-item"
+ );
+ if (mediaItemData) {
+ const dragData: DragData = JSON.parse(mediaItemData);
+
+ if (dragData.type === "text") {
+ // Text elements have default duration of 5 seconds
+ const newElementDuration = 5;
+ const snappedTime = getDropSnappedTime(
+ dropTime,
+ newElementDuration
+ );
+ const newElementEnd = snappedTime + newElementDuration;
+
+ wouldOverlap = track.elements.some((existingElement) => {
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return snappedTime < existingEnd && newElementEnd > existingStart;
+ });
+ } else {
+ // Media elements
+ const mediaItem = mediaItems.find(
+ (item) => item.id === dragData.id
+ );
+ if (mediaItem) {
+ const newElementDuration = mediaItem.duration || 5;
+ const snappedTime = getDropSnappedTime(
+ dropTime,
+ newElementDuration
+ );
+ const newElementEnd = snappedTime + newElementDuration;
+
+ wouldOverlap = track.elements.some((existingElement) => {
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return (
+ snappedTime < existingEnd && newElementEnd > existingStart
+ );
+ });
+ }
+ }
+ }
+ } catch (error) {
+ // Continue with default behavior
+ }
+ } else if (hasTimelineElement) {
+ try {
+ const timelineElementData = e.dataTransfer.getData(
+ "application/x-timeline-element"
+ );
+ if (timelineElementData) {
+ const { elementId, trackId: fromTrackId } =
+ JSON.parse(timelineElementData);
+ const sourceTrack = tracks.find(
+ (t: TimelineTrack) => t.id === fromTrackId
+ );
+ const movingElement = sourceTrack?.elements.find(
+ (c: any) => c.id === elementId
+ );
+
+ if (movingElement) {
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+ const snappedTime = getDropSnappedTime(
+ dropTime,
+ movingElementDuration,
+ elementId
+ );
+ const movingElementEnd = snappedTime + movingElementDuration;
+
+ wouldOverlap = track.elements.some((existingElement) => {
+ if (fromTrackId === track.id && existingElement.id === elementId)
+ return false;
+
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return (
+ snappedTime < existingEnd && movingElementEnd > existingStart
+ );
+ });
+ }
+ }
+ } catch (error) {
+ // Continue with default behavior
+ }
+ }
+
+ if (wouldOverlap) {
+ e.dataTransfer.dropEffect = "none";
+ setWouldOverlap(true);
+ // Use default duration for position indicator
+ setDropPosition(getDropSnappedTime(dropTime, 5));
+ return;
+ }
+
+ e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
+ setWouldOverlap(false);
+ // Use default duration for position indicator
+ setDropPosition(getDropSnappedTime(dropTime, 5));
+ };
+
+ const handleTrackDragEnter = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineElement && !hasMediaItem) return;
+
+ dragCounterRef.current++;
+ setIsDropping(true);
+ };
+
+ const handleTrackDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineElement && !hasMediaItem) return;
+
+ dragCounterRef.current--;
+
+ if (dragCounterRef.current === 0) {
+ setIsDropping(false);
+ setWouldOverlap(false);
+ setDropPosition(null);
+ }
+ };
+
+ const handleTrackDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Debug logging
+ console.log(
+ JSON.stringify({
+ message: "Drop event started in timeline track",
+ dataTransferTypes: Array.from(e.dataTransfer.types),
+ trackId: track.id,
+ trackType: track.type,
+ })
+ );
+
+ // Reset all drag states
+ dragCounterRef.current = 0;
+ setIsDropping(false);
+ setWouldOverlap(false);
+
+ const hasTimelineElement = e.dataTransfer.types.includes(
+ "application/x-timeline-element"
+ );
+ const hasMediaItem = e.dataTransfer.types.includes(
+ "application/x-media-item"
+ );
+
+ if (!hasTimelineElement && !hasMediaItem) return;
+
+ const trackContainer = e.currentTarget.querySelector(
+ ".track-elements-container"
+ ) as HTMLElement;
+ if (!trackContainer) return;
+
+ const rect = trackContainer.getBoundingClientRect();
+ const mouseX = Math.max(0, e.clientX - rect.left);
+ const mouseY = e.clientY - rect.top; // Get Y position relative to this track
+ const newStartTime =
+ mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ const snappedTime = snapTimeToFrame(newStartTime, projectFps);
+
+ // Calculate drop position relative to tracks
+ const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
+
+ // Determine drop zone within the track (top 20px, middle 20px, bottom 20px)
+ let dropPosition: "above" | "on" | "below";
+ if (mouseY < 20) {
+ dropPosition = "above";
+ } else if (mouseY > 40) {
+ dropPosition = "below";
+ } else {
+ dropPosition = "on";
+ }
+
+ try {
+ if (hasTimelineElement) {
+ // Handle timeline element movement
+ const timelineElementData = e.dataTransfer.getData(
+ "application/x-timeline-element"
+ );
+ if (!timelineElementData) return;
+
+ const {
+ elementId,
+ trackId: fromTrackId,
+ clickOffsetTime = 0,
+ } = JSON.parse(timelineElementData);
+
+ // Find the element being moved
+ const sourceTrack = tracks.find(
+ (t: TimelineTrack) => t.id === fromTrackId
+ );
+ const movingElement = sourceTrack?.elements.find(
+ (c: TimelineElementType) => c.id === elementId
+ );
+
+ if (!movingElement) {
+ toast.error("Element not found");
+ return;
+ }
+
+ // Check for overlaps with existing elements (excluding the moving element itself)
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+
+ // Adjust position based on where user clicked on the element
+ const adjustedStartTime = newStartTime - clickOffsetTime;
+ const snappedStartTime = getDropSnappedTime(
+ adjustedStartTime,
+ movingElementDuration,
+ elementId
+ );
+ const finalStartTime = Math.max(0, snappedStartTime);
+ const movingElementEnd = finalStartTime + movingElementDuration;
+
+ const hasOverlap = track.elements.some((existingElement) => {
+ // Skip the element being moved if it's on the same track
+ if (fromTrackId === track.id && existingElement.id === elementId)
+ return false;
+
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+
+ // Check if elements overlap
+ return (
+ finalStartTime < existingEnd && movingElementEnd > existingStart
+ );
+ });
+
+ if (hasOverlap) {
+ toast.error(
+ "Cannot move element here - it would overlap with existing elements"
+ );
+ return;
+ }
+
+ if (fromTrackId === track.id) {
+ // Moving within same track
+ if (rippleEditingEnabled) {
+ updateElementStartTimeWithRipple(
+ track.id,
+ elementId,
+ finalStartTime
+ );
+ } else {
+ updateElementStartTime(track.id, elementId, finalStartTime);
+ }
+ } else {
+ // Moving to different track
+ moveElementToTrack(fromTrackId, track.id, elementId);
+ requestAnimationFrame(() => {
+ if (rippleEditingEnabled) {
+ updateElementStartTimeWithRipple(
+ track.id,
+ elementId,
+ finalStartTime
+ );
+ } else {
+ updateElementStartTime(track.id, elementId, finalStartTime);
+ }
+ });
+ }
+ } else if (hasMediaItem) {
+ // Handle media item drop
+ const mediaItemData = e.dataTransfer.getData(
+ "application/x-media-item"
+ );
+ if (!mediaItemData) return;
+
+ const dragData: DragData = JSON.parse(mediaItemData);
+
+ if (dragData.type === "text") {
+ let targetTrackId = track.id;
+ let targetTrack = track;
+
+ // Handle position-aware track creation for text
+ if (track.type !== "text" || dropPosition !== "on") {
+ // Text tracks should go above the main track
+ const mainTrack = getMainTrack(tracks);
+ let insertIndex: number;
+
+ if (dropPosition === "above") {
+ insertIndex = currentTrackIndex;
+ } else if (dropPosition === "below") {
+ insertIndex = currentTrackIndex + 1;
+ } else {
+ // dropPosition === "on" but track is not text type
+ // Insert above main track if main track exists, otherwise at top
+ if (mainTrack) {
+ const mainTrackIndex = tracks.findIndex(
+ (t) => t.id === mainTrack.id
+ );
+ insertIndex = mainTrackIndex;
+ } else {
+ insertIndex = 0; // Top of timeline
+ }
+ }
+
+ targetTrackId = insertTrackAt("text", insertIndex);
+ // Get the updated tracks array after creating the new track
+ const updatedTracks = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
+ }
+
+ // Check for overlaps with existing elements in target track
+ const newElementDuration = 5; // Default text duration
+ const textSnappedTime = getDropSnappedTime(
+ newStartTime,
+ newElementDuration
+ );
+ const newElementEnd = textSnappedTime + newElementDuration;
+
+ const hasOverlap = targetTrack.elements.some((existingElement) => {
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+
+ // Check if elements overlap
+ return (
+ textSnappedTime < existingEnd && newElementEnd > existingStart
+ );
+ });
+
+ if (hasOverlap) {
+ toast.error(
+ "Cannot place element here - it would overlap with existing elements"
+ );
+ return;
+ }
+
+ addElementToTrack(targetTrackId, {
+ type: "text",
+ name: dragData.name || "Text",
+ content: dragData.content || "Default Text",
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: textSnappedTime,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: 48,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ backgroundColor: "transparent",
+ textAlign: "center",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ x: 0,
+ y: 0,
+ rotation: 0,
+ opacity: 1,
+ });
+ } else {
+ // Handle media items
+ const mediaItem = mediaItems.find((item) => item.id === dragData.id);
+
+ if (!mediaItem) {
+ toast.error("Media item not found");
+ return;
+ }
+
+ let targetTrackId = track.id;
+
+ // Check if track type is compatible
+ const isVideoOrImage =
+ dragData.type === "video" || dragData.type === "image";
+ const isAudio = dragData.type === "audio";
+ const isCompatible = isVideoOrImage
+ ? canElementGoOnTrack("media", track.type)
+ : isAudio
+ ? canElementGoOnTrack("media", track.type)
+ : false;
+
+ let targetTrack = tracks.find((t) => t.id === targetTrackId);
+
+ // Handle position-aware track creation for media elements
+ if (!isCompatible || dropPosition !== "on") {
+ if (isVideoOrImage) {
+ // For video/image, check if we need a main track or additional media track
+ const mainTrack = getMainTrack(tracks);
+
+ if (!mainTrack) {
+ // No main track exists, create it
+ targetTrackId = addTrack("media");
+ const updatedTracks = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
+ } else if (
+ mainTrack.elements.length === 0 &&
+ dropPosition === "on"
+ ) {
+ // Main track exists and is empty, use it
+ targetTrackId = mainTrack.id;
+ targetTrack = mainTrack;
+ } else {
+ // Create new media track
+ let insertIndex: number;
+
+ if (dropPosition === "above") {
+ insertIndex = currentTrackIndex;
+ } else if (dropPosition === "below") {
+ insertIndex = currentTrackIndex + 1;
+ } else {
+ // Insert above main track
+ const mainTrackIndex = tracks.findIndex(
+ (t) => t.id === mainTrack.id
+ );
+ insertIndex = mainTrackIndex;
+ }
+
+ targetTrackId = insertTrackAt("media", insertIndex);
+ const updatedTracks = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
+ }
+ } else if (isAudio) {
+ // Audio tracks go at the bottom
+ const mainTrack = getMainTrack(tracks);
+ let insertIndex: number;
+
+ if (dropPosition === "above") {
+ insertIndex = currentTrackIndex;
+ } else if (dropPosition === "below") {
+ insertIndex = currentTrackIndex + 1;
+ } else {
+ // Insert after main track (bottom area)
+ if (mainTrack) {
+ const mainTrackIndex = tracks.findIndex(
+ (t) => t.id === mainTrack.id
+ );
+ insertIndex = mainTrackIndex + 1;
+ } else {
+ insertIndex = tracks.length; // Bottom of timeline
+ }
+ }
+
+ targetTrackId = insertTrackAt("audio", insertIndex);
+ const updatedTracks = useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
+ if (!newTargetTrack) return;
+ targetTrack = newTargetTrack;
+ }
+ }
+
+ if (!targetTrack) return;
+
+ // Check for overlaps with existing elements in target track
+ const newElementDuration = mediaItem.duration || 5;
+ const mediaSnappedTime = getDropSnappedTime(
+ newStartTime,
+ newElementDuration
+ );
+ const newElementEnd = mediaSnappedTime + newElementDuration;
+
+ const hasOverlap = targetTrack.elements.some((existingElement) => {
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+
+ // Check if elements overlap
+ return (
+ mediaSnappedTime < existingEnd && newElementEnd > existingStart
+ );
+ });
+
+ if (hasOverlap) {
+ toast.error(
+ "Cannot place element here - it would overlap with existing elements"
+ );
+ return;
+ }
+
+ addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: mediaItem.id,
+ name: mediaItem.name,
+ duration: mediaItem.duration || 5,
+ startTime: mediaSnappedTime,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }
+ }
+ } catch (error) {
+ console.error("Error handling drop:", error);
+ toast.error("Failed to add media to track");
+ }
+ };
+
+ return (
+ <div
+ className="w-full h-full hover:bg-muted/20"
+ onClick={(e) => {
+ // If clicking empty area (not on an element), deselect all elements
+ if (!(e.target as HTMLElement).closest(".timeline-element")) {
+ clearSelectedElements();
+ }
+ }}
+ onDragOver={handleTrackDragOver}
+ onDragEnter={handleTrackDragEnter}
+ onDragLeave={handleTrackDragLeave}
+ onDrop={handleTrackDrop}
+ >
+ <div
+ ref={timelineRef}
+ className="h-full relative track-elements-container min-w-full"
+ >
+ {track.elements.length === 0 ? (
+ <div
+ className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
+ isDropping
+ ? wouldOverlap
+ ? "border-red-500 bg-red-500/10 text-red-600"
+ : "border-blue-500 bg-blue-500/10 text-blue-600"
+ : "border-muted/30"
+ }`}
+ >
+ {isDropping
+ ? wouldOverlap
+ ? "Cannot drop - would overlap"
+ : "Drop element here"
+ : ""}
+ </div>
+ ) : (
+ <>
+ {track.elements.map((element) => {
+ const isSelected = selectedElements.some(
+ (c) => c.trackId === track.id && c.elementId === element.id
+ );
+
+ const handleElementSplit = () => {
+ const { currentTime } = usePlaybackStore();
+ const { splitElement } = useTimelineStore();
+ const splitTime = currentTime;
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+
+ if (splitTime > effectiveStart && splitTime < effectiveEnd) {
+ const secondElementId = splitElement(
+ track.id,
+ element.id,
+ splitTime
+ );
+ if (!secondElementId) {
+ toast.error("Failed to split element");
+ }
+ } else {
+ toast.error("Playhead must be within element to split");
+ }
+ };
+
+ const handleElementDuplicate = () => {
+ const { addElementToTrack } = useTimelineStore.getState();
+ const { id, ...elementWithoutId } = element;
+ addElementToTrack(track.id, {
+ ...elementWithoutId,
+ name: element.name + " (copy)",
+ startTime:
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd) +
+ 0.1,
+ });
+ };
+
+ const handleElementDelete = () => {
+ const { removeElementFromTrack } = useTimelineStore.getState();
+ removeElementFromTrack(track.id, element.id);
+ };
+
+ return (
+ <TimelineElement
+ key={element.id}
+ element={element}
+ track={track}
+ zoomLevel={zoomLevel}
+ isSelected={isSelected}
+ onElementMouseDown={handleElementMouseDown}
+ onElementClick={handleElementClick}
+ />
+ );
+ })}
+ </>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index a02eaaad..9e68fd4d 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -3,33 +3,33 @@ import { ScrollArea } from "../ui/scroll-area"; import { Button } from "../ui/button"; import { - Scissors, - ArrowLeftToLine, - ArrowRightToLine, - Trash2, - Snowflake, - Copy, - SplitSquareHorizontal, - Pause, - Play, - Video, - Music, - TypeIcon, - Lock, - LockOpen, - Link, + Scissors, + ArrowLeftToLine, + ArrowRightToLine, + Trash2, + Snowflake, + Copy, + SplitSquareHorizontal, + Pause, + Play, + Video, + Music, + TypeIcon, + Lock, + LockOpen, + Link, } from "lucide-react"; import { - Tooltip, - TooltipContent, - TooltipTrigger, - TooltipProvider, + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipProvider, } from "../ui/tooltip"; import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, } from "../ui/context-menu"; import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; @@ -41,8 +41,8 @@ import { toast } from "sonner"; import { useState, useRef, useEffect, useCallback } from "react"; import { TimelineTrackContent } from "./timeline-track"; import { - TimelinePlayhead, - useTimelinePlayheadRuler, + TimelinePlayhead, + useTimelinePlayheadRuler, } from "./timeline-playhead"; import { SelectionBox } from "./selection-box"; import { useSelectionBox } from "@/hooks/use-selection-box"; @@ -50,1047 +50,1046 @@ import { SnapIndicator } from "./snap-indicator"; import { SnapPoint } from "@/hooks/use-timeline-snapping"; import type { DragData, TimelineTrack } from "@/types/timeline"; import { - getTrackHeight, - getCumulativeHeightBefore, - getTotalTracksHeight, - TIMELINE_CONSTANTS, - snapTimeToFrame, + getTrackHeight, + getCumulativeHeightBefore, + getTotalTracksHeight, + TIMELINE_CONSTANTS, + snapTimeToFrame, } from "@/constants/timeline-constants"; export function Timeline() { - // Timeline shows all tracks (video, audio, effects) and their elements. - // You can drag media here to add it to your project. - // elements can be trimmed, deleted, and moved. + // Timeline shows all tracks (video, audio, effects) and their elements. + // You can drag media here to add it to your project. + // elements can be trimmed, deleted, and moved. - const { - tracks, - addTrack, - addElementToTrack, - removeElementFromTrack, - removeElementFromTrackWithRipple, - getTotalDuration, - selectedElements, - clearSelectedElements, - setSelectedElements, - splitElement, - splitAndKeepLeft, - splitAndKeepRight, - toggleTrackMute, - separateAudio, - snappingEnabled, - toggleSnapping, - rippleEditingEnabled, - toggleRippleEditing, - dragState, - } = useTimelineStore(); - const { mediaItems, addMediaItem } = useMediaStore(); - const { activeProject } = useProjectStore(); - const { currentTime, duration, seek, setDuration, isPlaying, toggle } = - usePlaybackStore(); - const [isDragOver, setIsDragOver] = useState(false); - const [isProcessing, setIsProcessing] = useState(false); - const [progress, setProgress] = useState(0); - const dragCounterRef = useRef(0); - const timelineRef = useRef<HTMLDivElement>(null); - const rulerRef = useRef<HTMLDivElement>(null); - const [isInTimeline, setIsInTimeline] = useState(false); + const { + tracks, + addTrack, + addElementToTrack, + removeElementFromTrack, + removeElementFromTrackWithRipple, + getTotalDuration, + selectedElements, + clearSelectedElements, + setSelectedElements, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + toggleTrackMute, + separateAudio, + snappingEnabled, + toggleSnapping, + rippleEditingEnabled, + toggleRippleEditing, + dragState, + } = useTimelineStore(); + const { mediaItems, addMediaItem } = useMediaStore(); + const { activeProject } = useProjectStore(); + const { currentTime, duration, seek, setDuration, isPlaying, toggle } = + usePlaybackStore(); + const [isDragOver, setIsDragOver] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); + const dragCounterRef = useRef(0); + const timelineRef = useRef<HTMLDivElement>(null); + const rulerRef = useRef<HTMLDivElement>(null); + const [isInTimeline, setIsInTimeline] = useState(false); - // Track mouse down/up for distinguishing clicks from drag/resize ends - const mouseTrackingRef = useRef({ - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }); + // Track mouse down/up for distinguishing clicks from drag/resize ends + const mouseTrackingRef = useRef({ + isMouseDown: false, + downX: 0, + downY: 0, + downTime: 0, + }); - // Timeline zoom functionality - const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ - containerRef: timelineRef, - isInTimeline, - }); + // Timeline zoom functionality + const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ + containerRef: timelineRef, + isInTimeline, + }); - // Old marquee selection removed - using new SelectionBox component instead + // Old marquee selection removed - using new SelectionBox component instead - // Dynamic timeline width calculation based on playhead position and duration - const dynamicTimelineWidth = Math.max( - (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration - (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer - timelineRef.current?.clientWidth || 1000, // Minimum width - ); + // Dynamic timeline width calculation based on playhead position and duration + const dynamicTimelineWidth = Math.max( + (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration + (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer + timelineRef.current?.clientWidth || 1000 // Minimum width + ); - // Scroll synchronization and auto-scroll to playhead - const rulerScrollRef = useRef<HTMLDivElement>(null); - const tracksScrollRef = useRef<HTMLDivElement>(null); - const trackLabelsRef = useRef<HTMLDivElement>(null); - const playheadRef = useRef<HTMLDivElement>(null); - const trackLabelsScrollRef = useRef<HTMLDivElement>(null); - const isUpdatingRef = useRef(false); - const lastRulerSync = useRef(0); - const lastTracksSync = useRef(0); - const lastVerticalSync = useRef(0); + // Scroll synchronization and auto-scroll to playhead + const rulerScrollRef = useRef<HTMLDivElement>(null); + const tracksScrollRef = useRef<HTMLDivElement>(null); + const trackLabelsRef = useRef<HTMLDivElement>(null); + const playheadRef = useRef<HTMLDivElement>(null); + const trackLabelsScrollRef = useRef<HTMLDivElement>(null); + const isUpdatingRef = useRef(false); + const lastRulerSync = useRef(0); + const lastTracksSync = useRef(0); + const lastVerticalSync = useRef(0); - // Timeline playhead ruler handlers - const { handleRulerMouseDown } = useTimelinePlayheadRuler({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, - }); + // Timeline playhead ruler handlers + const { handleRulerMouseDown } = useTimelinePlayheadRuler({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); - // Selection box functionality - const tracksContainerRef = useRef<HTMLDivElement>(null); - const { - selectionBox, - handleMouseDown: handleSelectionMouseDown, - isSelecting, - justFinishedSelecting, - } = useSelectionBox({ - containerRef: tracksContainerRef, - playheadRef, - onSelectionComplete: (elements) => { - console.log(JSON.stringify({ onSelectionComplete: elements.length })); - setSelectedElements(elements); - }, - }); + // Selection box functionality + const tracksContainerRef = useRef<HTMLDivElement>(null); + const { + selectionBox, + handleMouseDown: handleSelectionMouseDown, + isSelecting, + justFinishedSelecting, + } = useSelectionBox({ + containerRef: tracksContainerRef, + playheadRef, + onSelectionComplete: (elements) => { + console.log(JSON.stringify({ onSelectionComplete: elements.length })); + setSelectedElements(elements); + }, + }); - // Calculate snap indicator state - const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( - null, - ); - const showSnapIndicator = - dragState.isDragging && snappingEnabled && currentSnapPoint !== null; + // Calculate snap indicator state + const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( + null + ); + const showSnapIndicator = + dragState.isDragging && snappingEnabled && currentSnapPoint !== null; - // Callback to handle snap point changes from TimelineTrackContent - const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { - setCurrentSnapPoint(snapPoint); - }, []); + // Callback to handle snap point changes from TimelineTrackContent + const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { + setCurrentSnapPoint(snapPoint); + }, []); - // Track mouse down to distinguish real clicks from drag/resize ends - const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => { - // Only track mouse down on timeline background areas (not elements) - const target = e.target as HTMLElement; - const isTimelineBackground = - !target.closest(".timeline-element") && - !playheadRef.current?.contains(target) && - !target.closest("[data-track-labels]"); + // Track mouse down to distinguish real clicks from drag/resize ends + const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => { + // Only track mouse down on timeline background areas (not elements) + const target = e.target as HTMLElement; + const isTimelineBackground = + !target.closest(".timeline-element") && + !playheadRef.current?.contains(target) && + !target.closest("[data-track-labels]"); - if (isTimelineBackground) { - mouseTrackingRef.current = { - isMouseDown: true, - downX: e.clientX, - downY: e.clientY, - downTime: e.timeStamp, - }; - } - }, []); + if (isTimelineBackground) { + mouseTrackingRef.current = { + isMouseDown: true, + downX: e.clientX, + downY: e.clientY, + downTime: e.timeStamp, + }; + } + }, []); - // Timeline content click to seek handler - const handleTimelineContentClick = useCallback( - (e: React.MouseEvent) => { - const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; + // Timeline content click to seek handler + const handleTimelineContentClick = useCallback( + (e: React.MouseEvent) => { + const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; - // Reset mouse tracking - mouseTrackingRef.current = { - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }; + // Reset mouse tracking + mouseTrackingRef.current = { + isMouseDown: false, + downX: 0, + downY: 0, + downTime: 0, + }; - // Only process as click if we tracked a mouse down on timeline background - if (!isMouseDown) { - console.log( - JSON.stringify({ - ignoredClickWithoutMouseDown: true, - timeStamp: e.timeStamp, - }), - ); - return; - } + // Only process as click if we tracked a mouse down on timeline background + if (!isMouseDown) { + console.log( + JSON.stringify({ + ignoredClickWithoutMouseDown: true, + timeStamp: e.timeStamp, + }) + ); + return; + } - // Check if mouse moved significantly (indicates drag, not click) - const deltaX = Math.abs(e.clientX - downX); - const deltaY = Math.abs(e.clientY - downY); - const deltaTime = e.timeStamp - downTime; + // Check if mouse moved significantly (indicates drag, not click) + const deltaX = Math.abs(e.clientX - downX); + const deltaY = Math.abs(e.clientY - downY); + const deltaTime = e.timeStamp - downTime; - if (deltaX > 5 || deltaY > 5 || deltaTime > 500) { - console.log( - JSON.stringify({ - ignoredDragNotClick: true, - deltaX, - deltaY, - deltaTime, - timeStamp: e.timeStamp, - }), - ); - return; - } + if (deltaX > 5 || deltaY > 5 || deltaTime > 500) { + console.log( + JSON.stringify({ + ignoredDragNotClick: true, + deltaX, + deltaY, + deltaTime, + timeStamp: e.timeStamp, + }) + ); + return; + } - // Don't seek if this was a selection box operation - if (isSelecting || justFinishedSelecting) { - return; - } + // Don't seek if this was a selection box operation + if (isSelecting || justFinishedSelecting) { + return; + } - // Don't seek if clicking on timeline elements, but still deselect - if ((e.target as HTMLElement).closest(".timeline-element")) { - return; - } + // Don't seek if clicking on timeline elements, but still deselect + if ((e.target as HTMLElement).closest(".timeline-element")) { + return; + } - // Don't seek if clicking on playhead - if (playheadRef.current?.contains(e.target as Node)) { - return; - } + // Don't seek if clicking on playhead + if (playheadRef.current?.contains(e.target as Node)) { + return; + } - // Don't seek if clicking on track labels - if ((e.target as HTMLElement).closest("[data-track-labels]")) { - clearSelectedElements(); - return; - } + // Don't seek if clicking on track labels + if ((e.target as HTMLElement).closest("[data-track-labels]")) { + clearSelectedElements(); + return; + } - // Clear selected elements when clicking empty timeline area - console.log(JSON.stringify({ clearingSelectedElements: true })); - clearSelectedElements(); + // Clear selected elements when clicking empty timeline area + console.log(JSON.stringify({ clearingSelectedElements: true })); + clearSelectedElements(); - // Determine if we're clicking in ruler or tracks area - const isRulerClick = (e.target as HTMLElement).closest( - "[data-ruler-area]", - ); + // Determine if we're clicking in ruler or tracks area + const isRulerClick = (e.target as HTMLElement).closest( + "[data-ruler-area]" + ); - let mouseX: number; - let scrollLeft = 0; + let mouseX: number; + let scrollLeft = 0; - if (isRulerClick) { - // Calculate based on ruler position - const rulerContent = rulerScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]", - ) as HTMLElement; - if (!rulerContent) return; - const rect = rulerContent.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - scrollLeft = rulerContent.scrollLeft; - } else { - // Calculate based on tracks content position - const tracksContent = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]", - ) as HTMLElement; - if (!tracksContent) return; - const rect = tracksContent.getBoundingClientRect(); - mouseX = e.clientX - rect.left; - scrollLeft = tracksContent.scrollLeft; - } + if (isRulerClick) { + // Calculate based on ruler position + const rulerContent = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + if (!rulerContent) return; + const rect = rulerContent.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + scrollLeft = rulerContent.scrollLeft; + } else { + // Calculate based on tracks content position + const tracksContent = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + if (!tracksContent) return; + const rect = tracksContent.getBoundingClientRect(); + mouseX = e.clientX - rect.left; + scrollLeft = tracksContent.scrollLeft; + } - const rawTime = Math.max( - 0, - Math.min( - duration, - (mouseX + scrollLeft) / - (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel), - ), - ); + const rawTime = Math.max( + 0, + Math.min( + duration, + (mouseX + scrollLeft) / + (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) + ) + ); - // Use frame snapping for timeline clicking - const projectFps = activeProject?.fps || 30; - const time = snapTimeToFrame(rawTime, projectFps); + // Use frame snapping for timeline clicking + const projectFps = activeProject?.fps || 30; + const time = snapTimeToFrame(rawTime, projectFps); - seek(time); - }, - [ - duration, - zoomLevel, - seek, - rulerScrollRef, - tracksScrollRef, - clearSelectedElements, - isSelecting, - justFinishedSelecting, - ], - ); + seek(time); + }, + [ + duration, + zoomLevel, + seek, + rulerScrollRef, + tracksScrollRef, + clearSelectedElements, + isSelecting, + justFinishedSelecting, + ] + ); - // Update timeline duration when tracks change - useEffect(() => { - const totalDuration = getTotalDuration(); - setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline - }, [tracks, setDuration, getTotalDuration]); + // Update timeline duration when tracks change + useEffect(() => { + const totalDuration = getTotalDuration(); + setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline + }, [tracks, setDuration, getTotalDuration]); - // Old marquee system removed - using new SelectionBox component instead + // Old marquee system removed - using new SelectionBox component instead - const handleDragEnter = (e: React.DragEvent) => { - // When something is dragged over the timeline, show overlay - e.preventDefault(); - // Don't show overlay for timeline elements - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-element")) { - return; - } - dragCounterRef.current += 1; - if (!isDragOver) { - setIsDragOver(true); - } - }; + const handleDragEnter = (e: React.DragEvent) => { + // When something is dragged over the timeline, show overlay + e.preventDefault(); + // Don't show overlay for timeline elements - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-element")) { + return; + } + dragCounterRef.current += 1; + if (!isDragOver) { + setIsDragOver(true); + } + }; - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - }; + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); - // Don't update state for timeline elements - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-element")) { - return; - } + // Don't update state for timeline elements - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-element")) { + return; + } - dragCounterRef.current -= 1; - if (dragCounterRef.current === 0) { - setIsDragOver(false); - } - }; + dragCounterRef.current -= 1; + if (dragCounterRef.current === 0) { + setIsDragOver(false); + } + }; - const handleDrop = async (e: React.DragEvent) => { - // When media is dropped, add it as a new track/element - e.preventDefault(); - setIsDragOver(false); - dragCounterRef.current = 0; + const handleDrop = async (e: React.DragEvent) => { + // When media is dropped, add it as a new track/element + e.preventDefault(); + setIsDragOver(false); + dragCounterRef.current = 0; - // Ignore timeline element drags - they're handled by track-specific handlers - const hasTimelineElement = e.dataTransfer.types.includes( - "application/x-timeline-element", - ); - if (hasTimelineElement) { - return; - } + // Ignore timeline element drags - they're handled by track-specific handlers + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element" + ); + if (hasTimelineElement) { + return; + } - const itemData = e.dataTransfer.getData("application/x-media-item"); - if (itemData) { - try { - const dragData: DragData = JSON.parse(itemData); + const itemData = e.dataTransfer.getData("application/x-media-item"); + if (itemData) { + try { + const dragData: DragData = JSON.parse(itemData); - if (dragData.type === "text") { - // Always create new text track to avoid overlaps - useTimelineStore.getState().addTextToNewTrack(dragData); - } else { - // Handle media items - const mediaItem = mediaItems.find( - (item: any) => item.id === dragData.id, - ); - if (!mediaItem) { - toast.error("Media item not found"); - return; - } + if (dragData.type === "text") { + // Always create new text track to avoid overlaps + useTimelineStore.getState().addTextToNewTrack(dragData); + } else { + // Handle media items + const mediaItem = mediaItems.find( + (item: any) => item.id === dragData.id + ); + if (!mediaItem) { + toast.error("Media item not found"); + return; + } - useTimelineStore.getState().addMediaToNewTrack(mediaItem); - } - } catch (error) { - console.error("Error parsing dropped item data:", error); - toast.error("Failed to add item to timeline"); - } - } else if (e.dataTransfer.files?.length > 0) { - // Handle file drops by creating new tracks - if (!activeProject) { - toast.error("No active project"); - return; - } + useTimelineStore.getState().addMediaToNewTrack(mediaItem); + } + } catch (error) { + console.error("Error parsing dropped item data:", error); + toast.error("Failed to add item to timeline"); + } + } else if (e.dataTransfer.files?.length > 0) { + // Handle file drops by creating new tracks + if (!activeProject) { + toast.error("No active project"); + return; + } - setIsProcessing(true); - setProgress(0); - try { - const processedItems = await processMediaFiles( - e.dataTransfer.files, - (p) => setProgress(p), - ); - for (const processedItem of processedItems) { - await addMediaItem(activeProject.id, processedItem); - const currentMediaItems = useMediaStore.getState().mediaItems; - const addedItem = currentMediaItems.find( - (item) => - item.name === processedItem.name && - item.url === processedItem.url, - ); - if (addedItem) { - useTimelineStore.getState().addMediaToNewTrack(addedItem); - } - } - } catch (error) { - // Show error if file processing fails - console.error("Error processing external files:", error); - toast.error("Failed to process dropped files"); - } finally { - setIsProcessing(false); - setProgress(0); - } - } - }; + setIsProcessing(true); + setProgress(0); + try { + const processedItems = await processMediaFiles( + e.dataTransfer.files, + (p) => setProgress(p) + ); + for (const processedItem of processedItems) { + await addMediaItem(activeProject.id, processedItem); + const currentMediaItems = useMediaStore.getState().mediaItems; + const addedItem = currentMediaItems.find( + (item) => + item.name === processedItem.name && item.url === processedItem.url + ); + if (addedItem) { + useTimelineStore.getState().addMediaToNewTrack(addedItem); + } + } + } catch (error) { + // Show error if file processing fails + console.error("Error processing external files:", error); + toast.error("Failed to process dropped files"); + } finally { + setIsProcessing(false); + setProgress(0); + } + } + }; - const dragProps = { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, - }; + const dragProps = { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }; - // Action handlers for toolbar - const handleSplitSelected = () => { - if (selectedElements.length === 0) { - toast.error("No elements selected"); - return; - } - let splitCount = 0; - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (element && track) { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + // Action handlers for toolbar + const handleSplitSelected = () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + let splitCount = 0; + selectedElements.forEach(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (element && track) { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const newElementId = splitElement(trackId, elementId, currentTime); - if (newElementId) splitCount++; - } - } - }); - if (splitCount === 0) { - toast.error("Playhead must be within selected elements to split"); - } - }; + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + const newElementId = splitElement(trackId, elementId, currentTime); + if (newElementId) splitCount++; + } + } + }); + if (splitCount === 0) { + toast.error("Playhead must be within selected elements to split"); + } + }; - const handleDuplicateSelected = () => { - if (selectedElements.length === 0) { - toast.error("No elements selected"); - return; - } - const canDuplicate = selectedElements.length === 1; - if (!canDuplicate) return; + const handleDuplicateSelected = () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + const canDuplicate = selectedElements.length === 1; + if (!canDuplicate) return; - const newSelections: { trackId: string; elementId: string }[] = []; + const newSelections: { trackId: string; elementId: string }[] = []; - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((el) => el.id === elementId); + selectedElements.forEach(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((el) => el.id === elementId); - if (element) { - const newStartTime = - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1; + if (element) { + const newStartTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1; - // Create element without id (will be generated by store) - const { id, ...elementWithoutId } = element; + // Create element without id (will be generated by store) + const { id, ...elementWithoutId } = element; - addElementToTrack(trackId, { - ...elementWithoutId, - startTime: newStartTime, - }); + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, + }); - // We can't predict the new id, so just clear selection for now - // TODO: addElementToTrack could return the new element id - } - }); + // We can't predict the new id, so just clear selection for now + // TODO: addElementToTrack could return the new element id + } + }); - clearSelectedElements(); - }; + clearSelectedElements(); + }; - const handleFreezeSelected = () => { - toast.info("Freeze frame functionality coming soon!"); - }; + const handleFreezeSelected = () => { + toast.info("Freeze frame functionality coming soon!"); + }; - const handleSplitAndKeepLeft = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepLeft(trackId, elementId, currentTime); - }; + const handleSplitAndKeepLeft = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepLeft(trackId, elementId, currentTime); + }; - const handleSplitAndKeepRight = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepRight(trackId, elementId, currentTime); - }; + const handleSplitAndKeepRight = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepRight(trackId, elementId, currentTime); + }; - const handleSeparateAudio = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one media element to separate audio"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - if (!track || track.type !== "media") { - toast.error("Select a media element to separate audio"); - return; - } - separateAudio(trackId, elementId); - }; + const handleSeparateAudio = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one media element to separate audio"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + if (!track || track.type !== "media") { + toast.error("Select a media element to separate audio"); + return; + } + separateAudio(trackId, elementId); + }; - const handleDeleteSelected = () => { - if (selectedElements.length === 0) { - toast.error("No elements selected"); - return; - } - selectedElements.forEach(({ trackId, elementId }) => { - if (rippleEditingEnabled) { - removeElementFromTrackWithRipple(trackId, elementId); - } else { - removeElementFromTrack(trackId, elementId); - } - }); - clearSelectedElements(); - }; + const handleDeleteSelected = () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + selectedElements.forEach(({ trackId, elementId }) => { + if (rippleEditingEnabled) { + removeElementFromTrackWithRipple(trackId, elementId); + } else { + removeElementFromTrack(trackId, elementId); + } + }); + clearSelectedElements(); + }; - // --- Scroll synchronization effect --- - useEffect(() => { - const rulerViewport = rulerScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]", - ) as HTMLElement; - const tracksViewport = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]", - ) as HTMLElement; - const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]", - ) as HTMLElement; + // --- Scroll synchronization effect --- + useEffect(() => { + const rulerViewport = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + const tracksViewport = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; - if (!rulerViewport || !tracksViewport) return; + if (!rulerViewport || !tracksViewport) return; - // Horizontal scroll synchronization between ruler and tracks - const handleRulerScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastRulerSync.current < 16) return; - lastRulerSync.current = now; - isUpdatingRef.current = true; - tracksViewport.scrollLeft = rulerViewport.scrollLeft; - isUpdatingRef.current = false; - }; - const handleTracksScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastTracksSync.current < 16) return; - lastTracksSync.current = now; - isUpdatingRef.current = true; - rulerViewport.scrollLeft = tracksViewport.scrollLeft; - isUpdatingRef.current = false; - }; + // Horizontal scroll synchronization between ruler and tracks + const handleRulerScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastRulerSync.current < 16) return; + lastRulerSync.current = now; + isUpdatingRef.current = true; + tracksViewport.scrollLeft = rulerViewport.scrollLeft; + isUpdatingRef.current = false; + }; + const handleTracksScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastTracksSync.current < 16) return; + lastTracksSync.current = now; + isUpdatingRef.current = true; + rulerViewport.scrollLeft = tracksViewport.scrollLeft; + isUpdatingRef.current = false; + }; - rulerViewport.addEventListener("scroll", handleRulerScroll); - tracksViewport.addEventListener("scroll", handleTracksScroll); + rulerViewport.addEventListener("scroll", handleRulerScroll); + tracksViewport.addEventListener("scroll", handleTracksScroll); - // Vertical scroll synchronization between track labels and tracks content - if (trackLabelsViewport) { - const handleTrackLabelsScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastVerticalSync.current < 16) - return; - lastVerticalSync.current = now; - isUpdatingRef.current = true; - tracksViewport.scrollTop = trackLabelsViewport.scrollTop; - isUpdatingRef.current = false; - }; - const handleTracksVerticalScroll = () => { - const now = Date.now(); - if (isUpdatingRef.current || now - lastVerticalSync.current < 16) - return; - lastVerticalSync.current = now; - isUpdatingRef.current = true; - trackLabelsViewport.scrollTop = tracksViewport.scrollTop; - isUpdatingRef.current = false; - }; + // Vertical scroll synchronization between track labels and tracks content + if (trackLabelsViewport) { + const handleTrackLabelsScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastVerticalSync.current < 16) + return; + lastVerticalSync.current = now; + isUpdatingRef.current = true; + tracksViewport.scrollTop = trackLabelsViewport.scrollTop; + isUpdatingRef.current = false; + }; + const handleTracksVerticalScroll = () => { + const now = Date.now(); + if (isUpdatingRef.current || now - lastVerticalSync.current < 16) + return; + lastVerticalSync.current = now; + isUpdatingRef.current = true; + trackLabelsViewport.scrollTop = tracksViewport.scrollTop; + isUpdatingRef.current = false; + }; - trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll); - tracksViewport.addEventListener("scroll", handleTracksVerticalScroll); + trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll); + tracksViewport.addEventListener("scroll", handleTracksVerticalScroll); - return () => { - rulerViewport.removeEventListener("scroll", handleRulerScroll); - tracksViewport.removeEventListener("scroll", handleTracksScroll); - trackLabelsViewport.removeEventListener( - "scroll", - handleTrackLabelsScroll, - ); - tracksViewport.removeEventListener( - "scroll", - handleTracksVerticalScroll, - ); - }; - } + return () => { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + tracksViewport.removeEventListener("scroll", handleTracksScroll); + trackLabelsViewport.removeEventListener( + "scroll", + handleTrackLabelsScroll + ); + tracksViewport.removeEventListener( + "scroll", + handleTracksVerticalScroll + ); + }; + } - return () => { - rulerViewport.removeEventListener("scroll", handleRulerScroll); - tracksViewport.removeEventListener("scroll", handleTracksScroll); - }; - }, []); + return () => { + rulerViewport.removeEventListener("scroll", handleRulerScroll); + tracksViewport.removeEventListener("scroll", handleTracksScroll); + }; + }, []); - return ( - <div - className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`} - {...dragProps} - onMouseEnter={() => setIsInTimeline(true)} - onMouseLeave={() => setIsInTimeline(false)} - > - {/* Toolbar */} - <div className="border-b flex items-center justify-between px-2 py-1"> - <div className="flex items-center gap-1 w-full"> - <TooltipProvider delayDuration={500}> - {/* Play/Pause Button */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={toggle} - className="mr-2" - > - {isPlaying ? ( - <Pause className="h-4 w-4" /> - ) : ( - <Play className="h-4 w-4" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent> - {isPlaying ? "Pause (Space)" : "Play (Space)"} - </TooltipContent> - </Tooltip> - <div className="w-px h-6 bg-border mx-1" /> - {/* Time Display */} - <div - className="text-xs text-muted-foreground font-mono px-2" - style={{ minWidth: "18ch", textAlign: "center" }} - > - {currentTime.toFixed(1)}s / {duration.toFixed(1)}s - </div> - {/* Test Clip Button - for debugging */} - {tracks.length === 0 && ( - <> - <div className="w-px h-6 bg-border mx-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="outline" - size="sm" - onClick={() => { - const trackId = addTrack("media"); - addElementToTrack(trackId, { - type: "media", - mediaId: "test", - name: "Test Clip", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, - }); - }} - className="text-xs" - > - Add Test Clip - </Button> - </TooltipTrigger> - <TooltipContent> - Add a test clip to try playback - </TooltipContent> - </Tooltip> - </> - )} - <div className="w-px h-6 bg-border mx-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSplitSelected} - > - <Scissors className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split element (Ctrl+S)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSplitAndKeepLeft} - > - <ArrowLeftToLine className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSplitAndKeepRight} - > - <ArrowRightToLine className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleSeparateAudio} - > - <SplitSquareHorizontal className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Separate audio (Ctrl+D)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleDuplicateSelected} - > - <Copy className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleFreezeSelected} - > - <Snowflake className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Freeze frame (F)</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={handleDeleteSelected} - > - <Trash2 className="h-4 w-4" /> - </Button> - </TooltipTrigger> - <TooltipContent>Delete element (Delete)</TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - <div className="flex items-center gap-1"> - <TooltipProvider delayDuration={500}> - <Tooltip> - <TooltipTrigger asChild> - <Button variant="text" size="icon" onClick={toggleSnapping}> - {snappingEnabled ? ( - <LockOpen className="h-4 w-4 text-primary" /> - ) : ( - <Lock className="h-4 w-4" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent>Auto snapping</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="text" - size="icon" - onClick={toggleRippleEditing} - > - <Link - className={`h-4 w-4 ${rippleEditingEnabled ? "text-primary" : ""}`} - /> - </Button> - </TooltipTrigger> - <TooltipContent> - {rippleEditingEnabled - ? "Disable Ripple Editing" - : "Enable Ripple Editing"} - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - </div> + return ( + <div + className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`} + {...dragProps} + onMouseEnter={() => setIsInTimeline(true)} + onMouseLeave={() => setIsInTimeline(false)} + > + {/* Toolbar */} + <div className="border-b flex items-center justify-between px-2 py-1"> + <div className="flex items-center gap-1 w-full"> + <TooltipProvider delayDuration={500}> + {/* Play/Pause Button */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={toggle} + className="mr-2" + > + {isPlaying ? ( + <Pause className="h-4 w-4" /> + ) : ( + <Play className="h-4 w-4" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + {isPlaying ? "Pause (Space)" : "Play (Space)"} + </TooltipContent> + </Tooltip> + <div className="w-px h-6 bg-border mx-1" /> + {/* Time Display */} + <div + className="text-xs text-muted-foreground font-mono px-2" + style={{ minWidth: "18ch", textAlign: "center" }} + > + {currentTime.toFixed(1)}s / {duration.toFixed(1)}s + </div> + {/* Test Clip Button - for debugging */} + {tracks.length === 0 && ( + <> + <div className="w-px h-6 bg-border mx-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={() => { + const trackId = addTrack("media"); + addElementToTrack(trackId, { + type: "media", + mediaId: "test", + name: "Test Clip", + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, + }); + }} + className="text-xs" + > + Add Test Clip + </Button> + </TooltipTrigger> + <TooltipContent> + Add a test clip to try playback + </TooltipContent> + </Tooltip> + </> + )} + <div className="w-px h-6 bg-border mx-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSplitSelected} + > + <Scissors className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split element (Ctrl+S)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSplitAndKeepLeft} + > + <ArrowLeftToLine className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSplitAndKeepRight} + > + <ArrowRightToLine className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleSeparateAudio} + > + <SplitSquareHorizontal className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Separate audio (Ctrl+D)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleDuplicateSelected} + > + <Copy className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleFreezeSelected} + > + <Snowflake className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Freeze frame (F)</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={handleDeleteSelected} + > + <Trash2 className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Delete element (Delete)</TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + <div className="flex items-center gap-1"> + <TooltipProvider delayDuration={500}> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="text" size="icon" onClick={toggleSnapping}> + {snappingEnabled ? ( + <LockOpen className="h-4 w-4 text-primary" /> + ) : ( + <Lock className="h-4 w-4" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent>Auto snapping</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="text" + size="icon" + onClick={toggleRippleEditing} + > + <Link + className={`h-4 w-4 ${rippleEditingEnabled ? "text-primary" : ""}`} + /> + </Button> + </TooltipTrigger> + <TooltipContent> + {rippleEditingEnabled + ? "Disable Ripple Editing" + : "Enable Ripple Editing"} + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + </div> - {/* Timeline Container */} - <div - className="flex-1 flex flex-col overflow-hidden relative" - ref={timelineRef} - > - <TimelinePlayhead - currentTime={currentTime} - duration={duration} - zoomLevel={zoomLevel} - tracks={tracks} - seek={seek} - rulerRef={rulerRef} - rulerScrollRef={rulerScrollRef} - tracksScrollRef={tracksScrollRef} - trackLabelsRef={trackLabelsRef} - timelineRef={timelineRef} - playheadRef={playheadRef} - isSnappingToPlayhead={ - showSnapIndicator && currentSnapPoint?.type === "playhead" - } - /> - <SnapIndicator - snapPoint={currentSnapPoint} - zoomLevel={zoomLevel} - tracks={tracks} - timelineRef={timelineRef} - trackLabelsRef={trackLabelsRef} - isVisible={showSnapIndicator} - /> - {/* Timeline Header with Ruler */} - <div className="flex bg-panel sticky top-0 z-10"> - {/* Track Labels Header */} - <div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2"> - {/* Empty space */} - <span className="text-sm font-medium text-muted-foreground opacity-0"> - . - </span> - </div> + {/* Timeline Container */} + <div + className="flex-1 flex flex-col overflow-hidden relative" + ref={timelineRef} + > + <TimelinePlayhead + currentTime={currentTime} + duration={duration} + zoomLevel={zoomLevel} + tracks={tracks} + seek={seek} + rulerRef={rulerRef} + rulerScrollRef={rulerScrollRef} + tracksScrollRef={tracksScrollRef} + trackLabelsRef={trackLabelsRef} + timelineRef={timelineRef} + playheadRef={playheadRef} + isSnappingToPlayhead={ + showSnapIndicator && currentSnapPoint?.type === "playhead" + } + /> + <SnapIndicator + snapPoint={currentSnapPoint} + zoomLevel={zoomLevel} + tracks={tracks} + timelineRef={timelineRef} + trackLabelsRef={trackLabelsRef} + isVisible={showSnapIndicator} + /> + {/* Timeline Header with Ruler */} + <div className="flex bg-panel sticky top-0 z-10"> + {/* Track Labels Header */} + <div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2"> + {/* Empty space */} + <span className="text-sm font-medium text-muted-foreground opacity-0"> + . + </span> + </div> - {/* Timeline Ruler */} - <div - className="flex-1 relative overflow-hidden h-4" - onWheel={handleWheel} - onMouseDown={handleSelectionMouseDown} - onClick={handleTimelineContentClick} - data-ruler-area - > - <ScrollArea className="w-full" ref={rulerScrollRef}> - <div - ref={rulerRef} - className="relative h-4 select-none cursor-default" - style={{ - width: `${dynamicTimelineWidth}px`, - }} - onMouseDown={handleRulerMouseDown} - > - {/* Time markers */} - {(() => { - // Calculate appropriate time interval based on zoom level - const getTimeInterval = (zoom: number) => { - const pixelsPerSecond = - TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; - if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in - if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in - if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom - if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out - if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out - if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out - return 30; // Every 30s when extremely zoomed out - }; + {/* Timeline Ruler */} + <div + className="flex-1 relative overflow-hidden h-4" + onWheel={handleWheel} + onMouseDown={handleSelectionMouseDown} + onClick={handleTimelineContentClick} + data-ruler-area + > + <ScrollArea className="w-full" ref={rulerScrollRef}> + <div + ref={rulerRef} + className="relative h-4 select-none cursor-default" + style={{ + width: `${dynamicTimelineWidth}px`, + }} + onMouseDown={handleRulerMouseDown} + > + {/* Time markers */} + {(() => { + // Calculate appropriate time interval based on zoom level + const getTimeInterval = (zoom: number) => { + const pixelsPerSecond = + TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; + if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in + if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in + if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom + if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out + if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out + if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out + return 30; // Every 30s when extremely zoomed out + }; - const interval = getTimeInterval(zoomLevel); - const markerCount = Math.ceil(duration / interval) + 1; + const interval = getTimeInterval(zoomLevel); + const markerCount = Math.ceil(duration / interval) + 1; - return Array.from({ length: markerCount }, (_, i) => { - const time = i * interval; - if (time > duration) return null; + return Array.from({ length: markerCount }, (_, i) => { + const time = i * interval; + if (time > duration) return null; - const isMainMarker = - time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; + const isMainMarker = + time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; - return ( - <div - key={i} - className={`absolute top-0 bottom-0 ${ - isMainMarker - ? "border-l border-muted-foreground/40" - : "border-l border-muted-foreground/20" - }`} - style={{ - left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, - }} - > - <span - className={`absolute top-1 left-1 text-[0.6rem] ${ - isMainMarker - ? "text-muted-foreground font-medium" - : "text-muted-foreground/70" - }`} - > - {(() => { - const formatTime = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; + return ( + <div + key={i} + className={`absolute top-0 bottom-0 ${ + isMainMarker + ? "border-l border-muted-foreground/40" + : "border-l border-muted-foreground/20" + }`} + style={{ + left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, + }} + > + <span + className={`absolute top-1 left-1 text-[0.6rem] ${ + isMainMarker + ? "text-muted-foreground font-medium" + : "text-muted-foreground/70" + }`} + > + {(() => { + const formatTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (minutes > 0) { - return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; - } else if (interval >= 1) { - return `${Math.floor(secs)}s`; - } else { - return `${secs.toFixed(1)}s`; - } - }; - return formatTime(time); - })()} - </span> - </div> - ); - }).filter(Boolean); - })()} - </div> - </ScrollArea> - </div> - </div> + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; + } else if (minutes > 0) { + return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; + } else if (interval >= 1) { + return `${Math.floor(secs)}s`; + } else { + return `${secs.toFixed(1)}s`; + } + }; + return formatTime(time); + })()} + </span> + </div> + ); + }).filter(Boolean); + })()} + </div> + </ScrollArea> + </div> + </div> - {/* Tracks Area */} - <div className="flex-1 flex overflow-hidden"> - {/* Track Labels */} - {tracks.length > 0 && ( - <div - ref={trackLabelsRef} - className="w-48 flex-shrink-0 border-r border-black overflow-y-auto" - data-track-labels - > - <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}> - <div className="flex flex-col gap-1"> - {tracks.map((track) => ( - <div - key={track.id} - className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5" - style={{ height: `${getTrackHeight(track.type)}px` }} - > - <div className="flex items-center flex-1 min-w-0"> - <TrackIcon track={track} /> - </div> - {track.muted && ( - <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0"> - Muted - </span> - )} - </div> - ))} - </div> - </ScrollArea> - </div> - )} + {/* Tracks Area */} + <div className="flex-1 flex overflow-hidden"> + {/* Track Labels */} + {tracks.length > 0 && ( + <div + ref={trackLabelsRef} + className="w-48 flex-shrink-0 border-r border-black overflow-y-auto" + data-track-labels + > + <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}> + <div className="flex flex-col gap-1"> + {tracks.map((track) => ( + <div + key={track.id} + className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5" + style={{ height: `${getTrackHeight(track.type)}px` }} + > + <div className="flex items-center flex-1 min-w-0"> + <TrackIcon track={track} /> + </div> + {track.muted && ( + <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0"> + Muted + </span> + )} + </div> + ))} + </div> + </ScrollArea> + </div> + )} - {/* Timeline Tracks Content */} - <div - className="flex-1 relative overflow-hidden" - onWheel={handleWheel} - onMouseDown={(e) => { - handleTimelineMouseDown(e); - handleSelectionMouseDown(e); - }} - onClick={handleTimelineContentClick} - ref={tracksContainerRef} - > - <SelectionBox - startPos={selectionBox?.startPos || null} - currentPos={selectionBox?.currentPos || null} - containerRef={tracksContainerRef} - isActive={selectionBox?.isActive || false} - /> - <ScrollArea className="w-full h-full" ref={tracksScrollRef}> - <div - className="relative flex-1" - style={{ - height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`, - width: `${dynamicTimelineWidth}px`, - }} - > - {tracks.length === 0 ? ( - <div></div> - ) : ( - <> - {tracks.map((track, index) => ( - <ContextMenu key={track.id}> - <ContextMenuTrigger asChild> - <div - className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]" - style={{ - top: `${getCumulativeHeightBefore(tracks, index)}px`, - height: `${getTrackHeight(track.type)}px`, - }} - onClick={(e) => { - // If clicking empty area (not on a element), deselect all elements - if ( - !(e.target as HTMLElement).closest( - ".timeline-element", - ) - ) { - clearSelectedElements(); - } - }} - > - <TimelineTrackContent - track={track} - zoomLevel={zoomLevel} - onSnapPointChange={handleSnapPointChange} - /> - </div> - </ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuItem - onClick={() => toggleTrackMute(track.id)} - > - {track.muted ? "Unmute Track" : "Mute Track"} - </ContextMenuItem> - <ContextMenuItem> - Track settings (soon) - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - ))} - </> - )} - </div> - </ScrollArea> - </div> - </div> - </div> - </div> - ); + {/* Timeline Tracks Content */} + <div + className="flex-1 relative overflow-hidden" + onWheel={handleWheel} + onMouseDown={(e) => { + handleTimelineMouseDown(e); + handleSelectionMouseDown(e); + }} + onClick={handleTimelineContentClick} + ref={tracksContainerRef} + > + <SelectionBox + startPos={selectionBox?.startPos || null} + currentPos={selectionBox?.currentPos || null} + containerRef={tracksContainerRef} + isActive={selectionBox?.isActive || false} + /> + <ScrollArea className="w-full h-full" ref={tracksScrollRef}> + <div + className="relative flex-1" + style={{ + height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`, + width: `${dynamicTimelineWidth}px`, + }} + > + {tracks.length === 0 ? ( + <div></div> + ) : ( + <> + {tracks.map((track, index) => ( + <ContextMenu key={track.id}> + <ContextMenuTrigger asChild> + <div + className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]" + style={{ + top: `${getCumulativeHeightBefore(tracks, index)}px`, + height: `${getTrackHeight(track.type)}px`, + }} + onClick={(e) => { + // If clicking empty area (not on a element), deselect all elements + if ( + !(e.target as HTMLElement).closest( + ".timeline-element" + ) + ) { + clearSelectedElements(); + } + }} + > + <TimelineTrackContent + track={track} + zoomLevel={zoomLevel} + onSnapPointChange={handleSnapPointChange} + /> + </div> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem + onClick={() => toggleTrackMute(track.id)} + > + {track.muted ? "Unmute Track" : "Mute Track"} + </ContextMenuItem> + <ContextMenuItem> + Track settings (soon) + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ))} + </> + )} + </div> + </ScrollArea> + </div> + </div> + </div> + </div> + ); } function TrackIcon({ track }: { track: TimelineTrack }) { - return ( - <> - {track.type === "media" && ( - <Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> - )} - {track.type === "text" && ( - <TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> - )} - {track.type === "audio" && ( - <Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> - )} - </> - ); + return ( + <> + {track.type === "media" && ( + <Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + )} + {track.type === "text" && ( + <TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + )} + {track.type === "audio" && ( + <Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> + )} + </> + ); } diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index 1385e377..5b7f2303 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -8,133 +8,133 @@ import { getStars } from "@/lib/fetch-github-stars"; import Image from "next/image"; export function Footer() { - const [star, setStar] = useState<string>(); + const [star, setStar] = useState<string>(); - useEffect(() => { - const fetchStars = async () => { - try { - const data = await getStars(); - setStar(data); - } catch (err) { - console.error("Failed to fetch GitHub stars", err); - } - }; + useEffect(() => { + const fetchStars = async () => { + try { + const data = await getStars(); + setStar(data); + } catch (err) { + console.error("Failed to fetch GitHub stars", err); + } + }; - fetchStars(); - }, []); + fetchStars(); + }, []); - return ( - <motion.footer - className="bg-background border-t" - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - transition={{ delay: 0.8, duration: 0.8 }} - > - <div className="max-w-5xl mx-auto px-8 py-10"> - <div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8"> - {/* Brand Section */} - <div className="md:col-span-1 max-w-sm"> - <div className="flex justify-start items-center gap-2 mb-4"> - <Image src="/logo.svg" alt="OpenCut" width={24} height={24} /> - <span className="font-bold text-lg">OpenCut</span> - </div> - <p className="text-sm md:text-left text-muted-foreground mb-5"> - The open source video editor that gets the job done. Simple, - powerful, and works on any platform. - </p> - <div className="flex justify-start gap-3"> - <Link - href="https://github.com/OpenCut-app/OpenCut" - className="text-muted-foreground hover:text-foreground transition-colors" - target="_blank" - rel="noopener noreferrer" - > - <RiGithubLine className="h-5 w-5" /> - </Link> - <Link - href="https://x.com/OpenCutApp" - className="text-muted-foreground hover:text-foreground transition-colors" - target="_blank" - rel="noopener noreferrer" - > - <RiTwitterXLine className="h-5 w-5" /> - </Link> - <Link - href="https://discord.com/invite/Mu3acKZvCp" - className="text-muted-foreground hover:text-foreground transition-colors" - target="_blank" - rel="noopener noreferrer" - > - <RiDiscordFill className="h-5 w-5" /> - </Link> - </div> - </div> + return ( + <motion.footer + className="bg-background border-t" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ delay: 0.8, duration: 0.8 }} + > + <div className="max-w-5xl mx-auto px-8 py-10"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8"> + {/* Brand Section */} + <div className="md:col-span-1 max-w-sm"> + <div className="flex justify-start items-center gap-2 mb-4"> + <Image src="/logo.svg" alt="OpenCut" width={24} height={24} /> + <span className="font-bold text-lg">OpenCut</span> + </div> + <p className="text-sm md:text-left text-muted-foreground mb-5"> + The open source video editor that gets the job done. Simple, + powerful, and works on any platform. + </p> + <div className="flex justify-start gap-3"> + <Link + href="https://github.com/OpenCut-app/OpenCut" + className="text-muted-foreground hover:text-foreground transition-colors" + target="_blank" + rel="noopener noreferrer" + > + <RiGithubLine className="h-5 w-5" /> + </Link> + <Link + href="https://x.com/OpenCutApp" + className="text-muted-foreground hover:text-foreground transition-colors" + target="_blank" + rel="noopener noreferrer" + > + <RiTwitterXLine className="h-5 w-5" /> + </Link> + <Link + href="https://discord.com/invite/Mu3acKZvCp" + className="text-muted-foreground hover:text-foreground transition-colors" + target="_blank" + rel="noopener noreferrer" + > + <RiDiscordFill className="h-5 w-5" /> + </Link> + </div> + </div> - <div className="flex gap-12 justify-start items-start py-2"> - <div> - <h3 className="font-semibold text-foreground mb-4">Resources</h3> - <ul className="space-y-2 text-sm"> - <li> - <Link - href="/roadmap" - className="text-muted-foreground hover:text-foreground transition-colors" - > - Roadmap - </Link> - </li> - <li> - <Link - href="/privacy" - className="text-muted-foreground hover:text-foreground transition-colors" - > - Privacy policy - </Link> - </li> - <li> - <Link - href="/terms" - className="text-muted-foreground hover:text-foreground transition-colors" - > - Terms of use - </Link> - </li> - </ul> - </div> + <div className="flex gap-12 justify-start items-start py-2"> + <div> + <h3 className="font-semibold text-foreground mb-4">Resources</h3> + <ul className="space-y-2 text-sm"> + <li> + <Link + href="/roadmap" + className="text-muted-foreground hover:text-foreground transition-colors" + > + Roadmap + </Link> + </li> + <li> + <Link + href="/privacy" + className="text-muted-foreground hover:text-foreground transition-colors" + > + Privacy policy + </Link> + </li> + <li> + <Link + href="/terms" + className="text-muted-foreground hover:text-foreground transition-colors" + > + Terms of use + </Link> + </li> + </ul> + </div> - {/* Company Links */} - <div> - <h3 className="font-semibold text-foreground mb-4">Company</h3> - <ul className="space-y-2 text-sm"> - <li> - <Link - href="/contributors" - className="text-muted-foreground hover:text-foreground transition-colors" - > - Contributors - </Link> - </li> - <li> - <Link - href="https://github.com/OpenCut-app/OpenCut/blob/main/README.md" - className="text-muted-foreground hover:text-foreground transition-colors" - target="_blank" - rel="noopener noreferrer" - > - About - </Link> - </li> - </ul> - </div> - </div> - </div> + {/* Company Links */} + <div> + <h3 className="font-semibold text-foreground mb-4">Company</h3> + <ul className="space-y-2 text-sm"> + <li> + <Link + href="/contributors" + className="text-muted-foreground hover:text-foreground transition-colors" + > + Contributors + </Link> + </li> + <li> + <Link + href="https://github.com/OpenCut-app/OpenCut/blob/main/README.md" + className="text-muted-foreground hover:text-foreground transition-colors" + target="_blank" + rel="noopener noreferrer" + > + About + </Link> + </li> + </ul> + </div> + </div> + </div> - {/* Bottom Section */} - <div className="pt-2 flex flex-col md:flex-row justify-between items-start gap-4"> - <div className="flex items-center gap-4 text-sm text-muted-foreground"> - <span>© 2025 OpenCut, All Rights Reserved</span> - </div> - </div> - </div> - </motion.footer> - ); + {/* Bottom Section */} + <div className="pt-2 flex flex-col md:flex-row justify-between items-start gap-4"> + <div className="flex items-center gap-4 text-sm text-muted-foreground"> + <span>© 2025 OpenCut, All Rights Reserved</span> + </div> + </div> + </div> + </motion.footer> + ); } diff --git a/apps/web/src/components/header-base.tsx b/apps/web/src/components/header-base.tsx index c87803f7..f27eb7fc 100644 --- a/apps/web/src/components/header-base.tsx +++ b/apps/web/src/components/header-base.tsx @@ -1,41 +1,41 @@ -"use client"; - -import { cn } from "@/lib/utils"; -import { ReactNode } from "react"; - -interface HeaderBaseProps { - leftContent?: ReactNode; - centerContent?: ReactNode; - rightContent?: ReactNode; - className?: string; - children?: ReactNode; -} - -export function HeaderBase({ - leftContent, - centerContent, - rightContent, - className, - children, -}: HeaderBaseProps) { - // If children is provided, render it directly without the grid layout - if (children) { - return ( - <header className={cn("px-6 h-16 flex items-center", className)}> - {children} - </header> - ); - } - - return ( - <header - className={cn("px-6 h-14 flex justify-between items-center", className)} - > - {leftContent && <div className="flex items-center">{leftContent}</div>} - {centerContent && ( - <div className="flex items-center">{centerContent}</div> - )} - {rightContent && <div className="flex items-center">{rightContent}</div>} - </header> - ); -} +"use client";
+
+import { cn } from "@/lib/utils";
+import { ReactNode } from "react";
+
+interface HeaderBaseProps {
+ leftContent?: ReactNode;
+ centerContent?: ReactNode;
+ rightContent?: ReactNode;
+ className?: string;
+ children?: ReactNode;
+}
+
+export function HeaderBase({
+ leftContent,
+ centerContent,
+ rightContent,
+ className,
+ children,
+}: HeaderBaseProps) {
+ // If children is provided, render it directly without the grid layout
+ if (children) {
+ return (
+ <header className={cn("px-6 h-16 flex items-center", className)}>
+ {children}
+ </header>
+ );
+ }
+
+ return (
+ <header
+ className={cn("px-6 h-14 flex justify-between items-center", className)}
+ >
+ {leftContent && <div className="flex items-center">{leftContent}</div>}
+ {centerContent && (
+ <div className="flex items-center">{centerContent}</div>
+ )}
+ {rightContent && <div className="flex items-center">{rightContent}</div>}
+ </header>
+ );
+}
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index a52ca4c7..bfe314d6 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -1,70 +1,70 @@ -"use client"; - -import Link from "next/link"; -import { Button } from "./ui/button"; -import { ArrowRight } from "lucide-react"; -import { HeaderBase } from "./header-base"; -import { useSession } from "@opencut/auth/client"; -import { getStars } from "@/lib/fetch-github-stars"; -import { useEffect, useState } from "react"; -import Image from "next/image"; - -export function Header() { - const { data: session } = useSession(); - const [star, setStar] = useState<string>(""); - - useEffect(() => { - const fetchStars = async () => { - try { - const data = await getStars(); - setStar(data); - } catch (err) { - console.error("Failed to fetch GitHub stars", err); - } - }; - - fetchStars(); - }, []); - - const leftContent = ( - <Link href="/" className="flex items-center gap-3"> - <Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} /> - <span className="text-xl font-medium hidden md:block">OpenCut</span> - </Link> - ); - - const rightContent = ( - <nav className="flex items-center gap-3"> - <Link href="/contributors"> - <Button variant="text" className="text-sm p-0"> - Contributors - </Button> - </Link> - {process.env.NODE_ENV === "development" ? ( - <Link href="/projects"> - <Button size="sm" className="text-sm ml-4"> - Projects - <ArrowRight className="h-4 w-4" /> - </Button> - </Link> - ) : ( - <Link href="https://github.com/OpenCut-app/OpenCut" target="_blank"> - <Button size="sm" className="text-sm ml-4"> - GitHub {star}+ - <ArrowRight className="h-4 w-4" /> - </Button> - </Link> - )} - </nav> - ); - - return ( - <div className="mx-4 md:mx-0"> - <HeaderBase - className="bg-accent border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]" - leftContent={leftContent} - rightContent={rightContent} - /> - </div> - ); -} +"use client";
+
+import Link from "next/link";
+import { Button } from "./ui/button";
+import { ArrowRight } from "lucide-react";
+import { HeaderBase } from "./header-base";
+import { useSession } from "@opencut/auth/client";
+import { getStars } from "@/lib/fetch-github-stars";
+import { useEffect, useState } from "react";
+import Image from "next/image";
+
+export function Header() {
+ const { data: session } = useSession();
+ const [star, setStar] = useState<string>("");
+
+ useEffect(() => {
+ const fetchStars = async () => {
+ try {
+ const data = await getStars();
+ setStar(data);
+ } catch (err) {
+ console.error("Failed to fetch GitHub stars", err);
+ }
+ };
+
+ fetchStars();
+ }, []);
+
+ const leftContent = (
+ <Link href="/" className="flex items-center gap-3">
+ <Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
+ <span className="text-xl font-medium hidden md:block">OpenCut</span>
+ </Link>
+ );
+
+ const rightContent = (
+ <nav className="flex items-center gap-3">
+ <Link href="/contributors">
+ <Button variant="text" className="text-sm p-0">
+ Contributors
+ </Button>
+ </Link>
+ {process.env.NODE_ENV === "development" ? (
+ <Link href="/projects">
+ <Button size="sm" className="text-sm ml-4">
+ Projects
+ <ArrowRight className="h-4 w-4" />
+ </Button>
+ </Link>
+ ) : (
+ <Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
+ <Button size="sm" className="text-sm ml-4">
+ GitHub {star}+
+ <ArrowRight className="h-4 w-4" />
+ </Button>
+ </Link>
+ )}
+ </nav>
+ );
+
+ return (
+ <div className="mx-4 md:mx-0">
+ <HeaderBase
+ className="bg-accent border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
+ leftContent={leftContent}
+ rightContent={rightContent}
+ />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx index 7bff8a1f..68e8f0ff 100644 --- a/apps/web/src/components/icons.tsx +++ b/apps/web/src/components/icons.tsx @@ -1,115 +1,108 @@ export function GoogleIcon({ className }: { className?: string }) { - return ( - <svg className={className} viewBox="0 0 24 24"> - <path - fill="#4285F4" - d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" - /> - <path - fill="#34A853" - d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" - /> - <path - fill="#FBBC05" - d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" - /> - <path - fill="#EA4335" - d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" - /> - </svg> - ); + return ( + <svg className={className} viewBox="0 0 24 24"> + <path + fill="#4285F4" + d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" + /> + <path + fill="#34A853" + d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" + /> + <path + fill="#FBBC05" + d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" + /> + <path + fill="#EA4335" + d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" + /> + </svg> + ); } export function GithubIcon({ className }: { className?: string }) { - return ( - <svg - xmlns="http://www.w3.org/2000/svg" - className={className} - viewBox="0 -3.5 256 256" - preserveAspectRatio="xMinYMin meet" - > - <g fill="currentColor"> - <path d="M127.505 0C57.095 0 0 57.085 0 127.505c0 56.336 36.534 104.13 87.196 120.99 6.372 1.18 8.712-2.766 8.712-6.134 0-3.04-.119-13.085-.173-23.739-35.473 7.713-42.958-15.044-42.958-15.044-5.8-14.738-14.157-18.656-14.157-18.656-11.568-7.914.872-7.752.872-7.752 12.804.9 19.546 13.14 19.546 13.14 11.372 19.493 29.828 13.857 37.104 10.6 1.144-8.242 4.449-13.866 8.095-17.05-28.32-3.225-58.092-14.158-58.092-63.014 0-13.92 4.981-25.295 13.138-34.224-1.324-3.212-5.688-16.18 1.235-33.743 0 0 10.707-3.427 35.073 13.07 10.17-2.826 21.078-4.242 31.914-4.29 10.836.048 21.752 1.464 31.942 4.29 24.337-16.497 35.029-13.07 35.029-13.07 6.94 17.563 2.574 30.531 1.25 33.743 8.175 8.929 13.122 20.303 13.122 34.224 0 48.972-29.828 59.756-58.22 62.912 4.573 3.957 8.648 11.717 8.648 23.612 0 17.06-.148 30.791-.148 34.991 0 3.393 2.295 7.369 8.759 6.117 50.634-16.879 87.122-64.656 87.122-120.973C255.009 57.085 197.922 0 127.505 0" /> + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + className={className} + viewBox="0 -3.5 256 256" + preserveAspectRatio="xMinYMin meet" + > + <g fill="currentColor"> + <path d="M127.505 0C57.095 0 0 57.085 0 127.505c0 56.336 36.534 104.13 87.196 120.99 6.372 1.18 8.712-2.766 8.712-6.134 0-3.04-.119-13.085-.173-23.739-35.473 7.713-42.958-15.044-42.958-15.044-5.8-14.738-14.157-18.656-14.157-18.656-11.568-7.914.872-7.752.872-7.752 12.804.9 19.546 13.14 19.546 13.14 11.372 19.493 29.828 13.857 37.104 10.6 1.144-8.242 4.449-13.866 8.095-17.05-28.32-3.225-58.092-14.158-58.092-63.014 0-13.92 4.981-25.295 13.138-34.224-1.324-3.212-5.688-16.18 1.235-33.743 0 0 10.707-3.427 35.073 13.07 10.17-2.826 21.078-4.242 31.914-4.29 10.836.048 21.752 1.464 31.942 4.29 24.337-16.497 35.029-13.07 35.029-13.07 6.94 17.563 2.574 30.531 1.25 33.743 8.175 8.929 13.122 20.303 13.122 34.224 0 48.972-29.828 59.756-58.22 62.912 4.573 3.957 8.648 11.717 8.648 23.612 0 17.06-.148 30.791-.148 34.991 0 3.393 2.295 7.369 8.759 6.117 50.634-16.879 87.122-64.656 87.122-120.973C255.009 57.085 197.922 0 127.505 0" /> - <path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398" /> - </g> - </svg> - ); + <path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398" /> + </g> + </svg> + ); } export function VercelIcon({ className }: { className?: string }) { - return ( - <svg - className={className} - width="20" - height="18" - viewBox="0 0 76 65" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > - <path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor" /> - </svg> - ); + return ( + <svg className={className} width="20" height="18" viewBox="0 0 76 65" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor"/> + </svg> + ); } export function BackgroundIcon({ className }: { className?: string }) { - return ( - <svg - xmlns="http://www.w3.org/2000/svg" - width="353" - height="353" - viewBox="0 0 353 353" - fill="none" - className={className} - > - <g clipPath="url(#clip0_1_3)"> - <rect - x="-241.816" - y="233.387" - width="592.187" - height="17.765" - transform="rotate(-37 -241.816 233.387)" - fill="white" - /> - <rect - x="-189.907" - y="306.804" - width="592.187" - height="17.765" - transform="rotate(-37 -189.907 306.804)" - fill="white" - /> - <rect - x="-146.928" - y="389.501" - width="592.187" - height="17.765" - transform="rotate(-37 -146.928 389.501)" - fill="white" - /> - <rect - x="-103.144" - y="477.904" - width="592.187" - height="17.765" - transform="rotate(-37 -103.144 477.904)" - fill="white" - /> - <rect - x="-57.169" - y="570.714" - width="592.187" - height="17.765" - transform="rotate(-37 -57.169 570.714)" - fill="white" - /> - </g> - <defs> - <clipPath id="clip0_1_3"> - <rect width="353" height="353" fill="white" /> - </clipPath> - </defs> - </svg> - ); -} + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="353" + height="353" + viewBox="0 0 353 353" + fill="none" + className={className} + > + <g clipPath="url(#clip0_1_3)"> + <rect + x="-241.816" + y="233.387" + width="592.187" + height="17.765" + transform="rotate(-37 -241.816 233.387)" + fill="white" + /> + <rect + x="-189.907" + y="306.804" + width="592.187" + height="17.765" + transform="rotate(-37 -189.907 306.804)" + fill="white" + /> + <rect + x="-146.928" + y="389.501" + width="592.187" + height="17.765" + transform="rotate(-37 -146.928 389.501)" + fill="white" + /> + <rect + x="-103.144" + y="477.904" + width="592.187" + height="17.765" + transform="rotate(-37 -103.144 477.904)" + fill="white" + /> + <rect + x="-57.169" + y="570.714" + width="592.187" + height="17.765" + transform="rotate(-37 -57.169 570.714)" + fill="white" + /> + </g> + <defs> + <clipPath id="clip0_1_3"> + <rect width="353" height="353" fill="white" /> + </clipPath> + </defs> + </svg> + ); +}
\ No newline at end of file diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx index 96deab72..4c8bfff5 100644 --- a/apps/web/src/components/keyboard-shortcuts-help.tsx +++ b/apps/web/src/components/keyboard-shortcuts-help.tsx @@ -1,261 +1,260 @@ "use client"; -import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useState, useEffect } from "react"; import { Button } from "./ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, } from "./ui/dialog"; import { getPlatformSpecialKey } from "@/lib/utils"; import { Keyboard } from "lucide-react"; import { - useKeyboardShortcutsHelp, - KeyboardShortcut, + useKeyboardShortcutsHelp, + KeyboardShortcut, } from "@/hooks/use-keyboard-shortcuts-help"; import { useKeybindingsStore } from "@/stores/keybindings-store"; import { toast } from "sonner"; const modifier: { - [key: string]: string; + [key: string]: string; } = { - Shift: "Shift", - Alt: "Alt", - ArrowLeft: "←", - ArrowRight: "→", - ArrowUp: "↑", - ArrowDown: "↓", - Space: "Space", + Shift: "Shift", + Alt: "Alt", + ArrowLeft: "←", + ArrowRight: "→", + ArrowUp: "↑", + ArrowDown: "↓", + Space: "Space", }; function getKeyWithModifier(key: string) { - if (key === "Ctrl") return getPlatformSpecialKey(); - return modifier[key] || key; + if (key === "Ctrl") return getPlatformSpecialKey(); + return modifier[key] || key; } const ShortcutItem = ({ - shortcut, - recordingKey, - onStartRecording, + shortcut, + recordingKey, + onStartRecording, }: { - shortcut: KeyboardShortcut; - recordingKey: string | null; - onStartRecording: (keyId: string, shortcut: KeyboardShortcut) => void; + shortcut: KeyboardShortcut; + recordingKey: string | null; + onStartRecording: (keyId: string, shortcut: KeyboardShortcut) => void; }) => { - // Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J" - const displayKeys = shortcut.keys.filter((key: string) => { - if ( - key.includes("Cmd") && - shortcut.keys.includes(key.replace("Cmd", "Ctrl")) - ) - return false; + // Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J" + const displayKeys = shortcut.keys.filter((key: string) => { + if ( + key.includes("Cmd") && + shortcut.keys.includes(key.replace("Cmd", "Ctrl")) + ) + return false; - return true; - }); + return true; + }); - return ( - <div className="flex items-center justify-between"> - <div className="flex items-center gap-3"> - {shortcut.icon && ( - <div className="text-muted-foreground">{shortcut.icon}</div> - )} - <span className="text-sm">{shortcut.description}</span> - </div> - <div className="flex items-center gap-1"> - {displayKeys.map((key: string, index: number) => ( - <div key={index} className="flex items-center gap-1"> - <div className="flex items-center"> - {key.split("+").map((keyPart: string, partIndex: number) => { - const keyId = `${shortcut.id}-${index}-${partIndex}`; - return ( - <EditableShortcutKey - key={partIndex} - keyId={keyId} - originalKey={key} - shortcut={shortcut} - isRecording={recordingKey === keyId} - onStartRecording={() => onStartRecording(keyId, shortcut)} - > - {getKeyWithModifier(keyPart)} - </EditableShortcutKey> - ); - })} - </div> - {index < displayKeys.length - 1 && ( - <span className="text-xs text-muted-foreground">or</span> - )} - </div> - ))} - </div> - </div> - ); + return ( + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + {shortcut.icon && ( + <div className="text-muted-foreground">{shortcut.icon}</div> + )} + <span className="text-sm">{shortcut.description}</span> + </div> + <div className="flex items-center gap-1"> + {displayKeys.map((key: string, index: number) => ( + <div key={index} className="flex items-center gap-1"> + <div className="flex items-center"> + {key.split("+").map((keyPart: string, partIndex: number) => { + const keyId = `${shortcut.id}-${index}-${partIndex}`; + return ( + <EditableShortcutKey + key={partIndex} + keyId={keyId} + originalKey={key} + shortcut={shortcut} + isRecording={recordingKey === keyId} + onStartRecording={() => onStartRecording(keyId, shortcut)} + > + {getKeyWithModifier(keyPart)} + </EditableShortcutKey> + ); + })} + </div> + {index < displayKeys.length - 1 && ( + <span className="text-xs text-muted-foreground">or</span> + )} + </div> + ))} + </div> + </div> + ); }; const EditableShortcutKey = ({ - children, - keyId, - originalKey, - shortcut, - isRecording, - onStartRecording, + children, + keyId, + originalKey, + shortcut, + isRecording, + onStartRecording, }: { - children: React.ReactNode; - keyId: string; - originalKey: string; - shortcut: KeyboardShortcut; - isRecording: boolean; - onStartRecording: () => void; + children: React.ReactNode; + keyId: string; + originalKey: string; + shortcut: KeyboardShortcut; + isRecording: boolean; + onStartRecording: () => void; }) => { - const handleClick = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onStartRecording(); - }; + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onStartRecording(); + }; - return ( - <kbd - className={`inline-flex font-sans text-xs rounded px-2 min-w-[1.5rem] min-h-[1.5rem] leading-none items-center justify-center shadow-sm border mr-1 cursor-pointer hover:bg-opacity-80 ${ - isRecording - ? "border-primary bg-primary/10" - : "border-white/10 bg-black/20" - }`} - onClick={handleClick} - title={ - isRecording ? "Press any key combination..." : "Click to edit shortcut" - } - > - {children} - </kbd> - ); + return ( + <kbd + className={`inline-flex font-sans text-xs rounded px-2 min-w-[1.5rem] min-h-[1.5rem] leading-none items-center justify-center shadow-sm border mr-1 cursor-pointer hover:bg-opacity-80 ${ + isRecording + ? "border-primary bg-primary/10" + : "border-white/10 bg-black/20" + }`} + onClick={handleClick} + title={ + isRecording ? "Press any key combination..." : "Click to edit shortcut" + } + > + {children} + </kbd> + ); }; export const KeyboardShortcutsHelp = () => { - const [open, setOpen] = useState(false); - const [recordingKey, setRecordingKey] = useState<string | null>(null); - const [recordingShortcut, setRecordingShortcut] = - useState<KeyboardShortcut | null>(null); + const [open, setOpen] = useState(false); + const [recordingKey, setRecordingKey] = useState<string | null>(null); + const [recordingShortcut, setRecordingShortcut] = + useState<KeyboardShortcut | null>(null); - const { - updateKeybinding, - removeKeybinding, - getKeybindingString, - validateKeybinding, - getKeybindingsForAction, - } = useKeybindingsStore(); + const { + updateKeybinding, + removeKeybinding, + getKeybindingString, + validateKeybinding, + getKeybindingsForAction, + } = useKeybindingsStore(); - // Get shortcuts from centralized hook - const { shortcuts } = useKeyboardShortcutsHelp(); + // Get shortcuts from centralized hook + const { shortcuts } = useKeyboardShortcutsHelp(); - const categories = Array.from(new Set(shortcuts.map((s) => s.category))); + const categories = Array.from(new Set(shortcuts.map((s) => s.category))); - useEffect(() => { - if (!recordingKey || !recordingShortcut) return; + useEffect(() => { + if (!recordingKey || !recordingShortcut) return; - const handleKeyDown = (e: KeyboardEvent) => { - e.preventDefault(); - e.stopPropagation(); + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); - const keyString = getKeybindingString(e); - if (keyString) { - // Auto-save the new keybinding - const conflict = validateKeybinding( - keyString, - recordingShortcut.action, - ); - if (conflict) { - toast.error( - `Key "${keyString}" is already bound to "${conflict.existingAction}"`, - ); - setRecordingKey(null); - setRecordingShortcut(null); - return; - } + const keyString = getKeybindingString(e); + if (keyString) { + // Auto-save the new keybinding + const conflict = validateKeybinding( + keyString, + recordingShortcut.action + ); + if (conflict) { + toast.error( + `Key "${keyString}" is already bound to "${conflict.existingAction}"` + ); + setRecordingKey(null); + setRecordingShortcut(null); + return; + } - // Remove old keybindings for this action - const oldKeys = getKeybindingsForAction(recordingShortcut.action); - oldKeys.forEach((key) => removeKeybinding(key)); + // Remove old keybindings for this action + const oldKeys = getKeybindingsForAction(recordingShortcut.action); + oldKeys.forEach((key) => removeKeybinding(key)); - // Add new keybinding - updateKeybinding(keyString, recordingShortcut.action); + // Add new keybinding + updateKeybinding(keyString, recordingShortcut.action); - setRecordingKey(null); - setRecordingShortcut(null); - } - }; + setRecordingKey(null); + setRecordingShortcut(null); + } + }; - const handleClickOutside = (e: MouseEvent) => { - setRecordingKey(null); - setRecordingShortcut(null); - }; + const handleClickOutside = (e: MouseEvent) => { + setRecordingKey(null); + setRecordingShortcut(null); + }; - document.addEventListener("keydown", handleKeyDown); - document.addEventListener("click", handleClickOutside); + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("click", handleClickOutside); - return () => { - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("click", handleClickOutside); - }; - }, [ - recordingKey, - recordingShortcut, - getKeybindingString, - updateKeybinding, - removeKeybinding, - validateKeybinding, - getKeybindingsForAction, - ]); + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("click", handleClickOutside); + }; + }, [ + recordingKey, + recordingShortcut, + getKeybindingString, + updateKeybinding, + removeKeybinding, + validateKeybinding, + getKeybindingsForAction, + ]); - const handleStartRecording = (keyId: string, shortcut: KeyboardShortcut) => { - setRecordingKey(keyId); - setRecordingShortcut(shortcut); - }; + const handleStartRecording = (keyId: string, shortcut: KeyboardShortcut) => { + setRecordingKey(keyId); + setRecordingShortcut(shortcut); + }; - return ( - <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger asChild> - <Button variant="text" size="sm" className="gap-2"> - <Keyboard className="w-4 h-4" /> - Shortcuts - </Button> - </DialogTrigger> - <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <Keyboard className="w-5 h-5" /> - Keyboard Shortcuts - </DialogTitle> - <DialogDescription> - Speed up your video editing workflow with these keyboard shortcuts. - Click any shortcut key to edit it. - </DialogDescription> - </DialogHeader> + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="text" size="sm" className="gap-2"> + <Keyboard className="w-4 h-4" /> + Shortcuts + </Button> + </DialogTrigger> + <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Keyboard className="w-5 h-5" /> + Keyboard Shortcuts + </DialogTitle> + <DialogDescription> + Speed up your video editing workflow with these keyboard shortcuts. + Click any shortcut key to edit it. + </DialogDescription> + </DialogHeader> - <div className="space-y-6"> - {categories.map((category) => ( - <div key={category} className="flex flex-col gap-1"> - <h3 className="text-xs text-muted-foreground uppercase tracking-wide font-medium"> - {category} - </h3> - <div className="space-y-0.5"> - {shortcuts - .filter((shortcut) => shortcut.category === category) - .map((shortcut, index) => ( - <ShortcutItem - key={index} - shortcut={shortcut} - recordingKey={recordingKey} - onStartRecording={handleStartRecording} - /> - ))} - </div> - </div> - ))} - </div> - </DialogContent> - </Dialog> - ); + <div className="space-y-6"> + {categories.map((category) => ( + <div key={category} className="flex flex-col gap-1"> + <h3 className="text-xs text-muted-foreground uppercase tracking-wide font-medium"> + {category} + </h3> + <div className="space-y-0.5"> + {shortcuts + .filter((shortcut) => shortcut.category === category) + .map((shortcut, index) => ( + <ShortcutItem + key={index} + shortcut={shortcut} + recordingKey={recordingKey} + onStartRecording={handleStartRecording} + /> + ))} + </div> + </div> + ))} + </div> + </DialogContent> + </Dialog> + ); }; diff --git a/apps/web/src/components/landing/handlebars.tsx b/apps/web/src/components/landing/handlebars.tsx index d65fccff..36705e90 100644 --- a/apps/web/src/components/landing/handlebars.tsx +++ b/apps/web/src/components/landing/handlebars.tsx @@ -4,161 +4,161 @@ import React, { useState, useRef, useEffect } from "react"; import { motion, useMotionValue, useTransform, PanInfo } from "motion/react"; interface HandlebarsProps { - children: React.ReactNode; - minWidth?: number; - maxWidth?: number; - onRangeChange?: (left: number, right: number) => void; + children: React.ReactNode; + minWidth?: number; + maxWidth?: number; + onRangeChange?: (left: number, right: number) => void; } export function Handlebars({ - children, - minWidth = 50, - maxWidth = 400, - onRangeChange, + children, + minWidth = 50, + maxWidth = 400, + onRangeChange, }: HandlebarsProps) { - const [leftHandle, setLeftHandle] = useState(0); - const [rightHandle, setRightHandle] = useState(maxWidth); - const [contentWidth, setContentWidth] = useState(maxWidth); + const [leftHandle, setLeftHandle] = useState(0); + const [rightHandle, setRightHandle] = useState(maxWidth); + const [contentWidth, setContentWidth] = useState(maxWidth); - const leftHandleX = useMotionValue(0); - const rightHandleX = useMotionValue(maxWidth); + const leftHandleX = useMotionValue(0); + const rightHandleX = useMotionValue(maxWidth); - const visibleWidth = useTransform( - [leftHandleX, rightHandleX], - (values: number[]) => values[1] - values[0], - ); + const visibleWidth = useTransform( + [leftHandleX, rightHandleX], + (values: number[]) => values[1] - values[0] + ); - const contentLeft = useTransform(leftHandleX, (left: number) => -left); + const contentLeft = useTransform(leftHandleX, (left: number) => -left); - const containerRef = useRef<HTMLDivElement>(null); - const measureRef = useRef<HTMLDivElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const measureRef = useRef<HTMLDivElement>(null); - useEffect(() => { - if (!measureRef.current) return; + useEffect(() => { + if (!measureRef.current) return; - const measureContent = () => { - if (measureRef.current) { - const width = measureRef.current.scrollWidth; - const paddedWidth = width + 32; - setContentWidth(paddedWidth); - setRightHandle(paddedWidth); - rightHandleX.set(paddedWidth); - } - }; + const measureContent = () => { + if (measureRef.current) { + const width = measureRef.current.scrollWidth; + const paddedWidth = width + 32; + setContentWidth(paddedWidth); + setRightHandle(paddedWidth); + rightHandleX.set(paddedWidth); + } + }; - measureContent(); - const timer = setTimeout(measureContent, 50); + measureContent(); + const timer = setTimeout(measureContent, 50); - return () => clearTimeout(timer); - }, [children, rightHandleX]); + return () => clearTimeout(timer); + }, [children, rightHandleX]); - useEffect(() => { - leftHandleX.set(leftHandle); - }, [leftHandle, leftHandleX]); + useEffect(() => { + leftHandleX.set(leftHandle); + }, [leftHandle, leftHandleX]); - useEffect(() => { - rightHandleX.set(rightHandle); - }, [rightHandle, rightHandleX]); + useEffect(() => { + rightHandleX.set(rightHandle); + }, [rightHandle, rightHandleX]); - useEffect(() => { - onRangeChange?.(leftHandle, rightHandle); - }, [leftHandle, rightHandle, onRangeChange]); + useEffect(() => { + onRangeChange?.(leftHandle, rightHandle); + }, [leftHandle, rightHandle, onRangeChange]); - const handleLeftDrag = (event: any, info: PanInfo) => { - const newLeft = Math.max( - 0, - Math.min(leftHandle + info.offset.x, rightHandle - minWidth), - ); - setLeftHandle(newLeft); - }; + const handleLeftDrag = (event: any, info: PanInfo) => { + const newLeft = Math.max( + 0, + Math.min(leftHandle + info.offset.x, rightHandle - minWidth) + ); + setLeftHandle(newLeft); + }; - const handleRightDrag = (event: any, info: PanInfo) => { - const newRight = Math.max( - leftHandle + minWidth, - Math.min(contentWidth, rightHandle + info.offset.x), - ); - setRightHandle(newRight); - }; + const handleRightDrag = (event: any, info: PanInfo) => { + const newRight = Math.max( + leftHandle + minWidth, + Math.min(contentWidth, rightHandle + info.offset.x) + ); + setRightHandle(newRight); + }; - return ( - <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2"> - <div - ref={measureRef} - className="absolute -left-[9999px] top-0 invisible px-4 whitespace-nowrap font-[inherit]" - > - {children} - </div> + return ( + <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2"> + <div + ref={measureRef} + className="absolute -left-[9999px] top-0 invisible px-4 whitespace-nowrap font-[inherit]" + > + {children} + </div> - <div - ref={containerRef} - className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2" - style={{ width: contentWidth }} - > - <div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between"> - <motion.div - className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none" - style={{ - position: "absolute", - x: leftHandleX, - left: 0, - zIndex: 10, - }} - drag="x" - dragConstraints={{ left: 0, right: rightHandle - minWidth }} - dragElastic={0} - dragMomentum={false} - onDrag={handleLeftDrag} - whileHover={{ scale: 1.05 }} - whileDrag={{ scale: 1.1 }} - transition={{ type: "spring", stiffness: 400, damping: 30 }} - > - <div className="w-2 h-8 rounded-full bg-yellow-500"></div> - </motion.div> + <div + ref={containerRef} + className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2" + style={{ width: contentWidth }} + > + <div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between"> + <motion.div + className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none" + style={{ + position: "absolute", + x: leftHandleX, + left: 0, + zIndex: 10, + }} + drag="x" + dragConstraints={{ left: 0, right: rightHandle - minWidth }} + dragElastic={0} + dragMomentum={false} + onDrag={handleLeftDrag} + whileHover={{ scale: 1.05 }} + whileDrag={{ scale: 1.1 }} + transition={{ type: "spring", stiffness: 400, damping: 30 }} + > + <div className="w-2 h-8 rounded-full bg-yellow-500"></div> + </motion.div> - <motion.div - className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none" - style={{ - position: "absolute", - x: rightHandleX, - left: -30, - zIndex: 10, - }} - drag="x" - dragConstraints={{ - left: leftHandle + minWidth, - right: contentWidth, - }} - dragElastic={0} - dragMomentum={false} - onDrag={handleRightDrag} - whileHover={{ scale: 1.05 }} - whileDrag={{ scale: 1.1 }} - transition={{ type: "spring", stiffness: 400, damping: 30 }} - > - <div className="w-2 h-8 rounded-full bg-yellow-500"></div> - </motion.div> - </div> + <motion.div + className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none" + style={{ + position: "absolute", + x: rightHandleX, + left: -30, + zIndex: 10, + }} + drag="x" + dragConstraints={{ + left: leftHandle + minWidth, + right: contentWidth, + }} + dragElastic={0} + dragMomentum={false} + onDrag={handleRightDrag} + whileHover={{ scale: 1.05 }} + whileDrag={{ scale: 1.1 }} + transition={{ type: "spring", stiffness: 400, damping: 30 }} + > + <div className="w-2 h-8 rounded-full bg-yellow-500"></div> + </motion.div> + </div> - <motion.div - className="relative overflow-hidden rounded-2xl" - style={{ - width: visibleWidth, - x: leftHandleX, - height: "100%", - }} - > - <motion.div - className="w-full h-full flex items-center justify-center px-4" - style={{ - x: contentLeft, - width: contentWidth, - whiteSpace: "nowrap", - }} - > - {children} - </motion.div> - </motion.div> - </div> - </div> - ); -} + <motion.div + className="relative overflow-hidden rounded-2xl" + style={{ + width: visibleWidth, + x: leftHandleX, + height: "100%", + }} + > + <motion.div + className="w-full h-full flex items-center justify-center px-4" + style={{ + x: contentLeft, + width: contentWidth, + whiteSpace: "nowrap", + }} + > + {children} + </motion.div> + </motion.div> + </div> + </div> + ); +}; diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index 4030c438..d5fe38d1 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -13,184 +13,184 @@ import Image from "next/image"; import { Handlebars } from "./handlebars"; export function Hero() { - const [email, setEmail] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const [csrfToken, setCsrfToken] = useState<string | null>(null); + const [email, setEmail] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [csrfToken, setCsrfToken] = useState<string | null>(null); - useEffect(() => { - let isMounted = true; - fetch("/api/waitlist/token", { - credentials: "include", - }) - .then((res) => res.json()) - .then((data) => { - if (isMounted && data.token) { - setCsrfToken(data.token); - } - }) - .catch((err) => { - console.error("Failed to fetch CSRF token:", err); - if (isMounted) { - toast.error("Security initialization failed", { - description: "Please refresh the page to continue.", - }); - } - }); - }, []); + useEffect(() => { + let isMounted = true; + fetch("/api/waitlist/token", { + credentials: "include", + }) + .then((res) => res.json()) + .then((data) => { + if (isMounted && data.token) { + setCsrfToken(data.token); + } + }) + .catch((err) => { + console.error("Failed to fetch CSRF token:", err); + if (isMounted) { + toast.error("Security initialization failed", { + description: "Please refresh the page to continue.", + }); + } + }); + }, []); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); - if (!email.trim()) { - toast.error("Email required", { - description: "Please enter your email address.", - }); - return; - } + if (!email.trim()) { + toast.error("Email required", { + description: "Please enter your email address.", + }); + return; + } - if (!csrfToken) { - toast.error("Security error", { - description: "Please refresh the page and try again.", - }); - return; - } + if (!csrfToken) { + toast.error("Security error", { + description: "Please refresh the page and try again.", + }); + return; + } - setIsSubmitting(true); + setIsSubmitting(true); - try { - const response = await fetch("/api/waitlist", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": csrfToken, - }, - credentials: "include", - body: JSON.stringify({ email: email.trim() }), - }); + try { + const response = await fetch("/api/waitlist", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken, + }, + credentials: "include", + body: JSON.stringify({ email: email.trim() }), + }); - const data = (await response.json()) as { error: string }; + const data = (await response.json()) as { error: string }; - if (response.ok) { - toast.success("Welcome to the waitlist! 🎉", { - description: "You'll be notified when we launch.", - }); - setEmail(""); + if (response.ok) { + toast.success("Welcome to the waitlist! 🎉", { + description: "You'll be notified when we launch.", + }); + setEmail(""); - fetch("/api/waitlist/token", { credentials: "include" }) - .then((res) => res.json()) - .then((data) => { - if (data.token) setCsrfToken(data.token); - }) - .catch((err) => { - console.error("Failed to refresh CSRF token:", err); - }); - } else { - toast.error("Oops!", { - description: - (data as { error: string }).error || - "Something went wrong. Please try again.", - }); - } - } catch (error) { - toast.error("Network error", { - description: "Please check your connection and try again.", - }); - } finally { - setIsSubmitting(false); - } - }; + fetch("/api/waitlist/token", { credentials: "include" }) + .then((res) => res.json()) + .then((data) => { + if (data.token) setCsrfToken(data.token); + }) + .catch((err) => { + console.error("Failed to refresh CSRF token:", err); + }); + } else { + toast.error("Oops!", { + description: + (data as { error: string }).error || + "Something went wrong. Please try again.", + }); + } + } catch (error) { + toast.error("Network error", { + description: "Please check your connection and try again.", + }); + } finally { + setIsSubmitting(false); + } + }; - return ( - <div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4"> - <Image - className="absolute top-0 left-0 -z-50 size-full object-cover" - src="/landing-page-bg.png" - height={1903.5} - width={1269} - alt="landing-page.bg" - /> - <motion.div - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - transition={{ duration: 1 }} - className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center" - > - <motion.div - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ delay: 0.6, duration: 0.8 }} - className="mb-8 flex justify-center" - > - <SponsorButton - href="https://vercel.com/?utm_source=opencut" - logo={VercelIcon} - companyName="Vercel" - /> - </motion.div> - <motion.div - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ delay: 0.2, duration: 0.8 }} - className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]" - > - <h1>The Open Source</h1> - <Handlebars>Video Editor</Handlebars> - </motion.div> + return ( + <div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4"> + <Image + className="absolute top-0 left-0 -z-50 size-full object-cover" + src="/landing-page-bg.png" + height={1903.5} + width={1269} + alt="landing-page.bg" + /> + <motion.div + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ duration: 1 }} + className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center" + > + <motion.div + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.6, duration: 0.8 }} + className="mb-8 flex justify-center" + > + <SponsorButton + href="https://vercel.com/?utm_source=opencut" + logo={VercelIcon} + companyName="Vercel" + /> + </motion.div> + <motion.div + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.2, duration: 0.8 }} + className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]" + > + <h1>The Open Source</h1> + <Handlebars>Video Editor</Handlebars> + </motion.div> - <motion.p - className="mt-10 text-base sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto" - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - transition={{ delay: 0.4, duration: 0.8 }} - > - A simple but powerful video editor that gets the job done. Works on - any platform. - </motion.p> + <motion.p + className="mt-10 text-base sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ delay: 0.4, duration: 0.8 }} + > + A simple but powerful video editor that gets the job done. Works on + any platform. + </motion.p> - <motion.div - className="mt-12 flex gap-8 justify-center" - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - transition={{ delay: 0.6, duration: 0.8 }} - > - <form - onSubmit={handleSubmit} - className="flex gap-3 w-full max-w-lg flex-col sm:flex-row" - > - <div className="relative w-full"> - <Input - type="email" - placeholder="Enter your email" - className="h-11 text-base flex-1" - value={email} - onChange={(e) => setEmail(e.target.value)} - disabled={isSubmitting || !csrfToken} - required - /> - </div> - <Button - type="submit" - size="lg" - className="px-6 h-11 text-base !bg-foreground" - disabled={isSubmitting || !csrfToken} - > - <span className="relative z-10"> - {isSubmitting ? "Joining..." : "Join waitlist"} - </span> - <ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" /> - </Button> - </form> - </motion.div> - <motion.div - initial={{ opacity: 0, y: 10 }} - animate={{ opacity: 1, y: 0 }} - transition={{ delay: 0.8, duration: 0.6 }} - className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center" - > - <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" /> - <span>50k+ people already joined</span> - </motion.div> - </motion.div> - </div> - ); + <motion.div + className="mt-12 flex gap-8 justify-center" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ delay: 0.6, duration: 0.8 }} + > + <form + onSubmit={handleSubmit} + className="flex gap-3 w-full max-w-lg flex-col sm:flex-row" + > + <div className="relative w-full"> + <Input + type="email" + placeholder="Enter your email" + className="h-11 text-base flex-1" + value={email} + onChange={(e) => setEmail(e.target.value)} + disabled={isSubmitting || !csrfToken} + required + /> + </div> + <Button + type="submit" + size="lg" + className="px-6 h-11 text-base !bg-foreground" + disabled={isSubmitting || !csrfToken} + > + <span className="relative z-10"> + {isSubmitting ? "Joining..." : "Join waitlist"} + </span> + <ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" /> + </Button> + </form> + </motion.div> + <motion.div + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.8, duration: 0.6 }} + className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center" + > + <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" /> + <span>50k+ people already joined</span> + </motion.div> + </motion.div> + </div> + ); } diff --git a/apps/web/src/components/onboarding.tsx b/apps/web/src/components/onboarding.tsx index c7144a97..a2943677 100644 --- a/apps/web/src/components/onboarding.tsx +++ b/apps/web/src/components/onboarding.tsx @@ -1,29 +1,29 @@ -"use client"; - -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "./ui/dialog"; - -interface OnboardingProps { - isOpen: boolean; - onClose: () => void; -} - -export function Onboarding({ isOpen, onClose }: OnboardingProps) { - return ( - <Dialog open={isOpen} onOpenChange={onClose}> - <DialogContent> - <DialogHeader> - <DialogTitle>Welcome to Clipture</DialogTitle> - <DialogDescription> - Let's get you started with the basics. - </DialogDescription> - </DialogHeader> - </DialogContent> - </Dialog> - ); -} +"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "./ui/dialog";
+
+interface OnboardingProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export function Onboarding({ isOpen, onClose }: OnboardingProps) {
+ return (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Welcome to Clipture</DialogTitle>
+ <DialogDescription>
+ Let's get you started with the basics.
+ </DialogDescription>
+ </DialogHeader>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/components/rename-project-dialog.tsx b/apps/web/src/components/rename-project-dialog.tsx index 7795c8ed..d84bad71 100644 --- a/apps/web/src/components/rename-project-dialog.tsx +++ b/apps/web/src/components/rename-project-dialog.tsx @@ -1,73 +1,73 @@ -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { useState } from "react"; - -export function RenameProjectDialog({ - isOpen, - onOpenChange, - onConfirm, - projectName, -}: { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - onConfirm: (name: string) => void; - projectName: string; -}) { - const [name, setName] = useState(projectName); - - // Reset the name when dialog opens - this is better UX than syncing with every prop change - const handleOpenChange = (open: boolean) => { - if (open) { - setName(projectName); - } - onOpenChange(open); - }; - - return ( - <Dialog open={isOpen} onOpenChange={handleOpenChange}> - <DialogContent> - <DialogHeader> - <DialogTitle>Rename Project</DialogTitle> - <DialogDescription> - Enter a new name for your project. - </DialogDescription> - </DialogHeader> - - <Input - value={name} - onChange={(e) => setName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - onConfirm(name); - } - }} - placeholder="Enter a new name" - className="mt-4" - /> - - <DialogFooter> - <Button - variant="outline" - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - onOpenChange(false); - }} - > - Cancel - </Button> - <Button onClick={() => onConfirm(name)}>Rename</Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} +import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { useState } from "react";
+
+export function RenameProjectDialog({
+ isOpen,
+ onOpenChange,
+ onConfirm,
+ projectName,
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: (name: string) => void;
+ projectName: string;
+}) {
+ const [name, setName] = useState(projectName);
+
+ // Reset the name when dialog opens - this is better UX than syncing with every prop change
+ const handleOpenChange = (open: boolean) => {
+ if (open) {
+ setName(projectName);
+ }
+ onOpenChange(open);
+ };
+
+ return (
+ <Dialog open={isOpen} onOpenChange={handleOpenChange}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Rename Project</DialogTitle>
+ <DialogDescription>
+ Enter a new name for your project.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Input
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ onConfirm(name);
+ }
+ }}
+ placeholder="Enter a new name"
+ className="mt-4"
+ />
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onOpenChange(false);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button onClick={() => onConfirm(name)}>Rename</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/components/storage-provider.tsx b/apps/web/src/components/storage-provider.tsx index 889b9b6f..48423b5c 100644 --- a/apps/web/src/components/storage-provider.tsx +++ b/apps/web/src/components/storage-provider.tsx @@ -1,80 +1,80 @@ -"use client"; - -import { createContext, useContext, useEffect, useState } from "react"; -import { useProjectStore } from "@/stores/project-store"; -import { useMediaStore } from "@/stores/media-store"; -import { storageService } from "@/lib/storage/storage-service"; -import { toast } from "sonner"; - -interface StorageContextType { - isInitialized: boolean; - isLoading: boolean; - hasSupport: boolean; - error: string | null; -} - -const StorageContext = createContext<StorageContextType | null>(null); - -export function useStorage() { - const context = useContext(StorageContext); - if (!context) { - throw new Error("useStorage must be used within StorageProvider"); - } - return context; -} - -interface StorageProviderProps { - children: React.ReactNode; -} - -export function StorageProvider({ children }: StorageProviderProps) { - const [status, setStatus] = useState<StorageContextType>({ - isInitialized: false, - isLoading: true, - hasSupport: false, - error: null, - }); - - const loadAllProjects = useProjectStore((state) => state.loadAllProjects); - - useEffect(() => { - const initializeStorage = async () => { - setStatus((prev) => ({ ...prev, isLoading: true })); - - try { - // Check browser support - const hasSupport = storageService.isFullySupported(); - - if (!hasSupport) { - toast.warning( - "Storage not fully supported. Some features may not work.", - ); - } - - // Load saved projects (media will be loaded when a project is loaded) - await loadAllProjects(); - - setStatus({ - isInitialized: true, - isLoading: false, - hasSupport, - error: null, - }); - } catch (error) { - console.error("Failed to initialize storage:", error); - setStatus({ - isInitialized: false, - isLoading: false, - hasSupport: storageService.isFullySupported(), - error: error instanceof Error ? error.message : "Unknown error", - }); - } - }; - - initializeStorage(); - }, [loadAllProjects]); - - return ( - <StorageContext.Provider value={status}>{children}</StorageContext.Provider> - ); -} +"use client";
+
+import { createContext, useContext, useEffect, useState } from "react";
+import { useProjectStore } from "@/stores/project-store";
+import { useMediaStore } from "@/stores/media-store";
+import { storageService } from "@/lib/storage/storage-service";
+import { toast } from "sonner";
+
+interface StorageContextType {
+ isInitialized: boolean;
+ isLoading: boolean;
+ hasSupport: boolean;
+ error: string | null;
+}
+
+const StorageContext = createContext<StorageContextType | null>(null);
+
+export function useStorage() {
+ const context = useContext(StorageContext);
+ if (!context) {
+ throw new Error("useStorage must be used within StorageProvider");
+ }
+ return context;
+}
+
+interface StorageProviderProps {
+ children: React.ReactNode;
+}
+
+export function StorageProvider({ children }: StorageProviderProps) {
+ const [status, setStatus] = useState<StorageContextType>({
+ isInitialized: false,
+ isLoading: true,
+ hasSupport: false,
+ error: null,
+ });
+
+ const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
+
+ useEffect(() => {
+ const initializeStorage = async () => {
+ setStatus((prev) => ({ ...prev, isLoading: true }));
+
+ try {
+ // Check browser support
+ const hasSupport = storageService.isFullySupported();
+
+ if (!hasSupport) {
+ toast.warning(
+ "Storage not fully supported. Some features may not work."
+ );
+ }
+
+ // Load saved projects (media will be loaded when a project is loaded)
+ await loadAllProjects();
+
+ setStatus({
+ isInitialized: true,
+ isLoading: false,
+ hasSupport,
+ error: null,
+ });
+ } catch (error) {
+ console.error("Failed to initialize storage:", error);
+ setStatus({
+ isInitialized: false,
+ isLoading: false,
+ hasSupport: storageService.isFullySupported(),
+ error: error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+ };
+
+ initializeStorage();
+ }, [loadAllProjects]);
+
+ return (
+ <StorageContext.Provider value={status}>{children}</StorageContext.Provider>
+ );
+}
diff --git a/apps/web/src/components/ui/accordion.tsx b/apps/web/src/components/ui/accordion.tsx index 3d97d714..68edd869 100644 --- a/apps/web/src/components/ui/accordion.tsx +++ b/apps/web/src/components/ui/accordion.tsx @@ -9,48 +9,48 @@ import { cn } from "../../lib/utils"; const Accordion = AccordionPrimitive.Root; const AccordionItem = React.forwardRef< - React.ElementRef<typeof AccordionPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> + React.ElementRef<typeof AccordionPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> >(({ className, ...props }, ref) => ( - <AccordionPrimitive.Item - ref={ref} - className={cn("border-b", className)} - {...props} - /> + <AccordionPrimitive.Item + ref={ref} + className={cn("border-b", className)} + {...props} + /> )); AccordionItem.displayName = "AccordionItem"; const AccordionTrigger = React.forwardRef< - React.ElementRef<typeof AccordionPrimitive.Trigger>, - React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> + React.ElementRef<typeof AccordionPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> >(({ className, children, ...props }, ref) => ( - <AccordionPrimitive.Header className="flex"> - <AccordionPrimitive.Trigger - ref={ref} - className={cn( - "flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180", - className, - )} - {...props} - > - {children} - <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" /> - </AccordionPrimitive.Trigger> - </AccordionPrimitive.Header> + <AccordionPrimitive.Header className="flex"> + <AccordionPrimitive.Trigger + ref={ref} + className={cn( + "flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180", + className + )} + {...props} + > + {children} + <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" /> + </AccordionPrimitive.Trigger> + </AccordionPrimitive.Header> )); AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; const AccordionContent = React.forwardRef< - React.ElementRef<typeof AccordionPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> + React.ElementRef<typeof AccordionPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> >(({ className, children, ...props }, ref) => ( - <AccordionPrimitive.Content - ref={ref} - className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" - {...props} - > - <div className={cn("pb-4 pt-0", className)}>{children}</div> - </AccordionPrimitive.Content> + <AccordionPrimitive.Content + ref={ref} + className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" + {...props} + > + <div className={cn("pb-4 pt-0", className)}>{children}</div> + </AccordionPrimitive.Content> )); AccordionContent.displayName = AccordionPrimitive.Content.displayName; diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx index 5912e7b1..2cce235c 100644 --- a/apps/web/src/components/ui/alert-dialog.tsx +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -13,129 +13,129 @@ const AlertDialogTrigger = AlertDialogPrimitive.Trigger; const AlertDialogPortal = AlertDialogPrimitive.Portal; const AlertDialogOverlay = React.forwardRef< - React.ElementRef<typeof AlertDialogPrimitive.Overlay>, - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> + React.ElementRef<typeof AlertDialogPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> >(({ className, ...props }, ref) => ( - <AlertDialogPrimitive.Overlay - className={cn( - "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", - className, - )} - {...props} - ref={ref} - /> + <AlertDialogPrimitive.Overlay + className={cn( + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className + )} + {...props} + ref={ref} + /> )); AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; const AlertDialogContent = React.forwardRef< - React.ElementRef<typeof AlertDialogPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> + React.ElementRef<typeof AlertDialogPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> >(({ className, ...props }, ref) => ( - <AlertDialogPortal> - <AlertDialogOverlay /> - <AlertDialogPrimitive.Content - ref={ref} - className={cn( - "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", - className, - )} - {...props} - /> - </AlertDialogPortal> + <AlertDialogPortal> + <AlertDialogOverlay /> + <AlertDialogPrimitive.Content + ref={ref} + className={cn( + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", + className + )} + {...props} + /> + </AlertDialogPortal> )); AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; const AlertDialogHeader = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col space-y-2 text-center sm:text-left", - className, - )} - {...props} - /> + <div + className={cn( + "flex flex-col space-y-2 text-center sm:text-left", + className + )} + {...props} + /> ); AlertDialogHeader.displayName = "AlertDialogHeader"; const AlertDialogFooter = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", - className, - )} - {...props} - /> + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className + )} + {...props} + /> ); AlertDialogFooter.displayName = "AlertDialogFooter"; const AlertDialogTitle = React.forwardRef< - React.ElementRef<typeof AlertDialogPrimitive.Title>, - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> + React.ElementRef<typeof AlertDialogPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> >(({ className, ...props }, ref) => ( - <AlertDialogPrimitive.Title - ref={ref} - className={cn("text-lg font-semibold", className)} - {...props} - /> + <AlertDialogPrimitive.Title + ref={ref} + className={cn("text-lg font-semibold", className)} + {...props} + /> )); AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; const AlertDialogDescription = React.forwardRef< - React.ElementRef<typeof AlertDialogPrimitive.Description>, - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> + React.ElementRef<typeof AlertDialogPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> >(({ className, ...props }, ref) => ( - <AlertDialogPrimitive.Description - ref={ref} - className={cn("text-sm text-muted-foreground", className)} - {...props} - /> + <AlertDialogPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> )); AlertDialogDescription.displayName = - AlertDialogPrimitive.Description.displayName; + AlertDialogPrimitive.Description.displayName; const AlertDialogAction = React.forwardRef< - React.ElementRef<typeof AlertDialogPrimitive.Action>, - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> + React.ElementRef<typeof AlertDialogPrimitive.Action>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> >(({ className, ...props }, ref) => ( - <AlertDialogPrimitive.Action - ref={ref} - className={cn(buttonVariants(), className)} - {...props} - /> + <AlertDialogPrimitive.Action + ref={ref} + className={cn(buttonVariants(), className)} + {...props} + /> )); AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; const AlertDialogCancel = React.forwardRef< - React.ElementRef<typeof AlertDialogPrimitive.Cancel>, - React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel> + React.ElementRef<typeof AlertDialogPrimitive.Cancel>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel> >(({ className, ...props }, ref) => ( - <AlertDialogPrimitive.Cancel - ref={ref} - className={cn( - buttonVariants({ variant: "outline" }), - "mt-2 sm:mt-0", - className, - )} - {...props} - /> + <AlertDialogPrimitive.Cancel + ref={ref} + className={cn( + buttonVariants({ variant: "outline" }), + "mt-2 sm:mt-0", + className + )} + {...props} + /> )); AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; export { - AlertDialog, - AlertDialogPortal, - AlertDialogOverlay, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogAction, - AlertDialogCancel, + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, }; diff --git a/apps/web/src/components/ui/alert.tsx b/apps/web/src/components/ui/alert.tsx index c5bc2a10..cef19199 100644 --- a/apps/web/src/components/ui/alert.tsx +++ b/apps/web/src/components/ui/alert.tsx @@ -4,55 +4,55 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "../../lib/utils"; const alertVariants = cva( - "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", - { - variants: { - variant: { - default: "bg-background text-foreground", - destructive: - "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", - }, - }, - defaultVariants: { - variant: "default", - }, - }, + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } ); const Alert = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> >(({ className, variant, ...props }, ref) => ( - <div - ref={ref} - role="alert" - className={cn(alertVariants({ variant }), className)} - {...props} - /> + <div + ref={ref} + role="alert" + className={cn(alertVariants({ variant }), className)} + {...props} + /> )); Alert.displayName = "Alert"; const AlertTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes<HTMLHeadingElement> + HTMLParagraphElement, + React.HTMLAttributes<HTMLHeadingElement> >(({ className, ...props }, ref) => ( - <h5 - ref={ref} - className={cn("mb-1 font-medium leading-none tracking-tight", className)} - {...props} - /> + <h5 + ref={ref} + className={cn("mb-1 font-medium leading-none tracking-tight", className)} + {...props} + /> )); AlertTitle.displayName = "AlertTitle"; const AlertDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes<HTMLParagraphElement> + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> >(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("text-sm [&_p]:leading-relaxed", className)} - {...props} - /> + <div + ref={ref} + className={cn("text-sm [&_p]:leading-relaxed", className)} + {...props} + /> )); AlertDescription.displayName = "AlertDescription"; diff --git a/apps/web/src/components/ui/aspect-ratio.tsx b/apps/web/src/components/ui/aspect-ratio.tsx index 082de9df..6f90bbb8 100644 --- a/apps/web/src/components/ui/aspect-ratio.tsx +++ b/apps/web/src/components/ui/aspect-ratio.tsx @@ -1,7 +1,7 @@ -"use client"; +"use client" -import { AspectRatio as AspectRatioPrimitive } from "radix-ui"; +import { AspectRatio as AspectRatioPrimitive } from "radix-ui" -const AspectRatio = AspectRatioPrimitive.Root; +const AspectRatio = AspectRatioPrimitive.Root -export { AspectRatio }; +export { AspectRatio } diff --git a/apps/web/src/components/ui/audio-player.tsx b/apps/web/src/components/ui/audio-player.tsx index df150105..774dd95c 100644 --- a/apps/web/src/components/ui/audio-player.tsx +++ b/apps/web/src/components/ui/audio-player.tsx @@ -1,127 +1,127 @@ -"use client"; - -import { useRef, useEffect } from "react"; -import { usePlaybackStore } from "@/stores/playback-store"; - -interface AudioPlayerProps { - src: string; - className?: string; - clipStartTime: number; - trimStart: number; - trimEnd: number; - clipDuration: number; - trackMuted?: boolean; -} - -export function AudioPlayer({ - src, - className = "", - clipStartTime, - trimStart, - trimEnd, - clipDuration, - trackMuted = false, -}: AudioPlayerProps) { - const audioRef = useRef<HTMLAudioElement>(null); - const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore(); - - // Calculate if we're within this clip's timeline range - const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd); - const isInClipRange = - currentTime >= clipStartTime && currentTime < clipEndTime; - - // Sync playback events - useEffect(() => { - const audio = audioRef.current; - if (!audio || !isInClipRange) return; - - const handleSeekEvent = (e: CustomEvent) => { - // Always update audio time, even if outside clip range - const timelineTime = e.detail.time; - const audioTime = Math.max( - trimStart, - Math.min( - clipDuration - trimEnd, - timelineTime - clipStartTime + trimStart, - ), - ); - audio.currentTime = audioTime; - }; - - const handleUpdateEvent = (e: CustomEvent) => { - // Always update audio time, even if outside clip range - const timelineTime = e.detail.time; - const targetTime = Math.max( - trimStart, - Math.min( - clipDuration - trimEnd, - timelineTime - clipStartTime + trimStart, - ), - ); - - if (Math.abs(audio.currentTime - targetTime) > 0.5) { - audio.currentTime = targetTime; - } - }; - - const handleSpeed = (e: CustomEvent) => { - audio.playbackRate = e.detail.speed; - }; - - window.addEventListener("playback-seek", handleSeekEvent as EventListener); - window.addEventListener( - "playback-update", - handleUpdateEvent as EventListener, - ); - window.addEventListener("playback-speed", handleSpeed as EventListener); - - return () => { - window.removeEventListener( - "playback-seek", - handleSeekEvent as EventListener, - ); - window.removeEventListener( - "playback-update", - handleUpdateEvent as EventListener, - ); - window.removeEventListener( - "playback-speed", - handleSpeed as EventListener, - ); - }; - }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]); - - // Sync playback state - useEffect(() => { - const audio = audioRef.current; - if (!audio) return; - - if (isPlaying && isInClipRange && !trackMuted) { - audio.play().catch(() => {}); - } else { - audio.pause(); - } - }, [isPlaying, isInClipRange, trackMuted]); - - // Sync volume and speed - useEffect(() => { - const audio = audioRef.current; - if (!audio) return; - - audio.volume = volume; - audio.muted = muted || trackMuted; - audio.playbackRate = speed; - }, [volume, speed, muted, trackMuted]); - - return ( - <audio - ref={audioRef} - src={src} - className={className} - preload="auto" - controls={false} - style={{ display: "none" }} // Audio elements don't need visual representation - onContextMenu={(e) => e.preventDefault()} - /> - ); -} +"use client";
+
+import { useRef, useEffect } from "react";
+import { usePlaybackStore } from "@/stores/playback-store";
+
+interface AudioPlayerProps {
+ src: string;
+ className?: string;
+ clipStartTime: number;
+ trimStart: number;
+ trimEnd: number;
+ clipDuration: number;
+ trackMuted?: boolean;
+}
+
+export function AudioPlayer({
+ src,
+ className = "",
+ clipStartTime,
+ trimStart,
+ trimEnd,
+ clipDuration,
+ trackMuted = false,
+}: AudioPlayerProps) {
+ const audioRef = useRef<HTMLAudioElement>(null);
+ const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
+
+ // Calculate if we're within this clip's timeline range
+ const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
+ const isInClipRange =
+ currentTime >= clipStartTime && currentTime < clipEndTime;
+
+ // Sync playback events
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio || !isInClipRange) return;
+
+ const handleSeekEvent = (e: CustomEvent) => {
+ // Always update audio time, even if outside clip range
+ const timelineTime = e.detail.time;
+ const audioTime = Math.max(
+ trimStart,
+ Math.min(
+ clipDuration - trimEnd,
+ timelineTime - clipStartTime + trimStart
+ )
+ );
+ audio.currentTime = audioTime;
+ };
+
+ const handleUpdateEvent = (e: CustomEvent) => {
+ // Always update audio time, even if outside clip range
+ const timelineTime = e.detail.time;
+ const targetTime = Math.max(
+ trimStart,
+ Math.min(
+ clipDuration - trimEnd,
+ timelineTime - clipStartTime + trimStart
+ )
+ );
+
+ if (Math.abs(audio.currentTime - targetTime) > 0.5) {
+ audio.currentTime = targetTime;
+ }
+ };
+
+ const handleSpeed = (e: CustomEvent) => {
+ audio.playbackRate = e.detail.speed;
+ };
+
+ window.addEventListener("playback-seek", handleSeekEvent as EventListener);
+ window.addEventListener(
+ "playback-update",
+ handleUpdateEvent as EventListener
+ );
+ window.addEventListener("playback-speed", handleSpeed as EventListener);
+
+ return () => {
+ window.removeEventListener(
+ "playback-seek",
+ handleSeekEvent as EventListener
+ );
+ window.removeEventListener(
+ "playback-update",
+ handleUpdateEvent as EventListener
+ );
+ window.removeEventListener(
+ "playback-speed",
+ handleSpeed as EventListener
+ );
+ };
+ }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
+
+ // Sync playback state
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio) return;
+
+ if (isPlaying && isInClipRange && !trackMuted) {
+ audio.play().catch(() => {});
+ } else {
+ audio.pause();
+ }
+ }, [isPlaying, isInClipRange, trackMuted]);
+
+ // Sync volume and speed
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio) return;
+
+ audio.volume = volume;
+ audio.muted = muted || trackMuted;
+ audio.playbackRate = speed;
+ }, [volume, speed, muted, trackMuted]);
+
+ return (
+ <audio
+ ref={audioRef}
+ src={src}
+ className={className}
+ preload="auto"
+ controls={false}
+ style={{ display: "none" }} // Audio elements don't need visual representation
+ onContextMenu={(e) => e.preventDefault()}
+ />
+ );
+}
diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx index 5c4a800b..7979c415 100644 --- a/apps/web/src/components/ui/avatar.tsx +++ b/apps/web/src/components/ui/avatar.tsx @@ -6,44 +6,44 @@ import { Avatar as AvatarPrimitive } from "radix-ui"; import { cn } from "../../lib/utils"; const Avatar = React.forwardRef< - React.ElementRef<typeof AvatarPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> + React.ElementRef<typeof AvatarPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> >(({ className, ...props }, ref) => ( - <AvatarPrimitive.Root - ref={ref} - className={cn( - "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", - className, - )} - {...props} - /> + <AvatarPrimitive.Root + ref={ref} + className={cn( + "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", + className + )} + {...props} + /> )); Avatar.displayName = AvatarPrimitive.Root.displayName; const AvatarImage = React.forwardRef< - React.ElementRef<typeof AvatarPrimitive.Image>, - React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> + React.ElementRef<typeof AvatarPrimitive.Image>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> >(({ className, ...props }, ref) => ( - <AvatarPrimitive.Image - ref={ref} - className={cn("aspect-square h-full w-full", className)} - {...props} - /> + <AvatarPrimitive.Image + ref={ref} + className={cn("aspect-square h-full w-full", className)} + {...props} + /> )); AvatarImage.displayName = AvatarPrimitive.Image.displayName; const AvatarFallback = React.forwardRef< - React.ElementRef<typeof AvatarPrimitive.Fallback>, - React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> + React.ElementRef<typeof AvatarPrimitive.Fallback>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> >(({ className, ...props }, ref) => ( - <AvatarPrimitive.Fallback - ref={ref} - className={cn( - "flex h-full w-full items-center justify-center rounded-full bg-muted", - className, - )} - {...props} - /> + <AvatarPrimitive.Fallback + ref={ref} + className={cn( + "flex h-full w-full items-center justify-center rounded-full bg-muted", + className + )} + {...props} + /> )); AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx index 2f9ff077..e37530f4 100644 --- a/apps/web/src/components/ui/badge.tsx +++ b/apps/web/src/components/ui/badge.tsx @@ -4,33 +4,33 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "../../lib/utils"; const badgeVariants = cva( - "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", - { - variants: { - variant: { - default: - "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", - secondary: - "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", - destructive: - "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", - outline: "text-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - }, + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } ); export interface BadgeProps - extends React.HTMLAttributes<HTMLDivElement>, - VariantProps<typeof badgeVariants> {} + extends React.HTMLAttributes<HTMLDivElement>, + VariantProps<typeof badgeVariants> {} function Badge({ className, variant, ...props }: BadgeProps) { - return ( - <div className={cn(badgeVariants({ variant }), className)} {...props} /> - ); + return ( + <div className={cn(badgeVariants({ variant }), className)} {...props} /> + ); } export { Badge, badgeVariants }; diff --git a/apps/web/src/components/ui/breadcrumb.tsx b/apps/web/src/components/ui/breadcrumb.tsx index a16bfd98..d9df9bf5 100644 --- a/apps/web/src/components/ui/breadcrumb.tsx +++ b/apps/web/src/components/ui/breadcrumb.tsx @@ -5,111 +5,111 @@ import { ChevronRight, MoreHorizontal } from "lucide-react"; import { cn } from "../../lib/utils"; const Breadcrumb = React.forwardRef< - HTMLElement, - React.ComponentPropsWithoutRef<"nav"> & { - separator?: React.ReactNode; - } + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode; + } >(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />); Breadcrumb.displayName = "Breadcrumb"; const BreadcrumbList = React.forwardRef< - HTMLOListElement, - React.ComponentPropsWithoutRef<"ol"> + HTMLOListElement, + React.ComponentPropsWithoutRef<"ol"> >(({ className, ...props }, ref) => ( - <ol - ref={ref} - className={cn( - "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5", - className, - )} - {...props} - /> + <ol + ref={ref} + className={cn( + "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5", + className + )} + {...props} + /> )); BreadcrumbList.displayName = "BreadcrumbList"; const BreadcrumbItem = React.forwardRef< - HTMLLIElement, - React.ComponentPropsWithoutRef<"li"> + HTMLLIElement, + React.ComponentPropsWithoutRef<"li"> >(({ className, ...props }, ref) => ( - <li - ref={ref} - className={cn("inline-flex items-center gap-1.5", className)} - {...props} - /> + <li + ref={ref} + className={cn("inline-flex items-center gap-1.5", className)} + {...props} + /> )); BreadcrumbItem.displayName = "BreadcrumbItem"; const BreadcrumbLink = React.forwardRef< - HTMLAnchorElement, - React.ComponentPropsWithoutRef<"a"> & { - asChild?: boolean; - } + HTMLAnchorElement, + React.ComponentPropsWithoutRef<"a"> & { + asChild?: boolean; + } >(({ asChild, className, ...props }, ref) => { - const Comp = asChild ? SlotPrimitive.Slot : "a"; + const Comp = asChild ? SlotPrimitive.Slot : "a"; - return ( - <Comp - ref={ref} - className={cn("transition-colors hover:text-foreground", className)} - {...props} - /> - ); + return ( + <Comp + ref={ref} + className={cn("transition-colors hover:text-foreground", className)} + {...props} + /> + ); }); BreadcrumbLink.displayName = "BreadcrumbLink"; const BreadcrumbPage = React.forwardRef< - HTMLSpanElement, - React.ComponentPropsWithoutRef<"span"> + HTMLSpanElement, + React.ComponentPropsWithoutRef<"span"> >(({ className, ...props }, ref) => ( - <span - ref={ref} - role="link" - aria-disabled="true" - aria-current="page" - className={cn("font-normal text-foreground", className)} - {...props} - /> + <span + ref={ref} + role="link" + aria-disabled="true" + aria-current="page" + className={cn("font-normal text-foreground", className)} + {...props} + /> )); BreadcrumbPage.displayName = "BreadcrumbPage"; const BreadcrumbSeparator = ({ - children, - className, - ...props + children, + className, + ...props }: React.ComponentProps<"li">) => ( - <li - role="presentation" - aria-hidden="true" - className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)} - {...props} - > - {children ?? <ChevronRight />} - </li> + <li + role="presentation" + aria-hidden="true" + className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)} + {...props} + > + {children ?? <ChevronRight />} + </li> ); BreadcrumbSeparator.displayName = "BreadcrumbSeparator"; const BreadcrumbEllipsis = ({ - className, - ...props + className, + ...props }: React.ComponentProps<"span">) => ( - <span - role="presentation" - aria-hidden="true" - className={cn("flex h-9 w-9 items-center justify-center", className)} - {...props} - > - <MoreHorizontal className="h-4 w-4" /> - <span className="sr-only">More</span> - </span> + <span + role="presentation" + aria-hidden="true" + className={cn("flex h-9 w-9 items-center justify-center", className)} + {...props} + > + <MoreHorizontal className="h-4 w-4" /> + <span className="sr-only">More</span> + </span> ); BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"; export { - Breadcrumb, - BreadcrumbList, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbPage, - BreadcrumbSeparator, - BreadcrumbEllipsis, + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, + BreadcrumbEllipsis, }; diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index e063e797..b8b9ab2f 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -5,53 +5,54 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "../../lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", - { - variants: { - variant: { - default: "bg-foreground text-background shadow hover:bg-foreground/90", - primary: - "bg-primary text-primary-foreground shadow hover:bg-primary/90", - destructive: - "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", - outline: - "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", - text: "bg-transparent p-0 rounded-none hover:text-muted-foreground", // Instead of ghost (matches app better) - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2", - sm: "h-8 rounded-sm px-3 text-xs", - lg: "h-10 rounded-md px-8", - icon: "h-7 w-7", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-foreground text-background shadow hover:bg-foreground/90", + primary: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + text: "bg-transparent p-0 rounded-none hover:text-muted-foreground", // Instead of ghost (matches app better) + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-sm px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-7 w-7", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } ); export interface ButtonProps - extends React.ButtonHTMLAttributes<HTMLButtonElement>, - VariantProps<typeof buttonVariants> { - asChild?: boolean; + extends React.ButtonHTMLAttributes<HTMLButtonElement>, + VariantProps<typeof buttonVariants> { + asChild?: boolean; } const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? SlotPrimitive.Slot : "button"; - return ( - <Comp - className={cn(buttonVariants({ variant, size, className }))} - ref={ref} - {...props} - /> - ); - }, + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? SlotPrimitive.Slot : "button"; + return ( + <Comp + className={cn(buttonVariants({ variant, size, className }))} + ref={ref} + {...props} + /> + ); + } ); Button.displayName = "Button"; diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx index 81760a7f..192643cb 100644 --- a/apps/web/src/components/ui/calendar.tsx +++ b/apps/web/src/components/ui/calendar.tsx @@ -10,62 +10,62 @@ import { buttonVariants } from "./button"; export type CalendarProps = React.ComponentProps<typeof DayPicker>; function Calendar({ - className, - classNames, - showOutsideDays = true, - ...props + className, + classNames, + showOutsideDays = true, + ...props }: CalendarProps) { - return ( - <DayPicker - showOutsideDays={showOutsideDays} - className={cn("p-3", className)} - classNames={{ - months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", - month: "space-y-4", - caption: "flex justify-center pt-1 relative items-center", - caption_label: "text-sm font-medium", - nav: "space-x-1 flex items-center", - nav_button: cn( - buttonVariants({ variant: "outline" }), - "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100", - ), - nav_button_previous: "absolute left-1", - nav_button_next: "absolute right-1", - table: "w-full border-collapse space-y-1", - head_row: "flex", - head_cell: - "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]", - row: "flex w-full mt-2", - cell: cn( - "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md", - props.mode === "range" - ? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" - : "[&:has([aria-selected])]:rounded-md", - ), - day: cn( - buttonVariants({ variant: "text" }), - "h-8 w-8 p-0 font-normal aria-selected:opacity-100", - ), - day_range_start: "day-range-start", - day_range_end: "day-range-end", - day_selected: - "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", - day_today: "bg-accent text-accent-foreground", - day_outside: - "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", - day_disabled: "text-muted-foreground opacity-50", - day_range_middle: - "aria-selected:bg-accent aria-selected:text-accent-foreground", - day_hidden: "invisible", - ...classNames, - }} - components={{ - IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />, - IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />, - }} - {...props} - /> - ); + return ( + <DayPicker + showOutsideDays={showOutsideDays} + className={cn("p-3", className)} + classNames={{ + months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", + month: "space-y-4", + caption: "flex justify-center pt-1 relative items-center", + caption_label: "text-sm font-medium", + nav: "space-x-1 flex items-center", + nav_button: cn( + buttonVariants({ variant: "outline" }), + "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" + ), + nav_button_previous: "absolute left-1", + nav_button_next: "absolute right-1", + table: "w-full border-collapse space-y-1", + head_row: "flex", + head_cell: + "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]", + row: "flex w-full mt-2", + cell: cn( + "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md", + props.mode === "range" + ? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md" + ), + day: cn( + buttonVariants({ variant: "text" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + ), + day_range_start: "day-range-start", + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />, + IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />, + }} + {...props} + /> + ); } Calendar.displayName = "Calendar"; diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx index a7a22245..c5ca9d9f 100644 --- a/apps/web/src/components/ui/card.tsx +++ b/apps/web/src/components/ui/card.tsx @@ -3,78 +3,81 @@ import * as React from "react"; import { cn } from "../../lib/utils"; const Card = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("rounded-xl border bg-card text-card-foreground", className)} - {...props} - /> + <div + ref={ref} + className={cn( + "rounded-xl border bg-card text-card-foreground", + className + )} + {...props} + /> )); Card.displayName = "Card"; const CardHeader = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("flex flex-col space-y-1.5 p-6", className)} - {...props} - /> + <div + ref={ref} + className={cn("flex flex-col space-y-1.5 p-6", className)} + {...props} + /> )); CardHeader.displayName = "CardHeader"; const CardTitle = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("font-semibold leading-none tracking-tight", className)} - {...props} - /> + <div + ref={ref} + className={cn("font-semibold leading-none tracking-tight", className)} + {...props} + /> )); CardTitle.displayName = "CardTitle"; const CardDescription = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("text-sm text-muted-foreground", className)} - {...props} - /> + <div + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> )); CardDescription.displayName = "CardDescription"; const CardContent = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( - <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> + <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> )); CardContent.displayName = "CardContent"; const CardFooter = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("flex items-center p-6 pt-0", className)} - {...props} - /> + <div + ref={ref} + className={cn("flex items-center p-6 pt-0", className)} + {...props} + /> )); CardFooter.displayName = "CardFooter"; export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardDescription, - CardContent, + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, }; diff --git a/apps/web/src/components/ui/carousel.tsx b/apps/web/src/components/ui/carousel.tsx index 9ab74471..3c0efdc6 100644 --- a/apps/web/src/components/ui/carousel.tsx +++ b/apps/web/src/components/ui/carousel.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import useEmblaCarousel, { - type UseEmblaCarouselType, + type UseEmblaCarouselType, } from "embla-carousel-react"; import { ArrowLeft, ArrowRight } from "lucide-react"; @@ -15,248 +15,248 @@ type CarouselOptions = UseCarouselParameters[0]; type CarouselPlugin = UseCarouselParameters[1]; type CarouselProps = { - opts?: CarouselOptions; - plugins?: CarouselPlugin; - orientation?: "horizontal" | "vertical"; - setApi?: (api: CarouselApi) => void; + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: "horizontal" | "vertical"; + setApi?: (api: CarouselApi) => void; }; type CarouselContextProps = { - carouselRef: ReturnType<typeof useEmblaCarousel>[0]; - api: ReturnType<typeof useEmblaCarousel>[1]; - scrollPrev: () => void; - scrollNext: () => void; - canScrollPrev: boolean; - canScrollNext: boolean; + carouselRef: ReturnType<typeof useEmblaCarousel>[0]; + api: ReturnType<typeof useEmblaCarousel>[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; } & CarouselProps; const CarouselContext = React.createContext<CarouselContextProps | null>(null); function useCarousel() { - const context = React.useContext(CarouselContext); + const context = React.useContext(CarouselContext); - if (!context) { - throw new Error("useCarousel must be used within a <Carousel />"); - } + if (!context) { + throw new Error("useCarousel must be used within a <Carousel />"); + } - return context; + return context; } const Carousel = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> & CarouselProps + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> & CarouselProps >( - ( - { - orientation = "horizontal", - opts, - setApi, - plugins, - className, - children, - ...props - }, - ref, - ) => { - const [carouselRef, api] = useEmblaCarousel( - { - ...opts, - axis: orientation === "horizontal" ? "x" : "y", - }, - plugins, - ); - const [canScrollPrev, setCanScrollPrev] = React.useState(false); - const [canScrollNext, setCanScrollNext] = React.useState(false); + ( + { + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref + ) => { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); - const onSelect = React.useCallback((api: CarouselApi) => { - if (!api) { - return; - } + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) { + return; + } - setCanScrollPrev(api.canScrollPrev()); - setCanScrollNext(api.canScrollNext()); - }, []); + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); - const scrollPrev = React.useCallback(() => { - api?.scrollPrev(); - }, [api]); + const scrollPrev = React.useCallback(() => { + api?.scrollPrev(); + }, [api]); - const scrollNext = React.useCallback(() => { - api?.scrollNext(); - }, [api]); + const scrollNext = React.useCallback(() => { + api?.scrollNext(); + }, [api]); - const handleKeyDown = React.useCallback( - (event: React.KeyboardEvent<HTMLDivElement>) => { - if (event.key === "ArrowLeft") { - event.preventDefault(); - scrollPrev(); - } else if (event.key === "ArrowRight") { - event.preventDefault(); - scrollNext(); - } - }, - [scrollPrev, scrollNext], - ); + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + scrollPrev(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + scrollNext(); + } + }, + [scrollPrev, scrollNext] + ); - React.useEffect(() => { - if (!api || !setApi) { - return; - } + React.useEffect(() => { + if (!api || !setApi) { + return; + } - setApi(api); - }, [api, setApi]); + setApi(api); + }, [api, setApi]); - React.useEffect(() => { - if (!api) { - return; - } + React.useEffect(() => { + if (!api) { + return; + } - onSelect(api); - api.on("reInit", onSelect); - api.on("select", onSelect); + onSelect(api); + api.on("reInit", onSelect); + api.on("select", onSelect); - return () => { - api?.off("select", onSelect); - }; - }, [api, onSelect]); + return () => { + api?.off("select", onSelect); + }; + }, [api, onSelect]); - return ( - <CarouselContext.Provider - value={{ - carouselRef, - api: api, - opts, - orientation: - orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), - scrollPrev, - scrollNext, - canScrollPrev, - canScrollNext, - }} - > - <div - ref={ref} - onKeyDownCapture={handleKeyDown} - className={cn("relative", className)} - role="region" - aria-roledescription="carousel" - {...props} - > - {children} - </div> - </CarouselContext.Provider> - ); - }, + return ( + <CarouselContext.Provider + value={{ + carouselRef, + api: api, + opts, + orientation: + orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), + scrollPrev, + scrollNext, + canScrollPrev, + canScrollNext, + }} + > + <div + ref={ref} + onKeyDownCapture={handleKeyDown} + className={cn("relative", className)} + role="region" + aria-roledescription="carousel" + {...props} + > + {children} + </div> + </CarouselContext.Provider> + ); + } ); Carousel.displayName = "Carousel"; const CarouselContent = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => { - const { carouselRef, orientation } = useCarousel(); + const { carouselRef, orientation } = useCarousel(); - return ( - <div ref={carouselRef} className="overflow-hidden"> - <div - ref={ref} - className={cn( - "flex", - orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", - className, - )} - {...props} - /> - </div> - ); + return ( + <div ref={carouselRef} className="overflow-hidden"> + <div + ref={ref} + className={cn( + "flex", + orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", + className + )} + {...props} + /> + </div> + ); }); CarouselContent.displayName = "CarouselContent"; const CarouselItem = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => { - const { orientation } = useCarousel(); + const { orientation } = useCarousel(); - return ( - <div - ref={ref} - role="group" - aria-roledescription="slide" - className={cn( - "min-w-0 shrink-0 grow-0 basis-full", - orientation === "horizontal" ? "pl-4" : "pt-4", - className, - )} - {...props} - /> - ); + return ( + <div + ref={ref} + role="group" + aria-roledescription="slide" + className={cn( + "min-w-0 shrink-0 grow-0 basis-full", + orientation === "horizontal" ? "pl-4" : "pt-4", + className + )} + {...props} + /> + ); }); CarouselItem.displayName = "CarouselItem"; const CarouselPrevious = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<typeof Button> + HTMLButtonElement, + React.ComponentProps<typeof Button> >(({ className, variant = "outline", size = "icon", ...props }, ref) => { - const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); - return ( - <Button - ref={ref} - variant={variant} - size={size} - className={cn( - "absolute h-8 w-8 rounded-full", - orientation === "horizontal" - ? "-left-12 top-1/2 -translate-y-1/2" - : "-top-12 left-1/2 -translate-x-1/2 rotate-90", - className, - )} - disabled={!canScrollPrev} - onClick={scrollPrev} - {...props} - > - <ArrowLeft className="h-4 w-4" /> - <span className="sr-only">Previous slide</span> - </Button> - ); + return ( + <Button + ref={ref} + variant={variant} + size={size} + className={cn( + "absolute h-8 w-8 rounded-full", + orientation === "horizontal" + ? "-left-12 top-1/2 -translate-y-1/2" + : "-top-12 left-1/2 -translate-x-1/2 rotate-90", + className + )} + disabled={!canScrollPrev} + onClick={scrollPrev} + {...props} + > + <ArrowLeft className="h-4 w-4" /> + <span className="sr-only">Previous slide</span> + </Button> + ); }); CarouselPrevious.displayName = "CarouselPrevious"; const CarouselNext = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<typeof Button> + HTMLButtonElement, + React.ComponentProps<typeof Button> >(({ className, variant = "outline", size = "icon", ...props }, ref) => { - const { orientation, scrollNext, canScrollNext } = useCarousel(); + const { orientation, scrollNext, canScrollNext } = useCarousel(); - return ( - <Button - ref={ref} - variant={variant} - size={size} - className={cn( - "absolute h-8 w-8 rounded-full", - orientation === "horizontal" - ? "-right-12 top-1/2 -translate-y-1/2" - : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", - className, - )} - disabled={!canScrollNext} - onClick={scrollNext} - {...props} - > - <ArrowRight className="h-4 w-4" /> - <span className="sr-only">Next slide</span> - </Button> - ); + return ( + <Button + ref={ref} + variant={variant} + size={size} + className={cn( + "absolute h-8 w-8 rounded-full", + orientation === "horizontal" + ? "-right-12 top-1/2 -translate-y-1/2" + : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", + className + )} + disabled={!canScrollNext} + onClick={scrollNext} + {...props} + > + <ArrowRight className="h-4 w-4" /> + <span className="sr-only">Next slide</span> + </Button> + ); }); CarouselNext.displayName = "CarouselNext"; export { - type CarouselApi, - Carousel, - CarouselContent, - CarouselItem, - CarouselPrevious, - CarouselNext, + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, }; diff --git a/apps/web/src/components/ui/chart.tsx b/apps/web/src/components/ui/chart.tsx index e1b7bc35..a2c19116 100644 --- a/apps/web/src/components/ui/chart.tsx +++ b/apps/web/src/components/ui/chart.tsx @@ -3,9 +3,9 @@ import * as React from "react"; import * as RechartsPrimitive from "recharts"; import { - NameType, - Payload, - ValueType, + NameType, + Payload, + ValueType, } from "recharts/types/component/DefaultTooltipContent"; import { cn } from "../../lib/utils"; @@ -14,357 +14,357 @@ import { cn } from "../../lib/utils"; const THEMES = { light: "", dark: ".dark" } as const; export type ChartConfig = { - [k in string]: { - label?: React.ReactNode; - icon?: React.ComponentType; - } & ( - | { color?: string; theme?: never } - | { color?: never; theme: Record<keyof typeof THEMES, string> } - ); + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record<keyof typeof THEMES, string> } + ); }; type ChartContextProps = { - config: ChartConfig; + config: ChartConfig; }; const ChartContext = React.createContext<ChartContextProps | null>(null); function useChart() { - const context = React.useContext(ChartContext); + const context = React.useContext(ChartContext); - if (!context) { - throw new Error("useChart must be used within a <ChartContainer />"); - } + if (!context) { + throw new Error("useChart must be used within a <ChartContainer />"); + } - return context; + return context; } const ChartContainer = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - config: ChartConfig; - children: React.ComponentProps< - typeof RechartsPrimitive.ResponsiveContainer - >["children"]; - } + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"]; + } >(({ id, className, children, config, ...props }, ref) => { - const uniqueId = React.useId(); - const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; - return ( - <ChartContext.Provider value={{ config }}> - <div - data-chart={chartId} - ref={ref} - className={cn( - "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", - className, - )} - {...props} - > - <ChartStyle id={chartId} config={config} /> - <RechartsPrimitive.ResponsiveContainer> - {children} - </RechartsPrimitive.ResponsiveContainer> - </div> - </ChartContext.Provider> - ); + return ( + <ChartContext.Provider value={{ config }}> + <div + data-chart={chartId} + ref={ref} + className={cn( + "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", + className + )} + {...props} + > + <ChartStyle id={chartId} config={config} /> + <RechartsPrimitive.ResponsiveContainer> + {children} + </RechartsPrimitive.ResponsiveContainer> + </div> + </ChartContext.Provider> + ); }); ChartContainer.displayName = "Chart"; const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { - const colorConfig = Object.entries(config).filter( - ([_, config]) => config.theme || config.color, - ); + const colorConfig = Object.entries(config).filter( + ([_, config]) => config.theme || config.color + ); - if (!colorConfig.length) { - return null; - } + if (!colorConfig.length) { + return null; + } - return ( - <style - dangerouslySetInnerHTML={{ - __html: Object.entries(THEMES) - .map( - ([theme, prefix]) => ` + return ( + <style + dangerouslySetInnerHTML={{ + __html: Object.entries(THEMES) + .map( + ([theme, prefix]) => ` ${prefix} [data-chart=${id}] { ${colorConfig - .map(([key, itemConfig]) => { - const color = - itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || - itemConfig.color; - return color ? ` --color-${key}: ${color};` : null; - }) - .join("\n")} + .map(([key, itemConfig]) => { + const color = + itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || + itemConfig.color; + return color ? ` --color-${key}: ${color};` : null; + }) + .join("\n")} } -`, - ) - .join("\n"), - }} - /> - ); +` + ) + .join("\n"), + }} + /> + ); }; const ChartTooltip = RechartsPrimitive.Tooltip; const ChartTooltipContent = React.forwardRef< - HTMLDivElement, - React.ComponentProps<typeof RechartsPrimitive.Tooltip> & - React.ComponentProps<"div"> & { - hideLabel?: boolean; - hideIndicator?: boolean; - indicator?: "line" | "dot" | "dashed"; - nameKey?: string; - labelKey?: string; - } + HTMLDivElement, + React.ComponentProps<typeof RechartsPrimitive.Tooltip> & + React.ComponentProps<"div"> & { + hideLabel?: boolean; + hideIndicator?: boolean; + indicator?: "line" | "dot" | "dashed"; + nameKey?: string; + labelKey?: string; + } >( - ( - { - active, - payload, - className, - indicator = "dot", - hideLabel = false, - hideIndicator = false, - label, - labelFormatter, - labelClassName, - formatter, - color, - nameKey, - labelKey, - }, - ref, - ) => { - const { config } = useChart(); + ( + { + active, + payload, + className, + indicator = "dot", + hideLabel = false, + hideIndicator = false, + label, + labelFormatter, + labelClassName, + formatter, + color, + nameKey, + labelKey, + }, + ref + ) => { + const { config } = useChart(); - const tooltipLabel = React.useMemo(() => { - if (hideLabel || !payload?.length) { - return null; - } + const tooltipLabel = React.useMemo(() => { + if (hideLabel || !payload?.length) { + return null; + } - const [item] = payload; - const key = `${labelKey || item.dataKey || item.name || "value"}`; - const itemConfig = getPayloadConfigFromPayload(config, item, key); - const value = - !labelKey && typeof label === "string" - ? config[label as keyof typeof config]?.label || label - : itemConfig?.label; + const [item] = payload; + const key = `${labelKey || item.dataKey || item.name || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + const value = + !labelKey && typeof label === "string" + ? config[label as keyof typeof config]?.label || label + : itemConfig?.label; - if (labelFormatter) { - return ( - <div className={cn("font-medium", labelClassName)}> - {labelFormatter(value, payload)} - </div> - ); - } + if (labelFormatter) { + return ( + <div className={cn("font-medium", labelClassName)}> + {labelFormatter(value, payload)} + </div> + ); + } - if (!value) { - return null; - } + if (!value) { + return null; + } - return <div className={cn("font-medium", labelClassName)}>{value}</div>; - }, [ - label, - labelFormatter, - payload, - hideLabel, - labelClassName, - config, - labelKey, - ]); + return <div className={cn("font-medium", labelClassName)}>{value}</div>; + }, [ + label, + labelFormatter, + payload, + hideLabel, + labelClassName, + config, + labelKey, + ]); - if (!active || !payload?.length) { - return null; - } + if (!active || !payload?.length) { + return null; + } - const nestLabel = payload.length === 1 && indicator !== "dot"; + const nestLabel = payload.length === 1 && indicator !== "dot"; - return ( - <div - ref={ref} - className={cn( - "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", - className, - )} - > - {!nestLabel ? tooltipLabel : null} - <div className="grid gap-1.5"> - {payload.map((item, index) => { - const key = `${nameKey || item.name || item.dataKey || "value"}`; - const itemConfig = getPayloadConfigFromPayload(config, item, key); - const indicatorColor = color || item.payload.fill || item.color; + return ( + <div + ref={ref} + className={cn( + "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", + className + )} + > + {!nestLabel ? tooltipLabel : null} + <div className="grid gap-1.5"> + {payload.map((item, index) => { + const key = `${nameKey || item.name || item.dataKey || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + const indicatorColor = color || item.payload.fill || item.color; - return ( - <div - key={item.dataKey} - className={cn( - "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", - indicator === "dot" && "items-center", - )} - > - {formatter && item?.value !== undefined && item.name ? ( - formatter(item.value, item.name, item, index, item.payload) - ) : ( - <> - {itemConfig?.icon ? ( - <itemConfig.icon /> - ) : ( - !hideIndicator && ( - <div - className={cn( - "shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", - { - "h-2.5 w-2.5": indicator === "dot", - "w-1": indicator === "line", - "w-0 border-[1.5px] border-dashed bg-transparent": - indicator === "dashed", - "my-0.5": nestLabel && indicator === "dashed", - }, - )} - style={ - { - "--color-bg": indicatorColor, - "--color-border": indicatorColor, - } as React.CSSProperties - } - /> - ) - )} - <div - className={cn( - "flex flex-1 justify-between leading-none", - nestLabel ? "items-end" : "items-center", - )} - > - <div className="grid gap-1.5"> - {nestLabel ? tooltipLabel : null} - <span className="text-muted-foreground"> - {itemConfig?.label || item.name} - </span> - </div> - {item.value && ( - <span className="font-mono font-medium tabular-nums text-foreground"> - {item.value.toLocaleString()} - </span> - )} - </div> - </> - )} - </div> - ); - })} - </div> - </div> - ); - }, + return ( + <div + key={item.dataKey} + className={cn( + "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", + indicator === "dot" && "items-center" + )} + > + {formatter && item?.value !== undefined && item.name ? ( + formatter(item.value, item.name, item, index, item.payload) + ) : ( + <> + {itemConfig?.icon ? ( + <itemConfig.icon /> + ) : ( + !hideIndicator && ( + <div + className={cn( + "shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", + { + "h-2.5 w-2.5": indicator === "dot", + "w-1": indicator === "line", + "w-0 border-[1.5px] border-dashed bg-transparent": + indicator === "dashed", + "my-0.5": nestLabel && indicator === "dashed", + } + )} + style={ + { + "--color-bg": indicatorColor, + "--color-border": indicatorColor, + } as React.CSSProperties + } + /> + ) + )} + <div + className={cn( + "flex flex-1 justify-between leading-none", + nestLabel ? "items-end" : "items-center" + )} + > + <div className="grid gap-1.5"> + {nestLabel ? tooltipLabel : null} + <span className="text-muted-foreground"> + {itemConfig?.label || item.name} + </span> + </div> + {item.value && ( + <span className="font-mono font-medium tabular-nums text-foreground"> + {item.value.toLocaleString()} + </span> + )} + </div> + </> + )} + </div> + ); + })} + </div> + </div> + ); + } ); ChartTooltipContent.displayName = "ChartTooltip"; const ChartLegend = RechartsPrimitive.Legend; const ChartLegendContent = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & - Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { - hideIcon?: boolean; - nameKey?: string; - } + HTMLDivElement, + React.ComponentProps<"div"> & + Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { + hideIcon?: boolean; + nameKey?: string; + } >( - ( - { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, - ref, - ) => { - const { config } = useChart(); + ( + { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, + ref + ) => { + const { config } = useChart(); - if (!payload?.length) { - return null; - } + if (!payload?.length) { + return null; + } - return ( - <div - ref={ref} - className={cn( - "flex items-center justify-center gap-4", - verticalAlign === "top" ? "pb-3" : "pt-3", - className, - )} - > - {payload.map((item) => { - const key = `${nameKey || item.dataKey || "value"}`; - const itemConfig = getPayloadConfigFromPayload(config, item, key); + return ( + <div + ref={ref} + className={cn( + "flex items-center justify-center gap-4", + verticalAlign === "top" ? "pb-3" : "pt-3", + className + )} + > + {payload.map((item) => { + const key = `${nameKey || item.dataKey || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); - return ( - <div - key={item.value} - className={cn( - "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground", - )} - > - {itemConfig?.icon && !hideIcon ? ( - <itemConfig.icon /> - ) : ( - <div - className="h-2 w-2 shrink-0 rounded-[2px]" - style={{ - backgroundColor: item.color, - }} - /> - )} - {itemConfig?.label} - </div> - ); - })} - </div> - ); - }, + return ( + <div + key={item.value} + className={cn( + "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground" + )} + > + {itemConfig?.icon && !hideIcon ? ( + <itemConfig.icon /> + ) : ( + <div + className="h-2 w-2 shrink-0 rounded-[2px]" + style={{ + backgroundColor: item.color, + }} + /> + )} + {itemConfig?.label} + </div> + ); + })} + </div> + ); + } ); ChartLegendContent.displayName = "ChartLegend"; // Helper to extract item config from a payload. function getPayloadConfigFromPayload( - config: ChartConfig, - payload: unknown, - key: string, + config: ChartConfig, + payload: unknown, + key: string ) { - if (typeof payload !== "object" || payload === null) { - return undefined; - } + if (typeof payload !== "object" || payload === null) { + return undefined; + } - const payloadPayload = - "payload" in payload && - typeof payload.payload === "object" && - payload.payload !== null - ? payload.payload - : undefined; + const payloadPayload = + "payload" in payload && + typeof payload.payload === "object" && + payload.payload !== null + ? payload.payload + : undefined; - let configLabelKey: string = key; + let configLabelKey: string = key; - if ( - key in payload && - typeof payload[key as keyof typeof payload] === "string" - ) { - configLabelKey = payload[key as keyof typeof payload] as string; - } else if ( - payloadPayload && - key in payloadPayload && - typeof payloadPayload[key as keyof typeof payloadPayload] === "string" - ) { - configLabelKey = payloadPayload[ - key as keyof typeof payloadPayload - ] as string; - } + if ( + key in payload && + typeof payload[key as keyof typeof payload] === "string" + ) { + configLabelKey = payload[key as keyof typeof payload] as string; + } else if ( + payloadPayload && + key in payloadPayload && + typeof payloadPayload[key as keyof typeof payloadPayload] === "string" + ) { + configLabelKey = payloadPayload[ + key as keyof typeof payloadPayload + ] as string; + } - return configLabelKey in config - ? config[configLabelKey] - : config[key as keyof typeof config]; + return configLabelKey in config + ? config[configLabelKey] + : config[key as keyof typeof config]; } export { - ChartContainer, - ChartTooltip, - ChartTooltipContent, - ChartLegend, - ChartLegendContent, - ChartStyle, + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + ChartStyle, }; diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx index c7f41c54..2b74bc52 100644 --- a/apps/web/src/components/ui/checkbox.tsx +++ b/apps/web/src/components/ui/checkbox.tsx @@ -7,23 +7,23 @@ import { Check } from "lucide-react"; import { cn } from "../../lib/utils"; const Checkbox = React.forwardRef< - React.ElementRef<typeof CheckboxPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> + React.ElementRef<typeof CheckboxPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> >(({ className, ...props }, ref) => ( - <CheckboxPrimitive.Root - ref={ref} - className={cn( - "peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", - className, - )} - {...props} - > - <CheckboxPrimitive.Indicator - className={cn("flex items-center justify-center text-current")} - > - <Check className="h-4 w-4" /> - </CheckboxPrimitive.Indicator> - </CheckboxPrimitive.Root> + <CheckboxPrimitive.Root + ref={ref} + className={cn( + "peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", + className + )} + {...props} + > + <CheckboxPrimitive.Indicator + className={cn("flex items-center justify-center text-current")} + > + <Check className="h-4 w-4" /> + </CheckboxPrimitive.Indicator> + </CheckboxPrimitive.Root> )); Checkbox.displayName = CheckboxPrimitive.Root.displayName; diff --git a/apps/web/src/components/ui/collapsible.tsx b/apps/web/src/components/ui/collapsible.tsx index acb56414..6642d604 100644 --- a/apps/web/src/components/ui/collapsible.tsx +++ b/apps/web/src/components/ui/collapsible.tsx @@ -1,11 +1,11 @@ -"use client"; +"use client" -import { Collapsible as CollapsiblePrimitive } from "radix-ui"; +import { Collapsible as CollapsiblePrimitive } from "radix-ui" -const Collapsible = CollapsiblePrimitive.Root; +const Collapsible = CollapsiblePrimitive.Root -const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger -const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent -export { Collapsible, CollapsibleTrigger, CollapsibleContent }; +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index 2bd5567e..11b27041 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -9,145 +9,145 @@ import { cn } from "../../lib/utils"; import { Dialog, DialogContent } from "./dialog"; const Command = React.forwardRef< - React.ElementRef<typeof CommandPrimitive>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive> + React.ElementRef<typeof CommandPrimitive>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive> >(({ className, ...props }, ref) => ( - <CommandPrimitive - ref={ref} - className={cn( - "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", - className, - )} - {...props} - /> + <CommandPrimitive + ref={ref} + className={cn( + "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", + className + )} + {...props} + /> )); Command.displayName = CommandPrimitive.displayName; const CommandDialog = ({ children, ...props }: DialogProps) => { - return ( - <Dialog {...props}> - <DialogContent className="overflow-hidden p-0"> - <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> - {children} - </Command> - </DialogContent> - </Dialog> - ); + return ( + <Dialog {...props}> + <DialogContent className="overflow-hidden p-0"> + <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> + {children} + </Command> + </DialogContent> + </Dialog> + ); }; const CommandInput = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Input>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> + React.ElementRef<typeof CommandPrimitive.Input>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> >(({ className, ...props }, ref) => ( - <div className="flex items-center border-b px-3" cmdk-input-wrapper=""> - <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> - <CommandPrimitive.Input - ref={ref} - className={cn( - "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", - className, - )} - {...props} - /> - </div> + <div className="flex items-center border-b px-3" cmdk-input-wrapper=""> + <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> + <CommandPrimitive.Input + ref={ref} + className={cn( + "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", + className + )} + {...props} + /> + </div> )); CommandInput.displayName = CommandPrimitive.Input.displayName; const CommandList = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.List>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> + React.ElementRef<typeof CommandPrimitive.List>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> >(({ className, ...props }, ref) => ( - <CommandPrimitive.List - ref={ref} - className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} - {...props} - /> + <CommandPrimitive.List + ref={ref} + className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} + {...props} + /> )); CommandList.displayName = CommandPrimitive.List.displayName; const CommandEmpty = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Empty>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> + React.ElementRef<typeof CommandPrimitive.Empty>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> >((props, ref) => ( - <CommandPrimitive.Empty - ref={ref} - className="py-6 text-center text-sm" - {...props} - /> + <CommandPrimitive.Empty + ref={ref} + className="py-6 text-center text-sm" + {...props} + /> )); CommandEmpty.displayName = CommandPrimitive.Empty.displayName; const CommandGroup = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Group>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> + React.ElementRef<typeof CommandPrimitive.Group>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> >(({ className, ...props }, ref) => ( - <CommandPrimitive.Group - ref={ref} - className={cn( - "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", - className, - )} - {...props} - /> + <CommandPrimitive.Group + ref={ref} + className={cn( + "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", + className + )} + {...props} + /> )); CommandGroup.displayName = CommandPrimitive.Group.displayName; const CommandSeparator = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Separator>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> + React.ElementRef<typeof CommandPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> >(({ className, ...props }, ref) => ( - <CommandPrimitive.Separator - ref={ref} - className={cn("-mx-1 h-px bg-border", className)} - {...props} - /> + <CommandPrimitive.Separator + ref={ref} + className={cn("-mx-1 h-px bg-border", className)} + {...props} + /> )); CommandSeparator.displayName = CommandPrimitive.Separator.displayName; const CommandItem = React.forwardRef< - React.ElementRef<typeof CommandPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> + React.ElementRef<typeof CommandPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> >(({ className, ...props }, ref) => ( - <CommandPrimitive.Item - ref={ref} - className={cn( - "relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", - className, - )} - {...props} - /> + <CommandPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + className + )} + {...props} + /> )); CommandItem.displayName = CommandPrimitive.Item.displayName; const CommandShortcut = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLSpanElement>) => { - return ( - <span - className={cn( - "ml-auto text-xs tracking-widest text-muted-foreground", - className, - )} - {...props} - /> - ); + return ( + <span + className={cn( + "ml-auto text-xs tracking-widest text-muted-foreground", + className + )} + {...props} + /> + ); }; CommandShortcut.displayName = "CommandShortcut"; export { - Command, - CommandDialog, - CommandInput, - CommandList, - CommandEmpty, - CommandGroup, - CommandItem, - CommandShortcut, - CommandSeparator, + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, }; diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx index c705b83d..7aa938f6 100644 --- a/apps/web/src/components/ui/context-menu.tsx +++ b/apps/web/src/components/ui/context-menu.tsx @@ -20,196 +20,196 @@ const ContextMenuSub = ContextMenuPrimitive.Sub; const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; const contextMenuItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", - { - variants: { - variant: { - default: "focus:opacity-65 focus:text-accent-foreground", - destructive: "text-destructive focus:text-destructive/80", - }, - }, - defaultVariants: { - variant: "default", - }, - }, + "relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "focus:opacity-65 focus:text-accent-foreground", + destructive: "text-destructive focus:text-destructive/80", + }, + }, + defaultVariants: { + variant: "default", + }, + } ); const ContextMenuSubTrigger = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & { - inset?: boolean; - variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; - } + React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & { + inset?: boolean; + variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; + } >(({ className, inset, children, variant = "default", ...props }, ref) => ( - <ContextMenuPrimitive.SubTrigger - ref={ref} - className={cn( - contextMenuItemVariants({ variant }), - "data-[state=open]:bg-accent data-[state=open]:opacity-65", - inset && "pl-8", - className, - )} - {...props} - > - {children} - <ChevronRight className="ml-auto" /> - </ContextMenuPrimitive.SubTrigger> + <ContextMenuPrimitive.SubTrigger + ref={ref} + className={cn( + contextMenuItemVariants({ variant }), + "data-[state=open]:bg-accent data-[state=open]:opacity-65", + inset && "pl-8", + className + )} + {...props} + > + {children} + <ChevronRight className="ml-auto" /> + </ContextMenuPrimitive.SubTrigger> )); ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; const ContextMenuSubContent = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.SubContent>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> + React.ElementRef<typeof ContextMenuPrimitive.SubContent>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> >(({ className, ...props }, ref) => ( - <ContextMenuPrimitive.SubContent - ref={ref} - className={cn( - "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - className, - )} - {...props} - /> + <ContextMenuPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> )); ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; const ContextMenuContent = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> + React.ElementRef<typeof ContextMenuPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> >(({ className, ...props }, ref) => ( - <ContextMenuPrimitive.Portal> - <ContextMenuPrimitive.Content - ref={ref} - className={cn( - "z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md", - "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - className, - )} - {...props} - /> - </ContextMenuPrimitive.Portal> + <ContextMenuPrimitive.Portal> + <ContextMenuPrimitive.Content + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md", + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> + </ContextMenuPrimitive.Portal> )); ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; const ContextMenuItem = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { - inset?: boolean; - variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; - } + React.ElementRef<typeof ContextMenuPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { + inset?: boolean; + variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; + } >(({ className, inset, variant = "default", ...props }, ref) => ( - <ContextMenuPrimitive.Item - ref={ref} - className={cn( - contextMenuItemVariants({ variant }), - inset && "pl-8", - className, - )} - {...props} - /> + <ContextMenuPrimitive.Item + ref={ref} + className={cn( + contextMenuItemVariants({ variant }), + inset && "pl-8", + className + )} + {...props} + /> )); ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; const ContextMenuCheckboxItem = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & { - variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; - } + React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & { + variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; + } >(({ className, children, checked, variant = "default", ...props }, ref) => ( - <ContextMenuPrimitive.CheckboxItem - ref={ref} - className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)} - checked={checked} - {...props} - > - <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> - <ContextMenuPrimitive.ItemIndicator> - <Check className="h-4 w-4" /> - </ContextMenuPrimitive.ItemIndicator> - </span> - {children} - </ContextMenuPrimitive.CheckboxItem> + <ContextMenuPrimitive.CheckboxItem + ref={ref} + className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)} + checked={checked} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <ContextMenuPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </ContextMenuPrimitive.ItemIndicator> + </span> + {children} + </ContextMenuPrimitive.CheckboxItem> )); ContextMenuCheckboxItem.displayName = - ContextMenuPrimitive.CheckboxItem.displayName; + ContextMenuPrimitive.CheckboxItem.displayName; const ContextMenuRadioItem = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.RadioItem>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & { - variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; - } + React.ElementRef<typeof ContextMenuPrimitive.RadioItem>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & { + variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; + } >(({ className, children, variant = "default", ...props }, ref) => ( - <ContextMenuPrimitive.RadioItem - ref={ref} - className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)} - {...props} - > - <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> - <ContextMenuPrimitive.ItemIndicator> - <Circle className="h-2 w-2 fill-current" /> - </ContextMenuPrimitive.ItemIndicator> - </span> - {children} - </ContextMenuPrimitive.RadioItem> + <ContextMenuPrimitive.RadioItem + ref={ref} + className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <ContextMenuPrimitive.ItemIndicator> + <Circle className="h-2 w-2 fill-current" /> + </ContextMenuPrimitive.ItemIndicator> + </span> + {children} + </ContextMenuPrimitive.RadioItem> )); ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; const ContextMenuLabel = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.Label>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & { - inset?: boolean; - } + React.ElementRef<typeof ContextMenuPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & { + inset?: boolean; + } >(({ className, inset, ...props }, ref) => ( - <ContextMenuPrimitive.Label - ref={ref} - className={cn( - "px-2 py-1.5 text-sm font-semibold", - inset && "pl-8", - className, - )} - {...props} - /> + <ContextMenuPrimitive.Label + ref={ref} + className={cn( + "px-2 py-1.5 text-sm font-semibold", + inset && "pl-8", + className + )} + {...props} + /> )); ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; const ContextMenuSeparator = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.Separator>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator> + React.ElementRef<typeof ContextMenuPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator> >(({ className, ...props }, ref) => ( - <ContextMenuPrimitive.Separator - ref={ref} - className={cn("-mx-1 my-1 h-px bg-foreground/10", className)} - {...props} - /> + <ContextMenuPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-foreground/10", className)} + {...props} + /> )); ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; const ContextMenuShortcut = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLSpanElement>) => { - return ( - <span - className={cn("ml-auto text-xs tracking-widest opacity-60", className)} - {...props} - /> - ); + return ( + <span + className={cn("ml-auto text-xs tracking-widest opacity-60", className)} + {...props} + /> + ); }; ContextMenuShortcut.displayName = "ContextMenuShortcut"; export { - ContextMenu, - ContextMenuTrigger, - ContextMenuContent, - ContextMenuItem, - ContextMenuCheckboxItem, - ContextMenuRadioItem, - ContextMenuLabel, - ContextMenuSeparator, - ContextMenuShortcut, - ContextMenuGroup, - ContextMenuPortal, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, - ContextMenuRadioGroup, + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, }; diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 4d4a8c68..ebfd8907 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -16,118 +16,118 @@ const DialogPortal = DialogPrimitive.Portal; const DialogClose = DialogPrimitive.Close; const DialogOverlay = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Overlay>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> + React.ElementRef<typeof DialogPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> >(({ className, ...props }, ref) => ( - <DialogPrimitive.Overlay - ref={ref} - className={cn( - "fixed inset-0 z-[100] bg-black/20 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", - className, - )} - {...props} - /> + <DialogPrimitive.Overlay + ref={ref} + className={cn( + "fixed inset-0 z-[100] bg-black/20 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className + )} + {...props} + /> )); DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> + React.ElementRef<typeof DialogPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> >(({ className, children, ...props }, ref) => ( - <DialogPortal> - <DialogOverlay /> - <DialogPrimitive.Content - ref={ref} - className={cn( - "fixed left-[50%] top-[50%] z-[150] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", - className, - )} - onCloseAutoFocus={(e) => { - e.stopPropagation(); - e.preventDefault(); - }} - onOpenAutoFocus={(e) => { - e.stopPropagation(); - e.preventDefault(); - }} - {...props} - > - <ScrollArea className="max-h-[85vh]"> - <div className="p-6 space-y-4">{children}</div> - </ScrollArea> - <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> - <X className="h-4 w-4" /> - <span className="sr-only">Close</span> - </DialogPrimitive.Close> - </DialogPrimitive.Content> - </DialogPortal> + <DialogPortal> + <DialogOverlay /> + <DialogPrimitive.Content + ref={ref} + className={cn( + "fixed left-[50%] top-[50%] z-[150] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", + className + )} + onCloseAutoFocus={(e) => { + e.stopPropagation(); + e.preventDefault(); + }} + onOpenAutoFocus={(e) => { + e.stopPropagation(); + e.preventDefault(); + }} + {...props} + > + <ScrollArea className="max-h-[85vh]"> + <div className="p-6 space-y-4">{children}</div> + </ScrollArea> + <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> + <X className="h-4 w-4" /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + </DialogPrimitive.Content> + </DialogPortal> )); DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col space-y-2 text-center sm:text-left", - className, - )} - {...props} - /> + <div + className={cn( + "flex flex-col space-y-2 text-center sm:text-left", + className + )} + {...props} + /> ); DialogHeader.displayName = "DialogHeader"; const DialogFooter = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", - className, - )} - {...props} - /> + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className + )} + {...props} + /> ); DialogFooter.displayName = "DialogFooter"; const DialogTitle = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Title>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> + React.ElementRef<typeof DialogPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> >(({ className, ...props }, ref) => ( - <DialogPrimitive.Title - ref={ref} - className={cn( - "text-lg font-semibold leading-none tracking-tight", - className, - )} - {...props} - /> + <DialogPrimitive.Title + ref={ref} + className={cn( + "text-lg font-semibold leading-none tracking-tight", + className + )} + {...props} + /> )); DialogTitle.displayName = DialogPrimitive.Title.displayName; const DialogDescription = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Description>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> + React.ElementRef<typeof DialogPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> >(({ className, ...props }, ref) => ( - <DialogPrimitive.Description - ref={ref} - className={cn("text-sm text-muted-foreground", className)} - {...props} - /> + <DialogPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> )); DialogDescription.displayName = DialogPrimitive.Description.displayName; export { - Dialog, - DialogPortal, - DialogOverlay, - DialogTrigger, - DialogClose, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription, + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, }; diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index 473b14df..dfb4dce7 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -3,9 +3,9 @@ import { AspectRatio } from "@/components/ui/aspect-ratio"; import { Button } from "@/components/ui/button"; import { - Tooltip, - TooltipContent, - TooltipTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, } from "@/components/ui/tooltip"; import { ReactNode, useState, useRef, useEffect } from "react"; import { createPortal } from "react-dom"; @@ -14,185 +14,185 @@ import { cn } from "@/lib/utils"; import { usePlaybackStore } from "@/stores/playback-store"; export interface DraggableMediaItemProps { - name: string; - preview: ReactNode; - dragData: Record<string, any>; - onDragStart?: (e: React.DragEvent) => void; - onAddToTimeline?: (currentTime: number) => void; - aspectRatio?: number; - className?: string; - showPlusOnDrag?: boolean; - showLabel?: boolean; - rounded?: boolean; + name: string; + preview: ReactNode; + dragData: Record<string, any>; + onDragStart?: (e: React.DragEvent) => void; + onAddToTimeline?: (currentTime: number) => void; + aspectRatio?: number; + className?: string; + showPlusOnDrag?: boolean; + showLabel?: boolean; + rounded?: boolean; } export function DraggableMediaItem({ - name, - preview, - dragData, - onDragStart, - onAddToTimeline, - aspectRatio = 16 / 9, - className = "", - showPlusOnDrag = true, - showLabel = true, - rounded = true, + name, + preview, + dragData, + onDragStart, + onAddToTimeline, + aspectRatio = 16 / 9, + className = "", + showPlusOnDrag = true, + showLabel = true, + rounded = true, }: DraggableMediaItemProps) { - const [isDragging, setIsDragging] = useState(false); - const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); - const dragRef = useRef<HTMLDivElement>(null); - const currentTime = usePlaybackStore((state) => state.currentTime); + const [isDragging, setIsDragging] = useState(false); + const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); + const dragRef = useRef<HTMLDivElement>(null); + const currentTime = usePlaybackStore((state) => state.currentTime); - const handleAddToTimeline = () => { - onAddToTimeline?.(currentTime); - }; + const handleAddToTimeline = () => { + onAddToTimeline?.(currentTime); + }; - const emptyImg = new window.Image(); - emptyImg.src = - "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; + const emptyImg = new window.Image(); + emptyImg.src = + "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; - useEffect(() => { - if (!isDragging) return; + useEffect(() => { + if (!isDragging) return; - const handleDragOver = (e: DragEvent) => { - setDragPosition({ x: e.clientX, y: e.clientY }); - }; + const handleDragOver = (e: DragEvent) => { + setDragPosition({ x: e.clientX, y: e.clientY }); + }; - document.addEventListener("dragover", handleDragOver); + document.addEventListener("dragover", handleDragOver); - return () => { - document.removeEventListener("dragover", handleDragOver); - }; - }, [isDragging]); + return () => { + document.removeEventListener("dragover", handleDragOver); + }; + }, [isDragging]); - const handleDragStart = (e: React.DragEvent) => { - e.dataTransfer.setDragImage(emptyImg, 0, 0); + const handleDragStart = (e: React.DragEvent) => { + e.dataTransfer.setDragImage(emptyImg, 0, 0); - // Set drag data - e.dataTransfer.setData( - "application/x-media-item", - JSON.stringify(dragData), - ); - e.dataTransfer.effectAllowed = "copy"; + // Set drag data + e.dataTransfer.setData( + "application/x-media-item", + JSON.stringify(dragData) + ); + e.dataTransfer.effectAllowed = "copy"; - // Set initial position and show custom drag preview - setDragPosition({ x: e.clientX, y: e.clientY }); - setIsDragging(true); + // Set initial position and show custom drag preview + setDragPosition({ x: e.clientX, y: e.clientY }); + setIsDragging(true); - onDragStart?.(e); - }; + onDragStart?.(e); + }; - const handleDragEnd = () => { - setIsDragging(false); - }; + const handleDragEnd = () => { + setIsDragging(false); + }; - return ( - <> - <div ref={dragRef} className="relative group w-28 h-28"> - <div - className={`flex flex-col gap-1 p-0 h-auto w-full relative cursor-default ${className}`} - > - <AspectRatio - ratio={aspectRatio} - className={cn( - "bg-accent relative overflow-hidden", - rounded && "rounded-md", - "[&::-webkit-drag-ghost]:opacity-0", // Webkit-specific ghost hiding - )} - draggable={true} - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - > - {preview} - {!isDragging && ( - <PlusButton - className="opacity-0 group-hover:opacity-100" - onClick={handleAddToTimeline} - /> - )} - </AspectRatio> - {showLabel && ( - <span - className="text-[0.7rem] text-muted-foreground truncate w-full text-left" - aria-label={name} - title={name} - > - {name.length > 8 - ? `${name.slice(0, 16)}...${name.slice(-3)}` - : name} - </span> - )} - </div> - </div> + return ( + <> + <div ref={dragRef} className="relative group w-28 h-28"> + <div + className={`flex flex-col gap-1 p-0 h-auto w-full relative cursor-default ${className}`} + > + <AspectRatio + ratio={aspectRatio} + className={cn( + "bg-accent relative overflow-hidden", + rounded && "rounded-md", + "[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding + )} + draggable={true} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + > + {preview} + {!isDragging && ( + <PlusButton + className="opacity-0 group-hover:opacity-100" + onClick={handleAddToTimeline} + /> + )} + </AspectRatio> + {showLabel && ( + <span + className="text-[0.7rem] text-muted-foreground truncate w-full text-left" + aria-label={name} + title={name} + > + {name.length > 8 + ? `${name.slice(0, 16)}...${name.slice(-3)}` + : name} + </span> + )} + </div> + </div> - {/* Custom drag preview */} - {isDragging && - typeof document !== "undefined" && - createPortal( - <div - className="fixed pointer-events-none z-[9999]" - style={{ - left: dragPosition.x - 40, // Center the preview (half of 80px) - top: dragPosition.y - 40, // Center the preview (half of 80px) - }} - > - <div className="w-[80px]"> - <AspectRatio - ratio={1} - className="relative rounded-md overflow-hidden shadow-2xl ring ring-primary" - > - <div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none"> - {preview} - </div> - {showPlusOnDrag && ( - <PlusButton - onClick={handleAddToTimeline} - tooltipText="Add to timeline or drag to position" - /> - )} - </AspectRatio> - </div> - </div>, - document.body, - )} - </> - ); + {/* Custom drag preview */} + {isDragging && + typeof document !== "undefined" && + createPortal( + <div + className="fixed pointer-events-none z-[9999]" + style={{ + left: dragPosition.x - 40, // Center the preview (half of 80px) + top: dragPosition.y - 40, // Center the preview (half of 80px) + }} + > + <div className="w-[80px]"> + <AspectRatio + ratio={1} + className="relative rounded-md overflow-hidden shadow-2xl ring ring-primary" + > + <div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none"> + {preview} + </div> + {showPlusOnDrag && ( + <PlusButton + onClick={handleAddToTimeline} + tooltipText="Add to timeline or drag to position" + /> + )} + </AspectRatio> + </div> + </div>, + document.body + )} + </> + ); } function PlusButton({ - className, - onClick, - tooltipText, + className, + onClick, + tooltipText, }: { - className?: string; - onClick?: () => void; - tooltipText?: string; + className?: string; + onClick?: () => void; + tooltipText?: string; }) { - const button = ( - <Button - size="icon" - className={cn("absolute bottom-2 right-2 size-4", className)} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - onClick?.(); - }} - title={tooltipText} - > - <Plus className="!size-3" /> - </Button> - ); + const button = ( + <Button + size="icon" + className={cn("absolute bottom-2 right-2 size-4", className)} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onClick?.(); + }} + title={tooltipText} + > + <Plus className="!size-3" /> + </Button> + ); - if (tooltipText) { - return ( - <Tooltip> - <TooltipTrigger asChild>{button}</TooltipTrigger> - <TooltipContent> - <p>{tooltipText}</p> - </TooltipContent> - </Tooltip> - ); - } + if (tooltipText) { + return ( + <Tooltip> + <TooltipTrigger asChild>{button}</TooltipTrigger> + <TooltipContent> + <p>{tooltipText}</p> + </TooltipContent> + </Tooltip> + ); + } - return button; + return button; } diff --git a/apps/web/src/components/ui/drawer.tsx b/apps/web/src/components/ui/drawer.tsx index bce4f383..7e065e5c 100644 --- a/apps/web/src/components/ui/drawer.tsx +++ b/apps/web/src/components/ui/drawer.tsx @@ -6,13 +6,13 @@ import { Drawer as DrawerPrimitive } from "vaul"; import { cn } from "../../lib/utils"; const Drawer = ({ - shouldScaleBackground = true, - ...props + shouldScaleBackground = true, + ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => ( - <DrawerPrimitive.Root - shouldScaleBackground={shouldScaleBackground} - {...props} - /> + <DrawerPrimitive.Root + shouldScaleBackground={shouldScaleBackground} + {...props} + /> ); Drawer.displayName = "Drawer"; @@ -23,96 +23,96 @@ const DrawerPortal = DrawerPrimitive.Portal; const DrawerClose = DrawerPrimitive.Close; const DrawerOverlay = React.forwardRef< - React.ElementRef<typeof DrawerPrimitive.Overlay>, - React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay> + React.ElementRef<typeof DrawerPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay> >(({ className, ...props }, ref) => ( - <DrawerPrimitive.Overlay - ref={ref} - className={cn("fixed inset-0 z-50 bg-black/80", className)} - {...props} - /> + <DrawerPrimitive.Overlay + ref={ref} + className={cn("fixed inset-0 z-50 bg-black/80", className)} + {...props} + /> )); DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; const DrawerContent = React.forwardRef< - React.ElementRef<typeof DrawerPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> + React.ElementRef<typeof DrawerPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> >(({ className, children, ...props }, ref) => ( - <DrawerPortal> - <DrawerOverlay /> - <DrawerPrimitive.Content - ref={ref} - className={cn( - "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background", - className, - )} - {...props} - > - <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" /> - {children} - </DrawerPrimitive.Content> - </DrawerPortal> + <DrawerPortal> + <DrawerOverlay /> + <DrawerPrimitive.Content + ref={ref} + className={cn( + "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background", + className + )} + {...props} + > + <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" /> + {children} + </DrawerPrimitive.Content> + </DrawerPortal> )); DrawerContent.displayName = "DrawerContent"; const DrawerHeader = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} - {...props} - /> + <div + className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} + {...props} + /> ); DrawerHeader.displayName = "DrawerHeader"; const DrawerFooter = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn("mt-auto flex flex-col gap-2 p-4", className)} - {...props} - /> + <div + className={cn("mt-auto flex flex-col gap-2 p-4", className)} + {...props} + /> ); DrawerFooter.displayName = "DrawerFooter"; const DrawerTitle = React.forwardRef< - React.ElementRef<typeof DrawerPrimitive.Title>, - React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title> + React.ElementRef<typeof DrawerPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title> >(({ className, ...props }, ref) => ( - <DrawerPrimitive.Title - ref={ref} - className={cn( - "text-lg font-semibold leading-none tracking-tight", - className, - )} - {...props} - /> + <DrawerPrimitive.Title + ref={ref} + className={cn( + "text-lg font-semibold leading-none tracking-tight", + className + )} + {...props} + /> )); DrawerTitle.displayName = DrawerPrimitive.Title.displayName; const DrawerDescription = React.forwardRef< - React.ElementRef<typeof DrawerPrimitive.Description>, - React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description> + React.ElementRef<typeof DrawerPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description> >(({ className, ...props }, ref) => ( - <DrawerPrimitive.Description - ref={ref} - className={cn("text-sm text-muted-foreground", className)} - {...props} - /> + <DrawerPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> )); DrawerDescription.displayName = DrawerPrimitive.Description.displayName; export { - Drawer, - DrawerPortal, - DrawerOverlay, - DrawerTrigger, - DrawerClose, - DrawerContent, - DrawerHeader, - DrawerFooter, - DrawerTitle, - DrawerDescription, + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, }; diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index 85642d07..d9962dd2 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -20,211 +20,211 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const dropdownMenuItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", - { - variants: { - variant: { - default: "focus:opacity-65 focus:text-accent-foreground", - destructive: "text-destructive focus:text-destructive/80", - }, - }, - defaultVariants: { - variant: "default", - }, - }, + "relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "focus:opacity-65 focus:text-accent-foreground", + destructive: "text-destructive focus:text-destructive/80", + }, + }, + defaultVariants: { + variant: "default", + }, + } ); const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { - inset?: boolean; - variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; - } + React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { + inset?: boolean; + variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; + } >(({ className, inset, children, variant = "default", ...props }, ref) => ( - <DropdownMenuPrimitive.SubTrigger - ref={ref} - className={cn( - dropdownMenuItemVariants({ variant }), - "data-[state=open]:bg-accent data-[state=open]:opacity-65", - inset && "pl-8", - className, - )} - {...props} - > - {children} - <ChevronRight className="ml-auto" /> - </DropdownMenuPrimitive.SubTrigger> + <DropdownMenuPrimitive.SubTrigger + ref={ref} + className={cn( + dropdownMenuItemVariants({ variant }), + "data-[state=open]:bg-accent data-[state=open]:opacity-65", + inset && "pl-8", + className + )} + {...props} + > + {children} + <ChevronRight className="ml-auto" /> + </DropdownMenuPrimitive.SubTrigger> )); DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName; + DropdownMenuPrimitive.SubTrigger.displayName; const DropdownMenuSubContent = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> + React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> >(({ className, ...props }, ref) => ( - <DropdownMenuPrimitive.SubContent - ref={ref} - className={cn( - "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - className, - )} - {...props} - /> + <DropdownMenuPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> )); DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName; + DropdownMenuPrimitive.SubContent.displayName; const DropdownMenuContent = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> + React.ElementRef<typeof DropdownMenuPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> >(({ className, sideOffset = 4, ...props }, ref) => ( - <DropdownMenuPrimitive.Portal> - <DropdownMenuPrimitive.Content - ref={ref} - sideOffset={sideOffset} - onCloseAutoFocus={(e) => { - e.stopPropagation(); - e.preventDefault(); - }} - className={cn( - "z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md", - "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - className, - )} - {...props} - /> - </DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Content + ref={ref} + sideOffset={sideOffset} + onCloseAutoFocus={(e) => { + e.stopPropagation(); + e.preventDefault(); + }} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md", + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> + </DropdownMenuPrimitive.Portal> )); DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; const DropdownMenuItem = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { - inset?: boolean; - variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; - } + React.ElementRef<typeof DropdownMenuPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { + inset?: boolean; + variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; + } >(({ className, inset, variant = "default", ...props }, ref) => ( - <DropdownMenuPrimitive.Item - ref={ref} - className={cn( - dropdownMenuItemVariants({ variant }), - inset && "pl-8", - className, - )} - {...props} - /> + <DropdownMenuPrimitive.Item + ref={ref} + className={cn( + dropdownMenuItemVariants({ variant }), + inset && "pl-8", + className + )} + {...props} + /> )); DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & { - variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; - } + React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & { + variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; + } >(({ className, children, checked, variant = "default", ...props }, ref) => ( - <DropdownMenuPrimitive.CheckboxItem - ref={ref} - className={cn( - dropdownMenuItemVariants({ variant }), - "pl-8 pr-2", - className, - )} - checked={checked} - {...props} - > - <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> - <DropdownMenuPrimitive.ItemIndicator> - <Check className="h-4 w-4" /> - </DropdownMenuPrimitive.ItemIndicator> - </span> - {children} - </DropdownMenuPrimitive.CheckboxItem> + <DropdownMenuPrimitive.CheckboxItem + ref={ref} + className={cn( + dropdownMenuItemVariants({ variant }), + "pl-8 pr-2", + className + )} + checked={checked} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.CheckboxItem> )); DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName; + DropdownMenuPrimitive.CheckboxItem.displayName; const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & { - variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; - } + React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & { + variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; + } >(({ className, children, variant = "default", ...props }, ref) => ( - <DropdownMenuPrimitive.RadioItem - ref={ref} - className={cn( - dropdownMenuItemVariants({ variant }), - "pl-8 pr-2", - className, - )} - {...props} - > - <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> - <DropdownMenuPrimitive.ItemIndicator> - <Circle className="h-2 w-2 fill-current" /> - </DropdownMenuPrimitive.ItemIndicator> - </span> - {children} - </DropdownMenuPrimitive.RadioItem> + <DropdownMenuPrimitive.RadioItem + ref={ref} + className={cn( + dropdownMenuItemVariants({ variant }), + "pl-8 pr-2", + className + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Circle className="h-2 w-2 fill-current" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.RadioItem> )); DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; const DropdownMenuLabel = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.Label>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { - inset?: boolean; - } + React.ElementRef<typeof DropdownMenuPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { + inset?: boolean; + } >(({ className, inset, ...props }, ref) => ( - <DropdownMenuPrimitive.Label - ref={ref} - className={cn( - "px-2 py-1.5 text-sm font-semibold", - inset && "pl-8", - className, - )} - {...props} - /> + <DropdownMenuPrimitive.Label + ref={ref} + className={cn( + "px-2 py-1.5 text-sm font-semibold", + inset && "pl-8", + className + )} + {...props} + /> )); DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; const DropdownMenuSeparator = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.Separator>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> + React.ElementRef<typeof DropdownMenuPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> >(({ className, ...props }, ref) => ( - <DropdownMenuPrimitive.Separator - ref={ref} - className={cn("-mx-1 my-1 h-px bg-foreground/10", className)} - {...props} - /> + <DropdownMenuPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-foreground/10", className)} + {...props} + /> )); DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; const DropdownMenuShortcut = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLSpanElement>) => { - return ( - <span - className={cn("ml-auto text-xs tracking-widest opacity-60", className)} - {...props} - /> - ); + return ( + <span + className={cn("ml-auto text-xs tracking-widest opacity-60", className)} + {...props} + /> + ); }; DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, }; diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx index 7705bcef..9a152fd4 100644 --- a/apps/web/src/components/ui/font-picker.tsx +++ b/apps/web/src/components/ui/font-picker.tsx @@ -1,40 +1,40 @@ -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { FONT_OPTIONS, FontFamily } from "@/constants/font-constants"; - -interface FontPickerProps { - defaultValue?: FontFamily; - onValueChange?: (value: FontFamily) => void; - className?: string; -} - -export function FontPicker({ - defaultValue, - onValueChange, - className, -}: FontPickerProps) { - return ( - <Select defaultValue={defaultValue} onValueChange={onValueChange}> - <SelectTrigger className={`w-full text-xs ${className || ""}`}> - <SelectValue placeholder="Select a font" /> - </SelectTrigger> - <SelectContent> - {FONT_OPTIONS.map((font) => ( - <SelectItem - key={font.value} - value={font.value} - className="text-xs" - style={{ fontFamily: font.value }} - > - {font.label} - </SelectItem> - ))} - </SelectContent> - </Select> - ); -} +import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { FONT_OPTIONS, FontFamily } from "@/constants/font-constants";
+
+interface FontPickerProps {
+ defaultValue?: FontFamily;
+ onValueChange?: (value: FontFamily) => void;
+ className?: string;
+}
+
+export function FontPicker({
+ defaultValue,
+ onValueChange,
+ className,
+}: FontPickerProps) {
+ return (
+ <Select defaultValue={defaultValue} onValueChange={onValueChange}>
+ <SelectTrigger className={`w-full text-xs ${className || ""}`}>
+ <SelectValue placeholder="Select a font" />
+ </SelectTrigger>
+ <SelectContent>
+ {FONT_OPTIONS.map((font) => (
+ <SelectItem
+ key={font.value}
+ value={font.value}
+ className="text-xs"
+ style={{ fontFamily: font.value }}
+ >
+ {font.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ );
+}
diff --git a/apps/web/src/components/ui/form.tsx b/apps/web/src/components/ui/form.tsx index f186d894..cb4f53a9 100644 --- a/apps/web/src/components/ui/form.tsx +++ b/apps/web/src/components/ui/form.tsx @@ -4,12 +4,12 @@ import * as React from "react"; import { Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui"; import { - Controller, - ControllerProps, - FieldPath, - FieldValues, - FormProvider, - useFormContext, + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, } from "react-hook-form"; import { cn } from "../../lib/utils"; @@ -18,162 +18,162 @@ import { Label } from "./label"; const Form = FormProvider; type FormFieldContextValue< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, > = { - name: TName; + name: TName; }; const FormFieldContext = React.createContext<FormFieldContextValue>( - {} as FormFieldContextValue, + {} as FormFieldContextValue ); const FormField = < - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, >({ - ...props + ...props }: ControllerProps<TFieldValues, TName>) => { - return ( - <FormFieldContext.Provider value={{ name: props.name }}> - <Controller {...props} /> - </FormFieldContext.Provider> - ); + return ( + <FormFieldContext.Provider value={{ name: props.name }}> + <Controller {...props} /> + </FormFieldContext.Provider> + ); }; const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext); - const itemContext = React.useContext(FormItemContext); - const { getFieldState, formState } = useFormContext(); + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); - const fieldState = getFieldState(fieldContext.name, formState); + const fieldState = getFieldState(fieldContext.name, formState); - if (!fieldContext) { - throw new Error("useFormField should be used within <FormField>"); - } + if (!fieldContext) { + throw new Error("useFormField should be used within <FormField>"); + } - const { id } = itemContext; + const { id } = itemContext; - return { - id, - name: fieldContext.name, - formItemId: `${id}-form-item`, - formDescriptionId: `${id}-form-item-description`, - formMessageId: `${id}-form-item-message`, - ...fieldState, - }; + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; }; type FormItemContextValue = { - id: string; + id: string; }; const FormItemContext = React.createContext<FormItemContextValue>( - {} as FormItemContextValue, + {} as FormItemContextValue ); const FormItem = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => { - const id = React.useId(); + const id = React.useId(); - return ( - <FormItemContext.Provider value={{ id }}> - <div ref={ref} className={cn("space-y-2", className)} {...props} /> - </FormItemContext.Provider> - ); + return ( + <FormItemContext.Provider value={{ id }}> + <div ref={ref} className={cn("space-y-2", className)} {...props} /> + </FormItemContext.Provider> + ); }); FormItem.displayName = "FormItem"; const FormLabel = React.forwardRef< - React.ElementRef<typeof LabelPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> >(({ className, ...props }, ref) => { - const { error, formItemId } = useFormField(); + const { error, formItemId } = useFormField(); - return ( - <Label - ref={ref} - className={cn(error && "text-destructive", className)} - htmlFor={formItemId} - {...props} - /> - ); + return ( + <Label + ref={ref} + className={cn(error && "text-destructive", className)} + htmlFor={formItemId} + {...props} + /> + ); }); FormLabel.displayName = "FormLabel"; const FormControl = React.forwardRef< - React.ElementRef<typeof SlotPrimitive.Slot>, - React.ComponentPropsWithoutRef<typeof SlotPrimitive.Slot> + React.ElementRef<typeof SlotPrimitive.Slot>, + React.ComponentPropsWithoutRef<typeof SlotPrimitive.Slot> >(({ ...props }, ref) => { - const { error, formItemId, formDescriptionId, formMessageId } = - useFormField(); + const { error, formItemId, formDescriptionId, formMessageId } = + useFormField(); - return ( - <SlotPrimitive.Slot - ref={ref} - id={formItemId} - aria-describedby={ - !error - ? `${formDescriptionId}` - : `${formDescriptionId} ${formMessageId}` - } - aria-invalid={!!error} - {...props} - /> - ); + return ( + <SlotPrimitive.Slot + ref={ref} + id={formItemId} + aria-describedby={ + !error + ? `${formDescriptionId}` + : `${formDescriptionId} ${formMessageId}` + } + aria-invalid={!!error} + {...props} + /> + ); }); FormControl.displayName = "FormControl"; const FormDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes<HTMLParagraphElement> + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> >(({ className, ...props }, ref) => { - const { formDescriptionId } = useFormField(); + const { formDescriptionId } = useFormField(); - return ( - <p - ref={ref} - id={formDescriptionId} - className={cn("text-[0.8rem] text-muted-foreground", className)} - {...props} - /> - ); + return ( + <p + ref={ref} + id={formDescriptionId} + className={cn("text-[0.8rem] text-muted-foreground", className)} + {...props} + /> + ); }); FormDescription.displayName = "FormDescription"; const FormMessage = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes<HTMLParagraphElement> + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> >(({ className, children, ...props }, ref) => { - const { error, formMessageId } = useFormField(); - const body = error ? String(error?.message) : children; + const { error, formMessageId } = useFormField(); + const body = error ? String(error?.message) : children; - if (!body) { - return null; - } + if (!body) { + return null; + } - return ( - <p - ref={ref} - id={formMessageId} - className={cn("text-[0.8rem] font-medium text-destructive", className)} - {...props} - > - {body} - </p> - ); + return ( + <p + ref={ref} + id={formMessageId} + className={cn("text-[0.8rem] font-medium text-destructive", className)} + {...props} + > + {body} + </p> + ); }); FormMessage.displayName = "FormMessage"; export { - useFormField, - Form, - FormItem, - FormLabel, - FormControl, - FormDescription, - FormMessage, - FormField, + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, }; diff --git a/apps/web/src/components/ui/hover-card.tsx b/apps/web/src/components/ui/hover-card.tsx index 52f6c17c..c3958fcf 100644 --- a/apps/web/src/components/ui/hover-card.tsx +++ b/apps/web/src/components/ui/hover-card.tsx @@ -10,19 +10,19 @@ const HoverCard = HoverCardPrimitive.Root; const HoverCardTrigger = HoverCardPrimitive.Trigger; const HoverCardContent = React.forwardRef< - React.ElementRef<typeof HoverCardPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> + React.ElementRef<typeof HoverCardPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - <HoverCardPrimitive.Content - ref={ref} - align={align} - sideOffset={sideOffset} - className={cn( - "z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - className, - )} - {...props} - /> + <HoverCardPrimitive.Content + ref={ref} + align={align} + sideOffset={sideOffset} + className={cn( + "z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> )); HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; diff --git a/apps/web/src/components/ui/image-timeline-treatment.tsx b/apps/web/src/components/ui/image-timeline-treatment.tsx index 97a401b6..31847df5 100644 --- a/apps/web/src/components/ui/image-timeline-treatment.tsx +++ b/apps/web/src/components/ui/image-timeline-treatment.tsx @@ -1,102 +1,102 @@ -"use client"; - -import { useState } from "react"; -import { cn } from "@/lib/utils"; -import type { BackgroundType } from "@/types/editor"; - -interface ImageTimelineTreatmentProps { - src: string; - alt: string; - targetAspectRatio?: number; // Default to 16:9 for video - className?: string; - backgroundType?: BackgroundType; - backgroundColor?: string; -} - -export function ImageTimelineTreatment({ - src, - alt, - targetAspectRatio = 16 / 9, - className, - backgroundType = "blur", - backgroundColor = "#000000", -}: ImageTimelineTreatmentProps) { - const [imageLoaded, setImageLoaded] = useState(false); - const [imageDimensions, setImageDimensions] = useState<{ - width: number; - height: number; - } | null>(null); - - const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => { - const img = e.currentTarget; - setImageDimensions({ - width: img.naturalWidth, - height: img.naturalHeight, - }); - setImageLoaded(true); - }; - - const imageAspectRatio = imageDimensions - ? imageDimensions.width / imageDimensions.height - : 1; - - const needsAspectRatioTreatment = imageAspectRatio !== targetAspectRatio; - - return ( - <div - className={cn("relative overflow-hidden", className)} - style={{ aspectRatio: targetAspectRatio }} - > - {/* Background Layer */} - {needsAspectRatioTreatment && imageLoaded && ( - <> - {backgroundType === "blur" && ( - <div className="absolute inset-0"> - <img - src={src} - alt="" - className="w-full h-full object-cover filter blur-xl scale-110 opacity-60" - aria-hidden="true" - /> - <div className="absolute inset-0 bg-black/20" /> - </div> - )} - - {backgroundType === "mirror" && ( - <div className="absolute inset-0"> - <img - src={src} - alt="" - className="w-full h-full object-cover opacity-30" - aria-hidden="true" - /> - </div> - )} - - {backgroundType === "color" && ( - <div className="absolute inset-0" style={{ backgroundColor }} /> - )} - </> - )} - - {/* Main Image Layer */} - <div className="absolute inset-0"> - <img - src={src} - alt={alt} - className="w-full h-full object-cover" - onLoad={handleImageLoad} - /> - </div> - - {/* Loading state */} - {!imageLoaded && ( - <div className="absolute inset-0 flex items-center justify-center bg-muted/30"> - <div className="animate-pulse text-xs text-muted-foreground"> - Loading... - </div> - </div> - )} - </div> - ); -} +"use client";
+
+import { useState } from "react";
+import { cn } from "@/lib/utils";
+import type { BackgroundType } from "@/types/editor";
+
+interface ImageTimelineTreatmentProps {
+ src: string;
+ alt: string;
+ targetAspectRatio?: number; // Default to 16:9 for video
+ className?: string;
+ backgroundType?: BackgroundType;
+ backgroundColor?: string;
+}
+
+export function ImageTimelineTreatment({
+ src,
+ alt,
+ targetAspectRatio = 16 / 9,
+ className,
+ backgroundType = "blur",
+ backgroundColor = "#000000",
+}: ImageTimelineTreatmentProps) {
+ const [imageLoaded, setImageLoaded] = useState(false);
+ const [imageDimensions, setImageDimensions] = useState<{
+ width: number;
+ height: number;
+ } | null>(null);
+
+ const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
+ const img = e.currentTarget;
+ setImageDimensions({
+ width: img.naturalWidth,
+ height: img.naturalHeight,
+ });
+ setImageLoaded(true);
+ };
+
+ const imageAspectRatio = imageDimensions
+ ? imageDimensions.width / imageDimensions.height
+ : 1;
+
+ const needsAspectRatioTreatment = imageAspectRatio !== targetAspectRatio;
+
+ return (
+ <div
+ className={cn("relative overflow-hidden", className)}
+ style={{ aspectRatio: targetAspectRatio }}
+ >
+ {/* Background Layer */}
+ {needsAspectRatioTreatment && imageLoaded && (
+ <>
+ {backgroundType === "blur" && (
+ <div className="absolute inset-0">
+ <img
+ src={src}
+ alt=""
+ className="w-full h-full object-cover filter blur-xl scale-110 opacity-60"
+ aria-hidden="true"
+ />
+ <div className="absolute inset-0 bg-black/20" />
+ </div>
+ )}
+
+ {backgroundType === "mirror" && (
+ <div className="absolute inset-0">
+ <img
+ src={src}
+ alt=""
+ className="w-full h-full object-cover opacity-30"
+ aria-hidden="true"
+ />
+ </div>
+ )}
+
+ {backgroundType === "color" && (
+ <div className="absolute inset-0" style={{ backgroundColor }} />
+ )}
+ </>
+ )}
+
+ {/* Main Image Layer */}
+ <div className="absolute inset-0">
+ <img
+ src={src}
+ alt={alt}
+ className="w-full h-full object-cover"
+ onLoad={handleImageLoad}
+ />
+ </div>
+
+ {/* Loading state */}
+ {!imageLoaded && (
+ <div className="absolute inset-0 flex items-center justify-center bg-muted/30">
+ <div className="animate-pulse text-xs text-muted-foreground">
+ Loading...
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/ui/input-otp.tsx b/apps/web/src/components/ui/input-otp.tsx index d8348449..1bffc451 100644 --- a/apps/web/src/components/ui/input-otp.tsx +++ b/apps/web/src/components/ui/input-otp.tsx @@ -7,68 +7,68 @@ import { Minus } from "lucide-react"; import { cn } from "../../lib/utils"; const InputOTP = React.forwardRef< - React.ElementRef<typeof OTPInput>, - React.ComponentPropsWithoutRef<typeof OTPInput> + React.ElementRef<typeof OTPInput>, + React.ComponentPropsWithoutRef<typeof OTPInput> >(({ className, containerClassName, ...props }, ref) => ( - <OTPInput - ref={ref} - containerClassName={cn( - "flex items-center justify-between gap-2 w-full has-[:disabled]:opacity-50", - containerClassName, - )} - className={cn("disabled:cursor-not-allowed", className)} - {...props} - /> + <OTPInput + ref={ref} + containerClassName={cn( + "flex items-center justify-between gap-2 w-full has-[:disabled]:opacity-50", + containerClassName + )} + className={cn("disabled:cursor-not-allowed", className)} + {...props} + /> )); InputOTP.displayName = "InputOTP"; const InputOTPGroup = React.forwardRef< - React.ElementRef<"div">, - React.ComponentPropsWithoutRef<"div"> + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> >(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("flex items-center flex-1 gap-2", className)} - {...props} - /> + <div + ref={ref} + className={cn("flex items-center flex-1 gap-2", className)} + {...props} + /> )); InputOTPGroup.displayName = "InputOTPGroup"; const InputOTPSlot = React.forwardRef< - React.ElementRef<"div">, - React.ComponentPropsWithoutRef<"div"> & { index: number } + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> & { index: number } >(({ index, className, ...props }, ref) => { - const inputOTPContext = React.useContext(OTPInputContext); - const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]; + const inputOTPContext = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]; - return ( - <div - ref={ref} - className={cn( - "relative flex aspect-square flex-1 min-w-[36px] items-center justify-center border border-input text-lg shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md", - isActive && "z-10 ring-1 ring-ring", - className, - )} - {...props} - > - {char} - {hasFakeCaret && ( - <div className="pointer-events-none absolute inset-0 flex items-center justify-center"> - <div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" /> - </div> - )} - </div> - ); + return ( + <div + ref={ref} + className={cn( + "relative flex aspect-square flex-1 min-w-[36px] items-center justify-center border border-input text-lg shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md", + isActive && "z-10 ring-1 ring-ring", + className + )} + {...props} + > + {char} + {hasFakeCaret && ( + <div className="pointer-events-none absolute inset-0 flex items-center justify-center"> + <div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" /> + </div> + )} + </div> + ); }); InputOTPSlot.displayName = "InputOTPSlot"; const InputOTPSeparator = React.forwardRef< - React.ElementRef<"div">, - React.ComponentPropsWithoutRef<"div"> + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> >(({ ...props }, ref) => ( - <div ref={ref} role="separator" {...props}> - <Minus /> - </div> + <div ref={ref} role="separator" {...props}> + <Minus /> + </div> )); InputOTPSeparator.displayName = "InputOTPSeparator"; diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 7bcf8e14..8fc31a58 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -5,51 +5,51 @@ import { cn } from "../../lib/utils"; import { Button } from "./button"; interface InputProps extends React.ComponentProps<"input"> { - showPassword?: boolean; - onShowPasswordChange?: (show: boolean) => void; + showPassword?: boolean; + onShowPasswordChange?: (show: boolean) => void; } const Input = React.forwardRef<HTMLInputElement, InputProps>( - ( - { className, type, showPassword, onShowPasswordChange, value, ...props }, - ref, - ) => { - const isPassword = type === "password"; - const showPasswordToggle = isPassword && onShowPasswordChange; - const inputType = isPassword && showPassword ? "text" : type; + ( + { className, type, showPassword, onShowPasswordChange, value, ...props }, + ref + ) => { + const isPassword = type === "password"; + const showPasswordToggle = isPassword && onShowPasswordChange; + const inputType = isPassword && showPassword ? "text" : type; - return ( - <div className={showPassword ? "relative w-full" : ""}> - <input - type={inputType} - className={cn( - "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", - showPasswordToggle && "pr-10", - className, - )} - ref={ref} - value={value} - {...props} - /> - {showPasswordToggle && ( - <Button - type="button" - variant="text" - size="icon" - onClick={() => onShowPasswordChange?.(!showPassword)} - className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground" - aria-label={showPassword ? "Hide password" : "Show password"} - > - {showPassword ? ( - <Eye className="h-4 w-4" /> - ) : ( - <EyeOff className="h-4 w-4" /> - )} - </Button> - )} - </div> - ); - }, + return ( + <div className={showPassword ? "relative w-full" : ""}> + <input + type={inputType} + className={cn( + "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + showPasswordToggle && "pr-10", + className + )} + ref={ref} + value={value} + {...props} + /> + {showPasswordToggle && ( + <Button + type="button" + variant="text" + size="icon" + onClick={() => onShowPasswordChange?.(!showPassword)} + className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground" + aria-label={showPassword ? "Hide password" : "Show password"} + > + {showPassword ? ( + <Eye className="h-4 w-4" /> + ) : ( + <EyeOff className="h-4 w-4" /> + )} + </Button> + )} + </div> + ); + } ); Input.displayName = "Input"; diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx index bca55d10..877af15b 100644 --- a/apps/web/src/components/ui/label.tsx +++ b/apps/web/src/components/ui/label.tsx @@ -7,19 +7,19 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "../../lib/utils"; const labelVariants = cva( - "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" ); const Label = React.forwardRef< - React.ElementRef<typeof LabelPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & - VariantProps<typeof labelVariants> + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & + VariantProps<typeof labelVariants> >(({ className, ...props }, ref) => ( - <LabelPrimitive.Root - ref={ref} - className={cn(labelVariants(), className)} - {...props} - /> + <LabelPrimitive.Root + ref={ref} + className={cn(labelVariants(), className)} + {...props} + /> )); Label.displayName = LabelPrimitive.Root.displayName; diff --git a/apps/web/src/components/ui/menubar.tsx b/apps/web/src/components/ui/menubar.tsx index 03ba0caa..642ffa2b 100644 --- a/apps/web/src/components/ui/menubar.tsx +++ b/apps/web/src/components/ui/menubar.tsx @@ -17,220 +17,220 @@ const MenubarSub = MenubarPrimitive.Sub; const MenubarRadioGroup = MenubarPrimitive.RadioGroup; const Menubar = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root> + React.ElementRef<typeof MenubarPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root> >(({ className, ...props }, ref) => ( - <MenubarPrimitive.Root - ref={ref} - className={cn( - "flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm", - className, - )} - {...props} - /> + <MenubarPrimitive.Root + ref={ref} + className={cn( + "flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm", + className + )} + {...props} + /> )); Menubar.displayName = MenubarPrimitive.Root.displayName; const MenubarTrigger = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.Trigger>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger> + React.ElementRef<typeof MenubarPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger> >(({ className, ...props }, ref) => ( - <MenubarPrimitive.Trigger - ref={ref} - className={cn( - "flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", - className, - )} - {...props} - /> + <MenubarPrimitive.Trigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", + className + )} + {...props} + /> )); MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName; const MenubarSubTrigger = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.SubTrigger>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & { - inset?: boolean; - } + React.ElementRef<typeof MenubarPrimitive.SubTrigger>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & { + inset?: boolean; + } >(({ className, inset, children, ...props }, ref) => ( - <MenubarPrimitive.SubTrigger - ref={ref} - className={cn( - "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", - inset && "pl-8", - className, - )} - {...props} - > - {children} - <ChevronRight className="ml-auto h-4 w-4" /> - </MenubarPrimitive.SubTrigger> + <MenubarPrimitive.SubTrigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", + inset && "pl-8", + className + )} + {...props} + > + {children} + <ChevronRight className="ml-auto h-4 w-4" /> + </MenubarPrimitive.SubTrigger> )); MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName; const MenubarSubContent = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.SubContent>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent> + React.ElementRef<typeof MenubarPrimitive.SubContent>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent> >(({ className, ...props }, ref) => ( - <MenubarPrimitive.SubContent - ref={ref} - className={cn( - "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - className, - )} - {...props} - /> + <MenubarPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> )); MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName; const MenubarContent = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content> + React.ElementRef<typeof MenubarPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content> >( - ( - { className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, - ref, - ) => ( - <MenubarPrimitive.Portal> - <MenubarPrimitive.Content - ref={ref} - align={align} - alignOffset={alignOffset} - sideOffset={sideOffset} - className={cn( - "z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - className, - )} - {...props} - /> - </MenubarPrimitive.Portal> - ), + ( + { className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, + ref + ) => ( + <MenubarPrimitive.Portal> + <MenubarPrimitive.Content + ref={ref} + align={align} + alignOffset={alignOffset} + sideOffset={sideOffset} + className={cn( + "z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> + </MenubarPrimitive.Portal> + ) ); MenubarContent.displayName = MenubarPrimitive.Content.displayName; const MenubarItem = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & { - inset?: boolean; - } + React.ElementRef<typeof MenubarPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & { + inset?: boolean; + } >(({ className, inset, ...props }, ref) => ( - <MenubarPrimitive.Item - ref={ref} - className={cn( - "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - inset && "pl-8", - className, - )} - {...props} - /> + <MenubarPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + inset && "pl-8", + className + )} + {...props} + /> )); MenubarItem.displayName = MenubarPrimitive.Item.displayName; const MenubarCheckboxItem = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.CheckboxItem>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem> + React.ElementRef<typeof MenubarPrimitive.CheckboxItem>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem> >(({ className, children, checked, ...props }, ref) => ( - <MenubarPrimitive.CheckboxItem - ref={ref} - className={cn( - "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - className, - )} - checked={checked} - {...props} - > - <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> - <MenubarPrimitive.ItemIndicator> - <Check className="h-4 w-4" /> - </MenubarPrimitive.ItemIndicator> - </span> - {children} - </MenubarPrimitive.CheckboxItem> + <MenubarPrimitive.CheckboxItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + checked={checked} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <MenubarPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </MenubarPrimitive.ItemIndicator> + </span> + {children} + </MenubarPrimitive.CheckboxItem> )); MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName; const MenubarRadioItem = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.RadioItem>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem> + React.ElementRef<typeof MenubarPrimitive.RadioItem>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem> >(({ className, children, ...props }, ref) => ( - <MenubarPrimitive.RadioItem - ref={ref} - className={cn( - "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - className, - )} - {...props} - > - <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> - <MenubarPrimitive.ItemIndicator> - <Circle className="h-4 w-4 fill-current" /> - </MenubarPrimitive.ItemIndicator> - </span> - {children} - </MenubarPrimitive.RadioItem> + <MenubarPrimitive.RadioItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <MenubarPrimitive.ItemIndicator> + <Circle className="h-4 w-4 fill-current" /> + </MenubarPrimitive.ItemIndicator> + </span> + {children} + </MenubarPrimitive.RadioItem> )); MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName; const MenubarLabel = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.Label>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & { - inset?: boolean; - } + React.ElementRef<typeof MenubarPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & { + inset?: boolean; + } >(({ className, inset, ...props }, ref) => ( - <MenubarPrimitive.Label - ref={ref} - className={cn( - "px-2 py-1.5 text-sm font-semibold", - inset && "pl-8", - className, - )} - {...props} - /> + <MenubarPrimitive.Label + ref={ref} + className={cn( + "px-2 py-1.5 text-sm font-semibold", + inset && "pl-8", + className + )} + {...props} + /> )); MenubarLabel.displayName = MenubarPrimitive.Label.displayName; const MenubarSeparator = React.forwardRef< - React.ElementRef<typeof MenubarPrimitive.Separator>, - React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator> + React.ElementRef<typeof MenubarPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator> >(({ className, ...props }, ref) => ( - <MenubarPrimitive.Separator - ref={ref} - className={cn("-mx-1 my-1 h-px bg-muted", className)} - {...props} - /> + <MenubarPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} + /> )); MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName; const MenubarShortcut = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLSpanElement>) => { - return ( - <span - className={cn( - "ml-auto text-xs tracking-widest text-muted-foreground", - className, - )} - {...props} - /> - ); + return ( + <span + className={cn( + "ml-auto text-xs tracking-widest text-muted-foreground", + className + )} + {...props} + /> + ); }; MenubarShortcut.displayname = "MenubarShortcut"; export { - Menubar, - MenubarMenu, - MenubarTrigger, - MenubarContent, - MenubarItem, - MenubarSeparator, - MenubarLabel, - MenubarCheckboxItem, - MenubarRadioGroup, - MenubarRadioItem, - MenubarPortal, - MenubarSubContent, - MenubarSubTrigger, - MenubarGroup, - MenubarSub, - MenubarShortcut, + Menubar, + MenubarMenu, + MenubarTrigger, + MenubarContent, + MenubarItem, + MenubarSeparator, + MenubarLabel, + MenubarCheckboxItem, + MenubarRadioGroup, + MenubarRadioItem, + MenubarPortal, + MenubarSubContent, + MenubarSubTrigger, + MenubarGroup, + MenubarSub, + MenubarShortcut, }; diff --git a/apps/web/src/components/ui/navigation-menu.tsx b/apps/web/src/components/ui/navigation-menu.tsx index 0844f989..b5dd3622 100644 --- a/apps/web/src/components/ui/navigation-menu.tsx +++ b/apps/web/src/components/ui/navigation-menu.tsx @@ -6,123 +6,123 @@ import { ChevronDown } from "lucide-react"; import { cn } from "../../lib/utils"; const NavigationMenu = React.forwardRef< - React.ElementRef<typeof NavigationMenuPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root> + React.ElementRef<typeof NavigationMenuPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root> >(({ className, children, ...props }, ref) => ( - <NavigationMenuPrimitive.Root - ref={ref} - className={cn( - "relative z-10 flex max-w-max flex-1 items-center justify-center", - className, - )} - {...props} - > - {children} - <NavigationMenuViewport /> - </NavigationMenuPrimitive.Root> + <NavigationMenuPrimitive.Root + ref={ref} + className={cn( + "relative z-10 flex max-w-max flex-1 items-center justify-center", + className + )} + {...props} + > + {children} + <NavigationMenuViewport /> + </NavigationMenuPrimitive.Root> )); NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName; const NavigationMenuList = React.forwardRef< - React.ElementRef<typeof NavigationMenuPrimitive.List>, - React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List> + React.ElementRef<typeof NavigationMenuPrimitive.List>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List> >(({ className, ...props }, ref) => ( - <NavigationMenuPrimitive.List - ref={ref} - className={cn( - "group flex flex-1 list-none items-center justify-center space-x-1", - className, - )} - {...props} - /> + <NavigationMenuPrimitive.List + ref={ref} + className={cn( + "group flex flex-1 list-none items-center justify-center space-x-1", + className + )} + {...props} + /> )); NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName; const NavigationMenuItem = NavigationMenuPrimitive.Item; const navigationMenuTriggerStyle = cva( - "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50", + "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50" ); const NavigationMenuTrigger = React.forwardRef< - React.ElementRef<typeof NavigationMenuPrimitive.Trigger>, - React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger> + React.ElementRef<typeof NavigationMenuPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger> >(({ className, children, ...props }, ref) => ( - <NavigationMenuPrimitive.Trigger - ref={ref} - className={cn(navigationMenuTriggerStyle(), "group", className)} - {...props} - > - {children}{" "} - <ChevronDown - className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180" - aria-hidden="true" - /> - </NavigationMenuPrimitive.Trigger> + <NavigationMenuPrimitive.Trigger + ref={ref} + className={cn(navigationMenuTriggerStyle(), "group", className)} + {...props} + > + {children}{" "} + <ChevronDown + className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180" + aria-hidden="true" + /> + </NavigationMenuPrimitive.Trigger> )); NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName; const NavigationMenuContent = React.forwardRef< - React.ElementRef<typeof NavigationMenuPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content> + React.ElementRef<typeof NavigationMenuPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content> >(({ className, ...props }, ref) => ( - <NavigationMenuPrimitive.Content - ref={ref} - className={cn( - "left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ", - className, - )} - {...props} - /> + <NavigationMenuPrimitive.Content + ref={ref} + className={cn( + "left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ", + className + )} + {...props} + /> )); NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName; const NavigationMenuLink = NavigationMenuPrimitive.Link; const NavigationMenuViewport = React.forwardRef< - React.ElementRef<typeof NavigationMenuPrimitive.Viewport>, - React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport> + React.ElementRef<typeof NavigationMenuPrimitive.Viewport>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport> >(({ className, ...props }, ref) => ( - <div className={cn("absolute left-0 top-full flex justify-center")}> - <NavigationMenuPrimitive.Viewport - className={cn( - "origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]", - className, - )} - ref={ref} - {...props} - /> - </div> + <div className={cn("absolute left-0 top-full flex justify-center")}> + <NavigationMenuPrimitive.Viewport + className={cn( + "origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]", + className + )} + ref={ref} + {...props} + /> + </div> )); NavigationMenuViewport.displayName = - NavigationMenuPrimitive.Viewport.displayName; + NavigationMenuPrimitive.Viewport.displayName; const NavigationMenuIndicator = React.forwardRef< - React.ElementRef<typeof NavigationMenuPrimitive.Indicator>, - React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator> + React.ElementRef<typeof NavigationMenuPrimitive.Indicator>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator> >(({ className, ...props }, ref) => ( - <NavigationMenuPrimitive.Indicator - ref={ref} - className={cn( - "top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in", - className, - )} - {...props} - > - <div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" /> - </NavigationMenuPrimitive.Indicator> + <NavigationMenuPrimitive.Indicator + ref={ref} + className={cn( + "top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in", + className + )} + {...props} + > + <div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" /> + </NavigationMenuPrimitive.Indicator> )); NavigationMenuIndicator.displayName = - NavigationMenuPrimitive.Indicator.displayName; + NavigationMenuPrimitive.Indicator.displayName; export { - navigationMenuTriggerStyle, - NavigationMenu, - NavigationMenuList, - NavigationMenuItem, - NavigationMenuContent, - NavigationMenuTrigger, - NavigationMenuLink, - NavigationMenuIndicator, - NavigationMenuViewport, + navigationMenuTriggerStyle, + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuIndicator, + NavigationMenuViewport, }; diff --git a/apps/web/src/components/ui/pagination.tsx b/apps/web/src/components/ui/pagination.tsx index ad132ee5..61766cec 100644 --- a/apps/web/src/components/ui/pagination.tsx +++ b/apps/web/src/components/ui/pagination.tsx @@ -5,113 +5,113 @@ import { cn } from "../../lib/utils"; import { ButtonProps, buttonVariants } from "./button"; const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( - <nav - role="navigation" - aria-label="pagination" - className={cn("mx-auto flex w-full justify-center", className)} - {...props} - /> + <nav + role="navigation" + aria-label="pagination" + className={cn("mx-auto flex w-full justify-center", className)} + {...props} + /> ); Pagination.displayName = "Pagination"; const PaginationContent = React.forwardRef< - HTMLUListElement, - React.ComponentProps<"ul"> + HTMLUListElement, + React.ComponentProps<"ul"> >(({ className, ...props }, ref) => ( - <ul - ref={ref} - className={cn("flex flex-row items-center gap-1", className)} - {...props} - /> + <ul + ref={ref} + className={cn("flex flex-row items-center gap-1", className)} + {...props} + /> )); PaginationContent.displayName = "PaginationContent"; const PaginationItem = React.forwardRef< - HTMLLIElement, - React.ComponentProps<"li"> + HTMLLIElement, + React.ComponentProps<"li"> >(({ className, ...props }, ref) => ( - <li ref={ref} className={cn("", className)} {...props} /> + <li ref={ref} className={cn("", className)} {...props} /> )); PaginationItem.displayName = "PaginationItem"; type PaginationLinkProps = { - isActive?: boolean; + isActive?: boolean; } & Pick<ButtonProps, "size"> & - React.ComponentProps<"a">; + React.ComponentProps<"a">; const PaginationLink = ({ - className, - isActive, - size = "icon", - ...props + className, + isActive, + size = "icon", + ...props }: PaginationLinkProps) => ( - <a - aria-current={isActive ? "page" : undefined} - className={cn( - buttonVariants({ - variant: isActive ? "outline" : "text", - size, - }), - className, - )} - {...props} - /> + <a + aria-current={isActive ? "page" : undefined} + className={cn( + buttonVariants({ + variant: isActive ? "outline" : "text", + size, + }), + className + )} + {...props} + /> ); PaginationLink.displayName = "PaginationLink"; const PaginationPrevious = ({ - className, - ...props + className, + ...props }: React.ComponentProps<typeof PaginationLink>) => ( - <PaginationLink - aria-label="Go to previous page" - size="default" - className={cn("gap-1 pl-2.5", className)} - {...props} - > - <ChevronLeft className="h-4 w-4" /> - <span>Previous</span> - </PaginationLink> + <PaginationLink + aria-label="Go to previous page" + size="default" + className={cn("gap-1 pl-2.5", className)} + {...props} + > + <ChevronLeft className="h-4 w-4" /> + <span>Previous</span> + </PaginationLink> ); PaginationPrevious.displayName = "PaginationPrevious"; const PaginationNext = ({ - className, - ...props + className, + ...props }: React.ComponentProps<typeof PaginationLink>) => ( - <PaginationLink - aria-label="Go to next page" - size="default" - className={cn("gap-1 pr-2.5", className)} - {...props} - > - <span>Next</span> - <ChevronRight className="h-4 w-4" /> - </PaginationLink> + <PaginationLink + aria-label="Go to next page" + size="default" + className={cn("gap-1 pr-2.5", className)} + {...props} + > + <span>Next</span> + <ChevronRight className="h-4 w-4" /> + </PaginationLink> ); PaginationNext.displayName = "PaginationNext"; const PaginationEllipsis = ({ - className, - ...props + className, + ...props }: React.ComponentProps<"span">) => ( - <span - aria-hidden - className={cn("flex h-9 w-9 items-center justify-center", className)} - {...props} - > - <MoreHorizontal className="h-4 w-4" /> - <span className="sr-only">More pages</span> - </span> + <span + aria-hidden + className={cn("flex h-9 w-9 items-center justify-center", className)} + {...props} + > + <MoreHorizontal className="h-4 w-4" /> + <span className="sr-only">More pages</span> + </span> ); PaginationEllipsis.displayName = "PaginationEllipsis"; export { - Pagination, - PaginationContent, - PaginationLink, - PaginationItem, - PaginationPrevious, - PaginationNext, - PaginationEllipsis, + Pagination, + PaginationContent, + PaginationLink, + PaginationItem, + PaginationPrevious, + PaginationNext, + PaginationEllipsis, }; diff --git a/apps/web/src/components/ui/phone-input.tsx b/apps/web/src/components/ui/phone-input.tsx index d682c813..95ad9131 100644 --- a/apps/web/src/components/ui/phone-input.tsx +++ b/apps/web/src/components/ui/phone-input.tsx @@ -1,167 +1,167 @@ -import * as React from "react"; -import { CheckIcon, ChevronsUpDown } from "lucide-react"; -import * as RPNInput from "react-phone-number-input"; -import flags from "react-phone-number-input/flags"; - -import { Button } from "./button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "./command"; -import { Input } from "./input"; -import { Popover, PopoverContent, PopoverTrigger } from "./popover"; -import { ScrollArea } from "./scroll-area"; -import { cn } from "../../lib/utils"; - -type PhoneInputProps = Omit< - React.ComponentProps<"input">, - "onChange" | "value" | "ref" -> & - Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & { - onChange?: (value: RPNInput.Value) => void; - }; - -const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> = - React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>( - ({ className, onChange, defaultCountry = "US", ...props }, ref) => { - return ( - <RPNInput.default - ref={ref} - className={cn("flex", className)} - flagComponent={FlagComponent} - countrySelectComponent={CountrySelect} - inputComponent={InputComponent} - defaultCountry={defaultCountry} - international - addInternationalOption - smartCaret={false} - /** - * Handles the onChange event. - * - * react-phone-number-input might trigger the onChange event as undefined - * when a valid phone number is not entered. To prevent this, - * the value is coerced to an empty string. - * - * @param {E164Number | undefined} value - The entered value - */ - onChange={(value) => onChange?.(value || ("" as RPNInput.Value))} - {...props} - /> - ); - }, - ); -PhoneInput.displayName = "PhoneInput"; - -const InputComponent = React.forwardRef< - HTMLInputElement, - React.ComponentProps<"input"> ->(({ className, ...props }, ref) => ( - <Input - className={cn("rounded-e-lg rounded-s-none", className)} - {...props} - ref={ref} - /> -)); -InputComponent.displayName = "InputComponent"; - -type CountryEntry = { label: string; value: RPNInput.Country | undefined }; - -type CountrySelectProps = { - disabled?: boolean; - value: RPNInput.Country; - options: CountryEntry[]; - onChange: (country: RPNInput.Country) => void; -}; - -const CountrySelect = ({ - disabled, - value: selectedCountry, - options: countryList, - onChange, -}: CountrySelectProps) => { - return ( - <Popover> - <PopoverTrigger asChild> - <Button - type="button" - variant="outline" - className="flex gap-1 rounded-e-none rounded-s-lg border-r-0 px-3 focus:z-10" - disabled={disabled} - > - <FlagComponent - country={selectedCountry} - countryName={selectedCountry} - /> - <ChevronsUpDown - className={cn( - "-mr-2 size-4 opacity-50", - disabled ? "hidden" : "opacity-100", - )} - /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-[300px] p-0"> - <Command> - <CommandInput placeholder="Search country..." /> - <CommandList> - <ScrollArea className="h-72"> - <CommandEmpty>No country found.</CommandEmpty> - <CommandGroup> - {countryList.map(({ value, label }) => - value ? ( - <CountrySelectOption - key={value} - country={value} - countryName={label} - selectedCountry={selectedCountry} - onChange={onChange} - /> - ) : null, - )} - </CommandGroup> - </ScrollArea> - </CommandList> - </Command> - </PopoverContent> - </Popover> - ); -}; - -interface CountrySelectOptionProps extends RPNInput.FlagProps { - selectedCountry: RPNInput.Country; - onChange: (country: RPNInput.Country) => void; -} - -const CountrySelectOption = ({ - country, - countryName, - selectedCountry, - onChange, -}: CountrySelectOptionProps) => { - return ( - <CommandItem className="gap-2" onSelect={() => onChange(country)}> - <FlagComponent country={country} countryName={countryName} /> - <span className="flex-1 text-sm">{countryName}</span> - <span className="text-sm text-foreground/50">{`+${RPNInput.getCountryCallingCode(country)}`}</span> - <CheckIcon - className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`} - /> - </CommandItem> - ); -}; - -const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => { - const Flag = flags[country]; - - return ( - <span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20 [&_svg]:size-full"> - {Flag && <Flag title={countryName} />} - </span> - ); -}; - -export { PhoneInput }; +import * as React from "react";
+import { CheckIcon, ChevronsUpDown } from "lucide-react";
+import * as RPNInput from "react-phone-number-input";
+import flags from "react-phone-number-input/flags";
+
+import { Button } from "./button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "./command";
+import { Input } from "./input";
+import { Popover, PopoverContent, PopoverTrigger } from "./popover";
+import { ScrollArea } from "./scroll-area";
+import { cn } from "../../lib/utils";
+
+type PhoneInputProps = Omit<
+ React.ComponentProps<"input">,
+ "onChange" | "value" | "ref"
+> &
+ Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
+ onChange?: (value: RPNInput.Value) => void;
+ };
+
+const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =
+ React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>(
+ ({ className, onChange, defaultCountry = "US", ...props }, ref) => {
+ return (
+ <RPNInput.default
+ ref={ref}
+ className={cn("flex", className)}
+ flagComponent={FlagComponent}
+ countrySelectComponent={CountrySelect}
+ inputComponent={InputComponent}
+ defaultCountry={defaultCountry}
+ international
+ addInternationalOption
+ smartCaret={false}
+ /**
+ * Handles the onChange event.
+ *
+ * react-phone-number-input might trigger the onChange event as undefined
+ * when a valid phone number is not entered. To prevent this,
+ * the value is coerced to an empty string.
+ *
+ * @param {E164Number | undefined} value - The entered value
+ */
+ onChange={(value) => onChange?.(value || ("" as RPNInput.Value))}
+ {...props}
+ />
+ );
+ }
+ );
+PhoneInput.displayName = "PhoneInput";
+
+const InputComponent = React.forwardRef<
+ HTMLInputElement,
+ React.ComponentProps<"input">
+>(({ className, ...props }, ref) => (
+ <Input
+ className={cn("rounded-e-lg rounded-s-none", className)}
+ {...props}
+ ref={ref}
+ />
+));
+InputComponent.displayName = "InputComponent";
+
+type CountryEntry = { label: string; value: RPNInput.Country | undefined };
+
+type CountrySelectProps = {
+ disabled?: boolean;
+ value: RPNInput.Country;
+ options: CountryEntry[];
+ onChange: (country: RPNInput.Country) => void;
+};
+
+const CountrySelect = ({
+ disabled,
+ value: selectedCountry,
+ options: countryList,
+ onChange,
+}: CountrySelectProps) => {
+ return (
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ type="button"
+ variant="outline"
+ className="flex gap-1 rounded-e-none rounded-s-lg border-r-0 px-3 focus:z-10"
+ disabled={disabled}
+ >
+ <FlagComponent
+ country={selectedCountry}
+ countryName={selectedCountry}
+ />
+ <ChevronsUpDown
+ className={cn(
+ "-mr-2 size-4 opacity-50",
+ disabled ? "hidden" : "opacity-100"
+ )}
+ />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[300px] p-0">
+ <Command>
+ <CommandInput placeholder="Search country..." />
+ <CommandList>
+ <ScrollArea className="h-72">
+ <CommandEmpty>No country found.</CommandEmpty>
+ <CommandGroup>
+ {countryList.map(({ value, label }) =>
+ value ? (
+ <CountrySelectOption
+ key={value}
+ country={value}
+ countryName={label}
+ selectedCountry={selectedCountry}
+ onChange={onChange}
+ />
+ ) : null
+ )}
+ </CommandGroup>
+ </ScrollArea>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ );
+};
+
+interface CountrySelectOptionProps extends RPNInput.FlagProps {
+ selectedCountry: RPNInput.Country;
+ onChange: (country: RPNInput.Country) => void;
+}
+
+const CountrySelectOption = ({
+ country,
+ countryName,
+ selectedCountry,
+ onChange,
+}: CountrySelectOptionProps) => {
+ return (
+ <CommandItem className="gap-2" onSelect={() => onChange(country)}>
+ <FlagComponent country={country} countryName={countryName} />
+ <span className="flex-1 text-sm">{countryName}</span>
+ <span className="text-sm text-foreground/50">{`+${RPNInput.getCountryCallingCode(country)}`}</span>
+ <CheckIcon
+ className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`}
+ />
+ </CommandItem>
+ );
+};
+
+const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
+ const Flag = flags[country];
+
+ return (
+ <span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20 [&_svg]:size-full">
+ {Flag && <Flag title={countryName} />}
+ </span>
+ );
+};
+
+export { PhoneInput };
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index cd304da5..cc4f6508 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -12,21 +12,21 @@ const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverAnchor = PopoverPrimitive.Anchor; const PopoverContent = React.forwardRef< - React.ElementRef<typeof PopoverPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> + React.ElementRef<typeof PopoverPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - <PopoverPrimitive.Portal> - <PopoverPrimitive.Content - ref={ref} - align={align} - sideOffset={sideOffset} - className={cn( - "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - className, - )} - {...props} - /> - </PopoverPrimitive.Portal> + <PopoverPrimitive.Portal> + <PopoverPrimitive.Content + ref={ref} + align={align} + sideOffset={sideOffset} + className={cn( + "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> + </PopoverPrimitive.Portal> )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; diff --git a/apps/web/src/components/ui/progress.tsx b/apps/web/src/components/ui/progress.tsx index d5bbd8e8..283ba17a 100644 --- a/apps/web/src/components/ui/progress.tsx +++ b/apps/web/src/components/ui/progress.tsx @@ -6,22 +6,22 @@ import { Progress as ProgressPrimitive } from "radix-ui"; import { cn } from "../../lib/utils"; const Progress = React.forwardRef< - React.ElementRef<typeof ProgressPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> + React.ElementRef<typeof ProgressPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> >(({ className, value, ...props }, ref) => ( - <ProgressPrimitive.Root - ref={ref} - className={cn( - "relative h-2 w-full overflow-hidden rounded-full bg-primary/20", - className, - )} - {...props} - > - <ProgressPrimitive.Indicator - className="h-full w-full flex-1 bg-primary transition-all" - style={{ transform: `translateX(-${100 - (value || 0)}%)` }} - /> - </ProgressPrimitive.Root> + <ProgressPrimitive.Root + ref={ref} + className={cn( + "relative h-2 w-full overflow-hidden rounded-full bg-primary/20", + className + )} + {...props} + > + <ProgressPrimitive.Indicator + className="h-full w-full flex-1 bg-primary transition-all" + style={{ transform: `translateX(-${100 - (value || 0)}%)` }} + /> + </ProgressPrimitive.Root> )); Progress.displayName = ProgressPrimitive.Root.displayName; diff --git a/apps/web/src/components/ui/radio-group.tsx b/apps/web/src/components/ui/radio-group.tsx index 5aeb3586..cc07d34f 100644 --- a/apps/web/src/components/ui/radio-group.tsx +++ b/apps/web/src/components/ui/radio-group.tsx @@ -7,37 +7,37 @@ import { Circle } from "lucide-react"; import { cn } from "../../lib/utils"; const RadioGroup = React.forwardRef< - React.ElementRef<typeof RadioGroupPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> + React.ElementRef<typeof RadioGroupPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> >(({ className, ...props }, ref) => { - return ( - <RadioGroupPrimitive.Root - className={cn("grid gap-2", className)} - {...props} - ref={ref} - /> - ); + return ( + <RadioGroupPrimitive.Root + className={cn("grid gap-2", className)} + {...props} + ref={ref} + /> + ); }); RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; const RadioGroupItem = React.forwardRef< - React.ElementRef<typeof RadioGroupPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> + React.ElementRef<typeof RadioGroupPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> >(({ className, ...props }, ref) => { - return ( - <RadioGroupPrimitive.Item - ref={ref} - className={cn( - "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", - className, - )} - {...props} - > - <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> - <Circle className="h-3.5 w-3.5 fill-primary" /> - </RadioGroupPrimitive.Indicator> - </RadioGroupPrimitive.Item> - ); + return ( + <RadioGroupPrimitive.Item + ref={ref} + className={cn( + "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", + className + )} + {...props} + > + <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> + <Circle className="h-3.5 w-3.5 fill-primary" /> + </RadioGroupPrimitive.Indicator> + </RadioGroupPrimitive.Item> + ); }); RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; diff --git a/apps/web/src/components/ui/resizable.tsx b/apps/web/src/components/ui/resizable.tsx index fa9fc3de..a4443996 100644 --- a/apps/web/src/components/ui/resizable.tsx +++ b/apps/web/src/components/ui/resizable.tsx @@ -5,34 +5,34 @@ import * as ResizablePrimitive from "react-resizable-panels"; import { cn } from "../../lib/utils"; const ResizablePanelGroup = ({ - className, - ...props + className, + ...props }: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => ( - <ResizablePrimitive.PanelGroup - className={cn( - "flex h-full w-full data-[panel-group-direction=vertical]:flex-col", - className, - )} - {...props} - /> + <ResizablePrimitive.PanelGroup + className={cn( + "flex h-full w-full data-[panel-group-direction=vertical]:flex-col", + className + )} + {...props} + /> ); const ResizablePanel = ResizablePrimitive.Panel; const ResizableHandle = ({ - withHandle, - className, - ...props + withHandle, + className, + ...props }: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { - withHandle?: boolean; + withHandle?: boolean; }) => ( - <ResizablePrimitive.PanelResizeHandle - className={cn( - "relative flex w-px items-center justify-center bg-transparent after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90", - className, - )} - {...props} - /> + <ResizablePrimitive.PanelResizeHandle + className={cn( + "relative flex w-px items-center justify-center bg-transparent after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90", + className + )} + {...props} + /> ); export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index 882d8745..6b7c3c53 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -6,42 +6,42 @@ import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"; import { cn } from "../../lib/utils"; const ScrollArea = React.forwardRef< - React.ElementRef<typeof ScrollAreaPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> + React.ElementRef<typeof ScrollAreaPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> >(({ className, children, ...props }, ref) => ( - <ScrollAreaPrimitive.Root - ref={ref} - className={cn("relative overflow-hidden", className)} - {...props} - > - <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> - {children} - </ScrollAreaPrimitive.Viewport> - <ScrollBar /> - <ScrollAreaPrimitive.Corner /> - </ScrollAreaPrimitive.Root> + <ScrollAreaPrimitive.Root + ref={ref} + className={cn("relative overflow-hidden", className)} + {...props} + > + <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> + {children} + </ScrollAreaPrimitive.Viewport> + <ScrollBar /> + <ScrollAreaPrimitive.Corner /> + </ScrollAreaPrimitive.Root> )); ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; const ScrollBar = React.forwardRef< - React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, - React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> + React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> >(({ className, orientation = "vertical", ...props }, ref) => ( - <ScrollAreaPrimitive.ScrollAreaScrollbar - ref={ref} - orientation={orientation} - className={cn( - "flex touch-none select-none transition-colors", - orientation === "vertical" && - "h-full w-2.5 border-l border-l-transparent p-[1px]", - orientation === "horizontal" && - "h-2.5 flex-col border-t border-t-transparent p-[1px]", - className, - )} - {...props} - > - <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" /> - </ScrollAreaPrimitive.ScrollAreaScrollbar> + <ScrollAreaPrimitive.ScrollAreaScrollbar + ref={ref} + orientation={orientation} + className={cn( + "flex touch-none select-none transition-colors", + orientation === "vertical" && + "h-full w-2.5 border-l border-l-transparent p-[1px]", + orientation === "horizontal" && + "h-2.5 flex-col border-t border-t-transparent p-[1px]", + className + )} + {...props} + > + <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" /> + </ScrollAreaPrimitive.ScrollAreaScrollbar> )); ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 96dd59d0..911b3e5d 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -14,165 +14,165 @@ const SelectGroup = SelectPrimitive.Group; const SelectValue = SelectPrimitive.Value; const selectItemVariants = cva( - "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50", - { - variants: { - variant: { - default: "focus:opacity-65 focus:text-accent-foreground", - destructive: "text-destructive focus:text-destructive/80", - }, - }, - defaultVariants: { - variant: "default", - }, - }, + "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + { + variants: { + variant: { + default: "focus:opacity-65 focus:text-accent-foreground", + destructive: "text-destructive focus:text-destructive/80", + }, + }, + defaultVariants: { + variant: "default", + }, + } ); const SelectTrigger = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.Trigger>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> + React.ElementRef<typeof SelectPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> >(({ className, children, ...props }, ref) => ( - <SelectPrimitive.Trigger - ref={ref} - className={cn( - "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", - className, - )} - {...props} - > - {children} - <SelectPrimitive.Icon asChild> - <ChevronDown className="h-4 w-4 opacity-50" /> - </SelectPrimitive.Icon> - </SelectPrimitive.Trigger> + <SelectPrimitive.Trigger + ref={ref} + className={cn( + "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + className + )} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <ChevronDown className="h-4 w-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> )); SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; const SelectScrollUpButton = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> + React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> >(({ className, ...props }, ref) => ( - <SelectPrimitive.ScrollUpButton - ref={ref} - className={cn( - "flex cursor-default items-center justify-center py-1", - className, - )} - {...props} - > - <ChevronUp className="h-4 w-4" /> - </SelectPrimitive.ScrollUpButton> + <SelectPrimitive.ScrollUpButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className + )} + {...props} + > + <ChevronUp className="h-4 w-4" /> + </SelectPrimitive.ScrollUpButton> )); SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; const SelectScrollDownButton = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> + React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> >(({ className, ...props }, ref) => ( - <SelectPrimitive.ScrollDownButton - ref={ref} - className={cn( - "flex cursor-default items-center justify-center py-1", - className, - )} - {...props} - > - <ChevronDown className="h-4 w-4" /> - </SelectPrimitive.ScrollDownButton> + <SelectPrimitive.ScrollDownButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className + )} + {...props} + > + <ChevronDown className="h-4 w-4" /> + </SelectPrimitive.ScrollDownButton> )); SelectScrollDownButton.displayName = - SelectPrimitive.ScrollDownButton.displayName; + SelectPrimitive.ScrollDownButton.displayName; const SelectContent = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> + React.ElementRef<typeof SelectPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> >(({ className, children, position = "popper", ...props }, ref) => ( - <SelectPrimitive.Portal> - <SelectPrimitive.Content - ref={ref} - className={cn( - "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - position === "popper" && - "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", - className, - )} - position={position} - onCloseAutoFocus={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - {...props} - > - <SelectScrollUpButton /> - <SelectPrimitive.Viewport - className={cn( - "p-1", - position === "popper" && - "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]", - )} - > - {children} - </SelectPrimitive.Viewport> - <SelectScrollDownButton /> - </SelectPrimitive.Content> - </SelectPrimitive.Portal> + <SelectPrimitive.Portal> + <SelectPrimitive.Content + ref={ref} + className={cn( + "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + position === "popper" && + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + className + )} + position={position} + onCloseAutoFocus={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + className={cn( + "p-1", + position === "popper" && + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" + )} + > + {children} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> )); SelectContent.displayName = SelectPrimitive.Content.displayName; const SelectLabel = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.Label>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> + React.ElementRef<typeof SelectPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> >(({ className, ...props }, ref) => ( - <SelectPrimitive.Label - ref={ref} - className={cn("px-2 py-1.5 text-sm font-semibold", className)} - {...props} - /> + <SelectPrimitive.Label + ref={ref} + className={cn("px-2 py-1.5 text-sm font-semibold", className)} + {...props} + /> )); SelectLabel.displayName = SelectPrimitive.Label.displayName; const SelectItem = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & { - variant?: VariantProps<typeof selectItemVariants>["variant"]; - } + React.ElementRef<typeof SelectPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & { + variant?: VariantProps<typeof selectItemVariants>["variant"]; + } >(({ className, children, variant = "default", ...props }, ref) => ( - <SelectPrimitive.Item - ref={ref} - className={cn(selectItemVariants({ variant }), className)} - {...props} - > - <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> - <SelectPrimitive.ItemIndicator> - <Check className="h-4 w-4" /> - </SelectPrimitive.ItemIndicator> - </span> - <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> - </SelectPrimitive.Item> + <SelectPrimitive.Item + ref={ref} + className={cn(selectItemVariants({ variant }), className)} + {...props} + > + <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </SelectPrimitive.ItemIndicator> + </span> + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> )); SelectItem.displayName = SelectPrimitive.Item.displayName; const SelectSeparator = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.Separator>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> + React.ElementRef<typeof SelectPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> >(({ className, ...props }, ref) => ( - <SelectPrimitive.Separator - ref={ref} - className={cn("-mx-1 my-1 h-px bg-foreground/10", className)} - {...props} - /> + <SelectPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-foreground/10", className)} + {...props} + /> )); SelectSeparator.displayName = SelectPrimitive.Separator.displayName; export { - Select, - SelectGroup, - SelectValue, - SelectTrigger, - SelectContent, - SelectLabel, - SelectItem, - SelectSeparator, - SelectScrollUpButton, - SelectScrollDownButton, + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, }; diff --git a/apps/web/src/components/ui/separator.tsx b/apps/web/src/components/ui/separator.tsx index b82f9120..6138cabf 100644 --- a/apps/web/src/components/ui/separator.tsx +++ b/apps/web/src/components/ui/separator.tsx @@ -6,25 +6,25 @@ import { Separator as SeparatorPrimitive } from "radix-ui"; import { cn } from "../../lib/utils"; const Separator = React.forwardRef< - React.ElementRef<typeof SeparatorPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> + React.ElementRef<typeof SeparatorPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> >( - ( - { className, orientation = "horizontal", decorative = true, ...props }, - ref, - ) => ( - <SeparatorPrimitive.Root - ref={ref} - decorative={decorative} - orientation={orientation} - className={cn( - "shrink-0 bg-border", - orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", - className, - )} - {...props} - /> - ), + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + <SeparatorPrimitive.Root + ref={ref} + decorative={decorative} + orientation={orientation} + className={cn( + "shrink-0 bg-border", + orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", + className + )} + {...props} + /> + ) ); Separator.displayName = SeparatorPrimitive.Root.displayName; diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx index 033f02f9..6aa092dc 100644 --- a/apps/web/src/components/ui/sheet.tsx +++ b/apps/web/src/components/ui/sheet.tsx @@ -16,125 +16,125 @@ const SheetClose = SheetPrimitive.Close; const SheetPortal = SheetPrimitive.Portal; const SheetOverlay = React.forwardRef< - React.ElementRef<typeof SheetPrimitive.Overlay>, - React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay> + React.ElementRef<typeof SheetPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay> >(({ className, ...props }, ref) => ( - <SheetPrimitive.Overlay - className={cn( - "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", - className, - )} - {...props} - ref={ref} - /> + <SheetPrimitive.Overlay + className={cn( + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className + )} + {...props} + ref={ref} + /> )); SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; const sheetVariants = cva( - "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease data-[state=closed]:duration-250 data-[state=open]:duration-250 data-[state=open]:animate-in data-[state=closed]:animate-out", - { - variants: { - side: { - top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", - bottom: - "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", - left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", - right: - "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", - }, - }, - defaultVariants: { - side: "right", - }, - }, + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease data-[state=closed]:duration-250 data-[state=open]:duration-250 data-[state=open]:animate-in data-[state=closed]:animate-out", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } ); interface SheetContentProps - extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, - VariantProps<typeof sheetVariants> {} + extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, + VariantProps<typeof sheetVariants> {} const SheetContent = React.forwardRef< - React.ElementRef<typeof SheetPrimitive.Content>, - SheetContentProps + React.ElementRef<typeof SheetPrimitive.Content>, + SheetContentProps >(({ side = "right", className, children, ...props }, ref) => ( - <SheetPortal> - <SheetOverlay /> - <SheetPrimitive.Content - ref={ref} - className={cn(sheetVariants({ side }), className)} - {...props} - > - <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> - <X className="h-4 w-4" /> - <span className="sr-only">Close</span> - </SheetPrimitive.Close> - {children} - </SheetPrimitive.Content> - </SheetPortal> + <SheetPortal> + <SheetOverlay /> + <SheetPrimitive.Content + ref={ref} + className={cn(sheetVariants({ side }), className)} + {...props} + > + <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> + <X className="h-4 w-4" /> + <span className="sr-only">Close</span> + </SheetPrimitive.Close> + {children} + </SheetPrimitive.Content> + </SheetPortal> )); SheetContent.displayName = SheetPrimitive.Content.displayName; const SheetHeader = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col space-y-2 text-center sm:text-left", - className, - )} - {...props} - /> + <div + className={cn( + "flex flex-col space-y-2 text-center sm:text-left", + className + )} + {...props} + /> ); SheetHeader.displayName = "SheetHeader"; const SheetFooter = ({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", - className, - )} - {...props} - /> + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className + )} + {...props} + /> ); SheetFooter.displayName = "SheetFooter"; const SheetTitle = React.forwardRef< - React.ElementRef<typeof SheetPrimitive.Title>, - React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> + React.ElementRef<typeof SheetPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> >(({ className, ...props }, ref) => ( - <SheetPrimitive.Title - ref={ref} - className={cn("text-lg font-semibold text-foreground", className)} - {...props} - /> + <SheetPrimitive.Title + ref={ref} + className={cn("text-lg font-semibold text-foreground", className)} + {...props} + /> )); SheetTitle.displayName = SheetPrimitive.Title.displayName; const SheetDescription = React.forwardRef< - React.ElementRef<typeof SheetPrimitive.Description>, - React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> + React.ElementRef<typeof SheetPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> >(({ className, ...props }, ref) => ( - <SheetPrimitive.Description - ref={ref} - className={cn("text-sm text-muted-foreground", className)} - {...props} - /> + <SheetPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> )); SheetDescription.displayName = SheetPrimitive.Description.displayName; export { - Sheet, - SheetPortal, - SheetOverlay, - SheetTrigger, - SheetClose, - SheetContent, - SheetHeader, - SheetFooter, - SheetTitle, - SheetDescription, + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, }; diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 6ea6ab32..c1583503 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -13,10 +13,10 @@ import { Separator } from "./separator"; import { Sheet, SheetContent, SheetTitle } from "./sheet"; import { Skeleton } from "./skeleton"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, } from "./tooltip"; const SIDEBAR_COOKIE_NAME = "sidebar:state"; @@ -27,745 +27,737 @@ const SIDEBAR_WIDTH_ICON = "3rem"; const SIDEBAR_KEYBOARD_SHORTCUT = "b"; type SidebarContext = { - state: "expanded" | "collapsed"; - open: boolean; - setOpen: (open: boolean) => void; - openMobile: boolean; - setOpenMobile: (open: boolean) => void; - isMobile: boolean; - toggleSidebar: () => void; + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; }; const SidebarContext = React.createContext<SidebarContext | null>(null); function useSidebar() { - const context = React.useContext(SidebarContext); - if (!context) { - throw new Error("useSidebar must be used within a SidebarProvider."); - } + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } - return context; + return context; } const SidebarProvider = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - defaultOpen?: boolean; - open?: boolean; - onOpenChange?: (open: boolean) => void; - } + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + } >( - ( - { - defaultOpen = true, - open: openProp, - onOpenChange: setOpenProp, - className, - style, - children, - ...props - }, - ref, - ) => { - const isMobile = useIsMobile(); - const [openMobile, setOpenMobile] = React.useState(false); + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref + ) => { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); - // This is the internal state of the sidebar. - // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(defaultOpen); - const open = openProp ?? _open; - const setOpen = React.useCallback( - (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === "function" ? value(open) : value; - if (setOpenProp) { - setOpenProp(openState); - } else { - _setOpen(openState); - } + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } - // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; - }, - [setOpenProp, open], - ); + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open] + ); - // Helper to toggle the sidebar. - const toggleSidebar = React.useCallback(() => { - return isMobile - ? setOpenMobile((open) => !open) - : setOpen((open) => !open); - }, [isMobile, setOpen, setOpenMobile]); + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile + ? setOpenMobile((open) => !open) + : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); - // Adds a keyboard shortcut to toggle the sidebar. - React.useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.key === SIDEBAR_KEYBOARD_SHORTCUT && - (event.metaKey || event.ctrlKey) - ) { - event.preventDefault(); - toggleSidebar(); - } - }; + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault(); + toggleSidebar(); + } + }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [toggleSidebar]); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); - // We add a state so that we can do data-state="expanded" or "collapsed". - // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed"; + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; - const contextValue = React.useMemo<SidebarContext>( - () => ({ - state, - open, - setOpen, - isMobile, - openMobile, - setOpenMobile, - toggleSidebar, - }), - [ - state, - open, - setOpen, - isMobile, - openMobile, - setOpenMobile, - toggleSidebar, - ], - ); + const contextValue = React.useMemo<SidebarContext>( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ); - return ( - <SidebarContext.Provider value={contextValue}> - <TooltipProvider delayDuration={0}> - <div - style={ - { - "--sidebar-width": SIDEBAR_WIDTH, - "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, - ...style, - } as React.CSSProperties - } - className={cn( - "group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", - className, - )} - ref={ref} - {...props} - > - {children} - </div> - </TooltipProvider> - </SidebarContext.Provider> - ); - }, + return ( + <SidebarContext.Provider value={contextValue}> + <TooltipProvider delayDuration={0}> + <div + style={ + { + "--sidebar-width": SIDEBAR_WIDTH, + "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, + ...style, + } as React.CSSProperties + } + className={cn( + "group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", + className + )} + ref={ref} + {...props} + > + {children} + </div> + </TooltipProvider> + </SidebarContext.Provider> + ); + } ); SidebarProvider.displayName = "SidebarProvider"; const Sidebar = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - side?: "left" | "right"; - variant?: "sidebar" | "floating" | "inset"; - collapsible?: "offcanvas" | "icon" | "none"; - } + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; + } >( - ( - { - side = "left", - variant = "sidebar", - collapsible = "offcanvas", - className, - children, - ...props - }, - ref, - ) => { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + ( + { + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props + }, + ref + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); - if (collapsible === "none") { - return ( - <div - className={cn( - "flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", - className, - )} - ref={ref} - {...props} - > - {children} - </div> - ); - } + if (collapsible === "none") { + return ( + <div + className={cn( + "flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", + className + )} + ref={ref} + {...props} + > + {children} + </div> + ); + } - if (isMobile) { - return ( - <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> - <SheetContent - data-sidebar="sidebar" - data-mobile="true" - className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" - style={ - { - "--sidebar-width": SIDEBAR_WIDTH_MOBILE, - } as React.CSSProperties - } - side={side} - > - <SheetTitle className="sr-only">Navigation Menu</SheetTitle> - <div className="flex h-full w-full flex-col">{children}</div> - </SheetContent> - </Sheet> - ); - } + if (isMobile) { + return ( + <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> + <SheetContent + data-sidebar="sidebar" + data-mobile="true" + className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" + style={ + { + "--sidebar-width": SIDEBAR_WIDTH_MOBILE, + } as React.CSSProperties + } + side={side} + > + <SheetTitle className="sr-only">Navigation Menu</SheetTitle> + <div className="flex h-full w-full flex-col">{children}</div> + </SheetContent> + </Sheet> + ); + } - return ( - <div - ref={ref} - className="group peer hidden md:block text-sidebar-foreground" - data-state={state} - data-collapsible={state === "collapsed" ? collapsible : ""} - data-variant={variant} - data-side={side} - > - {/* This is what handles the sidebar gap on desktop */} - <div - className={cn( - "duration-200 relative h-full w-[--sidebar-width] bg-transparent transition-[width] ease-linear", - "group-data-[collapsible=offcanvas]:w-0", - "group-data-[side=right]:rotate-180", - variant === "floating" || variant === "inset" - ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]" - : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]", - )} - /> - <div - className={cn( - "duration-200 fixed h-[calc(100vh-4rem)] top-16 z-10 hidden w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex", - side === "left" - ? "left-0 border-r group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" - : "right-0 border-l group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", - variant === "floating" || variant === "inset" - ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]" - : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]", - className, - )} - {...props} - > - <div - data-sidebar="sidebar" - className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow" - > - {children} - </div> - </div> - </div> - ); - }, + return ( + <div + ref={ref} + className="group peer hidden md:block text-sidebar-foreground" + data-state={state} + data-collapsible={state === "collapsed" ? collapsible : ""} + data-variant={variant} + data-side={side} + > + {/* This is what handles the sidebar gap on desktop */} + <div + className={cn( + "duration-200 relative h-full w-[--sidebar-width] bg-transparent transition-[width] ease-linear", + "group-data-[collapsible=offcanvas]:w-0", + "group-data-[side=right]:rotate-180", + variant === "floating" || variant === "inset" + ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]" + : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]" + )} + /> + <div + className={cn( + "duration-200 fixed h-[calc(100vh-4rem)] top-16 z-10 hidden w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex", + side === "left" + ? "left-0 border-r group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" + : "right-0 border-l group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", + variant === "floating" || variant === "inset" + ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]" + : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]", + className + )} + {...props} + > + <div + data-sidebar="sidebar" + className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow" + > + {children} + </div> + </div> + </div> + ); + } ); Sidebar.displayName = "Sidebar"; const SidebarTrigger = React.forwardRef< - React.ElementRef<typeof Button>, - React.ComponentProps<typeof Button> + React.ElementRef<typeof Button>, + React.ComponentProps<typeof Button> >(({ className, onClick, ...props }, ref) => { - const { toggleSidebar } = useSidebar(); + const { toggleSidebar } = useSidebar(); - return ( - <Button - ref={ref} - data-sidebar="trigger" - variant="text" - size="icon" - className={cn("h-8 w-8", className)} - onClick={(event) => { - onClick?.(event); - toggleSidebar(); - }} - {...props} - > - <PanelLeft /> - <span className="sr-only">Toggle Sidebar</span> - </Button> - ); + return ( + <Button + ref={ref} + data-sidebar="trigger" + variant="text" + size="icon" + className={cn("h-8 w-8", className)} + onClick={(event) => { + onClick?.(event); + toggleSidebar(); + }} + {...props} + > + <PanelLeft /> + <span className="sr-only">Toggle Sidebar</span> + </Button> + ); }); SidebarTrigger.displayName = "SidebarTrigger"; const SidebarRail = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<"button"> + HTMLButtonElement, + React.ComponentProps<"button"> >(({ className, ...props }, ref) => { - const { toggleSidebar } = useSidebar(); + const { toggleSidebar } = useSidebar(); - return ( - <button - ref={ref} - data-sidebar="rail" - aria-label="Toggle Sidebar" - tabIndex={-1} - onClick={toggleSidebar} - title="Toggle Sidebar" - className={cn( - "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex", - "[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize", - "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", - "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar", - "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", - "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", - className, - )} - {...props} - /> - ); + return ( + <button + ref={ref} + data-sidebar="rail" + aria-label="Toggle Sidebar" + tabIndex={-1} + onClick={toggleSidebar} + title="Toggle Sidebar" + className={cn( + "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex", + "[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize", + "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", + "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar", + "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", + "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", + className + )} + {...props} + /> + ); }); SidebarRail.displayName = "SidebarRail"; const SidebarInset = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"main"> + HTMLDivElement, + React.ComponentProps<"main"> >(({ className, ...props }, ref) => { - return ( - <main - ref={ref} - className={cn( - "relative flex min-h-svh flex-1 flex-col bg-background", - "peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow", - className, - )} - {...props} - /> - ); + return ( + <main + ref={ref} + className={cn( + "relative flex min-h-svh flex-1 flex-col bg-background", + "peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow", + className + )} + {...props} + /> + ); }); SidebarInset.displayName = "SidebarInset"; const SidebarInput = React.forwardRef< - React.ElementRef<typeof Input>, - React.ComponentProps<typeof Input> + React.ElementRef<typeof Input>, + React.ComponentProps<typeof Input> >(({ className, ...props }, ref) => { - return ( - <Input - ref={ref} - data-sidebar="input" - className={cn( - "h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring", - className, - )} - {...props} - /> - ); + return ( + <Input + ref={ref} + data-sidebar="input" + className={cn( + "h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring", + className + )} + {...props} + /> + ); }); SidebarInput.displayName = "SidebarInput"; const SidebarHeader = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> + HTMLDivElement, + React.ComponentProps<"div"> >(({ className, ...props }, ref) => { - return ( - <div - ref={ref} - data-sidebar="header" - className={cn("flex flex-col gap-2 p-2", className)} - {...props} - /> - ); + return ( + <div + ref={ref} + data-sidebar="header" + className={cn("flex flex-col gap-2 p-2", className)} + {...props} + /> + ); }); SidebarHeader.displayName = "SidebarHeader"; const SidebarFooter = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> + HTMLDivElement, + React.ComponentProps<"div"> >(({ className, ...props }, ref) => { - return ( - <div - ref={ref} - data-sidebar="footer" - className={cn("flex flex-col gap-2 p-2", className)} - {...props} - /> - ); + return ( + <div + ref={ref} + data-sidebar="footer" + className={cn("flex flex-col gap-2 p-2", className)} + {...props} + /> + ); }); SidebarFooter.displayName = "SidebarFooter"; const SidebarSeparator = React.forwardRef< - React.ElementRef<typeof Separator>, - React.ComponentProps<typeof Separator> + React.ElementRef<typeof Separator>, + React.ComponentProps<typeof Separator> >(({ className, ...props }, ref) => { - return ( - <Separator - ref={ref} - data-sidebar="separator" - className={cn("mx-2 w-auto bg-sidebar-border", className)} - {...props} - /> - ); + return ( + <Separator + ref={ref} + data-sidebar="separator" + className={cn("mx-2 w-auto bg-sidebar-border", className)} + {...props} + /> + ); }); SidebarSeparator.displayName = "SidebarSeparator"; const SidebarContent = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> + HTMLDivElement, + React.ComponentProps<"div"> >(({ className, ...props }, ref) => { - return ( - <div - ref={ref} - data-sidebar="content" - className={cn( - "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", - className, - )} - {...props} - /> - ); + return ( + <div + ref={ref} + data-sidebar="content" + className={cn( + "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", + className + )} + {...props} + /> + ); }); SidebarContent.displayName = "SidebarContent"; const SidebarGroup = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> + HTMLDivElement, + React.ComponentProps<"div"> >(({ className, ...props }, ref) => { - return ( - <div - ref={ref} - data-sidebar="group" - className={cn("relative flex w-full min-w-0 flex-col p-2", className)} - {...props} - /> - ); + return ( + <div + ref={ref} + data-sidebar="group" + className={cn("relative flex w-full min-w-0 flex-col p-2", className)} + {...props} + /> + ); }); SidebarGroup.displayName = "SidebarGroup"; const SidebarGroupLabel = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { asChild?: boolean } + HTMLDivElement, + React.ComponentProps<"div"> & { asChild?: boolean } >(({ className, asChild = false, ...props }, ref) => { - const Comp = asChild ? SlotPrimitive.Slot : "div"; + const Comp = asChild ? SlotPrimitive.Slot : "div"; - return ( - <Comp - ref={ref as any} - data-sidebar="group-label" - className={cn( - "duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", - className, - )} - {...props} - /> - ); + return ( + <Comp + ref={ref as any} + data-sidebar="group-label" + className={cn( + "duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", + className + )} + {...props} + /> + ); }); SidebarGroupLabel.displayName = "SidebarGroupLabel"; const SidebarGroupAction = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<"button"> & { asChild?: boolean } + HTMLButtonElement, + React.ComponentProps<"button"> & { asChild?: boolean } >(({ className, asChild = false, ...props }, ref) => { - const Comp = asChild ? SlotPrimitive.Slot : "button"; + const Comp = asChild ? SlotPrimitive.Slot : "button"; - return ( - <Comp - ref={ref as any} - data-sidebar="group-action" - className={cn( - "absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 after:md:hidden", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> - ); + return ( + <Comp + ref={ref as any} + data-sidebar="group-action" + className={cn( + "absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 after:md:hidden", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> + ); }); SidebarGroupAction.displayName = "SidebarGroupAction"; const SidebarGroupContent = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> + HTMLDivElement, + React.ComponentProps<"div"> >(({ className, ...props }, ref) => ( - <div - ref={ref} - data-sidebar="group-content" - className={cn("w-full text-sm", className)} - {...props} - /> + <div + ref={ref} + data-sidebar="group-content" + className={cn("w-full text-sm", className)} + {...props} + /> )); SidebarGroupContent.displayName = "SidebarGroupContent"; const SidebarMenu = React.forwardRef< - HTMLUListElement, - React.ComponentProps<"ul"> + HTMLUListElement, + React.ComponentProps<"ul"> >(({ className, ...props }, ref) => ( - <ul - ref={ref} - data-sidebar="menu" - className={cn("flex w-full min-w-0 flex-col gap-1", className)} - {...props} - /> + <ul + ref={ref} + data-sidebar="menu" + className={cn("flex w-full min-w-0 flex-col gap-1", className)} + {...props} + /> )); SidebarMenu.displayName = "SidebarMenu"; const SidebarMenuItem = React.forwardRef< - HTMLLIElement, - React.ComponentProps<"li"> + HTMLLIElement, + React.ComponentProps<"li"> >(({ className, ...props }, ref) => ( - <li - ref={ref} - data-sidebar="menu-item" - className={cn("group/menu-item relative", className)} - {...props} - /> + <li + ref={ref} + data-sidebar="menu-item" + className={cn("group/menu-item relative", className)} + {...props} + /> )); SidebarMenuItem.displayName = "SidebarMenuItem"; const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", - { - variants: { - variant: { - default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", - outline: - "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", - }, - size: { - default: "h-8 text-sm", - sm: "h-7 text-xs", - lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, + "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + outline: + "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", + }, + size: { + default: "h-8 text-sm", + sm: "h-7 text-xs", + lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } ); const SidebarMenuButton = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<"button"> & { - asChild?: boolean; - isActive?: boolean; - tooltip?: string | React.ComponentProps<typeof TooltipContent>; - } & VariantProps<typeof sidebarMenuButtonVariants> + HTMLButtonElement, + React.ComponentProps<"button"> & { + asChild?: boolean; + isActive?: boolean; + tooltip?: string | React.ComponentProps<typeof TooltipContent>; + } & VariantProps<typeof sidebarMenuButtonVariants> >( - ( - { - asChild = false, - isActive = false, - variant = "default", - size = "default", - tooltip, - className, - ...props - }, - ref, - ) => { - const Comp = asChild ? SlotPrimitive.Slot : "button"; - const { isMobile, state } = useSidebar(); + ( + { + asChild = false, + isActive = false, + variant = "default", + size = "default", + tooltip, + className, + ...props + }, + ref + ) => { + const Comp = asChild ? SlotPrimitive.Slot : "button"; + const { isMobile, state } = useSidebar(); - const button = ( - <Comp - ref={ref as any} - data-sidebar="menu-button" - data-size={size} - data-active={isActive} - className={cn(sidebarMenuButtonVariants({ variant, size }), className)} - {...props} - /> - ); + const button = ( + <Comp + ref={ref as any} + data-sidebar="menu-button" + data-size={size} + data-active={isActive} + className={cn(sidebarMenuButtonVariants({ variant, size }), className)} + {...props} + /> + ); - if (!tooltip) { - return button; - } + if (!tooltip) { + return button; + } - if (typeof tooltip === "string") { - tooltip = { - children: tooltip, - }; - } + if (typeof tooltip === "string") { + tooltip = { + children: tooltip, + }; + } - return ( - <Tooltip> - <TooltipTrigger asChild>{button}</TooltipTrigger> - <TooltipContent - side="right" - align="center" - hidden={state !== "collapsed" || isMobile} - {...tooltip} - /> - </Tooltip> - ); - }, + return ( + <Tooltip> + <TooltipTrigger asChild>{button}</TooltipTrigger> + <TooltipContent + side="right" + align="center" + hidden={state !== "collapsed" || isMobile} + {...tooltip} + /> + </Tooltip> + ); + } ); SidebarMenuButton.displayName = "SidebarMenuButton"; const SidebarMenuAction = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<"button"> & { - asChild?: boolean; - showOnHover?: boolean; - } + HTMLButtonElement, + React.ComponentProps<"button"> & { + asChild?: boolean; + showOnHover?: boolean; + } >(({ className, asChild = false, showOnHover = false, ...props }, ref) => { - const Comp = asChild ? SlotPrimitive.Slot : "button"; + const Comp = asChild ? SlotPrimitive.Slot : "button"; - return ( - <Comp - ref={ref as any} - data-sidebar="menu-action" - className={cn( - "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 after:md:hidden", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - showOnHover && - "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", - className, - )} - {...props} - /> - ); + return ( + <Comp + ref={ref as any} + data-sidebar="menu-action" + className={cn( + "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 after:md:hidden", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden", + showOnHover && + "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", + className + )} + {...props} + /> + ); }); SidebarMenuAction.displayName = "SidebarMenuAction"; const SidebarMenuBadge = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> + HTMLDivElement, + React.ComponentProps<"div"> >(({ className, ...props }, ref) => ( - <div - ref={ref} - data-sidebar="menu-badge" - className={cn( - "absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none", - "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> + <div + ref={ref} + data-sidebar="menu-badge" + className={cn( + "absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none", + "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> )); SidebarMenuBadge.displayName = "SidebarMenuBadge"; const SidebarMenuSkeleton = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - showIcon?: boolean; - } + HTMLDivElement, + React.ComponentProps<"div"> & { + showIcon?: boolean; + } >(({ className, showIcon = false, ...props }, ref) => { - // Random width between 50 to 90%. - const width = React.useMemo(() => { - return `${Math.floor(Math.random() * 40) + 50}%`; - }, []); + // Random width between 50 to 90%. + const width = React.useMemo(() => { + return `${Math.floor(Math.random() * 40) + 50}%`; + }, []); - return ( - <div - ref={ref} - data-sidebar="menu-skeleton" - className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)} - {...props} - > - {showIcon && ( - <Skeleton - className="size-4 rounded-md" - data-sidebar="menu-skeleton-icon" - /> - )} - <Skeleton - className="h-4 flex-1 max-w-[--skeleton-width]" - data-sidebar="menu-skeleton-text" - style={ - { - "--skeleton-width": width, - } as React.CSSProperties - } - /> - </div> - ); + return ( + <div + ref={ref} + data-sidebar="menu-skeleton" + className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)} + {...props} + > + {showIcon && ( + <Skeleton + className="size-4 rounded-md" + data-sidebar="menu-skeleton-icon" + /> + )} + <Skeleton + className="h-4 flex-1 max-w-[--skeleton-width]" + data-sidebar="menu-skeleton-text" + style={ + { + "--skeleton-width": width, + } as React.CSSProperties + } + /> + </div> + ); }); SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"; const SidebarMenuSub = React.forwardRef< - HTMLUListElement, - React.ComponentProps<"ul"> + HTMLUListElement, + React.ComponentProps<"ul"> >(({ className, ...props }, ref) => ( - <ul - ref={ref} - data-sidebar="menu-sub" - className={cn( - "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> + <ul + ref={ref} + data-sidebar="menu-sub" + className={cn( + "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> )); SidebarMenuSub.displayName = "SidebarMenuSub"; const SidebarMenuSubItem = React.forwardRef< - HTMLLIElement, - React.ComponentProps<"li"> + HTMLLIElement, + React.ComponentProps<"li"> >(({ ...props }, ref) => <li ref={ref} {...props} />); SidebarMenuSubItem.displayName = "SidebarMenuSubItem"; const SidebarMenuSubButton = React.forwardRef< - HTMLAnchorElement, - React.ComponentProps<"a"> & { - asChild?: boolean; - size?: "sm" | "md"; - isActive?: boolean; - } + HTMLAnchorElement, + React.ComponentProps<"a"> & { + asChild?: boolean; + size?: "sm" | "md"; + isActive?: boolean; + } >(({ asChild = false, size = "md", isActive, className, ...props }, ref) => { - const Comp = asChild ? SlotPrimitive.Slot : "a"; + const Comp = asChild ? SlotPrimitive.Slot : "a"; - return ( - <Comp - ref={ref as any} - data-sidebar="menu-sub-button" - data-size={size} - data-active={isActive} - className={cn( - "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", - "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", - size === "sm" && "text-xs", - size === "md" && "text-sm", - "group-data-[collapsible=icon]:hidden", - className, - )} - {...props} - /> - ); + return ( + <Comp + ref={ref as any} + data-sidebar="menu-sub-button" + data-size={size} + data-active={isActive} + className={cn( + "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", + "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", + size === "sm" && "text-xs", + size === "md" && "text-sm", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> + ); }); SidebarMenuSubButton.displayName = "SidebarMenuSubButton"; export { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupAction, - SidebarGroupContent, - SidebarGroupLabel, - SidebarHeader, - SidebarInput, - SidebarInset, - SidebarMenu, - SidebarMenuAction, - SidebarMenuBadge, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSkeleton, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, - SidebarProvider, - SidebarRail, - SidebarSeparator, - SidebarTrigger, - useSidebar, + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInput, + SidebarInset, + SidebarMenu, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSkeleton, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarRail, + SidebarSeparator, + SidebarTrigger, + useSidebar, }; diff --git a/apps/web/src/components/ui/skeleton.tsx b/apps/web/src/components/ui/skeleton.tsx index 4ef76077..4525c3a0 100644 --- a/apps/web/src/components/ui/skeleton.tsx +++ b/apps/web/src/components/ui/skeleton.tsx @@ -1,15 +1,15 @@ import { cn } from "../../lib/utils"; function Skeleton({ - className, - ...props + className, + ...props }: React.HTMLAttributes<HTMLDivElement>) { - return ( - <div - className={cn("animate-pulse rounded-md bg-primary/10", className)} - {...props} - /> - ); + return ( + <div + className={cn("animate-pulse rounded-md bg-primary/10", className)} + {...props} + /> + ); } export { Skeleton }; diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx index 7c42e862..4789ae17 100644 --- a/apps/web/src/components/ui/slider.tsx +++ b/apps/web/src/components/ui/slider.tsx @@ -6,22 +6,22 @@ import { Slider as SliderPrimitive } from "radix-ui"; import { cn } from "../../lib/utils"; const Slider = React.forwardRef< - React.ElementRef<typeof SliderPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> + React.ElementRef<typeof SliderPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> >(({ className, ...props }, ref) => ( - <SliderPrimitive.Root - ref={ref} - className={cn( - "relative flex w-full touch-none select-none items-center", - className, - )} - {...props} - > - <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20"> - <SliderPrimitive.Range className="absolute h-full bg-primary" /> - </SliderPrimitive.Track> - <SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" /> - </SliderPrimitive.Root> + <SliderPrimitive.Root + ref={ref} + className={cn( + "relative flex w-full touch-none select-none items-center", + className + )} + {...props} + > + <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20"> + <SliderPrimitive.Range className="absolute h-full bg-primary" /> + </SliderPrimitive.Track> + <SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" /> + </SliderPrimitive.Root> )); Slider.displayName = SliderPrimitive.Root.displayName; diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx index c5ba1bfe..452f4d9f 100644 --- a/apps/web/src/components/ui/sonner.tsx +++ b/apps/web/src/components/ui/sonner.tsx @@ -1,31 +1,31 @@ -"use client"; +"use client" -import { useTheme } from "next-themes"; -import { Toaster as Sonner } from "sonner"; +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" -type ToasterProps = React.ComponentProps<typeof Sonner>; +type ToasterProps = React.ComponentProps<typeof Sonner> const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme(); + const { theme = "system" } = useTheme() - return ( - <Sonner - theme={theme as ToasterProps["theme"]} - className="toaster group" - toastOptions={{ - classNames: { - toast: - "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", - description: "group-[.toast]:text-muted-foreground", - actionButton: - "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", - cancelButton: - "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", - }, - }} - {...props} - /> - ); -}; + return ( + <Sonner + theme={theme as ToasterProps["theme"]} + className="toaster group" + toastOptions={{ + classNames: { + toast: + "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", + description: "group-[.toast]:text-muted-foreground", + actionButton: + "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", + cancelButton: + "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", + }, + }} + {...props} + /> + ) +} -export { Toaster }; +export { Toaster } diff --git a/apps/web/src/components/ui/sponsor-button.tsx b/apps/web/src/components/ui/sponsor-button.tsx index ffbd8a4f..79c708a2 100644 --- a/apps/web/src/components/ui/sponsor-button.tsx +++ b/apps/web/src/components/ui/sponsor-button.tsx @@ -2,38 +2,39 @@ import { motion } from "motion/react"; import { ComponentType } from "react"; interface SponsorButtonProps { - href: string; - logo: ComponentType<{ className?: string }>; - companyName: string; - className?: string; + href: string; + logo: ComponentType<{ className?: string }>; + companyName: string; + className?: string; } export function SponsorButton({ - href, - logo: Logo, - companyName, - className = "", + href, + logo: Logo, + companyName, + className = "", }: SponsorButtonProps) { - return ( - <motion.a - href={href} - target="_blank" - rel="noopener noreferrer" - className={`inline-flex items-center gap-2 px-3 py-2 rounded-full border border-white/10 bg-white/5 backdrop-blur-sm hover:bg-white/10 hover:border-white/20 transition-all duration-200 group shadow-lg ${className}`} - > - <span className="text-xs font-medium text-zinc-400 group-hover:text-zinc-300 transition-colors"> - Sponsored by - </span> - <div className="flex items-center gap-1.5"> - <div className="text-zinc-100 group-hover:text-white transition-colors"> - <Logo className="w-4 h-4" /> - </div> + return ( + <motion.a + href={href} + target="_blank" + rel="noopener noreferrer" + className={`inline-flex items-center gap-2 px-3 py-2 rounded-full border border-white/10 bg-white/5 backdrop-blur-sm hover:bg-white/10 hover:border-white/20 transition-all duration-200 group shadow-lg ${className}`} + > + <span className="text-xs font-medium text-zinc-400 group-hover:text-zinc-300 transition-colors"> + Sponsored by + </span> - <span className="text-xs font-medium text-zinc-100 group-hover:text-white transition-colors"> - {companyName} - </span> - </div> - </motion.a> - ); + <div className="flex items-center gap-1.5"> + <div className="text-zinc-100 group-hover:text-white transition-colors"> + <Logo className="w-4 h-4" /> + </div> + + <span className="text-xs font-medium text-zinc-100 group-hover:text-white transition-colors"> + {companyName} + </span> + </div> + </motion.a> + ); } diff --git a/apps/web/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx index c77b564b..ba324f1d 100644 --- a/apps/web/src/components/ui/switch.tsx +++ b/apps/web/src/components/ui/switch.tsx @@ -6,23 +6,23 @@ import { Switch as SwitchPrimitives } from "radix-ui"; import { cn } from "../../lib/utils"; const Switch = React.forwardRef< - React.ElementRef<typeof SwitchPrimitives.Root>, - React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> + React.ElementRef<typeof SwitchPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> >(({ className, ...props }, ref) => ( - <SwitchPrimitives.Root - className={cn( - "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", - className, - )} - {...props} - ref={ref} - > - <SwitchPrimitives.Thumb - className={cn( - "pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0", - )} - /> - </SwitchPrimitives.Root> + <SwitchPrimitives.Root + className={cn( + "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", + className + )} + {...props} + ref={ref} + > + <SwitchPrimitives.Thumb + className={cn( + "pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" + )} + /> + </SwitchPrimitives.Root> )); Switch.displayName = SwitchPrimitives.Root.displayName; diff --git a/apps/web/src/components/ui/table.tsx b/apps/web/src/components/ui/table.tsx index ed57d4e8..6b83618c 100644 --- a/apps/web/src/components/ui/table.tsx +++ b/apps/web/src/components/ui/table.tsx @@ -3,118 +3,118 @@ import * as React from "react"; import { cn } from "../../lib/utils"; const Table = React.forwardRef< - HTMLTableElement, - React.HTMLAttributes<HTMLTableElement> + HTMLTableElement, + React.HTMLAttributes<HTMLTableElement> >(({ className, ...props }, ref) => ( - <div className="relative w-full overflow-auto"> - <table - ref={ref} - className={cn("w-full caption-bottom text-sm", className)} - {...props} - /> - </div> + <div className="relative w-full overflow-auto"> + <table + ref={ref} + className={cn("w-full caption-bottom text-sm", className)} + {...props} + /> + </div> )); Table.displayName = "Table"; const TableHeader = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes<HTMLTableSectionElement> + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> >(({ className, ...props }, ref) => ( - <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> + <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> )); TableHeader.displayName = "TableHeader"; const TableBody = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes<HTMLTableSectionElement> + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> >(({ className, ...props }, ref) => ( - <tbody - ref={ref} - className={cn("[&_tr:last-child]:border-0", className)} - {...props} - /> + <tbody + ref={ref} + className={cn("[&_tr:last-child]:border-0", className)} + {...props} + /> )); TableBody.displayName = "TableBody"; const TableFooter = React.forwardRef< - HTMLTableSectionElement, - React.HTMLAttributes<HTMLTableSectionElement> + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> >(({ className, ...props }, ref) => ( - <tfoot - ref={ref} - className={cn( - "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", - className, - )} - {...props} - /> + <tfoot + ref={ref} + className={cn( + "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", + className + )} + {...props} + /> )); TableFooter.displayName = "TableFooter"; const TableRow = React.forwardRef< - HTMLTableRowElement, - React.HTMLAttributes<HTMLTableRowElement> + HTMLTableRowElement, + React.HTMLAttributes<HTMLTableRowElement> >(({ className, ...props }, ref) => ( - <tr - ref={ref} - className={cn( - "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", - className, - )} - {...props} - /> + <tr + ref={ref} + className={cn( + "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", + className + )} + {...props} + /> )); TableRow.displayName = "TableRow"; const TableHead = React.forwardRef< - HTMLTableCellElement, - React.ThHTMLAttributes<HTMLTableCellElement> + HTMLTableCellElement, + React.ThHTMLAttributes<HTMLTableCellElement> >(({ className, ...props }, ref) => ( - <th - ref={ref} - className={cn( - "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", - className, - )} - {...props} - /> + <th + ref={ref} + className={cn( + "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> )); TableHead.displayName = "TableHead"; const TableCell = React.forwardRef< - HTMLTableCellElement, - React.TdHTMLAttributes<HTMLTableCellElement> + HTMLTableCellElement, + React.TdHTMLAttributes<HTMLTableCellElement> >(({ className, ...props }, ref) => ( - <td - ref={ref} - className={cn( - "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", - className, - )} - {...props} - /> + <td + ref={ref} + className={cn( + "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> )); TableCell.displayName = "TableCell"; const TableCaption = React.forwardRef< - HTMLTableCaptionElement, - React.HTMLAttributes<HTMLTableCaptionElement> + HTMLTableCaptionElement, + React.HTMLAttributes<HTMLTableCaptionElement> >(({ className, ...props }, ref) => ( - <caption - ref={ref} - className={cn("mt-4 text-sm text-muted-foreground", className)} - {...props} - /> + <caption + ref={ref} + className={cn("mt-4 text-sm text-muted-foreground", className)} + {...props} + /> )); TableCaption.displayName = "TableCaption"; export { - Table, - TableHeader, - TableBody, - TableFooter, - TableHead, - TableRow, - TableCell, - TableCaption, + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, }; diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx index 52cad853..a51a28a2 100644 --- a/apps/web/src/components/ui/tabs.tsx +++ b/apps/web/src/components/ui/tabs.tsx @@ -8,47 +8,47 @@ import { cn } from "../../lib/utils"; const Tabs = TabsPrimitive.Root; const TabsList = React.forwardRef< - React.ElementRef<typeof TabsPrimitive.List>, - React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> + React.ElementRef<typeof TabsPrimitive.List>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> >(({ className, ...props }, ref) => ( - <TabsPrimitive.List - ref={ref} - className={cn( - "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", - className, - )} - {...props} - /> + <TabsPrimitive.List + ref={ref} + className={cn( + "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", + className + )} + {...props} + /> )); TabsList.displayName = TabsPrimitive.List.displayName; const TabsTrigger = React.forwardRef< - React.ElementRef<typeof TabsPrimitive.Trigger>, - React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> + React.ElementRef<typeof TabsPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> >(({ className, ...props }, ref) => ( - <TabsPrimitive.Trigger - ref={ref} - className={cn( - "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow", - className, - )} - {...props} - /> + <TabsPrimitive.Trigger + ref={ref} + className={cn( + "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow", + className + )} + {...props} + /> )); TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; const TabsContent = React.forwardRef< - React.ElementRef<typeof TabsPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> + React.ElementRef<typeof TabsPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> >(({ className, ...props }, ref) => ( - <TabsPrimitive.Content - ref={ref} - className={cn( - "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", - className, - )} - {...props} - /> + <TabsPrimitive.Content + ref={ref} + className={cn( + "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + className + )} + {...props} + /> )); TabsContent.displayName = TabsPrimitive.Content.displayName; diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx index 303afeb4..64f81ce6 100644 --- a/apps/web/src/components/ui/textarea.tsx +++ b/apps/web/src/components/ui/textarea.tsx @@ -3,19 +3,19 @@ import * as React from "react"; import { cn } from "../../lib/utils"; const Textarea = React.forwardRef< - HTMLTextAreaElement, - React.ComponentProps<"textarea"> + HTMLTextAreaElement, + React.ComponentProps<"textarea"> >(({ className, ...props }, ref) => { - return ( - <textarea - className={cn( - "flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-xs", - className, - )} - ref={ref} - {...props} - /> - ); + return ( + <textarea + className={cn( + "flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-xs", + className + )} + ref={ref} + {...props} + /> + ); }); Textarea.displayName = "Textarea"; diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 102dde35..bf89e350 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -10,105 +10,105 @@ import { cn } from "../../lib/utils"; const ToastProvider = ToastPrimitives.Provider; const ToastViewport = React.forwardRef< - React.ElementRef<typeof ToastPrimitives.Viewport>, - React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> + React.ElementRef<typeof ToastPrimitives.Viewport>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> >(({ className, ...props }, ref) => ( - <ToastPrimitives.Viewport - ref={ref} - className={cn( - "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", - className, - )} - {...props} - /> + <ToastPrimitives.Viewport + ref={ref} + className={cn( + "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", + className + )} + {...props} + /> )); ToastViewport.displayName = ToastPrimitives.Viewport.displayName; const toastVariants = cva( - "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", - { - variants: { - variant: { - default: "border bg-background text-foreground", - destructive: - "destructive group border-destructive/35 bg-destructive/15 text-destructive-foreground backdrop-blur-lg", - }, - }, - defaultVariants: { - variant: "default", - }, - }, + "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive/35 bg-destructive/15 text-destructive-foreground backdrop-blur-lg", + }, + }, + defaultVariants: { + variant: "default", + }, + } ); const Toast = React.forwardRef< - React.ElementRef<typeof ToastPrimitives.Root>, - React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & - VariantProps<typeof toastVariants> + React.ElementRef<typeof ToastPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & + VariantProps<typeof toastVariants> >(({ className, variant, ...props }, ref) => { - return ( - <ToastPrimitives.Root - ref={ref} - className={cn(toastVariants({ variant }), className)} - {...props} - /> - ); + return ( + <ToastPrimitives.Root + ref={ref} + className={cn(toastVariants({ variant }), className)} + {...props} + /> + ); }); Toast.displayName = ToastPrimitives.Root.displayName; const ToastAction = React.forwardRef< - React.ElementRef<typeof ToastPrimitives.Action>, - React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> + React.ElementRef<typeof ToastPrimitives.Action>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> >(({ className, ...props }, ref) => ( - <ToastPrimitives.Action - ref={ref} - className={cn( - "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus-visible:outline-none focus-visible:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus-visible::ring-destructive", - className, - )} - {...props} - /> + <ToastPrimitives.Action + ref={ref} + className={cn( + "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus-visible:outline-none focus-visible:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus-visible::ring-destructive", + className + )} + {...props} + /> )); ToastAction.displayName = ToastPrimitives.Action.displayName; const ToastClose = React.forwardRef< - React.ElementRef<typeof ToastPrimitives.Close>, - React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> + React.ElementRef<typeof ToastPrimitives.Close>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> >(({ className, ...props }, ref) => ( - <ToastPrimitives.Close - ref={ref} - className={cn( - "absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus-visible::ring-1 group-hover:opacity-100 group-[.destructive]:text-muted-foreground group-[.destructive]:hover:text-red-50 group-[.destructive]:focus-visible:ring-red-400 group-[.destructive]:focus-visible:ring-offset-red-600", - className, - )} - toast-close="" - {...props} - > - <X className="h-4 w-4" /> - </ToastPrimitives.Close> + <ToastPrimitives.Close + ref={ref} + className={cn( + "absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus-visible::ring-1 group-hover:opacity-100 group-[.destructive]:text-muted-foreground group-[.destructive]:hover:text-red-50 group-[.destructive]:focus-visible:ring-red-400 group-[.destructive]:focus-visible:ring-offset-red-600", + className + )} + toast-close="" + {...props} + > + <X className="h-4 w-4" /> + </ToastPrimitives.Close> )); ToastClose.displayName = ToastPrimitives.Close.displayName; const ToastTitle = React.forwardRef< - React.ElementRef<typeof ToastPrimitives.Title>, - React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> + React.ElementRef<typeof ToastPrimitives.Title>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> >(({ className, ...props }, ref) => ( - <ToastPrimitives.Title - ref={ref} - className={cn("text-sm font-semibold [&+div]:text-xs", className)} - {...props} - /> + <ToastPrimitives.Title + ref={ref} + className={cn("text-sm font-semibold [&+div]:text-xs", className)} + {...props} + /> )); ToastTitle.displayName = ToastPrimitives.Title.displayName; const ToastDescription = React.forwardRef< - React.ElementRef<typeof ToastPrimitives.Description>, - React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> + React.ElementRef<typeof ToastPrimitives.Description>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> >(({ className, ...props }, ref) => ( - <ToastPrimitives.Description - ref={ref} - className={cn("text-sm opacity-90", className)} - {...props} - /> + <ToastPrimitives.Description + ref={ref} + className={cn("text-sm opacity-90", className)} + {...props} + /> )); ToastDescription.displayName = ToastPrimitives.Description.displayName; @@ -117,13 +117,13 @@ type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>; type ToastActionElement = React.ReactElement<typeof ToastAction>; export { - type ToastProps, - type ToastActionElement, - ToastProvider, - ToastViewport, - Toast, - ToastTitle, - ToastDescription, - ToastClose, - ToastAction, + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, }; diff --git a/apps/web/src/components/ui/toaster.tsx b/apps/web/src/components/ui/toaster.tsx index 69b18458..3eb8a720 100644 --- a/apps/web/src/components/ui/toaster.tsx +++ b/apps/web/src/components/ui/toaster.tsx @@ -2,34 +2,34 @@ import { useToast } from "../../hooks/use-toast"; import { - Toast, - ToastClose, - ToastDescription, - ToastProvider, - ToastTitle, - ToastViewport, + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, } from "./toast"; export function Toaster() { - const { toasts } = useToast(); + const { toasts } = useToast(); - return ( - <ToastProvider> - {toasts.map(function ({ id, title, description, action, ...props }) { - return ( - <Toast key={id} {...props}> - <div className="grid gap-1"> - {title && <ToastTitle>{title}</ToastTitle>} - {description && ( - <ToastDescription>{description}</ToastDescription> - )} - </div> - {action} - <ToastClose /> - </Toast> - ); - })} - <ToastViewport /> - </ToastProvider> - ); + return ( + <ToastProvider> + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + <Toast key={id} {...props}> + <div className="grid gap-1"> + {title && <ToastTitle>{title}</ToastTitle>} + {description && ( + <ToastDescription>{description}</ToastDescription> + )} + </div> + {action} + <ToastClose /> + </Toast> + ); + })} + <ToastViewport /> + </ToastProvider> + ); } diff --git a/apps/web/src/components/ui/toggle-group.tsx b/apps/web/src/components/ui/toggle-group.tsx index e7297524..5842a07d 100644 --- a/apps/web/src/components/ui/toggle-group.tsx +++ b/apps/web/src/components/ui/toggle-group.tsx @@ -8,52 +8,52 @@ import { cn } from "../../lib/utils"; import { toggleVariants } from "./toggle"; const ToggleGroupContext = React.createContext< - VariantProps<typeof toggleVariants> + VariantProps<typeof toggleVariants> >({ - size: "default", - variant: "default", + size: "default", + variant: "default", }); const ToggleGroup = React.forwardRef< - React.ElementRef<typeof ToggleGroupPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & - VariantProps<typeof toggleVariants> + React.ElementRef<typeof ToggleGroupPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & + VariantProps<typeof toggleVariants> >(({ className, variant, size, children, ...props }, ref) => ( - <ToggleGroupPrimitive.Root - ref={ref} - className={cn("flex items-center justify-center gap-1", className)} - {...props} - > - <ToggleGroupContext.Provider value={{ variant, size }}> - {children} - </ToggleGroupContext.Provider> - </ToggleGroupPrimitive.Root> + <ToggleGroupPrimitive.Root + ref={ref} + className={cn("flex items-center justify-center gap-1", className)} + {...props} + > + <ToggleGroupContext.Provider value={{ variant, size }}> + {children} + </ToggleGroupContext.Provider> + </ToggleGroupPrimitive.Root> )); ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; const ToggleGroupItem = React.forwardRef< - React.ElementRef<typeof ToggleGroupPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & - VariantProps<typeof toggleVariants> + React.ElementRef<typeof ToggleGroupPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & + VariantProps<typeof toggleVariants> >(({ className, children, variant, size, ...props }, ref) => { - const context = React.useContext(ToggleGroupContext); + const context = React.useContext(ToggleGroupContext); - return ( - <ToggleGroupPrimitive.Item - ref={ref} - className={cn( - toggleVariants({ - variant: context.variant || variant, - size: context.size || size, - }), - className, - )} - {...props} - > - {children} - </ToggleGroupPrimitive.Item> - ); + return ( + <ToggleGroupPrimitive.Item + ref={ref} + className={cn( + toggleVariants({ + variant: context.variant || variant, + size: context.size || size, + }), + className + )} + {...props} + > + {children} + </ToggleGroupPrimitive.Item> + ); }); ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; diff --git a/apps/web/src/components/ui/toggle.tsx b/apps/web/src/components/ui/toggle.tsx index 4275bee9..a4bfda42 100644 --- a/apps/web/src/components/ui/toggle.tsx +++ b/apps/web/src/components/ui/toggle.tsx @@ -7,37 +7,37 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "../../lib/utils"; const toggleVariants = cva( - "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", - { - variants: { - variant: { - default: "bg-transparent", - outline: - "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", - }, - size: { - default: "h-9 px-2 min-w-9", - sm: "h-8 px-1.5 min-w-8", - lg: "h-10 px-2.5 min-w-10", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-9 px-2 min-w-9", + sm: "h-8 px-1.5 min-w-8", + lg: "h-10 px-2.5 min-w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } ); const Toggle = React.forwardRef< - React.ElementRef<typeof TogglePrimitive.Root>, - React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & - VariantProps<typeof toggleVariants> + React.ElementRef<typeof TogglePrimitive.Root>, + React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & + VariantProps<typeof toggleVariants> >(({ className, variant, size, ...props }, ref) => ( - <TogglePrimitive.Root - ref={ref} - className={cn(toggleVariants({ variant, size, className }))} - {...props} - /> + <TogglePrimitive.Root + ref={ref} + className={cn(toggleVariants({ variant, size, className }))} + {...props} + /> )); Toggle.displayName = TogglePrimitive.Root.displayName; diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 4163c0dd..4971e262 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -12,20 +12,20 @@ const Tooltip = TooltipPrimitive.Root; const TooltipTrigger = TooltipPrimitive.Trigger; const TooltipContent = React.forwardRef< - React.ElementRef<typeof TooltipPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> + React.ElementRef<typeof TooltipPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> >(({ className, sideOffset = 4, ...props }, ref) => ( - <TooltipPrimitive.Portal> - <TooltipPrimitive.Content - ref={ref} - sideOffset={sideOffset} - className={cn( - "z-50 overflow-hidden rounded-md bg-foreground px-3 py-1.5 text-xs text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - className, - )} - {...props} - /> - </TooltipPrimitive.Portal> + <TooltipPrimitive.Portal> + <TooltipPrimitive.Content + ref={ref} + sideOffset={sideOffset} + className={cn( + "z-50 overflow-hidden rounded-md bg-foreground px-3 py-1.5 text-xs text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> + </TooltipPrimitive.Portal> )); TooltipContent.displayName = TooltipPrimitive.Content.displayName; diff --git a/apps/web/src/components/ui/video-player.tsx b/apps/web/src/components/ui/video-player.tsx index 1a44ef76..29d82cdf 100644 --- a/apps/web/src/components/ui/video-player.tsx +++ b/apps/web/src/components/ui/video-player.tsx @@ -4,128 +4,128 @@ import { useRef, useEffect } from "react"; import { usePlaybackStore } from "@/stores/playback-store"; interface VideoPlayerProps { - src: string; - poster?: string; - className?: string; - clipStartTime: number; - trimStart: number; - trimEnd: number; - clipDuration: number; + src: string; + poster?: string; + className?: string; + clipStartTime: number; + trimStart: number; + trimEnd: number; + clipDuration: number; } export function VideoPlayer({ - src, - poster, - className = "", - clipStartTime, - trimStart, - trimEnd, - clipDuration, + src, + poster, + className = "", + clipStartTime, + trimStart, + trimEnd, + clipDuration, }: VideoPlayerProps) { - const videoRef = useRef<HTMLVideoElement>(null); - const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore(); + const videoRef = useRef<HTMLVideoElement>(null); + const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore(); - // Calculate if we're within this clip's timeline range - const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd); - const isInClipRange = - currentTime >= clipStartTime && currentTime < clipEndTime; + // Calculate if we're within this clip's timeline range + const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd); + const isInClipRange = + currentTime >= clipStartTime && currentTime < clipEndTime; - // Sync playback events - useEffect(() => { - const video = videoRef.current; - if (!video || !isInClipRange) return; + // Sync playback events + useEffect(() => { + const video = videoRef.current; + if (!video || !isInClipRange) return; - const handleSeekEvent = (e: CustomEvent) => { - // Always update video time, even if outside clip range - const timelineTime = e.detail.time; - const videoTime = Math.max( - trimStart, - Math.min( - clipDuration - trimEnd, - timelineTime - clipStartTime + trimStart, - ), - ); - video.currentTime = videoTime; - }; + const handleSeekEvent = (e: CustomEvent) => { + // Always update video time, even if outside clip range + const timelineTime = e.detail.time; + const videoTime = Math.max( + trimStart, + Math.min( + clipDuration - trimEnd, + timelineTime - clipStartTime + trimStart + ) + ); + video.currentTime = videoTime; + }; - const handleUpdateEvent = (e: CustomEvent) => { - // Always update video time, even if outside clip range - const timelineTime = e.detail.time; - const targetTime = Math.max( - trimStart, - Math.min( - clipDuration - trimEnd, - timelineTime - clipStartTime + trimStart, - ), - ); + const handleUpdateEvent = (e: CustomEvent) => { + // Always update video time, even if outside clip range + const timelineTime = e.detail.time; + const targetTime = Math.max( + trimStart, + Math.min( + clipDuration - trimEnd, + timelineTime - clipStartTime + trimStart + ) + ); - if (Math.abs(video.currentTime - targetTime) > 0.5) { - video.currentTime = targetTime; - } - }; + if (Math.abs(video.currentTime - targetTime) > 0.5) { + video.currentTime = targetTime; + } + }; - const handleSpeed = (e: CustomEvent) => { - video.playbackRate = e.detail.speed; - }; + const handleSpeed = (e: CustomEvent) => { + video.playbackRate = e.detail.speed; + }; - window.addEventListener("playback-seek", handleSeekEvent as EventListener); - window.addEventListener( - "playback-update", - handleUpdateEvent as EventListener, - ); - window.addEventListener("playback-speed", handleSpeed as EventListener); + window.addEventListener("playback-seek", handleSeekEvent as EventListener); + window.addEventListener( + "playback-update", + handleUpdateEvent as EventListener + ); + window.addEventListener("playback-speed", handleSpeed as EventListener); - return () => { - window.removeEventListener( - "playback-seek", - handleSeekEvent as EventListener, - ); - window.removeEventListener( - "playback-update", - handleUpdateEvent as EventListener, - ); - window.removeEventListener( - "playback-speed", - handleSpeed as EventListener, - ); - }; - }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]); + return () => { + window.removeEventListener( + "playback-seek", + handleSeekEvent as EventListener + ); + window.removeEventListener( + "playback-update", + handleUpdateEvent as EventListener + ); + window.removeEventListener( + "playback-speed", + handleSpeed as EventListener + ); + }; + }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]); - // Sync playback state - useEffect(() => { - const video = videoRef.current; - if (!video) return; + // Sync playback state + useEffect(() => { + const video = videoRef.current; + if (!video) return; - if (isPlaying && isInClipRange) { - video.play().catch(() => {}); - } else { - video.pause(); - } - }, [isPlaying, isInClipRange]); + if (isPlaying && isInClipRange) { + video.play().catch(() => {}); + } else { + video.pause(); + } + }, [isPlaying, isInClipRange]); - // Sync volume and speed - useEffect(() => { - const video = videoRef.current; - if (!video) return; + // Sync volume and speed + useEffect(() => { + const video = videoRef.current; + if (!video) return; - video.volume = volume; - video.muted = muted; - video.playbackRate = speed; - }, [volume, speed, muted]); + video.volume = volume; + video.muted = muted; + video.playbackRate = speed; + }, [volume, speed, muted]); - return ( - <video - ref={videoRef} - src={src} - poster={poster} - className={`max-w-full max-h-full object-contain ${className}`} - playsInline - preload="auto" - controls={false} - disablePictureInPicture - disableRemotePlayback - style={{ pointerEvents: "none" }} - onContextMenu={(e) => e.preventDefault()} - /> - ); + return ( + <video + ref={videoRef} + src={src} + poster={poster} + className={`max-w-full max-h-full object-contain ${className}`} + playsInline + preload="auto" + controls={false} + disablePictureInPicture + disableRemotePlayback + style={{ pointerEvents: "none" }} + onContextMenu={(e) => e.preventDefault()} + /> + ); } diff --git a/apps/web/src/constants/actions.ts b/apps/web/src/constants/actions.ts index 644bb0ec..5f1883f1 100644 --- a/apps/web/src/constants/actions.ts +++ b/apps/web/src/constants/actions.ts @@ -3,49 +3,49 @@ */ import { - useEffect, - useRef, - useState, - useCallback, - MutableRefObject, + useEffect, + useRef, + useState, + useCallback, + MutableRefObject, } from "react"; // Simple event emitter for action changes class ActionEmitter { - private listeners: Array<(actions: Action[]) => void> = []; + private listeners: Array<(actions: Action[]) => void> = []; - subscribe(listener: (actions: Action[]) => void) { - this.listeners.push(listener); - return () => { - this.listeners = this.listeners.filter((l) => l !== listener); - }; - } + subscribe(listener: (actions: Action[]) => void) { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter((l) => l !== listener); + }; + } - emit(actions: Action[]) { - this.listeners.forEach((listener) => listener(actions)); - } + emit(actions: Action[]) { + this.listeners.forEach((listener) => listener(actions)); + } } const actionEmitter = new ActionEmitter(); export type Action = - | "toggle-play" // Toggle play/pause state - | "stop-playback" // Stop playback - | "seek-forward" // Seek forward in playback - | "seek-backward" // Seek backward in playback - | "frame-step-forward" // Step forward by one frame - | "frame-step-backward" // Step backward by one frame - | "jump-forward" // Jump forward by 5 seconds - | "jump-backward" // Jump backward by 5 seconds - | "goto-start" // Go to timeline start - | "goto-end" // Go to timeline end - | "split-element" // Split element at current time - | "delete-selected" // Delete selected elements - | "select-all" // Select all elements - | "duplicate-selected" // Duplicate selected element - | "toggle-snapping" // Toggle snapping - | "undo" // Undo last action - | "redo"; // Redo last undone action + | "toggle-play" // Toggle play/pause state + | "stop-playback" // Stop playback + | "seek-forward" // Seek forward in playback + | "seek-backward" // Seek backward in playback + | "frame-step-forward" // Step forward by one frame + | "frame-step-backward" // Step backward by one frame + | "jump-forward" // Jump forward by 5 seconds + | "jump-backward" // Jump backward by 5 seconds + | "goto-start" // Go to timeline start + | "goto-end" // Go to timeline end + | "split-element" // Split element at current time + | "delete-selected" // Delete selected elements + | "select-all" // Select all elements + | "duplicate-selected" // Duplicate selected element + | "toggle-snapping" // Toggle snapping + | "undo" // Undo last action + | "redo"; // Redo last undone action /** * Defines the arguments, if present for a given type that is required to be passed on @@ -59,14 +59,14 @@ export type Action = * will know if you got something wrong if there is a type error in this file */ type ActionArgsMap = { - "seek-forward": { seconds: number } | undefined; // Args needed for seeking forward (default: 1) - "seek-backward": { seconds: number } | undefined; // Args needed for seeking backward (default: 1) - "jump-forward": { seconds: number } | undefined; // Args needed for jumping forward (default: 5) - "jump-backward": { seconds: number } | undefined; // Args needed for jumping backward (default: 5) + "seek-forward": { seconds: number } | undefined; // Args needed for seeking forward (default: 1) + "seek-backward": { seconds: number } | undefined; // Args needed for seeking backward (default: 1) + "jump-forward": { seconds: number } | undefined; // Args needed for jumping forward (default: 5) + "jump-backward": { seconds: number } | undefined; // Args needed for jumping backward (default: 5) }; type KeysWithValueUndefined<T> = { - [K in keyof T]: undefined extends T[K] ? K : never; + [K in keyof T]: undefined extends T[K] ? K : never; }[keyof T]; /** @@ -79,8 +79,8 @@ export type ActionWithArgs = keyof ActionArgsMap; */ export type ActionWithOptionalArgs = - | ActionWithNoArgs - | KeysWithValueUndefined<ActionArgsMap>; + | ActionWithNoArgs + | KeysWithValueUndefined<ActionArgsMap>; /** * Actions which do not require arguments for their invocation @@ -91,18 +91,18 @@ export type ActionWithNoArgs = Exclude<Action, ActionWithArgs>; * Resolves the argument type for a given Action */ type ArgOfHoppAction<A extends Action> = A extends ActionWithArgs - ? ActionArgsMap[A] - : undefined; + ? ActionArgsMap[A] + : undefined; /** * Resolves the action function for a given Action, used by action handler function defs */ type ActionFunc<A extends Action> = A extends ActionWithArgs - ? (arg: ArgOfHoppAction<A>, trigger?: InvocationTriggers) => void - : (_?: undefined, trigger?: InvocationTriggers) => void; + ? (arg: ArgOfHoppAction<A>, trigger?: InvocationTriggers) => void + : (_?: undefined, trigger?: InvocationTriggers) => void; type BoundActionList = { - [A in Action]?: Array<ActionFunc<A>>; + [A in Action]?: Array<ActionFunc<A>>; }; const boundActions: BoundActionList = {}; @@ -110,34 +110,34 @@ const boundActions: BoundActionList = {}; let currentActiveActions: Action[] = []; function updateActiveActions() { - const newActions = Object.keys(boundActions) as Action[]; - currentActiveActions = newActions; - actionEmitter.emit(newActions); + const newActions = Object.keys(boundActions) as Action[]; + currentActiveActions = newActions; + actionEmitter.emit(newActions); } export function bindAction<A extends Action>( - action: A, - handler: ActionFunc<A>, + action: A, + handler: ActionFunc<A> ) { - if (boundActions[action]) { - boundActions[action]?.push(handler); - } else { - // 'any' assertion because TypeScript doesn't seem to be able to figure out the links. - boundActions[action] = [handler] as any; - } + if (boundActions[action]) { + boundActions[action]?.push(handler); + } else { + // 'any' assertion because TypeScript doesn't seem to be able to figure out the links. + boundActions[action] = [handler] as any; + } - updateActiveActions(); + updateActiveActions(); } export type InvocationTriggers = "keypress" | "mouseclick"; type InvokeActionFunc = { - ( - action: ActionWithOptionalArgs, - args?: undefined, - trigger?: InvocationTriggers, - ): void; - <A extends ActionWithArgs>(action: A, args: ActionArgsMap[A]): void; + ( + action: ActionWithOptionalArgs, + args?: undefined, + trigger?: InvocationTriggers + ): void; + <A extends ActionWithArgs>(action: A, args: ActionArgsMap[A]): void; }; /** @@ -148,27 +148,27 @@ type InvokeActionFunc = { * @param trigger Optionally supply the trigger that invoked the action (keypress/mouseclick) */ export const invokeAction: InvokeActionFunc = <A extends Action>( - action: A, - args?: ArgOfHoppAction<A>, - trigger?: InvocationTriggers, + action: A, + args?: ArgOfHoppAction<A>, + trigger?: InvocationTriggers ) => { - boundActions[action]?.forEach((handler) => (handler as any)(args, trigger)); + boundActions[action]?.forEach((handler) => (handler as any)(args, trigger)); }; export function unbindAction<A extends Action>( - action: A, - handler: ActionFunc<A>, + action: A, + handler: ActionFunc<A> ) { - // 'any' assertion because TypeScript doesn't seem to be able to figure out the links. - boundActions[action] = boundActions[action]?.filter( - (x) => x !== handler, - ) as any; + // 'any' assertion because TypeScript doesn't seem to be able to figure out the links. + boundActions[action] = boundActions[action]?.filter( + (x) => x !== handler + ) as any; - if (boundActions[action]?.length === 0) { - delete boundActions[action]; - } + if (boundActions[action]?.length === 0) { + delete boundActions[action]; + } - updateActiveActions(); + updateActiveActions(); } /** @@ -177,7 +177,7 @@ export function unbindAction<A extends Action>( * @param action The action to check */ export function isActionBound(action: Action): boolean { - return !!boundActions[action]; + return !!boundActions[action]; } /** @@ -189,65 +189,65 @@ export function isActionBound(action: Action): boolean { * @param isActive A ref that indicates whether the action is active */ export function useActionHandler<A extends Action>( - action: A, - handler: ActionFunc<A>, - isActive: MutableRefObject<boolean> | boolean | undefined = undefined, + action: A, + handler: ActionFunc<A>, + isActive: MutableRefObject<boolean> | boolean | undefined = undefined ) { - const handlerRef = useRef(handler); - const [isBound, setIsBound] = useState(false); + const handlerRef = useRef(handler); + const [isBound, setIsBound] = useState(false); - // Update handler ref when handler changes - useEffect(() => { - handlerRef.current = handler; - }, [handler]); + // Update handler ref when handler changes + useEffect(() => { + handlerRef.current = handler; + }, [handler]); - // Create a stable handler wrapper - const stableHandler = useCallback( - (args: any, trigger?: InvocationTriggers) => { - (handlerRef.current as any)(args, trigger); - }, - [], - ) as ActionFunc<A>; + // Create a stable handler wrapper + const stableHandler = useCallback( + (args: any, trigger?: InvocationTriggers) => { + (handlerRef.current as any)(args, trigger); + }, + [] + ) as ActionFunc<A>; - useEffect(() => { - const shouldBind = - isActive === undefined || - (typeof isActive === "boolean" ? isActive : isActive.current); + useEffect(() => { + const shouldBind = + isActive === undefined || + (typeof isActive === "boolean" ? isActive : isActive.current); - if (shouldBind && !isBound) { - bindAction(action, stableHandler); - setIsBound(true); - } else if (!shouldBind && isBound) { - unbindAction(action, stableHandler); - setIsBound(false); - } + if (shouldBind && !isBound) { + bindAction(action, stableHandler); + setIsBound(true); + } else if (!shouldBind && isBound) { + unbindAction(action, stableHandler); + setIsBound(false); + } - return () => { - if (isBound) { - unbindAction(action, stableHandler); - setIsBound(false); - } - }; - }, [action, stableHandler, isActive, isBound]); + return () => { + if (isBound) { + unbindAction(action, stableHandler); + setIsBound(false); + } + }; + }, [action, stableHandler, isActive, isBound]); - // Handle ref-based isActive changes - useEffect(() => { - if (isActive && typeof isActive === "object" && "current" in isActive) { - // Poll for ref changes - const interval = setInterval(() => { - const shouldBind = isActive.current; - if (shouldBind !== isBound) { - if (shouldBind) { - bindAction(action, stableHandler); - } else { - unbindAction(action, stableHandler); - } - setIsBound(shouldBind); - } - }, 100); - return () => clearInterval(interval); - } - }, [action, stableHandler, isActive, isBound]); + // Handle ref-based isActive changes + useEffect(() => { + if (isActive && typeof isActive === "object" && "current" in isActive) { + // Poll for ref changes + const interval = setInterval(() => { + const shouldBind = isActive.current; + if (shouldBind !== isBound) { + if (shouldBind) { + bindAction(action, stableHandler); + } else { + unbindAction(action, stableHandler); + } + setIsBound(shouldBind); + } + }, 100); + return () => clearInterval(interval); + } + }, [action, stableHandler, isActive, isBound]); } /** @@ -255,18 +255,18 @@ export function useActionHandler<A extends Action>( * and re-renders when the list changes */ export function useActiveActions(): Action[] { - const [activeActions, setActiveActions] = useState<Action[]>([]); + const [activeActions, setActiveActions] = useState<Action[]>([]); - useEffect(() => { - // Set initial value - setActiveActions(currentActiveActions); + useEffect(() => { + // Set initial value + setActiveActions(currentActiveActions); - // Subscribe to changes - const unsubscribe = actionEmitter.subscribe(setActiveActions); - return unsubscribe; - }, []); + // Subscribe to changes + const unsubscribe = actionEmitter.subscribe(setActiveActions); + return unsubscribe; + }, []); - return activeActions; + return activeActions; } /** @@ -274,20 +274,20 @@ export function useActiveActions(): Action[] { * and re-renders when the binding state changes */ export function useIsActionBound(action: Action): boolean { - const [isBound, setIsBound] = useState(() => isActionBound(action)); + const [isBound, setIsBound] = useState(() => isActionBound(action)); - useEffect(() => { - const updateBoundState = () => { - setIsBound(isActionBound(action)); - }; + useEffect(() => { + const updateBoundState = () => { + setIsBound(isActionBound(action)); + }; - // Set initial value - updateBoundState(); + // Set initial value + updateBoundState(); - // Subscribe to changes - const unsubscribe = actionEmitter.subscribe(updateBoundState); - return unsubscribe; - }, [action]); + // Subscribe to changes + const unsubscribe = actionEmitter.subscribe(updateBoundState); + return unsubscribe; + }, [action]); - return isBound; + return isBound; } diff --git a/apps/web/src/constants/font-constants.ts b/apps/web/src/constants/font-constants.ts index 46f84848..bfddc182 100644 --- a/apps/web/src/constants/font-constants.ts +++ b/apps/web/src/constants/font-constants.ts @@ -1,79 +1,79 @@ -export interface FontOption { - value: string; - label: string; - category: "system" | "google" | "custom"; - weights?: number[]; - hasClassName?: boolean; -} - -export const FONT_OPTIONS: FontOption[] = [ - // System fonts (always available) - { value: "Arial", label: "Arial", category: "system", hasClassName: false }, - { - value: "Helvetica", - label: "Helvetica", - category: "system", - hasClassName: false, - }, - { - value: "Times New Roman", - label: "Times New Roman", - category: "system", - hasClassName: false, - }, - { - value: "Georgia", - label: "Georgia", - category: "system", - hasClassName: false, - }, - - // Google Fonts (loaded in layout.tsx) - { - value: "Inter", - label: "Inter", - category: "google", - weights: [400, 700], - hasClassName: true, - }, - { - value: "Roboto", - label: "Roboto", - category: "google", - weights: [400, 700], - hasClassName: true, - }, - { - value: "Open Sans", - label: "Open Sans", - category: "google", - hasClassName: true, - }, - { - value: "Playfair Display", - label: "Playfair Display", - category: "google", - hasClassName: true, - }, - { - value: "Comic Neue", - label: "Comic Neue", - category: "google", - hasClassName: false, - }, -] as const; - -export const DEFAULT_FONT = "Arial"; - -// Type-safe font family union -export type FontFamily = (typeof FONT_OPTIONS)[number]["value"]; - -// Helper functions -export const getFontByValue = (value: string): FontOption | undefined => - FONT_OPTIONS.find((font) => font.value === value); - -export const getGoogleFonts = (): FontOption[] => - FONT_OPTIONS.filter((font) => font.category === "google"); - -export const getSystemFonts = (): FontOption[] => - FONT_OPTIONS.filter((font) => font.category === "system"); +export interface FontOption {
+ value: string;
+ label: string;
+ category: "system" | "google" | "custom";
+ weights?: number[];
+ hasClassName?: boolean;
+}
+
+export const FONT_OPTIONS: FontOption[] = [
+ // System fonts (always available)
+ { value: "Arial", label: "Arial", category: "system", hasClassName: false },
+ {
+ value: "Helvetica",
+ label: "Helvetica",
+ category: "system",
+ hasClassName: false,
+ },
+ {
+ value: "Times New Roman",
+ label: "Times New Roman",
+ category: "system",
+ hasClassName: false,
+ },
+ {
+ value: "Georgia",
+ label: "Georgia",
+ category: "system",
+ hasClassName: false,
+ },
+
+ // Google Fonts (loaded in layout.tsx)
+ {
+ value: "Inter",
+ label: "Inter",
+ category: "google",
+ weights: [400, 700],
+ hasClassName: true,
+ },
+ {
+ value: "Roboto",
+ label: "Roboto",
+ category: "google",
+ weights: [400, 700],
+ hasClassName: true,
+ },
+ {
+ value: "Open Sans",
+ label: "Open Sans",
+ category: "google",
+ hasClassName: true,
+ },
+ {
+ value: "Playfair Display",
+ label: "Playfair Display",
+ category: "google",
+ hasClassName: true,
+ },
+ {
+ value: "Comic Neue",
+ label: "Comic Neue",
+ category: "google",
+ hasClassName: false,
+ },
+] as const;
+
+export const DEFAULT_FONT = "Arial";
+
+// Type-safe font family union
+export type FontFamily = (typeof FONT_OPTIONS)[number]["value"];
+
+// Helper functions
+export const getFontByValue = (value: string): FontOption | undefined =>
+ FONT_OPTIONS.find((font) => font.value === value);
+
+export const getGoogleFonts = (): FontOption[] =>
+ FONT_OPTIONS.filter((font) => font.category === "google");
+
+export const getSystemFonts = (): FontOption[] =>
+ FONT_OPTIONS.filter((font) => font.category === "system");
diff --git a/apps/web/src/constants/timeline-constants.ts b/apps/web/src/constants/timeline-constants.ts index f670a319..8c76a2e8 100644 --- a/apps/web/src/constants/timeline-constants.ts +++ b/apps/web/src/constants/timeline-constants.ts @@ -2,106 +2,106 @@ import type { TrackType } from "@/types/timeline"; // Track color definitions export const TRACK_COLORS: Record< - TrackType, - { solid: string; background: string; border: string } + TrackType, + { solid: string; background: string; border: string } > = { - media: { - solid: "bg-blue-500", - background: "bg-blue-500/20", - border: "border-white/80", - }, - text: { - solid: "bg-[#9C4937]", - background: "bg-[#9C4937]", - border: "border-white/80", - }, - audio: { - solid: "bg-green-500", - background: "bg-green-500/20", - border: "border-white/80", - }, + media: { + solid: "bg-blue-500", + background: "bg-blue-500/20", + border: "border-white/80", + }, + text: { + solid: "bg-[#9C4937]", + background: "bg-[#9C4937]", + border: "border-white/80", + }, + audio: { + solid: "bg-green-500", + background: "bg-green-500/20", + border: "border-white/80", + }, } as const; // Utility functions export function getTrackColors(type: TrackType) { - return TRACK_COLORS[type]; + return TRACK_COLORS[type]; } export function getTrackElementClasses(type: TrackType) { - const colors = getTrackColors(type); - return `${colors.background} ${colors.border}`; + const colors = getTrackColors(type); + return `${colors.background} ${colors.border}`; } // Track height definitions export const TRACK_HEIGHTS: Record<TrackType, number> = { - media: 65, - text: 25, - audio: 50, + media: 65, + text: 25, + audio: 50, } as const; // Utility function for track heights export function getTrackHeight(type: TrackType): number { - return TRACK_HEIGHTS[type]; + return TRACK_HEIGHTS[type]; } // Calculate cumulative height up to (but not including) a track index export function getCumulativeHeightBefore( - tracks: Array<{ type: TrackType }>, - trackIndex: number, + tracks: Array<{ type: TrackType }>, + trackIndex: number ): number { - const GAP = 4; // 4px gap between tracks (equivalent to Tailwind's gap-1) - return tracks - .slice(0, trackIndex) - .reduce((sum, track) => sum + getTrackHeight(track.type) + GAP, 0); + const GAP = 4; // 4px gap between tracks (equivalent to Tailwind's gap-1) + return tracks + .slice(0, trackIndex) + .reduce((sum, track) => sum + getTrackHeight(track.type) + GAP, 0); } // Calculate total height of all tracks export function getTotalTracksHeight( - tracks: Array<{ type: TrackType }>, + tracks: Array<{ type: TrackType }> ): number { - const GAP = 4; // 4px gap between tracks (equivalent to Tailwind's gap-1) - const tracksHeight = tracks.reduce( - (sum, track) => sum + getTrackHeight(track.type), - 0, - ); - const gapsHeight = Math.max(0, tracks.length - 1) * GAP; // n-1 gaps for n tracks - return tracksHeight + gapsHeight; + const GAP = 4; // 4px gap between tracks (equivalent to Tailwind's gap-1) + const tracksHeight = tracks.reduce( + (sum, track) => sum + getTrackHeight(track.type), + 0 + ); + const gapsHeight = Math.max(0, tracks.length - 1) * GAP; // n-1 gaps for n tracks + return tracksHeight + gapsHeight; } // Other timeline constants export const TIMELINE_CONSTANTS = { - ELEMENT_MIN_WIDTH: 80, - PIXELS_PER_SECOND: 50, - TRACK_HEIGHT: 60, // Default fallback - DEFAULT_TEXT_DURATION: 5, - DEFAULT_IMAGE_DURATION: 5, - ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4], + ELEMENT_MIN_WIDTH: 80, + PIXELS_PER_SECOND: 50, + TRACK_HEIGHT: 60, // Default fallback + DEFAULT_TEXT_DURATION: 5, + DEFAULT_IMAGE_DURATION: 5, + ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4], } as const; // FPS presets for project settings export const FPS_PRESETS = [ - { value: "24", label: "24 fps (Film)" }, - { value: "25", label: "25 fps (PAL)" }, - { value: "30", label: "30 fps (NTSC)" }, - { value: "60", label: "60 fps (High)" }, - { value: "120", label: "120 fps (Slow-mo)" }, + { value: "24", label: "24 fps (Film)" }, + { value: "25", label: "25 fps (PAL)" }, + { value: "30", label: "30 fps (NTSC)" }, + { value: "60", label: "60 fps (High)" }, + { value: "120", label: "120 fps (Slow-mo)" }, ] as const; // Frame snapping utilities export function timeToFrame(time: number, fps: number): number { - return Math.round(time * fps); + return Math.round(time * fps); } export function frameToTime(frame: number, fps: number): number { - return frame / fps; + return frame / fps; } export function snapTimeToFrame(time: number, fps: number): number { - if (fps <= 0) return time; // Fallback for invalid FPS - const frame = timeToFrame(time, fps); - return frameToTime(frame, fps); + if (fps <= 0) return time; // Fallback for invalid FPS + const frame = timeToFrame(time, fps); + return frameToTime(frame, fps); } export function getFrameDuration(fps: number): number { - return 1 / fps; + return 1 / fps; } diff --git a/apps/web/src/data/colors.ts b/apps/web/src/data/colors.ts index 8a946df4..3ef094d6 100644 --- a/apps/web/src/data/colors.ts +++ b/apps/web/src/data/colors.ts @@ -1,244 +1,244 @@ -export const colors = [ - "#ffffff", - "#000000", - "#ffe2e2", - "#ffc9c9", - "#ffa2a2", - "#ff6467", - "#fb2c36", - "#e7000b", - "#c10007", - "#9f0712", - "#82181a", - "#460809", - "#fff7ed", - "#ffedd4", - "#ffd6a7", - "#ffb86a", - "#ff8904", - "#ff6900", - "#f54900", - "#ca3500", - "#9f2d00", - "#7e2a0c", - "#441306", - "#fffbeb", - "#fef3c6", - "#fee685", - "#ffd230", - "#ffb900", - "#fe9a00", - "#e17100", - "#bb4d00", - "#973c00", - "#7b3306", - "#461901", - "#fefce8", - "#fef9c2", - "#fff085", - "#ffdf20", - "#fdc700", - "#f0b100", - "#d08700", - "#a65f00", - "#894b00", - "#733e0a", - "#432004", - "#f7fee7", - "#ecfcca", - "#d8f999", - "#bbf451", - "#9ae600", - "#7ccf00", - "#5ea500", - "#497d00", - "#3c6300", - "#35530e", - "#192e03", - "#f0fdf4", - "#dcfce7", - "#b9f8cf", - "#7bf1a8", - "#05df72", - "#00c950", - "#00a63e", - "#008236", - "#016630", - "#0d542b", - "#032e15", - "#ecfdf5", - "#d0fae5", - "#a4f4cf", - "#5ee9b5", - "#00d492", - "#00bc7d", - "#009966", - "#007a55", - "#006045", - "#004f3b", - "#002c22", - "#f0fdfa", - "#cbfbf1", - "#96f7e4", - "#46ecd5", - "#00d5be", - "#00bba7", - "#009689", - "#00786f", - "#005f5a", - "#0b4f4a", - "#022f2e", - "#ecfeff", - "#cefafe", - "#a2f4fd", - "#53eafd", - "#00d3f2", - "#00b8db", - "#0092b8", - "#007595", - "#005f78", - "#104e64", - "#053345", - "#f0f9ff", - "#dff2fe", - "#b8e6fe", - "#74d4ff", - "#00bcff", - "#00a6f4", - "#0084d1", - "#0069a8", - "#00598a", - "#024a70", - "#052f4a", - "#eff6ff", - "#dbeafe", - "#bedbff", - "#8ec5ff", - "#51a2ff", - "#2b7fff", - "#155dfc", - "#1447e6", - "#193cb8", - "#1c398e", - "#162456", - "#eef2ff", - "#e0e7ff", - "#c6d2ff", - "#a3b3ff", - "#7c86ff", - "#615fff", - "#4f39f6", - "#432dd7", - "#372aac", - "#312c85", - "#1e1a4d", - "#f5f3ff", - "#ede9fe", - "#ddd6ff", - "#c4b4ff", - "#a684ff", - "#8e51ff", - "#7f22fe", - "#7008e7", - "#5d0ec0", - "#4d179a", - "#2f0d68", - "#faf5ff", - "#f3e8ff", - "#e9d4ff", - "#dab2ff", - "#c27aff", - "#ad46ff", - "#9810fa", - "#8200db", - "#6e11b0", - "#59168b", - "#3c0366", - "#fdf4ff", - "#fae8ff", - "#f6cfff", - "#f4a8ff", - "#ed6aff", - "#e12afb", - "#c800de", - "#a800b7", - "#8a0194", - "#721378", - "#4b004f", - "#fdf2f8", - "#fce7f3", - "#fccee8", - "#fda5d5", - "#fb64b6", - "#f6339a", - "#e60076", - "#c6005c", - "#a3004c", - "#861043", - "#510424", - "#fff1f2", - "#ffe4e6", - "#ffccd3", - "#ffa1ad", - "#ff637e", - "#ff2056", - "#ec003f", - "#c70036", - "#a50036", - "#8b0836", - "#4d0218", - "#f8fafc", - "#f1f5f9", - "#e2e8f0", - "#cad5e2", - "#90a1b9", - "#62748e", - "#45556c", - "#314158", - "#1d293d", - "#0f172b", - "#020618", - "#f9fafb", - "#f3f4f6", - "#e5e7eb", - "#d1d5dc", - "#99a1af", - "#6a7282", - "#4a5565", - "#364153", - "#1e2939", - "#101828", - "#030712", - "#fafafa", - "#f4f4f5", - "#e4e4e7", - "#d4d4d8", - "#9f9fa9", - "#71717b", - "#52525c", - "#3f3f46", - "#27272a", - "#18181b", - "#09090b", - "#f5f5f5", - "#e5e5e5", - "#d4d4d4", - "#a1a1a1", - "#737373", - "#525252", - "#404040", - "#262626", - "#171717", - "#0a0a0a", - "#fafaf9", - "#f5f5f4", - "#e7e5e4", - "#d6d3d1", - "#a6a09b", - "#79716b", - "#57534d", - "#44403b", - "#292524", - "#1c1917", - "#0c0a09", -]; +export const colors = [
+ "#ffffff",
+ "#000000",
+ "#ffe2e2",
+ "#ffc9c9",
+ "#ffa2a2",
+ "#ff6467",
+ "#fb2c36",
+ "#e7000b",
+ "#c10007",
+ "#9f0712",
+ "#82181a",
+ "#460809",
+ "#fff7ed",
+ "#ffedd4",
+ "#ffd6a7",
+ "#ffb86a",
+ "#ff8904",
+ "#ff6900",
+ "#f54900",
+ "#ca3500",
+ "#9f2d00",
+ "#7e2a0c",
+ "#441306",
+ "#fffbeb",
+ "#fef3c6",
+ "#fee685",
+ "#ffd230",
+ "#ffb900",
+ "#fe9a00",
+ "#e17100",
+ "#bb4d00",
+ "#973c00",
+ "#7b3306",
+ "#461901",
+ "#fefce8",
+ "#fef9c2",
+ "#fff085",
+ "#ffdf20",
+ "#fdc700",
+ "#f0b100",
+ "#d08700",
+ "#a65f00",
+ "#894b00",
+ "#733e0a",
+ "#432004",
+ "#f7fee7",
+ "#ecfcca",
+ "#d8f999",
+ "#bbf451",
+ "#9ae600",
+ "#7ccf00",
+ "#5ea500",
+ "#497d00",
+ "#3c6300",
+ "#35530e",
+ "#192e03",
+ "#f0fdf4",
+ "#dcfce7",
+ "#b9f8cf",
+ "#7bf1a8",
+ "#05df72",
+ "#00c950",
+ "#00a63e",
+ "#008236",
+ "#016630",
+ "#0d542b",
+ "#032e15",
+ "#ecfdf5",
+ "#d0fae5",
+ "#a4f4cf",
+ "#5ee9b5",
+ "#00d492",
+ "#00bc7d",
+ "#009966",
+ "#007a55",
+ "#006045",
+ "#004f3b",
+ "#002c22",
+ "#f0fdfa",
+ "#cbfbf1",
+ "#96f7e4",
+ "#46ecd5",
+ "#00d5be",
+ "#00bba7",
+ "#009689",
+ "#00786f",
+ "#005f5a",
+ "#0b4f4a",
+ "#022f2e",
+ "#ecfeff",
+ "#cefafe",
+ "#a2f4fd",
+ "#53eafd",
+ "#00d3f2",
+ "#00b8db",
+ "#0092b8",
+ "#007595",
+ "#005f78",
+ "#104e64",
+ "#053345",
+ "#f0f9ff",
+ "#dff2fe",
+ "#b8e6fe",
+ "#74d4ff",
+ "#00bcff",
+ "#00a6f4",
+ "#0084d1",
+ "#0069a8",
+ "#00598a",
+ "#024a70",
+ "#052f4a",
+ "#eff6ff",
+ "#dbeafe",
+ "#bedbff",
+ "#8ec5ff",
+ "#51a2ff",
+ "#2b7fff",
+ "#155dfc",
+ "#1447e6",
+ "#193cb8",
+ "#1c398e",
+ "#162456",
+ "#eef2ff",
+ "#e0e7ff",
+ "#c6d2ff",
+ "#a3b3ff",
+ "#7c86ff",
+ "#615fff",
+ "#4f39f6",
+ "#432dd7",
+ "#372aac",
+ "#312c85",
+ "#1e1a4d",
+ "#f5f3ff",
+ "#ede9fe",
+ "#ddd6ff",
+ "#c4b4ff",
+ "#a684ff",
+ "#8e51ff",
+ "#7f22fe",
+ "#7008e7",
+ "#5d0ec0",
+ "#4d179a",
+ "#2f0d68",
+ "#faf5ff",
+ "#f3e8ff",
+ "#e9d4ff",
+ "#dab2ff",
+ "#c27aff",
+ "#ad46ff",
+ "#9810fa",
+ "#8200db",
+ "#6e11b0",
+ "#59168b",
+ "#3c0366",
+ "#fdf4ff",
+ "#fae8ff",
+ "#f6cfff",
+ "#f4a8ff",
+ "#ed6aff",
+ "#e12afb",
+ "#c800de",
+ "#a800b7",
+ "#8a0194",
+ "#721378",
+ "#4b004f",
+ "#fdf2f8",
+ "#fce7f3",
+ "#fccee8",
+ "#fda5d5",
+ "#fb64b6",
+ "#f6339a",
+ "#e60076",
+ "#c6005c",
+ "#a3004c",
+ "#861043",
+ "#510424",
+ "#fff1f2",
+ "#ffe4e6",
+ "#ffccd3",
+ "#ffa1ad",
+ "#ff637e",
+ "#ff2056",
+ "#ec003f",
+ "#c70036",
+ "#a50036",
+ "#8b0836",
+ "#4d0218",
+ "#f8fafc",
+ "#f1f5f9",
+ "#e2e8f0",
+ "#cad5e2",
+ "#90a1b9",
+ "#62748e",
+ "#45556c",
+ "#314158",
+ "#1d293d",
+ "#0f172b",
+ "#020618",
+ "#f9fafb",
+ "#f3f4f6",
+ "#e5e7eb",
+ "#d1d5dc",
+ "#99a1af",
+ "#6a7282",
+ "#4a5565",
+ "#364153",
+ "#1e2939",
+ "#101828",
+ "#030712",
+ "#fafafa",
+ "#f4f4f5",
+ "#e4e4e7",
+ "#d4d4d8",
+ "#9f9fa9",
+ "#71717b",
+ "#52525c",
+ "#3f3f46",
+ "#27272a",
+ "#18181b",
+ "#09090b",
+ "#f5f5f5",
+ "#e5e5e5",
+ "#d4d4d4",
+ "#a1a1a1",
+ "#737373",
+ "#525252",
+ "#404040",
+ "#262626",
+ "#171717",
+ "#0a0a0a",
+ "#fafaf9",
+ "#f5f5f4",
+ "#e7e5e4",
+ "#d6d3d1",
+ "#a6a09b",
+ "#79716b",
+ "#57534d",
+ "#44403b",
+ "#292524",
+ "#1c1917",
+ "#0c0a09",
+];
diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 4696fc57..8071107e 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -5,23 +5,23 @@ import { keys as auth } from "@opencut/auth/keys"; import { keys as db } from "@opencut/db/keys"; export const env = createEnv({ - extends: [vercel(), auth(), db()], - server: { - ANALYZE: z.string().optional(), - // Added by Vercel - NEXT_RUNTIME: z.enum(["nodejs", "edge"]).optional(), - NODE_ENV: z - .enum(["development", "production", "test"]) - .default("development"), - UPSTASH_REDIS_REST_URL: z.string().url(), - UPSTASH_REDIS_REST_TOKEN: z.string(), - }, - client: {}, - runtimeEnv: { - ANALYZE: process.env.ANALYZE, - NEXT_RUNTIME: process.env.NEXT_RUNTIME, - NODE_ENV: process.env.NODE_ENV, - UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, - UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, - }, + extends: [vercel(), auth(), db()], + server: { + ANALYZE: z.string().optional(), + // Added by Vercel + NEXT_RUNTIME: z.enum(["nodejs", "edge"]).optional(), + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), + UPSTASH_REDIS_REST_URL: z.string().url(), + UPSTASH_REDIS_REST_TOKEN: z.string(), + }, + client: {}, + runtimeEnv: { + ANALYZE: process.env.ANALYZE, + NEXT_RUNTIME: process.env.NEXT_RUNTIME, + NODE_ENV: process.env.NODE_ENV, + UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, + UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, + }, }); diff --git a/apps/web/src/hooks/auth/useLogin.ts b/apps/web/src/hooks/auth/useLogin.ts index a3942506..12eddcbf 100644 --- a/apps/web/src/hooks/auth/useLogin.ts +++ b/apps/web/src/hooks/auth/useLogin.ts @@ -3,58 +3,58 @@ import { useRouter } from "next/navigation"; import { signIn } from "@opencut/auth/client"; export function useLogin() { - const router = useRouter(); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState<string | null>(null); - const [isEmailLoading, setIsEmailLoading] = useState(false); - const [isGoogleLoading, setIsGoogleLoading] = useState(false); + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState<string | null>(null); + const [isEmailLoading, setIsEmailLoading] = useState(false); + const [isGoogleLoading, setIsGoogleLoading] = useState(false); - const handleLogin = useCallback(async () => { - setError(null); - setIsEmailLoading(true); + const handleLogin = useCallback(async () => { + setError(null); + setIsEmailLoading(true); - const { error } = await signIn.email({ - email, - password, - }); + const { error } = await signIn.email({ + email, + password, + }); - if (error) { - setError(error.message || "An unexpected error occurred."); - setIsEmailLoading(false); - return; - } + if (error) { + setError(error.message || "An unexpected error occurred."); + setIsEmailLoading(false); + return; + } - router.push("/projects"); - }, [router, email, password]); + router.push("/projects"); + }, [router, email, password]); - const handleGoogleLogin = async () => { - setError(null); - setIsGoogleLoading(true); + const handleGoogleLogin = async () => { + setError(null); + setIsGoogleLoading(true); - try { - await signIn.social({ - provider: "google", - callbackURL: "/projects", - }); - } catch (error) { - setError("Failed to sign in with Google. Please try again."); - setIsGoogleLoading(false); - } - }; + try { + await signIn.social({ + provider: "google", + callbackURL: "/projects", + }); + } catch (error) { + setError("Failed to sign in with Google. Please try again."); + setIsGoogleLoading(false); + } + }; - const isAnyLoading = isEmailLoading || isGoogleLoading; + const isAnyLoading = isEmailLoading || isGoogleLoading; - return { - email, - setEmail, - password, - setPassword, - error, - isEmailLoading, - isGoogleLoading, - isAnyLoading, - handleLogin, - handleGoogleLogin, - }; + return { + email, + setEmail, + password, + setPassword, + error, + isEmailLoading, + isGoogleLoading, + isAnyLoading, + handleLogin, + handleGoogleLogin, + }; } diff --git a/apps/web/src/hooks/auth/useSignUp.ts b/apps/web/src/hooks/auth/useSignUp.ts index fae7c967..4cd2850a 100644 --- a/apps/web/src/hooks/auth/useSignUp.ts +++ b/apps/web/src/hooks/auth/useSignUp.ts @@ -3,63 +3,63 @@ import { useRouter } from "next/navigation"; import { signUp, signIn } from "@opencut/auth/client"; export function useSignUp() { - const router = useRouter(); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState<string | null>(null); - const [isEmailLoading, setIsEmailLoading] = useState(false); - const [isGoogleLoading, setIsGoogleLoading] = useState(false); + const router = useRouter(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState<string | null>(null); + const [isEmailLoading, setIsEmailLoading] = useState(false); + const [isGoogleLoading, setIsGoogleLoading] = useState(false); - const handleSignUp = useCallback(async () => { - setError(null); - setIsEmailLoading(true); + const handleSignUp = useCallback(async () => { + setError(null); + setIsEmailLoading(true); - const { error } = await signUp.email({ - name, - email, - password, - }); + const { error } = await signUp.email({ + name, + email, + password, + }); - if (error) { - setError(error.message || "An unexpected error occurred."); - setIsEmailLoading(false); - return; - } + if (error) { + setError(error.message || "An unexpected error occurred."); + setIsEmailLoading(false); + return; + } - router.push("/login"); - }, [name, email, password, router]); + router.push("/login"); + }, [name, email, password, router]); - const handleGoogleSignUp = useCallback(async () => { - setError(null); - setIsGoogleLoading(true); + const handleGoogleSignUp = useCallback(async () => { + setError(null); + setIsGoogleLoading(true); - try { - await signIn.social({ - provider: "google", - }); + try { + await signIn.social({ + provider: "google", + }); - router.push("/editor"); - } catch (error) { - setError("Failed to sign up with Google. Please try again."); - setIsGoogleLoading(false); - } - }, [router]); + router.push("/editor"); + } catch (error) { + setError("Failed to sign up with Google. Please try again."); + setIsGoogleLoading(false); + } + }, [router]); - const isAnyLoading = isEmailLoading || isGoogleLoading; + const isAnyLoading = isEmailLoading || isGoogleLoading; - return { - name, - setName, - email, - setEmail, - password, - setPassword, - error, - isEmailLoading, - isGoogleLoading, - isAnyLoading, - handleSignUp, - handleGoogleSignUp, - }; -} + return { + name, + setName, + email, + setEmail, + password, + setPassword, + error, + isEmailLoading, + isGoogleLoading, + isAnyLoading, + handleSignUp, + handleGoogleSignUp, + }; +}
\ No newline at end of file diff --git a/apps/web/src/hooks/use-aspect-ratio.ts b/apps/web/src/hooks/use-aspect-ratio.ts index b297f6b5..40de8454 100644 --- a/apps/web/src/hooks/use-aspect-ratio.ts +++ b/apps/web/src/hooks/use-aspect-ratio.ts @@ -1,92 +1,92 @@ -import { useEditorStore } from "@/stores/editor-store"; -import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store"; -import { useTimelineStore } from "@/stores/timeline-store"; - -export function useAspectRatio() { - const { canvasSize, canvasMode, canvasPresets } = useEditorStore(); - const { mediaItems } = useMediaStore(); - const { tracks } = useTimelineStore(); - - // Find the current preset based on canvas size - const currentPreset = canvasPresets.find( - (preset) => - preset.width === canvasSize.width && preset.height === canvasSize.height, - ); - - // Get the original aspect ratio from the first video/image in timeline - const getOriginalAspectRatio = (): number => { - // Find first video or image in timeline - for (const track of tracks) { - for (const element of track.elements) { - if (element.type === "media") { - const mediaItem = mediaItems.find( - (item) => item.id === element.mediaId, - ); - if ( - mediaItem && - (mediaItem.type === "video" || mediaItem.type === "image") - ) { - return getMediaAspectRatio(mediaItem); - } - } - } - } - return 16 / 9; // Default aspect ratio - }; - - // Get current aspect ratio - const getCurrentAspectRatio = (): number => { - return canvasSize.width / canvasSize.height; - }; - - // Format aspect ratio as a readable string - const formatAspectRatio = (aspectRatio: number): string => { - // Check if it matches a common aspect ratio - const ratios = [ - { ratio: 16 / 9, label: "16:9" }, - { ratio: 9 / 16, label: "9:16" }, - { ratio: 1, label: "1:1" }, - { ratio: 4 / 3, label: "4:3" }, - { ratio: 3 / 4, label: "3:4" }, - { ratio: 21 / 9, label: "21:9" }, - ]; - - for (const { ratio, label } of ratios) { - if (Math.abs(aspectRatio - ratio) < 0.01) { - return label; - } - } - - // If not a common ratio, format as decimal - return aspectRatio.toFixed(2); - }; - - // Check if current mode is "Original" - const isOriginal = canvasMode === "original"; - - // Get display name for current aspect ratio - const getDisplayName = (): string => { - // If explicitly set to original mode, always show "Original" - if (canvasMode === "original") { - return "Original"; - } - - if (currentPreset) { - return currentPreset.name; - } - - return formatAspectRatio(getCurrentAspectRatio()); - }; - - return { - currentPreset, - canvasMode, - isOriginal, - getCurrentAspectRatio, - getOriginalAspectRatio, - formatAspectRatio, - getDisplayName, - canvasSize, - canvasPresets, - }; -} +import { useEditorStore } from "@/stores/editor-store";
+import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store";
+import { useTimelineStore } from "@/stores/timeline-store";
+
+export function useAspectRatio() {
+ const { canvasSize, canvasMode, canvasPresets } = useEditorStore();
+ const { mediaItems } = useMediaStore();
+ const { tracks } = useTimelineStore();
+
+ // Find the current preset based on canvas size
+ const currentPreset = canvasPresets.find(
+ (preset) =>
+ preset.width === canvasSize.width && preset.height === canvasSize.height
+ );
+
+ // Get the original aspect ratio from the first video/image in timeline
+ const getOriginalAspectRatio = (): number => {
+ // Find first video or image in timeline
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ if (element.type === "media") {
+ const mediaItem = mediaItems.find(
+ (item) => item.id === element.mediaId
+ );
+ if (
+ mediaItem &&
+ (mediaItem.type === "video" || mediaItem.type === "image")
+ ) {
+ return getMediaAspectRatio(mediaItem);
+ }
+ }
+ }
+ }
+ return 16 / 9; // Default aspect ratio
+ };
+
+ // Get current aspect ratio
+ const getCurrentAspectRatio = (): number => {
+ return canvasSize.width / canvasSize.height;
+ };
+
+ // Format aspect ratio as a readable string
+ const formatAspectRatio = (aspectRatio: number): string => {
+ // Check if it matches a common aspect ratio
+ const ratios = [
+ { ratio: 16 / 9, label: "16:9" },
+ { ratio: 9 / 16, label: "9:16" },
+ { ratio: 1, label: "1:1" },
+ { ratio: 4 / 3, label: "4:3" },
+ { ratio: 3 / 4, label: "3:4" },
+ { ratio: 21 / 9, label: "21:9" },
+ ];
+
+ for (const { ratio, label } of ratios) {
+ if (Math.abs(aspectRatio - ratio) < 0.01) {
+ return label;
+ }
+ }
+
+ // If not a common ratio, format as decimal
+ return aspectRatio.toFixed(2);
+ };
+
+ // Check if current mode is "Original"
+ const isOriginal = canvasMode === "original";
+
+ // Get display name for current aspect ratio
+ const getDisplayName = (): string => {
+ // If explicitly set to original mode, always show "Original"
+ if (canvasMode === "original") {
+ return "Original";
+ }
+
+ if (currentPreset) {
+ return currentPreset.name;
+ }
+
+ return formatAspectRatio(getCurrentAspectRatio());
+ };
+
+ return {
+ currentPreset,
+ canvasMode,
+ isOriginal,
+ getCurrentAspectRatio,
+ getOriginalAspectRatio,
+ formatAspectRatio,
+ getDisplayName,
+ canvasSize,
+ canvasPresets,
+ };
+}
diff --git a/apps/web/src/hooks/use-drag-drop.ts b/apps/web/src/hooks/use-drag-drop.ts index 768014ec..eaab89d8 100644 --- a/apps/web/src/hooks/use-drag-drop.ts +++ b/apps/web/src/hooks/use-drag-drop.ts @@ -1,85 +1,85 @@ -import { useState, useRef } from "react"; - -interface UseDragDropOptions { - onDrop?: (files: FileList) => void; -} - -// Helper function to check if drag contains files from external sources (not internal app drags) -const containsFiles = (dataTransfer: DataTransfer): boolean => { - // Check if this is an internal app drag (media item) - if (dataTransfer.types.includes("application/x-media-item")) { - return false; - } - - // Only show overlay for external file drags - return dataTransfer.types.includes("Files"); -}; - -export function useDragDrop(options: UseDragDropOptions = {}) { - const [isDragOver, setIsDragOver] = useState(false); - const dragCounterRef = useRef(0); - - const handleDragEnter = (e: React.DragEvent) => { - e.preventDefault(); - - // Only handle external file drags, not internal app element drags - if (!containsFiles(e.dataTransfer)) { - return; - } - - dragCounterRef.current += 1; - if (!isDragOver) { - setIsDragOver(true); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - - // Only handle file drags - if (!containsFiles(e.dataTransfer)) { - return; - } - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - - // Only handle file drags - if (!containsFiles(e.dataTransfer)) { - return; - } - - dragCounterRef.current -= 1; - if (dragCounterRef.current === 0) { - setIsDragOver(false); - } - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(false); - dragCounterRef.current = 0; - - // Only handle file drops - if ( - options.onDrop && - e.dataTransfer.files && - containsFiles(e.dataTransfer) - ) { - options.onDrop(e.dataTransfer.files); - } - }; - - const dragProps = { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, - }; - - return { - isDragOver, - dragProps, - }; -} +import { useState, useRef } from "react";
+
+interface UseDragDropOptions {
+ onDrop?: (files: FileList) => void;
+}
+
+// Helper function to check if drag contains files from external sources (not internal app drags)
+const containsFiles = (dataTransfer: DataTransfer): boolean => {
+ // Check if this is an internal app drag (media item)
+ if (dataTransfer.types.includes("application/x-media-item")) {
+ return false;
+ }
+
+ // Only show overlay for external file drags
+ return dataTransfer.types.includes("Files");
+};
+
+export function useDragDrop(options: UseDragDropOptions = {}) {
+ const [isDragOver, setIsDragOver] = useState(false);
+ const dragCounterRef = useRef(0);
+
+ const handleDragEnter = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ // Only handle external file drags, not internal app element drags
+ if (!containsFiles(e.dataTransfer)) {
+ return;
+ }
+
+ dragCounterRef.current += 1;
+ if (!isDragOver) {
+ setIsDragOver(true);
+ }
+ };
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ // Only handle file drags
+ if (!containsFiles(e.dataTransfer)) {
+ return;
+ }
+ };
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+
+ // Only handle file drags
+ if (!containsFiles(e.dataTransfer)) {
+ return;
+ }
+
+ dragCounterRef.current -= 1;
+ if (dragCounterRef.current === 0) {
+ setIsDragOver(false);
+ }
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragOver(false);
+ dragCounterRef.current = 0;
+
+ // Only handle file drops
+ if (
+ options.onDrop &&
+ e.dataTransfer.files &&
+ containsFiles(e.dataTransfer)
+ ) {
+ options.onDrop(e.dataTransfer.files);
+ }
+ };
+
+ const dragProps = {
+ onDragEnter: handleDragEnter,
+ onDragOver: handleDragOver,
+ onDragLeave: handleDragLeave,
+ onDrop: handleDrop,
+ };
+
+ return {
+ isDragOver,
+ dragProps,
+ };
+}
diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts index 3a47dcee..e0d9b9eb 100644 --- a/apps/web/src/hooks/use-editor-actions.ts +++ b/apps/web/src/hooks/use-editor-actions.ts @@ -8,155 +8,155 @@ import { useProjectStore } from "@/stores/project-store"; import { toast } from "sonner"; export function useEditorActions() { - const { - tracks, - selectedElements, - clearSelectedElements, - setSelectedElements, - removeElementFromTrack, - splitElement, - addElementToTrack, - snappingEnabled, - toggleSnapping, - undo, - redo, - } = useTimelineStore(); + const { + tracks, + selectedElements, + clearSelectedElements, + setSelectedElements, + removeElementFromTrack, + splitElement, + addElementToTrack, + snappingEnabled, + toggleSnapping, + undo, + redo, + } = useTimelineStore(); - const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); - const { activeProject } = useProjectStore(); + const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); + const { activeProject } = useProjectStore(); - // Playback actions - useActionHandler("toggle-play", () => { - toggle(); - }); + // Playback actions + useActionHandler("toggle-play", () => { + toggle(); + }); - useActionHandler("stop-playback", () => { - if (isPlaying) { - toggle(); - } - seek(0); - }); + useActionHandler("stop-playback", () => { + if (isPlaying) { + toggle(); + } + seek(0); + }); - useActionHandler("seek-forward", (args) => { - const seconds = args?.seconds ?? 1; - seek(Math.min(duration, currentTime + seconds)); - }); + useActionHandler("seek-forward", (args) => { + const seconds = args?.seconds ?? 1; + seek(Math.min(duration, currentTime + seconds)); + }); - useActionHandler("seek-backward", (args) => { - const seconds = args?.seconds ?? 1; - seek(Math.max(0, currentTime - seconds)); - }); + useActionHandler("seek-backward", (args) => { + const seconds = args?.seconds ?? 1; + seek(Math.max(0, currentTime - seconds)); + }); - useActionHandler("frame-step-forward", () => { - const projectFps = activeProject?.fps || 30; - seek(Math.min(duration, currentTime + 1 / projectFps)); - }); + useActionHandler("frame-step-forward", () => { + const projectFps = activeProject?.fps || 30; + seek(Math.min(duration, currentTime + 1 / projectFps)); + }); - useActionHandler("frame-step-backward", () => { - const projectFps = activeProject?.fps || 30; - seek(Math.max(0, currentTime - 1 / projectFps)); - }); + useActionHandler("frame-step-backward", () => { + const projectFps = activeProject?.fps || 30; + seek(Math.max(0, currentTime - 1 / projectFps)); + }); - useActionHandler("jump-forward", (args) => { - const seconds = args?.seconds ?? 5; - seek(Math.min(duration, currentTime + seconds)); - }); + useActionHandler("jump-forward", (args) => { + const seconds = args?.seconds ?? 5; + seek(Math.min(duration, currentTime + seconds)); + }); - useActionHandler("jump-backward", (args) => { - const seconds = args?.seconds ?? 5; - seek(Math.max(0, currentTime - seconds)); - }); + useActionHandler("jump-backward", (args) => { + const seconds = args?.seconds ?? 5; + seek(Math.max(0, currentTime - seconds)); + }); - useActionHandler("goto-start", () => { - seek(0); - }); + useActionHandler("goto-start", () => { + seek(0); + }); - useActionHandler("goto-end", () => { - seek(duration); - }); + useActionHandler("goto-end", () => { + seek(duration); + }); - // Timeline editing actions - useActionHandler("split-element", () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element to split"); - return; - } + // Timeline editing actions + useActionHandler("split-element", () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element to split"); + return; + } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t: any) => t.id === trackId); - const element = track?.elements.find((el: any) => el.id === elementId); + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t: any) => t.id === trackId); + const element = track?.elements.find((el: any) => el.id === elementId); - if (element) { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + if (element) { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - splitElement(trackId, elementId, currentTime); - } else { - toast.error("Playhead must be within selected element"); - } - } - }); + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + splitElement(trackId, elementId, currentTime); + } else { + toast.error("Playhead must be within selected element"); + } + } + }); - useActionHandler("delete-selected", () => { - if (selectedElements.length === 0) { - toast.error("No elements selected"); - return; - } - selectedElements.forEach( - ({ trackId, elementId }: { trackId: string; elementId: string }) => { - removeElementFromTrack(trackId, elementId); - }, - ); - clearSelectedElements(); - }); + useActionHandler("delete-selected", () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + selectedElements.forEach( + ({ trackId, elementId }: { trackId: string; elementId: string }) => { + removeElementFromTrack(trackId, elementId); + } + ); + clearSelectedElements(); + }); - useActionHandler("select-all", () => { - const allElements = tracks.flatMap((track: any) => - track.elements.map((element: any) => ({ - trackId: track.id, - elementId: element.id, - })), - ); - setSelectedElements(allElements); - }); + useActionHandler("select-all", () => { + const allElements = tracks.flatMap((track: any) => + track.elements.map((element: any) => ({ + trackId: track.id, + elementId: element.id, + })) + ); + setSelectedElements(allElements); + }); - useActionHandler("duplicate-selected", () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element to duplicate"); - return; - } + useActionHandler("duplicate-selected", () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element to duplicate"); + return; + } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t: any) => t.id === trackId); - const element = track?.elements.find((el: any) => el.id === elementId); + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t: any) => t.id === trackId); + const element = track?.elements.find((el: any) => el.id === elementId); - if (element) { - const newStartTime = - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1; - const { id, ...elementWithoutId } = element; + if (element) { + const newStartTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1; + const { id, ...elementWithoutId } = element; - addElementToTrack(trackId, { - ...elementWithoutId, - startTime: newStartTime, - }); - } - }); + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, + }); + } + }); - useActionHandler("toggle-snapping", () => { - toggleSnapping(); - }); + useActionHandler("toggle-snapping", () => { + toggleSnapping(); + }); - // History actions - useActionHandler("undo", () => { - undo(); - }); + // History actions + useActionHandler("undo", () => { + undo(); + }); - useActionHandler("redo", () => { - redo(); - }); + useActionHandler("redo", () => { + redo(); + }); } diff --git a/apps/web/src/hooks/use-keybinding-conflicts.ts b/apps/web/src/hooks/use-keybinding-conflicts.ts index 0720982e..cc5dd340 100644 --- a/apps/web/src/hooks/use-keybinding-conflicts.ts +++ b/apps/web/src/hooks/use-keybinding-conflicts.ts @@ -5,55 +5,55 @@ import { useKeybindingsStore } from "@/stores/keybindings-store"; import { ActionWithOptionalArgs } from "@/constants/actions"; export interface KeybindingConflictInfo { - key: string; - actions: ActionWithOptionalArgs[]; - isConflict: boolean; + key: string; + actions: ActionWithOptionalArgs[]; + isConflict: boolean; } export const useKeybindingConflicts = () => { - const { keybindings } = useKeybindingsStore(); + const { keybindings } = useKeybindingsStore(); - const conflicts = useMemo(() => { - const keyToActions: Record<string, ActionWithOptionalArgs[]> = {}; - const conflictList: KeybindingConflictInfo[] = []; + const conflicts = useMemo(() => { + const keyToActions: Record<string, ActionWithOptionalArgs[]> = {}; + const conflictList: KeybindingConflictInfo[] = []; - // Group actions by key - Object.entries(keybindings).forEach(([key, action]) => { - if (!keyToActions[key]) { - keyToActions[key] = []; - } - keyToActions[key].push(action); - }); + // Group actions by key + Object.entries(keybindings).forEach(([key, action]) => { + if (!keyToActions[key]) { + keyToActions[key] = []; + } + keyToActions[key].push(action); + }); - // Find conflicts - Object.entries(keyToActions).forEach(([key, actions]) => { - const uniqueActions = [...new Set(actions)]; - conflictList.push({ - key, - actions: uniqueActions, - isConflict: uniqueActions.length > 1, - }); - }); + // Find conflicts + Object.entries(keyToActions).forEach(([key, actions]) => { + const uniqueActions = [...new Set(actions)]; + conflictList.push({ + key, + actions: uniqueActions, + isConflict: uniqueActions.length > 1, + }); + }); - return conflictList.filter((item) => item.isConflict); - }, [keybindings]); + return conflictList.filter((item) => item.isConflict); + }, [keybindings]); - const hasConflicts = conflicts.length > 0; + const hasConflicts = conflicts.length > 0; - const getConflictsForKey = (key: string): KeybindingConflictInfo | null => { - return conflicts.find((conflict) => conflict.key === key) || null; - }; + const getConflictsForKey = (key: string): KeybindingConflictInfo | null => { + return conflicts.find((conflict) => conflict.key === key) || null; + }; - const getConflictsForAction = ( - action: ActionWithOptionalArgs, - ): KeybindingConflictInfo[] => { - return conflicts.filter((conflict) => conflict.actions.includes(action)); - }; + const getConflictsForAction = ( + action: ActionWithOptionalArgs + ): KeybindingConflictInfo[] => { + return conflicts.filter((conflict) => conflict.actions.includes(action)); + }; - return { - conflicts, - hasConflicts, - getConflictsForKey, - getConflictsForAction, - }; + return { + conflicts, + hasConflicts, + getConflictsForKey, + getConflictsForAction, + }; }; diff --git a/apps/web/src/hooks/use-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts index 1c001820..7887f3f1 100644 --- a/apps/web/src/hooks/use-keybindings.ts +++ b/apps/web/src/hooks/use-keybindings.ts @@ -8,56 +8,56 @@ import { useKeybindingsStore } from "@/stores/keybindings-store"; * the appropriate actions based on keybindings */ export function useKeybindingsListener() { - const { keybindings, getKeybindingString, keybindingsEnabled } = - useKeybindingsStore(); + const { keybindings, getKeybindingString, keybindingsEnabled } = + useKeybindingsStore(); - useEffect(() => { - const handleKeyDown = (ev: KeyboardEvent) => { - // Do not check keybinds if the mode is disabled - if (!keybindingsEnabled) return; + useEffect(() => { + const handleKeyDown = (ev: KeyboardEvent) => { + // Do not check keybinds if the mode is disabled + if (!keybindingsEnabled) return; - const binding = getKeybindingString(ev); - if (!binding) return; + const binding = getKeybindingString(ev); + if (!binding) return; - const boundAction = keybindings[binding]; - if (!boundAction) return; + const boundAction = keybindings[binding]; + if (!boundAction) return; - ev.preventDefault(); + ev.preventDefault(); - // Handle actions with default arguments - let actionArgs: any = undefined; + // Handle actions with default arguments + let actionArgs: any = undefined; - if (boundAction === "seek-forward") { - actionArgs = { seconds: 1 }; - } else if (boundAction === "seek-backward") { - actionArgs = { seconds: 1 }; - } else if (boundAction === "jump-forward") { - actionArgs = { seconds: 5 }; - } else if (boundAction === "jump-backward") { - actionArgs = { seconds: 5 }; - } + if (boundAction === "seek-forward") { + actionArgs = { seconds: 1 }; + } else if (boundAction === "seek-backward") { + actionArgs = { seconds: 1 }; + } else if (boundAction === "jump-forward") { + actionArgs = { seconds: 5 }; + } else if (boundAction === "jump-backward") { + actionArgs = { seconds: 5 }; + } - invokeAction(boundAction, actionArgs, "keypress"); - }; + invokeAction(boundAction, actionArgs, "keypress"); + }; - document.addEventListener("keydown", handleKeyDown); + document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("keydown", handleKeyDown); - }; - }, [keybindings, getKeybindingString, keybindingsEnabled]); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [keybindings, getKeybindingString, keybindingsEnabled]); } /** * This composable allows for the UI component to be disabled if the component in question is mounted */ export function useKeybindingDisabler() { - const { disableKeybindings, enableKeybindings } = useKeybindingsStore(); + const { disableKeybindings, enableKeybindings } = useKeybindingsStore(); - return { - disableKeybindings, - enableKeybindings, - }; + return { + disableKeybindings, + enableKeybindings, + }; } // Export the bindings for backward compatibility diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts index dd52f00b..c7de8466 100644 --- a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts +++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts @@ -5,125 +5,125 @@ import { useKeybindingsStore } from "@/stores/keybindings-store"; import { Action } from "@/constants/actions"; export interface KeyboardShortcut { - id: string; - keys: string[]; - description: string; - category: string; - action: Action; - icon?: React.ReactNode; + id: string; + keys: string[]; + description: string; + category: string; + action: Action; + icon?: React.ReactNode; } // Map actions to their descriptions and categories const actionDescriptions: Record< - Action, - { description: string; category: string } + Action, + { description: string; category: string } > = { - "toggle-play": { description: "Play/Pause", category: "Playback" }, - "stop-playback": { description: "Stop playback", category: "Playback" }, - "seek-forward": { - description: "Seek forward 1 second", - category: "Playback", - }, - "seek-backward": { - description: "Seek backward 1 second", - category: "Playback", - }, - "frame-step-forward": { - description: "Frame step forward", - category: "Navigation", - }, - "frame-step-backward": { - description: "Frame step backward", - category: "Navigation", - }, - "jump-forward": { - description: "Jump forward 5 seconds", - category: "Navigation", - }, - "jump-backward": { - description: "Jump backward 5 seconds", - category: "Navigation", - }, - "goto-start": { description: "Go to timeline start", category: "Navigation" }, - "goto-end": { description: "Go to timeline end", category: "Navigation" }, - "split-element": { - description: "Split element at playhead", - category: "Editing", - }, - "delete-selected": { - description: "Delete selected elements", - category: "Editing", - }, - "select-all": { description: "Select all elements", category: "Selection" }, - "duplicate-selected": { - description: "Duplicate selected element", - category: "Selection", - }, - "toggle-snapping": { description: "Toggle snapping", category: "Editing" }, - undo: { description: "Undo", category: "History" }, - redo: { description: "Redo", category: "History" }, + "toggle-play": { description: "Play/Pause", category: "Playback" }, + "stop-playback": { description: "Stop playback", category: "Playback" }, + "seek-forward": { + description: "Seek forward 1 second", + category: "Playback", + }, + "seek-backward": { + description: "Seek backward 1 second", + category: "Playback", + }, + "frame-step-forward": { + description: "Frame step forward", + category: "Navigation", + }, + "frame-step-backward": { + description: "Frame step backward", + category: "Navigation", + }, + "jump-forward": { + description: "Jump forward 5 seconds", + category: "Navigation", + }, + "jump-backward": { + description: "Jump backward 5 seconds", + category: "Navigation", + }, + "goto-start": { description: "Go to timeline start", category: "Navigation" }, + "goto-end": { description: "Go to timeline end", category: "Navigation" }, + "split-element": { + description: "Split element at playhead", + category: "Editing", + }, + "delete-selected": { + description: "Delete selected elements", + category: "Editing", + }, + "select-all": { description: "Select all elements", category: "Selection" }, + "duplicate-selected": { + description: "Duplicate selected element", + category: "Selection", + }, + "toggle-snapping": { description: "Toggle snapping", category: "Editing" }, + undo: { description: "Undo", category: "History" }, + redo: { description: "Redo", category: "History" }, }; // Convert key binding format to display format const formatKey = (key: string): string => { - return key - .replace("ctrl", "Cmd") - .replace("alt", "Alt") - .replace("shift", "Shift") - .replace("left", "ArrowLeft") - .replace("right", "ArrowRight") - .replace("up", "ArrowUp") - .replace("down", "ArrowDown") - .replace("space", "Space") - .replace("home", "Home") - .replace("end", "End") - .replace("delete", "Delete") - .replace("backspace", "Backspace") - .replace("-", "+"); + return key + .replace("ctrl", "Cmd") + .replace("alt", "Alt") + .replace("shift", "Shift") + .replace("left", "ArrowLeft") + .replace("right", "ArrowRight") + .replace("up", "ArrowUp") + .replace("down", "ArrowDown") + .replace("space", "Space") + .replace("home", "Home") + .replace("end", "End") + .replace("delete", "Delete") + .replace("backspace", "Backspace") + .replace("-", "+"); }; export const useKeyboardShortcutsHelp = () => { - const { keybindings } = useKeybindingsStore(); + const { keybindings } = useKeybindingsStore(); - const shortcuts = useMemo(() => { - const result: KeyboardShortcut[] = []; + const shortcuts = useMemo(() => { + const result: KeyboardShortcut[] = []; - // Group keybindings by action - const actionToKeys: Record<Action, string[]> = {} as any; + // Group keybindings by action + const actionToKeys: Record<Action, string[]> = {} as any; - Object.entries(keybindings).forEach(([key, action]) => { - if (action) { - if (!actionToKeys[action]) { - actionToKeys[action] = []; - } - actionToKeys[action].push(formatKey(key)); - } - }); + Object.entries(keybindings).forEach(([key, action]) => { + if (action) { + if (!actionToKeys[action]) { + actionToKeys[action] = []; + } + actionToKeys[action].push(formatKey(key)); + } + }); - // Convert to shortcuts format - Object.entries(actionToKeys).forEach(([action, keys]) => { - const actionInfo = actionDescriptions[action as Action]; - if (actionInfo) { - result.push({ - id: action, - keys, - description: actionInfo.description, - category: actionInfo.category, - action: action as Action, - }); - } - }); + // Convert to shortcuts format + Object.entries(actionToKeys).forEach(([action, keys]) => { + const actionInfo = actionDescriptions[action as Action]; + if (actionInfo) { + result.push({ + id: action, + keys, + description: actionInfo.description, + category: actionInfo.category, + action: action as Action, + }); + } + }); - // Sort shortcuts by category first, then by description to ensure consistent ordering - return result.sort((a, b) => { - if (a.category !== b.category) { - return a.category.localeCompare(b.category); - } - return a.description.localeCompare(b.description); - }); - }, [keybindings]); + // Sort shortcuts by category first, then by description to ensure consistent ordering + return result.sort((a, b) => { + if (a.category !== b.category) { + return a.category.localeCompare(b.category); + } + return a.description.localeCompare(b.description); + }); + }, [keybindings]); - return { - shortcuts, - }; + return { + shortcuts, + }; }; diff --git a/apps/web/src/hooks/use-mobile.tsx b/apps/web/src/hooks/use-mobile.tsx index 0a892310..2b0fe1df 100644 --- a/apps/web/src/hooks/use-mobile.tsx +++ b/apps/web/src/hooks/use-mobile.tsx @@ -1,21 +1,19 @@ -import * as React from "react"; +import * as React from "react" -const MOBILE_BREAKPOINT = 768; +const MOBILE_BREAKPOINT = 768 export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState<boolean | undefined>( - undefined, - ); + const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); - }; - mql.addEventListener("change", onChange); - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); - return () => mql.removeEventListener("change", onChange); - }, []); + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange) + }, []) - return !!isMobile; + return !!isMobile } diff --git a/apps/web/src/hooks/use-playback-controls.ts b/apps/web/src/hooks/use-playback-controls.ts index 39b5c841..ecbd1232 100644 --- a/apps/web/src/hooks/use-playback-controls.ts +++ b/apps/web/src/hooks/use-playback-controls.ts @@ -4,106 +4,106 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { toast } from "sonner"; export const usePlaybackControls = () => { - const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore(); + const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore(); - const { - selectedElements, - tracks, - splitElement, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, - } = useTimelineStore(); + const { + selectedElements, + tracks, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + } = useTimelineStore(); - const handleSplitSelectedElement = useCallback(() => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element to split"); - return; - } + const handleSplitSelectedElement = useCallback(() => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element to split"); + return; + } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((e) => e.id === elementId); - if (!element) return; + if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } - splitElement(trackId, elementId, currentTime); - }, [selectedElements, tracks, currentTime, splitElement]); + splitElement(trackId, elementId, currentTime); + }, [selectedElements, tracks, currentTime, splitElement]); - const handleSplitAndKeepLeftCallback = useCallback(() => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } + const handleSplitAndKeepLeftCallback = useCallback(() => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((e) => e.id === elementId); - if (!element) return; + if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } - splitAndKeepLeft(trackId, elementId, currentTime); - }, [selectedElements, tracks, currentTime, splitAndKeepLeft]); + splitAndKeepLeft(trackId, elementId, currentTime); + }, [selectedElements, tracks, currentTime, splitAndKeepLeft]); - const handleSplitAndKeepRightCallback = useCallback(() => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } + const handleSplitAndKeepRightCallback = useCallback(() => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((e) => e.id === elementId); - if (!element) return; + if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } - splitAndKeepRight(trackId, elementId, currentTime); - }, [selectedElements, tracks, currentTime, splitAndKeepRight]); + splitAndKeepRight(trackId, elementId, currentTime); + }, [selectedElements, tracks, currentTime, splitAndKeepRight]); - const handleSeparateAudioCallback = useCallback(() => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one media element to separate audio"); - return; - } + const handleSeparateAudioCallback = useCallback(() => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one media element to separate audio"); + return; + } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); - if (!track || track.type !== "media") { - toast.error("Select a media element to separate audio"); - return; - } + if (!track || track.type !== "media") { + toast.error("Select a media element to separate audio"); + return; + } - separateAudio(trackId, elementId); - }, [selectedElements, tracks, separateAudio]); + separateAudio(trackId, elementId); + }, [selectedElements, tracks, separateAudio]); }; diff --git a/apps/web/src/hooks/use-selection-box.ts b/apps/web/src/hooks/use-selection-box.ts index 0abf8707..c40617c1 100644 --- a/apps/web/src/hooks/use-selection-box.ts +++ b/apps/web/src/hooks/use-selection-box.ts @@ -1,199 +1,199 @@ -import { useState, useEffect, useCallback } from "react"; - -interface UseSelectionBoxProps { - containerRef: React.RefObject<HTMLElement>; - playheadRef?: React.RefObject<HTMLElement>; - onSelectionComplete: ( - elements: { trackId: string; elementId: string }[], - ) => void; - isEnabled?: boolean; -} - -interface SelectionBoxState { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; - isActive: boolean; -} - -export function useSelectionBox({ - containerRef, - playheadRef, - onSelectionComplete, - isEnabled = true, -}: UseSelectionBoxProps) { - const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>( - null, - ); - const [justFinishedSelecting, setJustFinishedSelecting] = useState(false); - - // Mouse down handler to start selection - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - if (!isEnabled) return; - - // Only start selection on empty space clicks - if ((e.target as HTMLElement).closest(".timeline-element")) { - return; - } - if (playheadRef?.current?.contains(e.target as Node)) { - return; - } - if ((e.target as HTMLElement).closest("[data-track-labels]")) { - return; - } - // Don't start selection when clicking in the ruler area - this interferes with playhead dragging - if ((e.target as HTMLElement).closest("[data-ruler-area]")) { - return; - } - - setSelectionBox({ - startPos: { x: e.clientX, y: e.clientY }, - currentPos: { x: e.clientX, y: e.clientY }, - isActive: false, // Will become active when mouse moves - }); - }, - [isEnabled, playheadRef], - ); - - // Function to select elements within the selection box - const selectElementsInBox = useCallback( - (startPos: { x: number; y: number }, endPos: { x: number; y: number }) => { - if (!containerRef.current) return; - - const container = containerRef.current; - const containerRect = container.getBoundingClientRect(); - - // Calculate selection rectangle in container coordinates - const startX = startPos.x - containerRect.left; - const startY = startPos.y - containerRect.top; - const endX = endPos.x - containerRect.left; - const endY = endPos.y - containerRect.top; - - const selectionRect = { - left: Math.min(startX, endX), - top: Math.min(startY, endY), - right: Math.max(startX, endX), - bottom: Math.max(startY, endY), - }; - - // Find all timeline elements within the selection rectangle - const timelineElements = container.querySelectorAll(".timeline-element"); - - const selectedElements: { trackId: string; elementId: string }[] = []; - - timelineElements.forEach((element) => { - const elementRect = element.getBoundingClientRect(); - // Use absolute coordinates for more accurate intersection detection - const elementAbsolute = { - left: elementRect.left, - top: elementRect.top, - right: elementRect.right, - bottom: elementRect.bottom, - }; - - const selectionAbsolute = { - left: startPos.x, - top: startPos.y, - right: endPos.x, - bottom: endPos.y, - }; - - // Normalize selection rectangle (handle dragging in any direction) - const normalizedSelection = { - left: Math.min(selectionAbsolute.left, selectionAbsolute.right), - top: Math.min(selectionAbsolute.top, selectionAbsolute.bottom), - right: Math.max(selectionAbsolute.left, selectionAbsolute.right), - bottom: Math.max(selectionAbsolute.top, selectionAbsolute.bottom), - }; - - const elementId = element.getAttribute("data-element-id"); - const trackId = element.getAttribute("data-track-id"); - - // Check if element intersects with selection rectangle (any overlap) - // Using absolute coordinates for more precise detection - const intersects = !( - elementAbsolute.right < normalizedSelection.left || - elementAbsolute.left > normalizedSelection.right || - elementAbsolute.bottom < normalizedSelection.top || - elementAbsolute.top > normalizedSelection.bottom - ); - - if (intersects && elementId && trackId) { - selectedElements.push({ trackId, elementId }); - } - }); - - // Always call the callback - with elements or empty array to clear selection - console.log( - JSON.stringify({ selectElementsInBox: selectedElements.length }), - ); - onSelectionComplete(selectedElements); - }, - [containerRef, onSelectionComplete], - ); - - // Effect to track selection box movement - useEffect(() => { - if (!selectionBox) return; - - const handleMouseMove = (e: MouseEvent) => { - const deltaX = Math.abs(e.clientX - selectionBox.startPos.x); - const deltaY = Math.abs(e.clientY - selectionBox.startPos.y); - - // Start selection if mouse moved more than 5px - const shouldActivate = deltaX > 5 || deltaY > 5; - - const newSelectionBox = { - ...selectionBox, - currentPos: { x: e.clientX, y: e.clientY }, - isActive: shouldActivate || selectionBox.isActive, - }; - - setSelectionBox(newSelectionBox); - - // Real-time visual feedback: update selection as we drag - if (newSelectionBox.isActive) { - selectElementsInBox( - newSelectionBox.startPos, - newSelectionBox.currentPos, - ); - } - }; - - const handleMouseUp = () => { - console.log( - JSON.stringify({ mouseUp: { wasActive: selectionBox?.isActive } }), - ); - - // If we had an active selection, mark that we just finished selecting - if (selectionBox?.isActive) { - console.log(JSON.stringify({ settingJustFinishedSelecting: true })); - setJustFinishedSelecting(true); - // Clear the flag after a short delay to allow click events to check it - setTimeout(() => { - console.log(JSON.stringify({ clearingJustFinishedSelecting: true })); - setJustFinishedSelecting(false); - }, 50); - } - - // Don't call selectElementsInBox again - real-time selection already handled it - // Just clean up the selection box visual - setSelectionBox(null); - }; - - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - - return () => { - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - }; - }, [selectionBox, selectElementsInBox]); - - return { - selectionBox, - handleMouseDown, - isSelecting: selectionBox?.isActive || false, - justFinishedSelecting, - }; -} +import { useState, useEffect, useCallback } from "react";
+
+interface UseSelectionBoxProps {
+ containerRef: React.RefObject<HTMLElement>;
+ playheadRef?: React.RefObject<HTMLElement>;
+ onSelectionComplete: (
+ elements: { trackId: string; elementId: string }[]
+ ) => void;
+ isEnabled?: boolean;
+}
+
+interface SelectionBoxState {
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+ isActive: boolean;
+}
+
+export function useSelectionBox({
+ containerRef,
+ playheadRef,
+ onSelectionComplete,
+ isEnabled = true,
+}: UseSelectionBoxProps) {
+ const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
+ null
+ );
+ const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
+
+ // Mouse down handler to start selection
+ const handleMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ if (!isEnabled) return;
+
+ // Only start selection on empty space clicks
+ if ((e.target as HTMLElement).closest(".timeline-element")) {
+ return;
+ }
+ if (playheadRef?.current?.contains(e.target as Node)) {
+ return;
+ }
+ if ((e.target as HTMLElement).closest("[data-track-labels]")) {
+ return;
+ }
+ // Don't start selection when clicking in the ruler area - this interferes with playhead dragging
+ if ((e.target as HTMLElement).closest("[data-ruler-area]")) {
+ return;
+ }
+
+ setSelectionBox({
+ startPos: { x: e.clientX, y: e.clientY },
+ currentPos: { x: e.clientX, y: e.clientY },
+ isActive: false, // Will become active when mouse moves
+ });
+ },
+ [isEnabled, playheadRef]
+ );
+
+ // Function to select elements within the selection box
+ const selectElementsInBox = useCallback(
+ (startPos: { x: number; y: number }, endPos: { x: number; y: number }) => {
+ if (!containerRef.current) return;
+
+ const container = containerRef.current;
+ const containerRect = container.getBoundingClientRect();
+
+ // Calculate selection rectangle in container coordinates
+ const startX = startPos.x - containerRect.left;
+ const startY = startPos.y - containerRect.top;
+ const endX = endPos.x - containerRect.left;
+ const endY = endPos.y - containerRect.top;
+
+ const selectionRect = {
+ left: Math.min(startX, endX),
+ top: Math.min(startY, endY),
+ right: Math.max(startX, endX),
+ bottom: Math.max(startY, endY),
+ };
+
+ // Find all timeline elements within the selection rectangle
+ const timelineElements = container.querySelectorAll(".timeline-element");
+
+ const selectedElements: { trackId: string; elementId: string }[] = [];
+
+ timelineElements.forEach((element) => {
+ const elementRect = element.getBoundingClientRect();
+ // Use absolute coordinates for more accurate intersection detection
+ const elementAbsolute = {
+ left: elementRect.left,
+ top: elementRect.top,
+ right: elementRect.right,
+ bottom: elementRect.bottom,
+ };
+
+ const selectionAbsolute = {
+ left: startPos.x,
+ top: startPos.y,
+ right: endPos.x,
+ bottom: endPos.y,
+ };
+
+ // Normalize selection rectangle (handle dragging in any direction)
+ const normalizedSelection = {
+ left: Math.min(selectionAbsolute.left, selectionAbsolute.right),
+ top: Math.min(selectionAbsolute.top, selectionAbsolute.bottom),
+ right: Math.max(selectionAbsolute.left, selectionAbsolute.right),
+ bottom: Math.max(selectionAbsolute.top, selectionAbsolute.bottom),
+ };
+
+ const elementId = element.getAttribute("data-element-id");
+ const trackId = element.getAttribute("data-track-id");
+
+ // Check if element intersects with selection rectangle (any overlap)
+ // Using absolute coordinates for more precise detection
+ const intersects = !(
+ elementAbsolute.right < normalizedSelection.left ||
+ elementAbsolute.left > normalizedSelection.right ||
+ elementAbsolute.bottom < normalizedSelection.top ||
+ elementAbsolute.top > normalizedSelection.bottom
+ );
+
+ if (intersects && elementId && trackId) {
+ selectedElements.push({ trackId, elementId });
+ }
+ });
+
+ // Always call the callback - with elements or empty array to clear selection
+ console.log(
+ JSON.stringify({ selectElementsInBox: selectedElements.length })
+ );
+ onSelectionComplete(selectedElements);
+ },
+ [containerRef, onSelectionComplete]
+ );
+
+ // Effect to track selection box movement
+ useEffect(() => {
+ if (!selectionBox) return;
+
+ const handleMouseMove = (e: MouseEvent) => {
+ const deltaX = Math.abs(e.clientX - selectionBox.startPos.x);
+ const deltaY = Math.abs(e.clientY - selectionBox.startPos.y);
+
+ // Start selection if mouse moved more than 5px
+ const shouldActivate = deltaX > 5 || deltaY > 5;
+
+ const newSelectionBox = {
+ ...selectionBox,
+ currentPos: { x: e.clientX, y: e.clientY },
+ isActive: shouldActivate || selectionBox.isActive,
+ };
+
+ setSelectionBox(newSelectionBox);
+
+ // Real-time visual feedback: update selection as we drag
+ if (newSelectionBox.isActive) {
+ selectElementsInBox(
+ newSelectionBox.startPos,
+ newSelectionBox.currentPos
+ );
+ }
+ };
+
+ const handleMouseUp = () => {
+ console.log(
+ JSON.stringify({ mouseUp: { wasActive: selectionBox?.isActive } })
+ );
+
+ // If we had an active selection, mark that we just finished selecting
+ if (selectionBox?.isActive) {
+ console.log(JSON.stringify({ settingJustFinishedSelecting: true }));
+ setJustFinishedSelecting(true);
+ // Clear the flag after a short delay to allow click events to check it
+ setTimeout(() => {
+ console.log(JSON.stringify({ clearingJustFinishedSelecting: true }));
+ setJustFinishedSelecting(false);
+ }, 50);
+ }
+
+ // Don't call selectElementsInBox again - real-time selection already handled it
+ // Just clean up the selection box visual
+ setSelectionBox(null);
+ };
+
+ window.addEventListener("mousemove", handleMouseMove);
+ window.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ window.removeEventListener("mousemove", handleMouseMove);
+ window.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [selectionBox, selectElementsInBox]);
+
+ return {
+ selectionBox,
+ handleMouseDown,
+ isSelecting: selectionBox?.isActive || false,
+ justFinishedSelecting,
+ };
+}
diff --git a/apps/web/src/hooks/use-timeline-element-resize.ts b/apps/web/src/hooks/use-timeline-element-resize.ts index 9b664af4..f16ca8e2 100644 --- a/apps/web/src/hooks/use-timeline-element-resize.ts +++ b/apps/web/src/hooks/use-timeline-element-resize.ts @@ -4,197 +4,197 @@ import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; interface UseTimelineElementResizeProps { - element: TimelineElement; - track: TimelineTrack; - zoomLevel: number; - onUpdateTrim: ( - trackId: string, - elementId: string, - trimStart: number, - trimEnd: number, - ) => void; - onUpdateDuration: ( - trackId: string, - elementId: string, - duration: number, - ) => void; + element: TimelineElement; + track: TimelineTrack; + zoomLevel: number; + onUpdateTrim: ( + trackId: string, + elementId: string, + trimStart: number, + trimEnd: number + ) => void; + onUpdateDuration: ( + trackId: string, + elementId: string, + duration: number + ) => void; } export function useTimelineElementResize({ - element, - track, - zoomLevel, - onUpdateTrim, - onUpdateDuration, + element, + track, + zoomLevel, + onUpdateTrim, + onUpdateDuration, }: UseTimelineElementResizeProps) { - const [resizing, setResizing] = useState<ResizeState | null>(null); - const { mediaItems } = useMediaStore(); - const { updateElementStartTime } = useTimelineStore(); + const [resizing, setResizing] = useState<ResizeState | null>(null); + const { mediaItems } = useMediaStore(); + const { updateElementStartTime } = useTimelineStore(); - // Set up document-level mouse listeners during resize (like proper drag behavior) - useEffect(() => { - if (!resizing) return; + // Set up document-level mouse listeners during resize (like proper drag behavior) + useEffect(() => { + if (!resizing) return; - const handleDocumentMouseMove = (e: MouseEvent) => { - updateTrimFromMouseMove({ clientX: e.clientX }); - }; + const handleDocumentMouseMove = (e: MouseEvent) => { + updateTrimFromMouseMove({ clientX: e.clientX }); + }; - const handleDocumentMouseUp = () => { - handleResizeEnd(); - }; + const handleDocumentMouseUp = () => { + handleResizeEnd(); + }; - // Add document-level listeners for proper drag behavior - document.addEventListener("mousemove", handleDocumentMouseMove); - document.addEventListener("mouseup", handleDocumentMouseUp); + // Add document-level listeners for proper drag behavior + document.addEventListener("mousemove", handleDocumentMouseMove); + document.addEventListener("mouseup", handleDocumentMouseUp); - return () => { - document.removeEventListener("mousemove", handleDocumentMouseMove); - document.removeEventListener("mouseup", handleDocumentMouseUp); - }; - }, [resizing]); // Re-run when resizing state changes + return () => { + document.removeEventListener("mousemove", handleDocumentMouseMove); + document.removeEventListener("mouseup", handleDocumentMouseUp); + }; + }, [resizing]); // Re-run when resizing state changes - const handleResizeStart = ( - e: React.MouseEvent, - elementId: string, - side: "left" | "right", - ) => { - e.stopPropagation(); - e.preventDefault(); + const handleResizeStart = ( + e: React.MouseEvent, + elementId: string, + side: "left" | "right" + ) => { + e.stopPropagation(); + e.preventDefault(); - setResizing({ - elementId, - side, - startX: e.clientX, - initialTrimStart: element.trimStart, - initialTrimEnd: element.trimEnd, - }); - }; + setResizing({ + elementId, + side, + startX: e.clientX, + initialTrimStart: element.trimStart, + initialTrimEnd: element.trimEnd, + }); + }; - const canExtendElementDuration = () => { - // Text elements can always be extended - if (element.type === "text") { - return true; - } + const canExtendElementDuration = () => { + // Text elements can always be extended + if (element.type === "text") { + return true; + } - // Media elements - check the media type - if (element.type === "media") { - const mediaItem = mediaItems.find((item) => item.id === element.mediaId); - if (!mediaItem) return false; + // Media elements - check the media type + if (element.type === "media") { + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + if (!mediaItem) return false; - // Images can be extended (static content) - if (mediaItem.type === "image") { - return true; - } + // Images can be extended (static content) + if (mediaItem.type === "image") { + return true; + } - // Videos and audio cannot be extended beyond their natural duration - // (no additional content exists) - return false; - } + // Videos and audio cannot be extended beyond their natural duration + // (no additional content exists) + return false; + } - return false; - }; + return false; + }; - const updateTrimFromMouseMove = (e: { clientX: number }) => { - if (!resizing) return; + const updateTrimFromMouseMove = (e: { clientX: number }) => { + if (!resizing) return; - const deltaX = e.clientX - resizing.startX; - // Reasonable sensitivity for resize operations - similar to timeline scale - const deltaTime = deltaX / (50 * zoomLevel); + const deltaX = e.clientX - resizing.startX; + // Reasonable sensitivity for resize operations - similar to timeline scale + const deltaTime = deltaX / (50 * zoomLevel); - if (resizing.side === "left") { - // Left resize - different behavior for media vs text/image elements - const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1; - const calculated = resizing.initialTrimStart + deltaTime; + if (resizing.side === "left") { + // Left resize - different behavior for media vs text/image elements + const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1; + const calculated = resizing.initialTrimStart + deltaTime; - if (calculated >= 0) { - // Normal trimming within available content - const newTrimStart = Math.min(maxAllowed, calculated); - const trimDelta = newTrimStart - resizing.initialTrimStart; - const newStartTime = element.startTime + trimDelta; + if (calculated >= 0) { + // Normal trimming within available content + const newTrimStart = Math.min(maxAllowed, calculated); + const trimDelta = newTrimStart - resizing.initialTrimStart; + const newStartTime = element.startTime + trimDelta; - onUpdateTrim( - track.id, - element.id, - newTrimStart, - resizing.initialTrimEnd, - ); - updateElementStartTime(track.id, element.id, newStartTime); - } else { - // Trying to extend beyond trimStart = 0 - if (canExtendElementDuration()) { - // Text/Image: extend element to the left by moving startTime and increasing duration - const extensionAmount = Math.abs(calculated); - const newStartTime = element.startTime - extensionAmount; - const newDuration = element.duration + extensionAmount; + onUpdateTrim( + track.id, + element.id, + newTrimStart, + resizing.initialTrimEnd + ); + updateElementStartTime(track.id, element.id, newStartTime); + } else { + // Trying to extend beyond trimStart = 0 + if (canExtendElementDuration()) { + // Text/Image: extend element to the left by moving startTime and increasing duration + const extensionAmount = Math.abs(calculated); + const newStartTime = element.startTime - extensionAmount; + const newDuration = element.duration + extensionAmount; - // Keep trimStart at 0 and extend the element - onUpdateTrim(track.id, element.id, 0, resizing.initialTrimEnd); - onUpdateDuration(track.id, element.id, newDuration); - updateElementStartTime(track.id, element.id, newStartTime); - } else { - // Video/Audio: can't extend beyond original content - limit to trimStart = 0 - const newTrimStart = 0; - const trimDelta = newTrimStart - resizing.initialTrimStart; - const newStartTime = element.startTime + trimDelta; + // Keep trimStart at 0 and extend the element + onUpdateTrim(track.id, element.id, 0, resizing.initialTrimEnd); + onUpdateDuration(track.id, element.id, newDuration); + updateElementStartTime(track.id, element.id, newStartTime); + } else { + // Video/Audio: can't extend beyond original content - limit to trimStart = 0 + const newTrimStart = 0; + const trimDelta = newTrimStart - resizing.initialTrimStart; + const newStartTime = element.startTime + trimDelta; - onUpdateTrim( - track.id, - element.id, - newTrimStart, - resizing.initialTrimEnd, - ); - updateElementStartTime(track.id, element.id, newStartTime); - } - } - } else { - // Right resize - can extend duration for supported element types - const calculated = resizing.initialTrimEnd - deltaTime; + onUpdateTrim( + track.id, + element.id, + newTrimStart, + resizing.initialTrimEnd + ); + updateElementStartTime(track.id, element.id, newStartTime); + } + } + } else { + // Right resize - can extend duration for supported element types + const calculated = resizing.initialTrimEnd - deltaTime; - if (calculated < 0) { - // We're trying to extend beyond original duration - if (canExtendElementDuration()) { - // Extend the duration instead of reducing trimEnd further - const extensionNeeded = Math.abs(calculated); - const newDuration = element.duration + extensionNeeded; - const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending + if (calculated < 0) { + // We're trying to extend beyond original duration + if (canExtendElementDuration()) { + // Extend the duration instead of reducing trimEnd further + const extensionNeeded = Math.abs(calculated); + const newDuration = element.duration + extensionNeeded; + const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending - // Update duration first, then trim - onUpdateDuration(track.id, element.id, newDuration); - onUpdateTrim( - track.id, - element.id, - resizing.initialTrimStart, - newTrimEnd, - ); - } else { - // Can't extend - just set trimEnd to 0 (maximum possible extension) - onUpdateTrim(track.id, element.id, resizing.initialTrimStart, 0); - } - } else { - // Normal trimming within original duration - const maxTrimEnd = element.duration - resizing.initialTrimStart - 0.1; // Leave at least 0.1s visible - const newTrimEnd = Math.max(0, Math.min(maxTrimEnd, calculated)); + // Update duration first, then trim + onUpdateDuration(track.id, element.id, newDuration); + onUpdateTrim( + track.id, + element.id, + resizing.initialTrimStart, + newTrimEnd + ); + } else { + // Can't extend - just set trimEnd to 0 (maximum possible extension) + onUpdateTrim(track.id, element.id, resizing.initialTrimStart, 0); + } + } else { + // Normal trimming within original duration + const maxTrimEnd = element.duration - resizing.initialTrimStart - 0.1; // Leave at least 0.1s visible + const newTrimEnd = Math.max(0, Math.min(maxTrimEnd, calculated)); - onUpdateTrim( - track.id, - element.id, - resizing.initialTrimStart, - newTrimEnd, - ); - } - } - }; + onUpdateTrim( + track.id, + element.id, + resizing.initialTrimStart, + newTrimEnd + ); + } + } + }; - const handleResizeEnd = () => { - setResizing(null); - }; + const handleResizeEnd = () => { + setResizing(null); + }; - return { - resizing, - isResizing: resizing !== null, - handleResizeStart, - // Return empty handlers since we use document listeners now - handleResizeMove: () => {}, // Not used anymore - handleResizeEnd: () => {}, // Not used anymore - }; + return { + resizing, + isResizing: resizing !== null, + handleResizeStart, + // Return empty handlers since we use document listeners now + handleResizeMove: () => {}, // Not used anymore + handleResizeEnd: () => {}, // Not used anymore + }; } diff --git a/apps/web/src/hooks/use-timeline-playhead.ts b/apps/web/src/hooks/use-timeline-playhead.ts index cd2fc76e..9a82c23f 100644 --- a/apps/web/src/hooks/use-timeline-playhead.ts +++ b/apps/web/src/hooks/use-timeline-playhead.ts @@ -1,157 +1,157 @@ -import { snapTimeToFrame } from "@/constants/timeline-constants"; -import { useProjectStore } from "@/stores/project-store"; -import { useState, useEffect, useCallback } from "react"; - -interface UseTimelinePlayheadProps { - currentTime: number; - duration: number; - zoomLevel: number; - seek: (time: number) => void; - rulerRef: React.RefObject<HTMLDivElement>; - rulerScrollRef: React.RefObject<HTMLDivElement>; - tracksScrollRef: React.RefObject<HTMLDivElement>; - playheadRef?: React.RefObject<HTMLDivElement>; -} - -export function useTimelinePlayhead({ - currentTime, - duration, - zoomLevel, - seek, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, -}: UseTimelinePlayheadProps) { - // Playhead scrubbing state - const [isScrubbing, setIsScrubbing] = useState(false); - const [scrubTime, setScrubTime] = useState<number | null>(null); - - // Ruler drag detection state - const [isDraggingRuler, setIsDraggingRuler] = useState(false); - const [hasDraggedRuler, setHasDraggedRuler] = useState(false); - - const playheadPosition = - isScrubbing && scrubTime !== null ? scrubTime : currentTime; - - // --- Playhead Scrubbing Handlers --- - const handlePlayheadMouseDown = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); // Prevent ruler drag from triggering - setIsScrubbing(true); - handleScrub(e); - }, - [duration, zoomLevel], - ); - - // Ruler mouse down handler - const handleRulerMouseDown = useCallback( - (e: React.MouseEvent) => { - // Only handle left mouse button - if (e.button !== 0) return; - - // Don't interfere if clicking on the playhead itself - if (playheadRef?.current?.contains(e.target as Node)) return; - - e.preventDefault(); - setIsDraggingRuler(true); - setHasDraggedRuler(false); - - // Start scrubbing immediately - setIsScrubbing(true); - handleScrub(e); - }, - [duration, zoomLevel], - ); - - const handleScrub = useCallback( - (e: MouseEvent | React.MouseEvent) => { - const ruler = rulerRef.current; - if (!ruler) return; - const rect = ruler.getBoundingClientRect(); - const x = e.clientX - rect.left; - const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel))); - // Use frame snapping for playhead scrubbing - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - const time = snapTimeToFrame(rawTime, projectFps); - setScrubTime(time); - seek(time); // update video preview in real time - }, - [duration, zoomLevel, seek, rulerRef], - ); - - // Mouse move/up event handlers - useEffect(() => { - if (!isScrubbing) return; - const onMouseMove = (e: MouseEvent) => { - handleScrub(e); - // Mark that we've dragged if ruler drag is active - if (isDraggingRuler) { - setHasDraggedRuler(true); - } - }; - const onMouseUp = (e: MouseEvent) => { - setIsScrubbing(false); - if (scrubTime !== null) seek(scrubTime); // finalize seek - setScrubTime(null); - - // Handle ruler click vs drag - if (isDraggingRuler) { - setIsDraggingRuler(false); - // If we didn't drag, treat it as a click-to-seek - if (!hasDraggedRuler) { - handleScrub(e); - } - setHasDraggedRuler(false); - } - }; - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - }; - }, [ - isScrubbing, - scrubTime, - seek, - handleScrub, - isDraggingRuler, - hasDraggedRuler, - ]); - - // --- Playhead auto-scroll effect --- - useEffect(() => { - const rulerViewport = rulerScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]", - ) as HTMLElement; - const tracksViewport = tracksScrollRef.current?.querySelector( - "[data-radix-scroll-area-viewport]", - ) as HTMLElement; - if (!rulerViewport || !tracksViewport) return; - const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50 - const viewportWidth = rulerViewport.clientWidth; - const scrollMin = 0; - const scrollMax = rulerViewport.scrollWidth - viewportWidth; - // Center the playhead if it's not visible (100px buffer) - const desiredScroll = Math.max( - scrollMin, - Math.min(scrollMax, playheadPx - viewportWidth / 2), - ); - if ( - playheadPx < rulerViewport.scrollLeft + 100 || - playheadPx > rulerViewport.scrollLeft + viewportWidth - 100 - ) { - rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; - } - }, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]); - - return { - playheadPosition, - handlePlayheadMouseDown, - handleRulerMouseDown, - isDraggingRuler, - }; -} +import { snapTimeToFrame } from "@/constants/timeline-constants";
+import { useProjectStore } from "@/stores/project-store";
+import { useState, useEffect, useCallback } from "react";
+
+interface UseTimelinePlayheadProps {
+ currentTime: number;
+ duration: number;
+ zoomLevel: number;
+ seek: (time: number) => void;
+ rulerRef: React.RefObject<HTMLDivElement>;
+ rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+ playheadRef?: React.RefObject<HTMLDivElement>;
+}
+
+export function useTimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+}: UseTimelinePlayheadProps) {
+ // Playhead scrubbing state
+ const [isScrubbing, setIsScrubbing] = useState(false);
+ const [scrubTime, setScrubTime] = useState<number | null>(null);
+
+ // Ruler drag detection state
+ const [isDraggingRuler, setIsDraggingRuler] = useState(false);
+ const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
+
+ const playheadPosition =
+ isScrubbing && scrubTime !== null ? scrubTime : currentTime;
+
+ // --- Playhead Scrubbing Handlers ---
+ const handlePlayheadMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation(); // Prevent ruler drag from triggering
+ setIsScrubbing(true);
+ handleScrub(e);
+ },
+ [duration, zoomLevel]
+ );
+
+ // Ruler mouse down handler
+ const handleRulerMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ // Only handle left mouse button
+ if (e.button !== 0) return;
+
+ // Don't interfere if clicking on the playhead itself
+ if (playheadRef?.current?.contains(e.target as Node)) return;
+
+ e.preventDefault();
+ setIsDraggingRuler(true);
+ setHasDraggedRuler(false);
+
+ // Start scrubbing immediately
+ setIsScrubbing(true);
+ handleScrub(e);
+ },
+ [duration, zoomLevel]
+ );
+
+ const handleScrub = useCallback(
+ (e: MouseEvent | React.MouseEvent) => {
+ const ruler = rulerRef.current;
+ if (!ruler) return;
+ const rect = ruler.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
+ // Use frame snapping for playhead scrubbing
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ const time = snapTimeToFrame(rawTime, projectFps);
+ setScrubTime(time);
+ seek(time); // update video preview in real time
+ },
+ [duration, zoomLevel, seek, rulerRef]
+ );
+
+ // Mouse move/up event handlers
+ useEffect(() => {
+ if (!isScrubbing) return;
+ const onMouseMove = (e: MouseEvent) => {
+ handleScrub(e);
+ // Mark that we've dragged if ruler drag is active
+ if (isDraggingRuler) {
+ setHasDraggedRuler(true);
+ }
+ };
+ const onMouseUp = (e: MouseEvent) => {
+ setIsScrubbing(false);
+ if (scrubTime !== null) seek(scrubTime); // finalize seek
+ setScrubTime(null);
+
+ // Handle ruler click vs drag
+ if (isDraggingRuler) {
+ setIsDraggingRuler(false);
+ // If we didn't drag, treat it as a click-to-seek
+ if (!hasDraggedRuler) {
+ handleScrub(e);
+ }
+ setHasDraggedRuler(false);
+ }
+ };
+ window.addEventListener("mousemove", onMouseMove);
+ window.addEventListener("mouseup", onMouseUp);
+ return () => {
+ window.removeEventListener("mousemove", onMouseMove);
+ window.removeEventListener("mouseup", onMouseUp);
+ };
+ }, [
+ isScrubbing,
+ scrubTime,
+ seek,
+ handleScrub,
+ isDraggingRuler,
+ hasDraggedRuler,
+ ]);
+
+ // --- Playhead auto-scroll effect ---
+ useEffect(() => {
+ const rulerViewport = rulerScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ const tracksViewport = tracksScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ if (!rulerViewport || !tracksViewport) return;
+ const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
+ const viewportWidth = rulerViewport.clientWidth;
+ const scrollMin = 0;
+ const scrollMax = rulerViewport.scrollWidth - viewportWidth;
+ // Center the playhead if it's not visible (100px buffer)
+ const desiredScroll = Math.max(
+ scrollMin,
+ Math.min(scrollMax, playheadPx - viewportWidth / 2)
+ );
+ if (
+ playheadPx < rulerViewport.scrollLeft + 100 ||
+ playheadPx > rulerViewport.scrollLeft + viewportWidth - 100
+ ) {
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
+ }
+ }, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]);
+
+ return {
+ playheadPosition,
+ handlePlayheadMouseDown,
+ handleRulerMouseDown,
+ isDraggingRuler,
+ };
+}
diff --git a/apps/web/src/hooks/use-timeline-snapping.ts b/apps/web/src/hooks/use-timeline-snapping.ts index e51f6a22..f7f96392 100644 --- a/apps/web/src/hooks/use-timeline-snapping.ts +++ b/apps/web/src/hooks/use-timeline-snapping.ts @@ -3,176 +3,176 @@ import { TimelineTrack } from "@/types/timeline"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; export interface SnapPoint { - time: number; - type: "element-start" | "element-end" | "playhead"; - elementId?: string; - trackId?: string; + time: number; + type: "element-start" | "element-end" | "playhead"; + elementId?: string; + trackId?: string; } export interface SnapResult { - snappedTime: number; - snapPoint: SnapPoint | null; - snapDistance: number; + snappedTime: number; + snapPoint: SnapPoint | null; + snapDistance: number; } export interface UseTimelineSnappingOptions { - snapThreshold?: number; // Distance in pixels to trigger snapping - enableElementSnapping?: boolean; - enablePlayheadSnapping?: boolean; + snapThreshold?: number; // Distance in pixels to trigger snapping + enableElementSnapping?: boolean; + enablePlayheadSnapping?: boolean; } export function useTimelineSnapping({ - snapThreshold = 10, - enableElementSnapping = true, - enablePlayheadSnapping = true, + snapThreshold = 10, + enableElementSnapping = true, + enablePlayheadSnapping = true, }: UseTimelineSnappingOptions = {}) { - const findSnapPoints = useCallback( - ( - tracks: TimelineTrack[], - currentTime: number, - playheadTime: number, - zoomLevel: number, - excludeElementId?: string, - ): SnapPoint[] => { - const snapPoints: SnapPoint[] = []; + const findSnapPoints = useCallback( + ( + tracks: TimelineTrack[], + currentTime: number, + playheadTime: number, + zoomLevel: number, + excludeElementId?: string + ): SnapPoint[] => { + const snapPoints: SnapPoint[] = []; - // Add element snap points - if (enableElementSnapping) { - tracks.forEach((track) => { - track.elements.forEach((element) => { - // Skip the element being dragged - if (element.id === excludeElementId) return; + // Add element snap points + if (enableElementSnapping) { + tracks.forEach((track) => { + track.elements.forEach((element) => { + // Skip the element being dragged + if (element.id === excludeElementId) return; - const elementStart = element.startTime; - const elementEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); + const elementStart = element.startTime; + const elementEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); - snapPoints.push( - { - time: elementStart, - type: "element-start", - elementId: element.id, - trackId: track.id, - }, - { - time: elementEnd, - type: "element-end", - elementId: element.id, - trackId: track.id, - }, - ); - }); - }); - } + snapPoints.push( + { + time: elementStart, + type: "element-start", + elementId: element.id, + trackId: track.id, + }, + { + time: elementEnd, + type: "element-end", + elementId: element.id, + trackId: track.id, + } + ); + }); + }); + } - // Add playhead snap point - if (enablePlayheadSnapping) { - snapPoints.push({ - time: playheadTime, - type: "playhead", - }); - } + // Add playhead snap point + if (enablePlayheadSnapping) { + snapPoints.push({ + time: playheadTime, + type: "playhead", + }); + } - return snapPoints; - }, - [enableElementSnapping, enablePlayheadSnapping], - ); + return snapPoints; + }, + [enableElementSnapping, enablePlayheadSnapping] + ); - const snapToNearestPoint = useCallback( - ( - targetTime: number, - snapPoints: SnapPoint[], - zoomLevel: number, - ): SnapResult => { - const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const thresholdInSeconds = snapThreshold / pixelsPerSecond; + const snapToNearestPoint = useCallback( + ( + targetTime: number, + snapPoints: SnapPoint[], + zoomLevel: number + ): SnapResult => { + const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const thresholdInSeconds = snapThreshold / pixelsPerSecond; - let closestSnapPoint: SnapPoint | null = null; - let closestDistance = Infinity; + let closestSnapPoint: SnapPoint | null = null; + let closestDistance = Infinity; - snapPoints.forEach((snapPoint) => { - const distance = Math.abs(targetTime - snapPoint.time); - if (distance < thresholdInSeconds && distance < closestDistance) { - closestDistance = distance; - closestSnapPoint = snapPoint; - } - }); + snapPoints.forEach((snapPoint) => { + const distance = Math.abs(targetTime - snapPoint.time); + if (distance < thresholdInSeconds && distance < closestDistance) { + closestDistance = distance; + closestSnapPoint = snapPoint; + } + }); - return { - snappedTime: closestSnapPoint - ? (closestSnapPoint as SnapPoint).time - : targetTime, - snapPoint: closestSnapPoint, - snapDistance: closestDistance, - }; - }, - [snapThreshold], - ); + return { + snappedTime: closestSnapPoint + ? (closestSnapPoint as SnapPoint).time + : targetTime, + snapPoint: closestSnapPoint, + snapDistance: closestDistance, + }; + }, + [snapThreshold] + ); - const snapElementPosition = useCallback( - ( - targetTime: number, - tracks: TimelineTrack[], - playheadTime: number, - zoomLevel: number, - excludeElementId?: string, - ): SnapResult => { - const snapPoints = findSnapPoints( - tracks, - targetTime, - playheadTime, - zoomLevel, - excludeElementId, - ); + const snapElementPosition = useCallback( + ( + targetTime: number, + tracks: TimelineTrack[], + playheadTime: number, + zoomLevel: number, + excludeElementId?: string + ): SnapResult => { + const snapPoints = findSnapPoints( + tracks, + targetTime, + playheadTime, + zoomLevel, + excludeElementId + ); - return snapToNearestPoint(targetTime, snapPoints, zoomLevel); - }, - [findSnapPoints, snapToNearestPoint], - ); + return snapToNearestPoint(targetTime, snapPoints, zoomLevel); + }, + [findSnapPoints, snapToNearestPoint] + ); - const snapElementEdge = useCallback( - ( - targetTime: number, - elementDuration: number, - tracks: TimelineTrack[], - playheadTime: number, - zoomLevel: number, - excludeElementId?: string, - snapToStart = true, // true for start edge, false for end edge - ): SnapResult => { - const snapPoints = findSnapPoints( - tracks, - targetTime, - playheadTime, - zoomLevel, - excludeElementId, - ); + const snapElementEdge = useCallback( + ( + targetTime: number, + elementDuration: number, + tracks: TimelineTrack[], + playheadTime: number, + zoomLevel: number, + excludeElementId?: string, + snapToStart = true // true for start edge, false for end edge + ): SnapResult => { + const snapPoints = findSnapPoints( + tracks, + targetTime, + playheadTime, + zoomLevel, + excludeElementId + ); - // For end edge snapping, we need to account for element duration - const effectiveTargetTime = snapToStart - ? targetTime - : targetTime + elementDuration; - const snapResult = snapToNearestPoint( - effectiveTargetTime, - snapPoints, - zoomLevel, - ); + // For end edge snapping, we need to account for element duration + const effectiveTargetTime = snapToStart + ? targetTime + : targetTime + elementDuration; + const snapResult = snapToNearestPoint( + effectiveTargetTime, + snapPoints, + zoomLevel + ); - // Adjust the snapped time back for end edge - if (!snapToStart && snapResult.snapPoint) { - snapResult.snappedTime = snapResult.snappedTime - elementDuration; - } + // Adjust the snapped time back for end edge + if (!snapToStart && snapResult.snapPoint) { + snapResult.snappedTime = snapResult.snappedTime - elementDuration; + } - return snapResult; - }, - [findSnapPoints, snapToNearestPoint], - ); + return snapResult; + }, + [findSnapPoints, snapToNearestPoint] + ); - return { - snapElementPosition, - snapElementEdge, - findSnapPoints, - snapToNearestPoint, - }; + return { + snapElementPosition, + snapElementEdge, + findSnapPoints, + snapToNearestPoint, + }; } diff --git a/apps/web/src/hooks/use-timeline-zoom.ts b/apps/web/src/hooks/use-timeline-zoom.ts index 00511290..ce32c9f0 100644 --- a/apps/web/src/hooks/use-timeline-zoom.ts +++ b/apps/web/src/hooks/use-timeline-zoom.ts @@ -1,54 +1,54 @@ -import { useState, useCallback, useEffect, RefObject } from "react"; - -interface UseTimelineZoomProps { - containerRef: RefObject<HTMLDivElement>; - isInTimeline?: boolean; -} - -interface UseTimelineZoomReturn { - zoomLevel: number; - setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void; - handleWheel: (e: React.WheelEvent) => void; -} - -export function useTimelineZoom({ - containerRef, - isInTimeline = false, -}: UseTimelineZoomProps): UseTimelineZoomReturn { - const [zoomLevel, setZoomLevel] = useState(1); - - const handleWheel = useCallback((e: React.WheelEvent) => { - // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true) - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - const delta = e.deltaY > 0 ? -0.15 : 0.15; - setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta))); - } - // Otherwise, allow normal scrolling - }, []); - - // Prevent browser zooming in/out when in timeline - useEffect(() => { - const preventZoom = (e: WheelEvent) => { - if ( - isInTimeline && - (e.ctrlKey || e.metaKey) && - containerRef.current?.contains(e.target as Node) - ) { - e.preventDefault(); - } - }; - - document.addEventListener("wheel", preventZoom, { passive: false }); - - return () => { - document.removeEventListener("wheel", preventZoom); - }; - }, [isInTimeline, containerRef]); - - return { - zoomLevel, - setZoomLevel, - handleWheel, - }; -} +import { useState, useCallback, useEffect, RefObject } from "react";
+
+interface UseTimelineZoomProps {
+ containerRef: RefObject<HTMLDivElement>;
+ isInTimeline?: boolean;
+}
+
+interface UseTimelineZoomReturn {
+ zoomLevel: number;
+ setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
+ handleWheel: (e: React.WheelEvent) => void;
+}
+
+export function useTimelineZoom({
+ containerRef,
+ isInTimeline = false,
+}: UseTimelineZoomProps): UseTimelineZoomReturn {
+ const [zoomLevel, setZoomLevel] = useState(1);
+
+ const handleWheel = useCallback((e: React.WheelEvent) => {
+ // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
+ if (e.ctrlKey || e.metaKey) {
+ e.preventDefault();
+ const delta = e.deltaY > 0 ? -0.15 : 0.15;
+ setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
+ }
+ // Otherwise, allow normal scrolling
+ }, []);
+
+ // Prevent browser zooming in/out when in timeline
+ useEffect(() => {
+ const preventZoom = (e: WheelEvent) => {
+ if (
+ isInTimeline &&
+ (e.ctrlKey || e.metaKey) &&
+ containerRef.current?.contains(e.target as Node)
+ ) {
+ e.preventDefault();
+ }
+ };
+
+ document.addEventListener("wheel", preventZoom, { passive: false });
+
+ return () => {
+ document.removeEventListener("wheel", preventZoom);
+ };
+ }, [isInTimeline, containerRef]);
+
+ return {
+ zoomLevel,
+ setZoomLevel,
+ handleWheel,
+ };
+}
diff --git a/apps/web/src/hooks/use-toast.ts b/apps/web/src/hooks/use-toast.ts index 78cc46e8..c682cd1f 100644 --- a/apps/web/src/hooks/use-toast.ts +++ b/apps/web/src/hooks/use-toast.ts @@ -9,121 +9,121 @@ const TOAST_LIMIT = 1; const TOAST_REMOVE_DELAY = 1000000; type ToasterToast = ToastProps & { - id: string; - title?: React.ReactNode; - description?: React.ReactNode; - action?: ToastActionElement; + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: ToastActionElement; }; const actionTypes = { - ADD_TOAST: "ADD_TOAST", - UPDATE_TOAST: "UPDATE_TOAST", - DISMISS_TOAST: "DISMISS_TOAST", - REMOVE_TOAST: "REMOVE_TOAST", + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", } as const; let count = 0; function genId() { - count = (count + 1) % Number.MAX_SAFE_INTEGER; - return count.toString(); + count = (count + 1) % Number.MAX_SAFE_INTEGER; + return count.toString(); } type ActionType = typeof actionTypes; type Action = - | { - type: ActionType["ADD_TOAST"]; - toast: ToasterToast; - } - | { - type: ActionType["UPDATE_TOAST"]; - toast: Partial<ToasterToast>; - } - | { - type: ActionType["DISMISS_TOAST"]; - toastId?: ToasterToast["id"]; - } - | { - type: ActionType["REMOVE_TOAST"]; - toastId?: ToasterToast["id"]; - }; + | { + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; + } + | { + type: ActionType["UPDATE_TOAST"]; + toast: Partial<ToasterToast>; + } + | { + type: ActionType["DISMISS_TOAST"]; + toastId?: ToasterToast["id"]; + } + | { + type: ActionType["REMOVE_TOAST"]; + toastId?: ToasterToast["id"]; + }; interface State { - toasts: ToasterToast[]; + toasts: ToasterToast[]; } const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>(); const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return; - } + if (toastTimeouts.has(toastId)) { + return; + } - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId); - dispatch({ - type: "REMOVE_TOAST", - toastId: toastId, - }); - }, TOAST_REMOVE_DELAY); + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }); + }, TOAST_REMOVE_DELAY); - toastTimeouts.set(toastId, timeout); + toastTimeouts.set(toastId, timeout); }; export const reducer = (state: State, action: Action): State => { - switch (action.type) { - case "ADD_TOAST": - return { - ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - }; + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; - case "UPDATE_TOAST": - return { - ...state, - toasts: state.toasts.map((t) => - t.id === action.toast.id ? { ...t, ...action.toast } : t, - ), - }; + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + }; - case "DISMISS_TOAST": { - const { toastId } = action; + case "DISMISS_TOAST": { + const { toastId } = action; - // ! Side effects ! - This could be extracted into a dismissToast() action, - // but I'll keep it here for simplicity - if (toastId) { - addToRemoveQueue(toastId); - } else { - state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id); - }); - } + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId); + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id); + }); + } - return { - ...state, - toasts: state.toasts.map((t) => - t.id === toastId || toastId === undefined - ? { - ...t, - open: false, - } - : t, - ), - }; - } - case "REMOVE_TOAST": - if (action.toastId === undefined) { - return { - ...state, - toasts: [], - }; - } - return { - ...state, - toasts: state.toasts.filter((t) => t.id !== action.toastId), - }; - } + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + }; + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } }; const listeners: Array<(state: State) => void> = []; @@ -131,61 +131,61 @@ const listeners: Array<(state: State) => void> = []; let memoryState: State = { toasts: [] }; function dispatch(action: Action) { - memoryState = reducer(memoryState, action); - listeners.forEach((listener) => { - listener(memoryState); - }); + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); } type Toast = Omit<ToasterToast, "id">; function toast({ ...props }: Toast) { - const id = genId(); + const id = genId(); - const update = (props: ToasterToast) => - dispatch({ - type: "UPDATE_TOAST", - toast: { ...props, id }, - }); - const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }); + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); - dispatch({ - type: "ADD_TOAST", - toast: { - ...props, - id, - open: true, - onOpenChange: (open) => { - if (!open) dismiss(); - }, - }, - }); + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss(); + }, + }, + }); - return { - id: id, - dismiss, - update, - }; + return { + id: id, + dismiss, + update, + }; } function useToast() { - const [state, setState] = React.useState<State>(memoryState); + const [state, setState] = React.useState<State>(memoryState); - React.useEffect(() => { - listeners.push(setState); - return () => { - const index = listeners.indexOf(setState); - if (index > -1) { - listeners.splice(index, 1); - } - }; - }, [state]); + React.useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }, [state]); - return { - ...state, - toast, - dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), - }; + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + }; } export { useToast, toast }; diff --git a/apps/web/src/lib/fetch-github-stars.ts b/apps/web/src/lib/fetch-github-stars.ts index 74675be9..c4444a62 100644 --- a/apps/web/src/lib/fetch-github-stars.ts +++ b/apps/web/src/lib/fetch-github-stars.ts @@ -1,29 +1,29 @@ export async function getStars(): Promise<string> { - try { - const res = await fetch( - "https://api.github.com/repos/OpenCut-app/OpenCut", - { - next: { revalidate: 3600 }, - }, - ); + try { + const res = await fetch( + "https://api.github.com/repos/OpenCut-app/OpenCut", + { + next: { revalidate: 3600 }, + } + ); - if (!res.ok) { - throw new Error(`GitHub API error: ${res.status} ${res.statusText}`); - } - const data = (await res.json()) as { stargazers_count: number }; - const count = data.stargazers_count; + if (!res.ok) { + throw new Error(`GitHub API error: ${res.status} ${res.statusText}`); + } + const data = (await res.json()) as { stargazers_count: number }; + const count = data.stargazers_count; - if (typeof count !== "number") { - throw new Error("Invalid stargazers_count from GitHub API"); - } + if (typeof count !== "number") { + throw new Error("Invalid stargazers_count from GitHub API"); + } - if (count >= 1_000_000) - return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M"; - if (count >= 1_000) - return (count / 1_000).toFixed(1).replace(/\.0$/, "") + "k"; - return count.toString(); - } catch (error) { - console.error("Failed to fetch GitHub stars:", error); - return "1.5k"; - } + if (count >= 1_000_000) + return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M"; + if (count >= 1_000) + return (count / 1_000).toFixed(1).replace(/\.0$/, "") + "k"; + return count.toString(); + } catch (error) { + console.error("Failed to fetch GitHub stars:", error); + return "1.5k"; + } } diff --git a/apps/web/src/lib/ffmpeg-utils.ts b/apps/web/src/lib/ffmpeg-utils.ts index 80d28c21..ae0c267b 100644 --- a/apps/web/src/lib/ffmpeg-utils.ts +++ b/apps/web/src/lib/ffmpeg-utils.ts @@ -1,270 +1,231 @@ -import { FFmpeg } from "@ffmpeg/ffmpeg"; -import { toBlobURL } from "@ffmpeg/util"; +import { FFmpeg } from '@ffmpeg/ffmpeg'; +import { toBlobURL } from '@ffmpeg/util'; let ffmpeg: FFmpeg | null = null; export const initFFmpeg = async (): Promise<FFmpeg> => { - if (ffmpeg) return ffmpeg; + if (ffmpeg) return ffmpeg; - ffmpeg = new FFmpeg(); + ffmpeg = new FFmpeg(); + + // Use locally hosted files instead of CDN + const baseURL = '/ffmpeg'; + + await ffmpeg.load({ + coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'), + wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'), + }); - // Use locally hosted files instead of CDN - const baseURL = "/ffmpeg"; - - await ffmpeg.load({ - coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"), - wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"), - }); - - return ffmpeg; + return ffmpeg; }; export const generateThumbnail = async ( - videoFile: File, - timeInSeconds: number = 1, + videoFile: File, + timeInSeconds: number = 1 ): Promise<string> => { - const ffmpeg = await initFFmpeg(); - - const inputName = "input.mp4"; - const outputName = "thumbnail.jpg"; - - // Write input file - await ffmpeg.writeFile( - inputName, - new Uint8Array(await videoFile.arrayBuffer()), - ); - - // Generate thumbnail at specific time - await ffmpeg.exec([ - "-i", - inputName, - "-ss", - timeInSeconds.toString(), - "-vframes", - "1", - "-vf", - "scale=320:240", - "-q:v", - "2", - outputName, - ]); - - // Read output file - const data = await ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: "image/jpeg" }); - - // Cleanup - await ffmpeg.deleteFile(inputName); - await ffmpeg.deleteFile(outputName); - - return URL.createObjectURL(blob); + const ffmpeg = await initFFmpeg(); + + const inputName = 'input.mp4'; + const outputName = 'thumbnail.jpg'; + + // Write input file + await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer())); + + // Generate thumbnail at specific time + await ffmpeg.exec([ + '-i', inputName, + '-ss', timeInSeconds.toString(), + '-vframes', '1', + '-vf', 'scale=320:240', + '-q:v', '2', + outputName + ]); + + // Read output file + const data = await ffmpeg.readFile(outputName); + const blob = new Blob([data], { type: 'image/jpeg' }); + + // Cleanup + await ffmpeg.deleteFile(inputName); + await ffmpeg.deleteFile(outputName); + + return URL.createObjectURL(blob); }; export const trimVideo = async ( - videoFile: File, - startTime: number, - endTime: number, - onProgress?: (progress: number) => void, + videoFile: File, + startTime: number, + endTime: number, + onProgress?: (progress: number) => void ): Promise<Blob> => { - const ffmpeg = await initFFmpeg(); - - const inputName = "input.mp4"; - const outputName = "output.mp4"; - - // Set up progress callback - if (onProgress) { - ffmpeg.on("progress", ({ progress }) => { - onProgress(progress * 100); - }); - } - - // Write input file - await ffmpeg.writeFile( - inputName, - new Uint8Array(await videoFile.arrayBuffer()), - ); - - const duration = endTime - startTime; - - // Trim video - await ffmpeg.exec([ - "-i", - inputName, - "-ss", - startTime.toString(), - "-t", - duration.toString(), - "-c", - "copy", // Use stream copy for faster processing - outputName, - ]); - - // Read output file - const data = await ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: "video/mp4" }); - - // Cleanup - await ffmpeg.deleteFile(inputName); - await ffmpeg.deleteFile(outputName); - - return blob; + const ffmpeg = await initFFmpeg(); + + const inputName = 'input.mp4'; + const outputName = 'output.mp4'; + + // Set up progress callback + if (onProgress) { + ffmpeg.on('progress', ({ progress }) => { + onProgress(progress * 100); + }); + } + + // Write input file + await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer())); + + const duration = endTime - startTime; + + // Trim video + await ffmpeg.exec([ + '-i', inputName, + '-ss', startTime.toString(), + '-t', duration.toString(), + '-c', 'copy', // Use stream copy for faster processing + outputName + ]); + + // Read output file + const data = await ffmpeg.readFile(outputName); + const blob = new Blob([data], { type: 'video/mp4' }); + + // Cleanup + await ffmpeg.deleteFile(inputName); + await ffmpeg.deleteFile(outputName); + + return blob; }; -export const getVideoInfo = async ( - videoFile: File, -): Promise<{ - duration: number; - width: number; - height: number; - fps: number; +export const getVideoInfo = async (videoFile: File): Promise<{ + duration: number; + width: number; + height: number; + fps: number; }> => { - const ffmpeg = await initFFmpeg(); + const ffmpeg = await initFFmpeg(); - const inputName = "input.mp4"; + const inputName = 'input.mp4'; - // Write input file - await ffmpeg.writeFile( - inputName, - new Uint8Array(await videoFile.arrayBuffer()), - ); + // Write input file + await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer())); - // Capture FFmpeg stderr output with a one-time listener pattern - let ffmpegOutput = ""; - let listening = true; - const listener = (data: string) => { - if (listening) ffmpegOutput += data; - }; - ffmpeg.on("log", ({ message }) => listener(message)); + // Capture FFmpeg stderr output with a one-time listener pattern + let ffmpegOutput = ''; + let listening = true; + const listener = (data: string) => { + if (listening) ffmpegOutput += data; + }; + ffmpeg.on('log', ({ message }) => listener(message)); - // Run ffmpeg to get info (stderr will contain the info) - try { - await ffmpeg.exec(["-i", inputName, "-f", "null", "-"]); - } catch (error) { - listening = false; - await ffmpeg.deleteFile(inputName); - console.error("FFmpeg execution failed:", error); - throw new Error( - "Failed to extract video info. The file may be corrupted or in an unsupported format.", - ); - } + // Run ffmpeg to get info (stderr will contain the info) + try { + await ffmpeg.exec(['-i', inputName, '-f', 'null', '-']); + } catch (error) { + listening = false; + await ffmpeg.deleteFile(inputName); + console.error('FFmpeg execution failed:', error); + throw new Error('Failed to extract video info. The file may be corrupted or in an unsupported format.'); + } - // Disable listener after exec completes - listening = false; + // Disable listener after exec completes + listening = false; - // Cleanup - await ffmpeg.deleteFile(inputName); + // Cleanup + await ffmpeg.deleteFile(inputName); - // Parse output for duration, resolution, and fps - // Example: Duration: 00:00:10.00, start: 0.000000, bitrate: 1234 kb/s - // Example: Stream #0:0: Video: h264 (High), yuv420p(progressive), 1920x1080 [SAR 1:1 DAR 16:9], 30 fps, 30 tbr, 90k tbn, 60 tbc + // Parse output for duration, resolution, and fps + // Example: Duration: 00:00:10.00, start: 0.000000, bitrate: 1234 kb/s + // Example: Stream #0:0: Video: h264 (High), yuv420p(progressive), 1920x1080 [SAR 1:1 DAR 16:9], 30 fps, 30 tbr, 90k tbn, 60 tbc - const durationMatch = ffmpegOutput.match(/Duration: (\d+):(\d+):([\d.]+)/); - let duration = 0; - if (durationMatch) { - const [, h, m, s] = durationMatch; - duration = parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s); - } + const durationMatch = ffmpegOutput.match(/Duration: (\d+):(\d+):([\d.]+)/); + let duration = 0; + if (durationMatch) { + const [, h, m, s] = durationMatch; + duration = parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s); + } - const videoStreamMatch = ffmpegOutput.match( - /Video:.* (\d+)x(\d+)[^,]*, ([\d.]+) fps/, - ); - let width = 0, - height = 0, - fps = 0; - if (videoStreamMatch) { - width = parseInt(videoStreamMatch[1]); - height = parseInt(videoStreamMatch[2]); - fps = parseFloat(videoStreamMatch[3]); - } + const videoStreamMatch = ffmpegOutput.match(/Video:.* (\d+)x(\d+)[^,]*, ([\d.]+) fps/); + let width = 0, height = 0, fps = 0; + if (videoStreamMatch) { + width = parseInt(videoStreamMatch[1]); + height = parseInt(videoStreamMatch[2]); + fps = parseFloat(videoStreamMatch[3]); + } - return { - duration, - width, - height, - fps, - }; + return { + duration, + width, + height, + fps + }; }; export const convertToWebM = async ( - videoFile: File, - onProgress?: (progress: number) => void, + videoFile: File, + onProgress?: (progress: number) => void ): Promise<Blob> => { - const ffmpeg = await initFFmpeg(); - - const inputName = "input.mp4"; - const outputName = "output.webm"; - - // Set up progress callback - if (onProgress) { - ffmpeg.on("progress", ({ progress }) => { - onProgress(progress * 100); - }); - } - - // Write input file - await ffmpeg.writeFile( - inputName, - new Uint8Array(await videoFile.arrayBuffer()), - ); - - // Convert to WebM - await ffmpeg.exec([ - "-i", - inputName, - "-c:v", - "libvpx-vp9", - "-crf", - "30", - "-b:v", - "0", - "-c:a", - "libopus", - outputName, - ]); - - // Read output file - const data = await ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: "video/webm" }); - - // Cleanup - await ffmpeg.deleteFile(inputName); - await ffmpeg.deleteFile(outputName); - - return blob; + const ffmpeg = await initFFmpeg(); + + const inputName = 'input.mp4'; + const outputName = 'output.webm'; + + // Set up progress callback + if (onProgress) { + ffmpeg.on('progress', ({ progress }) => { + onProgress(progress * 100); + }); + } + + // Write input file + await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer())); + + // Convert to WebM + await ffmpeg.exec([ + '-i', inputName, + '-c:v', 'libvpx-vp9', + '-crf', '30', + '-b:v', '0', + '-c:a', 'libopus', + outputName + ]); + + // Read output file + const data = await ffmpeg.readFile(outputName); + const blob = new Blob([data], { type: 'video/webm' }); + + // Cleanup + await ffmpeg.deleteFile(inputName); + await ffmpeg.deleteFile(outputName); + + return blob; }; export const extractAudio = async ( - videoFile: File, - format: "mp3" | "wav" = "mp3", + videoFile: File, + format: 'mp3' | 'wav' = 'mp3' ): Promise<Blob> => { - const ffmpeg = await initFFmpeg(); - - const inputName = "input.mp4"; - const outputName = `output.${format}`; - - // Write input file - await ffmpeg.writeFile( - inputName, - new Uint8Array(await videoFile.arrayBuffer()), - ); - - // Extract audio - await ffmpeg.exec([ - "-i", - inputName, - "-vn", // Disable video - "-acodec", - format === "mp3" ? "libmp3lame" : "pcm_s16le", - outputName, - ]); - - // Read output file - const data = await ffmpeg.readFile(outputName); - const blob = new Blob([data], { type: `audio/${format}` }); - - // Cleanup - await ffmpeg.deleteFile(inputName); - await ffmpeg.deleteFile(outputName); - - return blob; -}; + const ffmpeg = await initFFmpeg(); + + const inputName = 'input.mp4'; + const outputName = `output.${format}`; + + // Write input file + await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer())); + + // Extract audio + await ffmpeg.exec([ + '-i', inputName, + '-vn', // Disable video + '-acodec', format === 'mp3' ? 'libmp3lame' : 'pcm_s16le', + outputName + ]); + + // Read output file + const data = await ffmpeg.readFile(outputName); + const blob = new Blob([data], { type: `audio/${format}` }); + + // Cleanup + await ffmpeg.deleteFile(inputName); + await ffmpeg.deleteFile(outputName); + + return blob; +};
\ No newline at end of file diff --git a/apps/web/src/lib/font-config.ts b/apps/web/src/lib/font-config.ts index 8b19609a..0389054a 100644 --- a/apps/web/src/lib/font-config.ts +++ b/apps/web/src/lib/font-config.ts @@ -1,39 +1,39 @@ -import { - Inter, - Roboto, - Open_Sans, - Playfair_Display, - Comic_Neue, -} from "next/font/google"; - -// Configure all fonts -const inter = Inter({ subsets: ["latin"] }); -const roboto = Roboto({ subsets: ["latin"], weight: ["400", "700"] }); -const openSans = Open_Sans({ subsets: ["latin"] }); -const playfairDisplay = Playfair_Display({ subsets: ["latin"] }); -const comicNeue = Comic_Neue({ subsets: ["latin"], weight: ["400", "700"] }); - -// Export font class mapping for use in components -export const FONT_CLASS_MAP = { - Inter: inter.className, - Roboto: roboto.className, - "Open Sans": openSans.className, - "Playfair Display": playfairDisplay.className, - "Comic Neue": comicNeue.className, - Arial: "", - Helvetica: "", - "Times New Roman": "", - Georgia: "", -} as const; - -// Export individual fonts for use in layout -export const fonts = { - inter, - roboto, - openSans, - playfairDisplay, - comicNeue, -}; - -// Default font for the body -export const defaultFont = inter; +import {
+ Inter,
+ Roboto,
+ Open_Sans,
+ Playfair_Display,
+ Comic_Neue,
+} from "next/font/google";
+
+// Configure all fonts
+const inter = Inter({ subsets: ["latin"] });
+const roboto = Roboto({ subsets: ["latin"], weight: ["400", "700"] });
+const openSans = Open_Sans({ subsets: ["latin"] });
+const playfairDisplay = Playfair_Display({ subsets: ["latin"] });
+const comicNeue = Comic_Neue({ subsets: ["latin"], weight: ["400", "700"] });
+
+// Export font class mapping for use in components
+export const FONT_CLASS_MAP = {
+ Inter: inter.className,
+ Roboto: roboto.className,
+ "Open Sans": openSans.className,
+ "Playfair Display": playfairDisplay.className,
+ "Comic Neue": comicNeue.className,
+ Arial: "",
+ Helvetica: "",
+ "Times New Roman": "",
+ Georgia: "",
+} as const;
+
+// Export individual fonts for use in layout
+export const fonts = {
+ inter,
+ roboto,
+ openSans,
+ playfairDisplay,
+ comicNeue,
+};
+
+// Default font for the body
+export const defaultFont = inter;
diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts index 738621b7..353c3113 100644 --- a/apps/web/src/lib/media-processing.ts +++ b/apps/web/src/lib/media-processing.ts @@ -1,101 +1,101 @@ import { toast } from "sonner"; import { - getFileType, - generateVideoThumbnail, - getMediaDuration, - getImageDimensions, - type MediaItem, + getFileType, + generateVideoThumbnail, + getMediaDuration, + getImageDimensions, + type MediaItem, } from "@/stores/media-store"; import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {} export async function processMediaFiles( - files: FileList | File[], - onProgress?: (progress: number) => void, + files: FileList | File[], + onProgress?: (progress: number) => void ): Promise<ProcessedMediaItem[]> { - const fileArray = Array.from(files); - const processedItems: ProcessedMediaItem[] = []; + const fileArray = Array.from(files); + const processedItems: ProcessedMediaItem[] = []; - const total = fileArray.length; - let completed = 0; + const total = fileArray.length; + let completed = 0; - for (const file of fileArray) { - const fileType = getFileType(file); + for (const file of fileArray) { + const fileType = getFileType(file); - if (!fileType) { - toast.error(`Unsupported file type: ${file.name}`); - continue; - } + if (!fileType) { + toast.error(`Unsupported file type: ${file.name}`); + continue; + } - const url = URL.createObjectURL(file); - let thumbnailUrl: string | undefined; - let duration: number | undefined; - let width: number | undefined; - let height: number | undefined; - let fps: number | undefined; + const url = URL.createObjectURL(file); + let thumbnailUrl: string | undefined; + let duration: number | undefined; + let width: number | undefined; + let height: number | undefined; + let fps: number | undefined; - try { - if (fileType === "image") { - // Get image dimensions - const dimensions = await getImageDimensions(file); - width = dimensions.width; - height = dimensions.height; - } else if (fileType === "video") { - try { - // Use FFmpeg for comprehensive video info extraction - const videoInfo = await getVideoInfo(file); - duration = videoInfo.duration; - width = videoInfo.width; - height = videoInfo.height; - fps = videoInfo.fps; + try { + if (fileType === "image") { + // Get image dimensions + const dimensions = await getImageDimensions(file); + width = dimensions.width; + height = dimensions.height; + } else if (fileType === "video") { + try { + // Use FFmpeg for comprehensive video info extraction + const videoInfo = await getVideoInfo(file); + duration = videoInfo.duration; + width = videoInfo.width; + height = videoInfo.height; + fps = videoInfo.fps; - // Generate thumbnail using FFmpeg - thumbnailUrl = await generateThumbnail(file, 1); - } catch (error) { - console.warn( - "FFmpeg processing failed, falling back to basic processing:", - error, - ); - // Fallback to basic processing - const videoResult = await generateVideoThumbnail(file); - thumbnailUrl = videoResult.thumbnailUrl; - width = videoResult.width; - height = videoResult.height; - duration = await getMediaDuration(file); - // FPS will remain undefined for fallback - } - } else if (fileType === "audio") { - // For audio, we don't set width/height/fps (they'll be undefined) - duration = await getMediaDuration(file); - } + // Generate thumbnail using FFmpeg + thumbnailUrl = await generateThumbnail(file, 1); + } catch (error) { + console.warn( + "FFmpeg processing failed, falling back to basic processing:", + error + ); + // Fallback to basic processing + const videoResult = await generateVideoThumbnail(file); + thumbnailUrl = videoResult.thumbnailUrl; + width = videoResult.width; + height = videoResult.height; + duration = await getMediaDuration(file); + // FPS will remain undefined for fallback + } + } else if (fileType === "audio") { + // For audio, we don't set width/height/fps (they'll be undefined) + duration = await getMediaDuration(file); + } - processedItems.push({ - name: file.name, - type: fileType, - file, - url, - thumbnailUrl, - duration, - width, - height, - fps, - }); + processedItems.push({ + name: file.name, + type: fileType, + file, + url, + thumbnailUrl, + duration, + width, + height, + fps, + }); - // Yield back to the event loop to keep the UI responsive - await new Promise((resolve) => setTimeout(resolve, 0)); + // Yield back to the event loop to keep the UI responsive + await new Promise((resolve) => setTimeout(resolve, 0)); - completed += 1; - if (onProgress) { - const percent = Math.round((completed / total) * 100); - onProgress(percent); - } - } catch (error) { - console.error("Error processing file:", file.name, error); - toast.error(`Failed to process ${file.name}`); - URL.revokeObjectURL(url); // Clean up on error - } - } + completed += 1; + if (onProgress) { + const percent = Math.round((completed / total) * 100); + onProgress(percent); + } + } catch (error) { + console.error("Error processing file:", file.name, error); + toast.error(`Failed to process ${file.name}`); + URL.revokeObjectURL(url); // Clean up on error + } + } - return processedItems; + return processedItems; } diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts index c139471c..ac63a64c 100644 --- a/apps/web/src/lib/rate-limit.ts +++ b/apps/web/src/lib/rate-limit.ts @@ -1,16 +1,16 @@ -// lib/rate-limit.ts -import { Ratelimit } from "@upstash/ratelimit"; -import { Redis } from "@upstash/redis"; -import { env } from "@/env"; - -const redis = new Redis({ - url: env.UPSTASH_REDIS_REST_URL, - token: env.UPSTASH_REDIS_REST_TOKEN, -}); - -export const waitlistRateLimit = new Ratelimit({ - redis, - limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 requests per minute - analytics: true, - prefix: "waitlist-rate-limit", -}); +// lib/rate-limit.ts
+import { Ratelimit } from "@upstash/ratelimit";
+import { Redis } from "@upstash/redis";
+import { env } from "@/env";
+
+const redis = new Redis({
+ url: env.UPSTASH_REDIS_REST_URL,
+ token: env.UPSTASH_REDIS_REST_TOKEN,
+});
+
+export const waitlistRateLimit = new Ratelimit({
+ redis,
+ limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 requests per minute
+ analytics: true,
+ prefix: "waitlist-rate-limit",
+});
diff --git a/apps/web/src/lib/storage/indexeddb-adapter.ts b/apps/web/src/lib/storage/indexeddb-adapter.ts index b599e2df..f43a8798 100644 --- a/apps/web/src/lib/storage/indexeddb-adapter.ts +++ b/apps/web/src/lib/storage/indexeddb-adapter.ts @@ -1,89 +1,89 @@ -import { StorageAdapter } from "./types"; - -export class IndexedDBAdapter<T> implements StorageAdapter<T> { - private dbName: string; - private storeName: string; - private version: number; - - constructor(dbName: string, storeName: string, version: number = 1) { - this.dbName = dbName; - this.storeName = storeName; - this.version = version; - } - - private async getDB(): Promise<IDBDatabase> { - return new Promise((resolve, reject) => { - const request = indexedDB.open(this.dbName, this.version); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - if (!db.objectStoreNames.contains(this.storeName)) { - db.createObjectStore(this.storeName, { keyPath: "id" }); - } - }; - }); - } - - async get(key: string): Promise<T | null> { - const db = await this.getDB(); - const transaction = db.transaction([this.storeName], "readonly"); - const store = transaction.objectStore(this.storeName); - - return new Promise((resolve, reject) => { - const request = store.get(key); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result || null); - }); - } - - async set(key: string, value: T): Promise<void> { - const db = await this.getDB(); - const transaction = db.transaction([this.storeName], "readwrite"); - const store = transaction.objectStore(this.storeName); - - return new Promise((resolve, reject) => { - const request = store.put({ id: key, ...value }); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); - } - - async remove(key: string): Promise<void> { - const db = await this.getDB(); - const transaction = db.transaction([this.storeName], "readwrite"); - const store = transaction.objectStore(this.storeName); - - return new Promise((resolve, reject) => { - const request = store.delete(key); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); - } - - async list(): Promise<string[]> { - const db = await this.getDB(); - const transaction = db.transaction([this.storeName], "readonly"); - const store = transaction.objectStore(this.storeName); - - return new Promise((resolve, reject) => { - const request = store.getAllKeys(); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result as string[]); - }); - } - - async clear(): Promise<void> { - const db = await this.getDB(); - const transaction = db.transaction([this.storeName], "readwrite"); - const store = transaction.objectStore(this.storeName); - - return new Promise((resolve, reject) => { - const request = store.clear(); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); - } -} +import { StorageAdapter } from "./types";
+
+export class IndexedDBAdapter<T> implements StorageAdapter<T> {
+ private dbName: string;
+ private storeName: string;
+ private version: number;
+
+ constructor(dbName: string, storeName: string, version: number = 1) {
+ this.dbName = dbName;
+ this.storeName = storeName;
+ this.version = version;
+ }
+
+ private async getDB(): Promise<IDBDatabase> {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(this.dbName, this.version);
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve(request.result);
+
+ request.onupgradeneeded = (event) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+ if (!db.objectStoreNames.contains(this.storeName)) {
+ db.createObjectStore(this.storeName, { keyPath: "id" });
+ }
+ };
+ });
+ }
+
+ async get(key: string): Promise<T | null> {
+ const db = await this.getDB();
+ const transaction = db.transaction([this.storeName], "readonly");
+ const store = transaction.objectStore(this.storeName);
+
+ return new Promise((resolve, reject) => {
+ const request = store.get(key);
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve(request.result || null);
+ });
+ }
+
+ async set(key: string, value: T): Promise<void> {
+ const db = await this.getDB();
+ const transaction = db.transaction([this.storeName], "readwrite");
+ const store = transaction.objectStore(this.storeName);
+
+ return new Promise((resolve, reject) => {
+ const request = store.put({ id: key, ...value });
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve();
+ });
+ }
+
+ async remove(key: string): Promise<void> {
+ const db = await this.getDB();
+ const transaction = db.transaction([this.storeName], "readwrite");
+ const store = transaction.objectStore(this.storeName);
+
+ return new Promise((resolve, reject) => {
+ const request = store.delete(key);
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve();
+ });
+ }
+
+ async list(): Promise<string[]> {
+ const db = await this.getDB();
+ const transaction = db.transaction([this.storeName], "readonly");
+ const store = transaction.objectStore(this.storeName);
+
+ return new Promise((resolve, reject) => {
+ const request = store.getAllKeys();
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve(request.result as string[]);
+ });
+ }
+
+ async clear(): Promise<void> {
+ const db = await this.getDB();
+ const transaction = db.transaction([this.storeName], "readwrite");
+ const store = transaction.objectStore(this.storeName);
+
+ return new Promise((resolve, reject) => {
+ const request = store.clear();
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve();
+ });
+ }
+}
diff --git a/apps/web/src/lib/storage/opfs-adapter.ts b/apps/web/src/lib/storage/opfs-adapter.ts index ccd9735d..c01954e5 100644 --- a/apps/web/src/lib/storage/opfs-adapter.ts +++ b/apps/web/src/lib/storage/opfs-adapter.ts @@ -1,73 +1,73 @@ import { StorageAdapter } from "./types"; export class OPFSAdapter implements StorageAdapter<File> { - private directoryName: string; + private directoryName: string; - constructor(directoryName: string = "media") { - this.directoryName = directoryName; - } + constructor(directoryName: string = "media") { + this.directoryName = directoryName; + } - private async getDirectory(): Promise<FileSystemDirectoryHandle> { - const opfsRoot = await navigator.storage.getDirectory(); - return await opfsRoot.getDirectoryHandle(this.directoryName, { - create: true, - }); - } + private async getDirectory(): Promise<FileSystemDirectoryHandle> { + const opfsRoot = await navigator.storage.getDirectory(); + return await opfsRoot.getDirectoryHandle(this.directoryName, { + create: true, + }); + } - async get(key: string): Promise<File | null> { - try { - const directory = await this.getDirectory(); - const fileHandle = await directory.getFileHandle(key); - return await fileHandle.getFile(); - } catch (error) { - if ((error as Error).name === "NotFoundError") { - return null; - } - throw error; - } - } + async get(key: string): Promise<File | null> { + try { + const directory = await this.getDirectory(); + const fileHandle = await directory.getFileHandle(key); + return await fileHandle.getFile(); + } catch (error) { + if ((error as Error).name === "NotFoundError") { + return null; + } + throw error; + } + } - async set(key: string, file: File): Promise<void> { - const directory = await this.getDirectory(); - const fileHandle = await directory.getFileHandle(key, { create: true }); - const writable = await fileHandle.createWritable(); + async set(key: string, file: File): Promise<void> { + const directory = await this.getDirectory(); + const fileHandle = await directory.getFileHandle(key, { create: true }); + const writable = await fileHandle.createWritable(); - await writable.write(file); - await writable.close(); - } + await writable.write(file); + await writable.close(); + } - async remove(key: string): Promise<void> { - try { - const directory = await this.getDirectory(); - await directory.removeEntry(key); - } catch (error) { - if ((error as Error).name !== "NotFoundError") { - throw error; - } - } - } + async remove(key: string): Promise<void> { + try { + const directory = await this.getDirectory(); + await directory.removeEntry(key); + } catch (error) { + if ((error as Error).name !== "NotFoundError") { + throw error; + } + } + } - async list(): Promise<string[]> { - const directory = await this.getDirectory(); - const keys: string[] = []; + async list(): Promise<string[]> { + const directory = await this.getDirectory(); + const keys: string[] = []; - for await (const name of directory.keys()) { - keys.push(name); - } + for await (const name of directory.keys()) { + keys.push(name); + } - return keys; - } + return keys; + } - async clear(): Promise<void> { - const directory = await this.getDirectory(); + async clear(): Promise<void> { + const directory = await this.getDirectory(); - for await (const name of directory.keys()) { - await directory.removeEntry(name); - } - } + for await (const name of directory.keys()) { + await directory.removeEntry(name); + } + } - // Helper method to check OPFS support - static isSupported(): boolean { - return "storage" in navigator && "getDirectory" in navigator.storage; - } + // Helper method to check OPFS support + static isSupported(): boolean { + return "storage" in navigator && "getDirectory" in navigator.storage; + } } diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts index 044f2dff..0037dbdb 100644 --- a/apps/web/src/lib/storage/storage-service.ts +++ b/apps/web/src/lib/storage/storage-service.ts @@ -3,275 +3,275 @@ import { MediaItem } from "@/stores/media-store"; import { IndexedDBAdapter } from "./indexeddb-adapter"; import { OPFSAdapter } from "./opfs-adapter"; import { - MediaFileData, - StorageConfig, - SerializedProject, - TimelineData, + MediaFileData, + StorageConfig, + SerializedProject, + TimelineData, } from "./types"; import { TimelineTrack } from "@/types/timeline"; class StorageService { - private projectsAdapter: IndexedDBAdapter<SerializedProject>; - private config: StorageConfig; + private projectsAdapter: IndexedDBAdapter<SerializedProject>; + private config: StorageConfig; - constructor() { - this.config = { - projectsDb: "video-editor-projects", - mediaDb: "video-editor-media", - timelineDb: "video-editor-timelines", - version: 1, - }; + constructor() { + this.config = { + projectsDb: "video-editor-projects", + mediaDb: "video-editor-media", + timelineDb: "video-editor-timelines", + version: 1, + }; - this.projectsAdapter = new IndexedDBAdapter<SerializedProject>( - this.config.projectsDb, - "projects", - this.config.version, - ); - } + this.projectsAdapter = new IndexedDBAdapter<SerializedProject>( + this.config.projectsDb, + "projects", + this.config.version + ); + } - // Helper to get project-specific media adapters - private getProjectMediaAdapters(projectId: string) { - const mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>( - `${this.config.mediaDb}-${projectId}`, - "media-metadata", - this.config.version, - ); + // Helper to get project-specific media adapters + private getProjectMediaAdapters(projectId: string) { + const mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>( + `${this.config.mediaDb}-${projectId}`, + "media-metadata", + this.config.version + ); - const mediaFilesAdapter = new OPFSAdapter(`media-files-${projectId}`); + const mediaFilesAdapter = new OPFSAdapter(`media-files-${projectId}`); - return { mediaMetadataAdapter, mediaFilesAdapter }; - } + return { mediaMetadataAdapter, mediaFilesAdapter }; + } - // Helper to get project-specific timeline adapter - private getProjectTimelineAdapter(projectId: string) { - return new IndexedDBAdapter<TimelineData>( - `${this.config.timelineDb}-${projectId}`, - "timeline", - this.config.version, - ); - } + // Helper to get project-specific timeline adapter + private getProjectTimelineAdapter(projectId: string) { + return new IndexedDBAdapter<TimelineData>( + `${this.config.timelineDb}-${projectId}`, + "timeline", + this.config.version + ); + } - // Project operations - async saveProject(project: TProject): Promise<void> { - // Convert TProject to serializable format - const serializedProject: SerializedProject = { - id: project.id, - name: project.name, - thumbnail: project.thumbnail, - createdAt: project.createdAt.toISOString(), - updatedAt: project.updatedAt.toISOString(), - backgroundColor: project.backgroundColor, - backgroundType: project.backgroundType, - blurIntensity: project.blurIntensity, - }; + // Project operations + async saveProject(project: TProject): Promise<void> { + // Convert TProject to serializable format + const serializedProject: SerializedProject = { + id: project.id, + name: project.name, + thumbnail: project.thumbnail, + createdAt: project.createdAt.toISOString(), + updatedAt: project.updatedAt.toISOString(), + backgroundColor: project.backgroundColor, + backgroundType: project.backgroundType, + blurIntensity: project.blurIntensity, + }; - await this.projectsAdapter.set(project.id, serializedProject); - } + await this.projectsAdapter.set(project.id, serializedProject); + } - async loadProject(id: string): Promise<TProject | null> { - const serializedProject = await this.projectsAdapter.get(id); + async loadProject(id: string): Promise<TProject | null> { + const serializedProject = await this.projectsAdapter.get(id); - if (!serializedProject) return null; + if (!serializedProject) return null; - // Convert back to TProject format - return { - id: serializedProject.id, - name: serializedProject.name, - thumbnail: serializedProject.thumbnail, - createdAt: new Date(serializedProject.createdAt), - updatedAt: new Date(serializedProject.updatedAt), - backgroundColor: serializedProject.backgroundColor, - backgroundType: serializedProject.backgroundType, - blurIntensity: serializedProject.blurIntensity, - }; - } + // Convert back to TProject format + return { + id: serializedProject.id, + name: serializedProject.name, + thumbnail: serializedProject.thumbnail, + createdAt: new Date(serializedProject.createdAt), + updatedAt: new Date(serializedProject.updatedAt), + backgroundColor: serializedProject.backgroundColor, + backgroundType: serializedProject.backgroundType, + blurIntensity: serializedProject.blurIntensity, + }; + } - async loadAllProjects(): Promise<TProject[]> { - const projectIds = await this.projectsAdapter.list(); - const projects: TProject[] = []; + async loadAllProjects(): Promise<TProject[]> { + const projectIds = await this.projectsAdapter.list(); + const projects: TProject[] = []; - for (const id of projectIds) { - const project = await this.loadProject(id); - if (project) { - projects.push(project); - } - } + for (const id of projectIds) { + const project = await this.loadProject(id); + if (project) { + projects.push(project); + } + } - // Sort by last updated (most recent first) - return projects.sort( - (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(), - ); - } + // Sort by last updated (most recent first) + return projects.sort( + (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime() + ); + } - async deleteProject(id: string): Promise<void> { - await this.projectsAdapter.remove(id); - } + async deleteProject(id: string): Promise<void> { + await this.projectsAdapter.remove(id); + } - // Media operations - now project-specific - async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise<void> { - const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + // Media operations - now project-specific + async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise<void> { + const { mediaMetadataAdapter, mediaFilesAdapter } = + this.getProjectMediaAdapters(projectId); - // Save file to project-specific OPFS - await mediaFilesAdapter.set(mediaItem.id, mediaItem.file); + // Save file to project-specific OPFS + await mediaFilesAdapter.set(mediaItem.id, mediaItem.file); - // Save metadata to project-specific IndexedDB - const metadata: MediaFileData = { - id: mediaItem.id, - name: mediaItem.name, - type: mediaItem.type, - size: mediaItem.file.size, - lastModified: mediaItem.file.lastModified, - width: mediaItem.width, - height: mediaItem.height, - duration: mediaItem.duration, - }; + // Save metadata to project-specific IndexedDB + const metadata: MediaFileData = { + id: mediaItem.id, + name: mediaItem.name, + type: mediaItem.type, + size: mediaItem.file.size, + lastModified: mediaItem.file.lastModified, + width: mediaItem.width, + height: mediaItem.height, + duration: mediaItem.duration, + }; - await mediaMetadataAdapter.set(mediaItem.id, metadata); - } + await mediaMetadataAdapter.set(mediaItem.id, metadata); + } - async loadMediaItem( - projectId: string, - id: string, - ): Promise<MediaItem | null> { - const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + async loadMediaItem( + projectId: string, + id: string + ): Promise<MediaItem | null> { + const { mediaMetadataAdapter, mediaFilesAdapter } = + this.getProjectMediaAdapters(projectId); - const [file, metadata] = await Promise.all([ - mediaFilesAdapter.get(id), - mediaMetadataAdapter.get(id), - ]); + const [file, metadata] = await Promise.all([ + mediaFilesAdapter.get(id), + mediaMetadataAdapter.get(id), + ]); - if (!file || !metadata) return null; + if (!file || !metadata) return null; - // Create new object URL for the file - const url = URL.createObjectURL(file); + // Create new object URL for the file + const url = URL.createObjectURL(file); - return { - id: metadata.id, - name: metadata.name, - type: metadata.type, - file, - url, - width: metadata.width, - height: metadata.height, - duration: metadata.duration, - // thumbnailUrl would need to be regenerated or cached separately - }; - } + return { + id: metadata.id, + name: metadata.name, + type: metadata.type, + file, + url, + width: metadata.width, + height: metadata.height, + duration: metadata.duration, + // thumbnailUrl would need to be regenerated or cached separately + }; + } - async loadAllMediaItems(projectId: string): Promise<MediaItem[]> { - const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); + async loadAllMediaItems(projectId: string): Promise<MediaItem[]> { + const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); - const mediaIds = await mediaMetadataAdapter.list(); - const mediaItems: MediaItem[] = []; + const mediaIds = await mediaMetadataAdapter.list(); + const mediaItems: MediaItem[] = []; - for (const id of mediaIds) { - const item = await this.loadMediaItem(projectId, id); - if (item) { - mediaItems.push(item); - } - } + for (const id of mediaIds) { + const item = await this.loadMediaItem(projectId, id); + if (item) { + mediaItems.push(item); + } + } - return mediaItems; - } + return mediaItems; + } - async deleteMediaItem(projectId: string, id: string): Promise<void> { - const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + async deleteMediaItem(projectId: string, id: string): Promise<void> { + const { mediaMetadataAdapter, mediaFilesAdapter } = + this.getProjectMediaAdapters(projectId); - await Promise.all([ - mediaFilesAdapter.remove(id), - mediaMetadataAdapter.remove(id), - ]); - } + await Promise.all([ + mediaFilesAdapter.remove(id), + mediaMetadataAdapter.remove(id), + ]); + } - async deleteProjectMedia(projectId: string): Promise<void> { - const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + async deleteProjectMedia(projectId: string): Promise<void> { + const { mediaMetadataAdapter, mediaFilesAdapter } = + this.getProjectMediaAdapters(projectId); - await Promise.all([ - mediaMetadataAdapter.clear(), - mediaFilesAdapter.clear(), - ]); - } + await Promise.all([ + mediaMetadataAdapter.clear(), + mediaFilesAdapter.clear(), + ]); + } - // Timeline operations - now project-specific - async saveTimeline( - projectId: string, - tracks: TimelineTrack[], - ): Promise<void> { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); - const timelineData: TimelineData = { - tracks, - lastModified: new Date().toISOString(), - }; - await timelineAdapter.set("timeline", timelineData); - } + // Timeline operations - now project-specific + async saveTimeline( + projectId: string, + tracks: TimelineTrack[] + ): Promise<void> { + const timelineAdapter = this.getProjectTimelineAdapter(projectId); + const timelineData: TimelineData = { + tracks, + lastModified: new Date().toISOString(), + }; + await timelineAdapter.set("timeline", timelineData); + } - async loadTimeline(projectId: string): Promise<TimelineTrack[] | null> { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); - const timelineData = await timelineAdapter.get("timeline"); - return timelineData ? timelineData.tracks : null; - } + async loadTimeline(projectId: string): Promise<TimelineTrack[] | null> { + const timelineAdapter = this.getProjectTimelineAdapter(projectId); + const timelineData = await timelineAdapter.get("timeline"); + return timelineData ? timelineData.tracks : null; + } - async deleteProjectTimeline(projectId: string): Promise<void> { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); - await timelineAdapter.remove("timeline"); - } + async deleteProjectTimeline(projectId: string): Promise<void> { + const timelineAdapter = this.getProjectTimelineAdapter(projectId); + await timelineAdapter.remove("timeline"); + } - // Utility methods - async clearAllData(): Promise<void> { - // Clear all projects - await this.projectsAdapter.clear(); + // Utility methods + async clearAllData(): Promise<void> { + // Clear all projects + await this.projectsAdapter.clear(); - // Note: Project-specific media and timelines will be cleaned up when projects are deleted - } + // Note: Project-specific media and timelines will be cleaned up when projects are deleted + } - async getStorageInfo(): Promise<{ - projects: number; - isOPFSSupported: boolean; - isIndexedDBSupported: boolean; - }> { - const projectIds = await this.projectsAdapter.list(); + async getStorageInfo(): Promise<{ + projects: number; + isOPFSSupported: boolean; + isIndexedDBSupported: boolean; + }> { + const projectIds = await this.projectsAdapter.list(); - return { - projects: projectIds.length, - isOPFSSupported: this.isOPFSSupported(), - isIndexedDBSupported: this.isIndexedDBSupported(), - }; - } + return { + projects: projectIds.length, + isOPFSSupported: this.isOPFSSupported(), + isIndexedDBSupported: this.isIndexedDBSupported(), + }; + } - async getProjectStorageInfo(projectId: string): Promise<{ - mediaItems: number; - hasTimeline: boolean; - }> { - const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); - const timelineAdapter = this.getProjectTimelineAdapter(projectId); + async getProjectStorageInfo(projectId: string): Promise<{ + mediaItems: number; + hasTimeline: boolean; + }> { + const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); + const timelineAdapter = this.getProjectTimelineAdapter(projectId); - const [mediaIds, timelineData] = await Promise.all([ - mediaMetadataAdapter.list(), - timelineAdapter.get("timeline"), - ]); + const [mediaIds, timelineData] = await Promise.all([ + mediaMetadataAdapter.list(), + timelineAdapter.get("timeline"), + ]); - return { - mediaItems: mediaIds.length, - hasTimeline: !!timelineData, - }; - } + return { + mediaItems: mediaIds.length, + hasTimeline: !!timelineData, + }; + } - // Check browser support - isOPFSSupported(): boolean { - return OPFSAdapter.isSupported(); - } + // Check browser support + isOPFSSupported(): boolean { + return OPFSAdapter.isSupported(); + } - isIndexedDBSupported(): boolean { - return "indexedDB" in window; - } + isIndexedDBSupported(): boolean { + return "indexedDB" in window; + } - isFullySupported(): boolean { - return this.isIndexedDBSupported() && this.isOPFSSupported(); - } + isFullySupported(): boolean { + return this.isIndexedDBSupported() && this.isOPFSSupported(); + } } // Export singleton instance diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts index 1adbb3b8..b662c164 100644 --- a/apps/web/src/lib/storage/types.ts +++ b/apps/web/src/lib/storage/types.ts @@ -2,48 +2,48 @@ import { TProject } from "@/types/project"; import { TimelineTrack } from "@/types/timeline"; export interface StorageAdapter<T> { - get(key: string): Promise<T | null>; - set(key: string, value: T): Promise<void>; - remove(key: string): Promise<void>; - list(): Promise<string[]>; - clear(): Promise<void>; + get(key: string): Promise<T | null>; + set(key: string, value: T): Promise<void>; + remove(key: string): Promise<void>; + list(): Promise<string[]>; + clear(): Promise<void>; } export interface MediaFileData { - id: string; - name: string; - type: "image" | "video" | "audio"; - size: number; - lastModified: number; - width?: number; - height?: number; - duration?: number; - // File will be stored separately in OPFS + id: string; + name: string; + type: "image" | "video" | "audio"; + size: number; + lastModified: number; + width?: number; + height?: number; + duration?: number; + // File will be stored separately in OPFS } export interface TimelineData { - tracks: TimelineTrack[]; - lastModified: string; + tracks: TimelineTrack[]; + lastModified: string; } export interface StorageConfig { - projectsDb: string; - mediaDb: string; - timelineDb: string; - version: number; + projectsDb: string; + mediaDb: string; + timelineDb: string; + version: number; } // Helper type for serialization - converts Date objects to strings export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & { - createdAt: string; - updatedAt: string; + createdAt: string; + updatedAt: string; }; // Extend FileSystemDirectoryHandle with missing async iterator methods declare global { - interface FileSystemDirectoryHandle { - keys(): AsyncIterableIterator<string>; - values(): AsyncIterableIterator<FileSystemHandle>; - entries(): AsyncIterableIterator<[string, FileSystemHandle]>; - } + interface FileSystemDirectoryHandle { + keys(): AsyncIterableIterator<string>; + values(): AsyncIterableIterator<FileSystemHandle>; + entries(): AsyncIterableIterator<[string, FileSystemHandle]>; + } } diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts index 6afcd03e..b3147946 100644 --- a/apps/web/src/lib/time.ts +++ b/apps/web/src/lib/time.ts @@ -1,25 +1,25 @@ -// Time-related utility functions - -// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS, HH:MM:SS:FF) -export const formatTimeCode = ( - timeInSeconds: number, - format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF" = "HH:MM:SS:CS", - fps: number = 30, -): string => { - const hours = Math.floor(timeInSeconds / 3600); - const minutes = Math.floor((timeInSeconds % 3600) / 60); - const seconds = Math.floor(timeInSeconds % 60); - const centiseconds = Math.floor((timeInSeconds % 1) * 100); - const frames = Math.floor((timeInSeconds % 1) * fps); - - switch (format) { - case "MM:SS": - return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; - case "HH:MM:SS": - return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; - case "HH:MM:SS:CS": - return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`; - case "HH:MM:SS:FF": - return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`; - } -}; +// Time-related utility functions
+
+// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS, HH:MM:SS:FF)
+export const formatTimeCode = (
+ timeInSeconds: number,
+ format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF" = "HH:MM:SS:CS",
+ fps: number = 30
+): string => {
+ const hours = Math.floor(timeInSeconds / 3600);
+ const minutes = Math.floor((timeInSeconds % 3600) / 60);
+ const seconds = Math.floor(timeInSeconds % 60);
+ const centiseconds = Math.floor((timeInSeconds % 1) * 100);
+ const frames = Math.floor((timeInSeconds % 1) * fps);
+
+ switch (format) {
+ case "MM:SS":
+ return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
+ case "HH:MM:SS":
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
+ case "HH:MM:SS:CS":
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
+ case "HH:MM:SS:FF":
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
+ }
+};
diff --git a/apps/web/src/lib/timeline.ts b/apps/web/src/lib/timeline.ts index 8e7648f4..531defac 100644 --- a/apps/web/src/lib/timeline.ts +++ b/apps/web/src/lib/timeline.ts @@ -1,54 +1,54 @@ -import { TimelineElement } from "@/types/timeline"; - -// Helper function to check for element overlaps and prevent invalid timeline states -export const checkElementOverlaps = (elements: TimelineElement[]): boolean => { - // Sort elements by start time - const sortedElements = [...elements].sort( - (a, b) => a.startTime - b.startTime, - ); - - for (let i = 0; i < sortedElements.length - 1; i++) { - const current = sortedElements[i]; - const next = sortedElements[i + 1]; - - const currentEnd = - current.startTime + - (current.duration - current.trimStart - current.trimEnd); - - // Check if current element overlaps with next element - if (currentEnd > next.startTime) return true; // Overlap detected - } - - return false; // No overlaps -}; - -// Helper function to resolve overlaps by adjusting element positions -export const resolveElementOverlaps = ( - elements: TimelineElement[], -): TimelineElement[] => { - // Sort elements by start time - const sortedElements = [...elements].sort( - (a, b) => a.startTime - b.startTime, - ); - const resolvedElements: TimelineElement[] = []; - - for (let i = 0; i < sortedElements.length; i++) { - const current = { ...sortedElements[i] }; - - if (resolvedElements.length > 0) { - const previous = resolvedElements[resolvedElements.length - 1]; - const previousEnd = - previous.startTime + - (previous.duration - previous.trimStart - previous.trimEnd); - - // If current element would overlap with previous, push it after previous ends - if (current.startTime < previousEnd) { - current.startTime = previousEnd; - } - } - - resolvedElements.push(current); - } - - return resolvedElements; -}; +import { TimelineElement } from "@/types/timeline";
+
+// Helper function to check for element overlaps and prevent invalid timeline states
+export const checkElementOverlaps = (elements: TimelineElement[]): boolean => {
+ // Sort elements by start time
+ const sortedElements = [...elements].sort(
+ (a, b) => a.startTime - b.startTime
+ );
+
+ for (let i = 0; i < sortedElements.length - 1; i++) {
+ const current = sortedElements[i];
+ const next = sortedElements[i + 1];
+
+ const currentEnd =
+ current.startTime +
+ (current.duration - current.trimStart - current.trimEnd);
+
+ // Check if current element overlaps with next element
+ if (currentEnd > next.startTime) return true; // Overlap detected
+ }
+
+ return false; // No overlaps
+};
+
+// Helper function to resolve overlaps by adjusting element positions
+export const resolveElementOverlaps = (
+ elements: TimelineElement[]
+): TimelineElement[] => {
+ // Sort elements by start time
+ const sortedElements = [...elements].sort(
+ (a, b) => a.startTime - b.startTime
+ );
+ const resolvedElements: TimelineElement[] = [];
+
+ for (let i = 0; i < sortedElements.length; i++) {
+ const current = { ...sortedElements[i] };
+
+ if (resolvedElements.length > 0) {
+ const previous = resolvedElements[resolvedElements.length - 1];
+ const previousEnd =
+ previous.startTime +
+ (previous.duration - previous.trimStart - previous.trimEnd);
+
+ // If current element would overlap with previous, push it after previous ends
+ if (current.startTime < previousEnd) {
+ current.startTime = previousEnd;
+ }
+ }
+
+ resolvedElements.push(current);
+ }
+
+ return resolvedElements;
+};
diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 4eb1d409..8d2ee03a 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -4,7 +4,7 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)); } /** @@ -12,65 +12,65 @@ export function cn(...inputs: ClassValue[]) { * Uses crypto.randomUUID() if available, otherwise falls back to a custom implementation */ export function generateUUID(): string { - // Use the native crypto.randomUUID if available - if ( - typeof crypto !== "undefined" && - typeof crypto.randomUUID === "function" - ) { - return crypto.randomUUID(); - } + // Use the native crypto.randomUUID if available + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return crypto.randomUUID(); + } - // Secure fallback using crypto.getRandomValues - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); + // Secure fallback using crypto.getRandomValues + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); - // Set version 4 (UUIDv4) - bytes[6] = (bytes[6] & 0x0f) | 0x40; - // Set variant 10xxxxxx - bytes[8] = (bytes[8] & 0x3f) | 0x80; + // Set version 4 (UUIDv4) + bytes[6] = (bytes[6] & 0x0f) | 0x40; + // Set variant 10xxxxxx + bytes[8] = (bytes[8] & 0x3f) | 0x80; - const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")); + const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")); - return ( - hex.slice(0, 4).join("") + - "-" + - hex.slice(4, 6).join("") + - "-" + - hex.slice(6, 8).join("") + - "-" + - hex.slice(8, 10).join("") + - "-" + - hex.slice(10, 16).join("") - ); + return ( + hex.slice(0, 4).join("") + + "-" + + hex.slice(4, 6).join("") + + "-" + + hex.slice(6, 8).join("") + + "-" + + hex.slice(8, 10).join("") + + "-" + + hex.slice(10, 16).join("") + ); } export function isDOMElement(el: any): el is HTMLElement { - return !!el && (el instanceof Element || el instanceof HTMLElement); + return !!el && (el instanceof Element || el instanceof HTMLElement); } export function isTypableElement(el: HTMLElement): boolean { - // If content editable, then it is editable - if (el.isContentEditable) return true; + // If content editable, then it is editable + if (el.isContentEditable) return true; - // If element is an input and the input is enabled, then it is typable - if (el.tagName === "INPUT") { - return !(el as HTMLInputElement).disabled; - } - // If element is a textarea and the input is enabled, then it is typable - if (el.tagName === "TEXTAREA") { - return !(el as HTMLTextAreaElement).disabled; - } + // If element is an input and the input is enabled, then it is typable + if (el.tagName === "INPUT") { + return !(el as HTMLInputElement).disabled; + } + // If element is a textarea and the input is enabled, then it is typable + if (el.tagName === "TEXTAREA") { + return !(el as HTMLTextAreaElement).disabled; + } - return false; + return false; } export function isAppleDevice() { - return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); + return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); } export function getPlatformSpecialKey() { - return isAppleDevice() ? "⌘" : "Ctrl"; + return isAppleDevice() ? "⌘" : "Ctrl"; } export function getPlatformAlternateKey() { - return isAppleDevice() ? "⌥" : "Alt"; + return isAppleDevice() ? "⌥" : "Alt"; } diff --git a/apps/web/src/lib/waitlist.ts b/apps/web/src/lib/waitlist.ts index dacf2615..22b15b78 100644 --- a/apps/web/src/lib/waitlist.ts +++ b/apps/web/src/lib/waitlist.ts @@ -1,13 +1,13 @@ -import { db, sql, waitlist } from "@opencut/db"; - -export async function getWaitlistCount() { - try { - const result = await db - .select({ count: sql<number>`count(*)` }) - .from(waitlist); - return result[0]?.count || 0; - } catch (error) { - console.error("Failed to fetch waitlist count:", error); - return 0; - } -} +import { db, sql, waitlist } from "@opencut/db";
+
+export async function getWaitlistCount() {
+ try {
+ const result = await db
+ .select({ count: sql<number>`count(*)` })
+ .from(waitlist);
+ return result[0]?.count || 0;
+ } catch (error) {
+ console.error("Failed to fetch waitlist count:", error);
+ return 0;
+ }
+}
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index e6fc3bd2..b571061c 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,48 +1,48 @@ -import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; -import { env } from "./env"; -import { getSessionCookie } from "better-auth/cookies"; - -export async function middleware(request: NextRequest) { - const protectedPaths = ["/editor", "/projects"]; - const sessionCookie = getSessionCookie(request); - - const canAccessProtectedPaths = (request: NextRequest) => { - if (env.NODE_ENV === "development") { - return true; - } - - if (sessionCookie) { - return true; - } - return false; - }; - - // Handle fuckcapcut.com domain redirect - if (request.headers.get("host") === "fuckcapcut.com") { - return NextResponse.redirect("https://opencut.app/why-not-capcut", 301); - } - - const path = request.nextUrl.pathname; - - if (protectedPaths.includes(path) && !canAccessProtectedPaths(request)) { - const homeUrl = new URL("/", request.url); - homeUrl.searchParams.set("redirect", request.url); - return NextResponse.redirect(homeUrl); - } - - return NextResponse.next(); -} - -export const config = { - matcher: [ - /* - * Match all request paths except for the ones starting with: - * - api (API routes) - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico (favicon file) - */ - "/((?!api|_next/static|_next/image|favicon.ico).*)", - ], -}; +import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+import { env } from "./env";
+import { getSessionCookie } from "better-auth/cookies";
+
+export async function middleware(request: NextRequest) {
+ const protectedPaths = ["/editor", "/projects"];
+ const sessionCookie = getSessionCookie(request);
+
+ const canAccessProtectedPaths = (request: NextRequest) => {
+ if (env.NODE_ENV === "development") {
+ return true;
+ }
+
+ if (sessionCookie) {
+ return true;
+ }
+ return false;
+ };
+
+ // Handle fuckcapcut.com domain redirect
+ if (request.headers.get("host") === "fuckcapcut.com") {
+ return NextResponse.redirect("https://opencut.app/why-not-capcut", 301);
+ }
+
+ const path = request.nextUrl.pathname;
+
+ if (protectedPaths.includes(path) && !canAccessProtectedPaths(request)) {
+ const homeUrl = new URL("/", request.url);
+ homeUrl.searchParams.set("redirect", request.url);
+ return NextResponse.redirect(homeUrl);
+ }
+
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: [
+ /*
+ * Match all request paths except for the ones starting with:
+ * - api (API routes)
+ * - _next/static (static files)
+ * - _next/image (image optimization files)
+ * - favicon.ico (favicon file)
+ */
+ "/((?!api|_next/static|_next/image|favicon.ico).*)",
+ ],
+};
diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts index 2b7e6181..c24929a2 100644 --- a/apps/web/src/stores/editor-store.ts +++ b/apps/web/src/stores/editor-store.ts @@ -1,105 +1,105 @@ -import { create } from "zustand"; -import { CanvasSize, CanvasPreset } from "@/types/editor"; - -type CanvasMode = "preset" | "original" | "custom"; - -interface EditorState { - // Loading states - isInitializing: boolean; - isPanelsReady: boolean; - - // Canvas/Project settings - canvasSize: CanvasSize; - canvasMode: CanvasMode; - canvasPresets: CanvasPreset[]; - - // Actions - setInitializing: (loading: boolean) => void; - setPanelsReady: (ready: boolean) => void; - initializeApp: () => Promise<void>; - setCanvasSize: (size: CanvasSize) => void; - setCanvasSizeToOriginal: (aspectRatio: number) => void; - setCanvasSizeFromAspectRatio: (aspectRatio: number) => void; -} - -const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [ - { name: "16:9", width: 1920, height: 1080 }, - { name: "9:16", width: 1080, height: 1920 }, - { name: "1:1", width: 1080, height: 1080 }, - { name: "4:3", width: 1440, height: 1080 }, -]; - -// Helper function to find the best matching canvas preset for an aspect ratio -const findBestCanvasPreset = (aspectRatio: number): CanvasSize => { - // Calculate aspect ratio for each preset and find the closest match - let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD - let smallestDifference = Math.abs( - aspectRatio - bestMatch.width / bestMatch.height, - ); - - for (const preset of DEFAULT_CANVAS_PRESETS) { - const presetAspectRatio = preset.width / preset.height; - const difference = Math.abs(aspectRatio - presetAspectRatio); - - if (difference < smallestDifference) { - smallestDifference = difference; - bestMatch = preset; - } - } - - // If the difference is still significant (> 0.1), create a custom size - // based on the media aspect ratio with a reasonable resolution - const bestAspectRatio = bestMatch.width / bestMatch.height; - if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) { - // Create custom dimensions based on the aspect ratio - if (aspectRatio > 1) { - // Landscape - use 1920 width - return { width: 1920, height: Math.round(1920 / aspectRatio) }; - } else { - // Portrait or square - use 1080 height - return { width: Math.round(1080 * aspectRatio), height: 1080 }; - } - } - - return { width: bestMatch.width, height: bestMatch.height }; -}; - -export const useEditorStore = create<EditorState>((set, get) => ({ - // Initial states - isInitializing: true, - isPanelsReady: false, - canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD - canvasMode: "preset" as CanvasMode, - canvasPresets: DEFAULT_CANVAS_PRESETS, - - // Actions - setInitializing: (loading) => { - set({ isInitializing: loading }); - }, - - setPanelsReady: (ready) => { - set({ isPanelsReady: ready }); - }, - - initializeApp: async () => { - console.log("Initializing video editor..."); - set({ isInitializing: true, isPanelsReady: false }); - - set({ isPanelsReady: true, isInitializing: false }); - console.log("Video editor ready"); - }, - - setCanvasSize: (size) => { - set({ canvasSize: size, canvasMode: "preset" }); - }, - - setCanvasSizeToOriginal: (aspectRatio) => { - const newCanvasSize = findBestCanvasPreset(aspectRatio); - set({ canvasSize: newCanvasSize, canvasMode: "original" }); - }, - - setCanvasSizeFromAspectRatio: (aspectRatio) => { - const newCanvasSize = findBestCanvasPreset(aspectRatio); - set({ canvasSize: newCanvasSize, canvasMode: "custom" }); - }, -})); +import { create } from "zustand";
+import { CanvasSize, CanvasPreset } from "@/types/editor";
+
+type CanvasMode = "preset" | "original" | "custom";
+
+interface EditorState {
+ // Loading states
+ isInitializing: boolean;
+ isPanelsReady: boolean;
+
+ // Canvas/Project settings
+ canvasSize: CanvasSize;
+ canvasMode: CanvasMode;
+ canvasPresets: CanvasPreset[];
+
+ // Actions
+ setInitializing: (loading: boolean) => void;
+ setPanelsReady: (ready: boolean) => void;
+ initializeApp: () => Promise<void>;
+ setCanvasSize: (size: CanvasSize) => void;
+ setCanvasSizeToOriginal: (aspectRatio: number) => void;
+ setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
+}
+
+const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
+ { name: "16:9", width: 1920, height: 1080 },
+ { name: "9:16", width: 1080, height: 1920 },
+ { name: "1:1", width: 1080, height: 1080 },
+ { name: "4:3", width: 1440, height: 1080 },
+];
+
+// Helper function to find the best matching canvas preset for an aspect ratio
+const findBestCanvasPreset = (aspectRatio: number): CanvasSize => {
+ // Calculate aspect ratio for each preset and find the closest match
+ let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
+ let smallestDifference = Math.abs(
+ aspectRatio - bestMatch.width / bestMatch.height
+ );
+
+ for (const preset of DEFAULT_CANVAS_PRESETS) {
+ const presetAspectRatio = preset.width / preset.height;
+ const difference = Math.abs(aspectRatio - presetAspectRatio);
+
+ if (difference < smallestDifference) {
+ smallestDifference = difference;
+ bestMatch = preset;
+ }
+ }
+
+ // If the difference is still significant (> 0.1), create a custom size
+ // based on the media aspect ratio with a reasonable resolution
+ const bestAspectRatio = bestMatch.width / bestMatch.height;
+ if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
+ // Create custom dimensions based on the aspect ratio
+ if (aspectRatio > 1) {
+ // Landscape - use 1920 width
+ return { width: 1920, height: Math.round(1920 / aspectRatio) };
+ } else {
+ // Portrait or square - use 1080 height
+ return { width: Math.round(1080 * aspectRatio), height: 1080 };
+ }
+ }
+
+ return { width: bestMatch.width, height: bestMatch.height };
+};
+
+export const useEditorStore = create<EditorState>((set, get) => ({
+ // Initial states
+ isInitializing: true,
+ isPanelsReady: false,
+ canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
+ canvasMode: "preset" as CanvasMode,
+ canvasPresets: DEFAULT_CANVAS_PRESETS,
+
+ // Actions
+ setInitializing: (loading) => {
+ set({ isInitializing: loading });
+ },
+
+ setPanelsReady: (ready) => {
+ set({ isPanelsReady: ready });
+ },
+
+ initializeApp: async () => {
+ console.log("Initializing video editor...");
+ set({ isInitializing: true, isPanelsReady: false });
+
+ set({ isPanelsReady: true, isInitializing: false });
+ console.log("Video editor ready");
+ },
+
+ setCanvasSize: (size) => {
+ set({ canvasSize: size, canvasMode: "preset" });
+ },
+
+ setCanvasSizeToOriginal: (aspectRatio) => {
+ const newCanvasSize = findBestCanvasPreset(aspectRatio);
+ set({ canvasSize: newCanvasSize, canvasMode: "original" });
+ },
+
+ setCanvasSizeFromAspectRatio: (aspectRatio) => {
+ const newCanvasSize = findBestCanvasPreset(aspectRatio);
+ set({ canvasSize: newCanvasSize, canvasMode: "custom" });
+ },
+}));
diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index b12c83ad..60c71afd 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -8,234 +8,234 @@ import { KeybindingConfig, ShortcutKey } from "@/types/keybinding"; // Default keybindings configuration export const defaultKeybindings: KeybindingConfig = { - space: "toggle-play", - j: "seek-backward", - k: "toggle-play", - l: "seek-forward", - left: "frame-step-backward", - right: "frame-step-forward", - "shift+left": "jump-backward", - "shift+right": "jump-forward", - home: "goto-start", - end: "goto-end", - s: "split-element", - n: "toggle-snapping", - "ctrl+a": "select-all", - "ctrl+d": "duplicate-selected", - "ctrl+z": "undo", - "ctrl+shift+z": "redo", - "ctrl+y": "redo", - delete: "delete-selected", - backspace: "delete-selected", + space: "toggle-play", + j: "seek-backward", + k: "toggle-play", + l: "seek-forward", + left: "frame-step-backward", + right: "frame-step-forward", + "shift+left": "jump-backward", + "shift+right": "jump-forward", + home: "goto-start", + end: "goto-end", + s: "split-element", + n: "toggle-snapping", + "ctrl+a": "select-all", + "ctrl+d": "duplicate-selected", + "ctrl+z": "undo", + "ctrl+shift+z": "redo", + "ctrl+y": "redo", + delete: "delete-selected", + backspace: "delete-selected", }; export interface KeybindingConflict { - key: ShortcutKey; - existingAction: ActionWithOptionalArgs; - newAction: ActionWithOptionalArgs; + key: ShortcutKey; + existingAction: ActionWithOptionalArgs; + newAction: ActionWithOptionalArgs; } interface KeybindingsState { - keybindings: KeybindingConfig; - isCustomized: boolean; - keybindingsEnabled: boolean; + keybindings: KeybindingConfig; + isCustomized: boolean; + keybindingsEnabled: boolean; - // Actions - updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void; - removeKeybinding: (key: ShortcutKey) => void; - resetToDefaults: () => void; - importKeybindings: (config: KeybindingConfig) => void; - exportKeybindings: () => KeybindingConfig; - enableKeybindings: () => void; - disableKeybindings: () => void; + // Actions + updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void; + removeKeybinding: (key: ShortcutKey) => void; + resetToDefaults: () => void; + importKeybindings: (config: KeybindingConfig) => void; + exportKeybindings: () => KeybindingConfig; + enableKeybindings: () => void; + disableKeybindings: () => void; - // Validation - validateKeybinding: ( - key: ShortcutKey, - action: ActionWithOptionalArgs, - ) => KeybindingConflict | null; - getKeybindingsForAction: (action: ActionWithOptionalArgs) => ShortcutKey[]; + // Validation + validateKeybinding: ( + key: ShortcutKey, + action: ActionWithOptionalArgs + ) => KeybindingConflict | null; + getKeybindingsForAction: (action: ActionWithOptionalArgs) => ShortcutKey[]; - // Utility - getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; + // Utility + getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; } export const useKeybindingsStore = create<KeybindingsState>()( - persist( - (set, get) => ({ - keybindings: { ...defaultKeybindings }, - isCustomized: false, - keybindingsEnabled: true, + persist( + (set, get) => ({ + keybindings: { ...defaultKeybindings }, + isCustomized: false, + keybindingsEnabled: true, - updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => { - set((state) => { - const newKeybindings = { ...state.keybindings }; - newKeybindings[key] = action; + updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => { + set((state) => { + const newKeybindings = { ...state.keybindings }; + newKeybindings[key] = action; - return { - keybindings: newKeybindings, - isCustomized: true, - }; - }); - }, + return { + keybindings: newKeybindings, + isCustomized: true, + }; + }); + }, - removeKeybinding: (key: ShortcutKey) => { - set((state) => { - const newKeybindings = { ...state.keybindings }; - delete newKeybindings[key]; + removeKeybinding: (key: ShortcutKey) => { + set((state) => { + const newKeybindings = { ...state.keybindings }; + delete newKeybindings[key]; - return { - keybindings: newKeybindings, - isCustomized: true, - }; - }); - }, + return { + keybindings: newKeybindings, + isCustomized: true, + }; + }); + }, - resetToDefaults: () => { - set({ - keybindings: { ...defaultKeybindings }, - isCustomized: false, - }); - }, + resetToDefaults: () => { + set({ + keybindings: { ...defaultKeybindings }, + isCustomized: false, + }); + }, - enableKeybindings: () => { - set({ keybindingsEnabled: true }); - }, + enableKeybindings: () => { + set({ keybindingsEnabled: true }); + }, - disableKeybindings: () => { - set({ keybindingsEnabled: false }); - }, + disableKeybindings: () => { + set({ keybindingsEnabled: false }); + }, - importKeybindings: (config: KeybindingConfig) => { - // Validate all keys and actions - for (const [key, action] of Object.entries(config)) { - // Validate the key format - if (typeof key !== "string" || key.length === 0) { - throw new Error(`Invalid key format: ${key}`); - } - } - set({ - keybindings: { ...config }, - isCustomized: true, - }); - }, + importKeybindings: (config: KeybindingConfig) => { + // Validate all keys and actions + for (const [key, action] of Object.entries(config)) { + // Validate the key format + if (typeof key !== "string" || key.length === 0) { + throw new Error(`Invalid key format: ${key}`); + } + } + set({ + keybindings: { ...config }, + isCustomized: true, + }); + }, - exportKeybindings: () => { - return get().keybindings; - }, + exportKeybindings: () => { + return get().keybindings; + }, - validateKeybinding: ( - key: ShortcutKey, - action: ActionWithOptionalArgs, - ) => { - const { keybindings } = get(); - const existingAction = keybindings[key]; + validateKeybinding: ( + key: ShortcutKey, + action: ActionWithOptionalArgs + ) => { + const { keybindings } = get(); + const existingAction = keybindings[key]; - if (existingAction && existingAction !== action) { - return { - key, - existingAction, - newAction: action, - }; - } + if (existingAction && existingAction !== action) { + return { + key, + existingAction, + newAction: action, + }; + } - return null; - }, + return null; + }, - getKeybindingsForAction: (action: ActionWithOptionalArgs) => { - const { keybindings } = get(); - return Object.keys(keybindings).filter( - (key) => keybindings[key as ShortcutKey] === action, - ) as ShortcutKey[]; - }, + getKeybindingsForAction: (action: ActionWithOptionalArgs) => { + const { keybindings } = get(); + return Object.keys(keybindings).filter( + (key) => keybindings[key as ShortcutKey] === action + ) as ShortcutKey[]; + }, - getKeybindingString: (ev: KeyboardEvent) => { - return generateKeybindingString(ev) as ShortcutKey | null; - }, - }), - { - name: "opencut-keybindings", - version: 1, - }, - ), + getKeybindingString: (ev: KeyboardEvent) => { + return generateKeybindingString(ev) as ShortcutKey | null; + }, + }), + { + name: "opencut-keybindings", + version: 1, + } + ) ); // Utility functions function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { - const target = ev.target; + const target = ev.target; - // We may or may not have a modifier key - const modifierKey = getActiveModifier(ev); + // We may or may not have a modifier key + const modifierKey = getActiveModifier(ev); - // We will always have a non-modifier key - const key = getPressedKey(ev); - if (!key) return null; + // We will always have a non-modifier key + const key = getPressedKey(ev); + if (!key) return null; - // All key combos backed by modifiers are valid shortcuts (whether currently typing or not) - if (modifierKey) { - // If the modifier is shift and the target is an input, we ignore - if ( - modifierKey === "shift" && - isDOMElement(target) && - isTypableElement(target) - ) { - return null; - } + // All key combos backed by modifiers are valid shortcuts (whether currently typing or not) + if (modifierKey) { + // If the modifier is shift and the target is an input, we ignore + if ( + modifierKey === "shift" && + isDOMElement(target) && + isTypableElement(target) + ) { + return null; + } - return `${modifierKey}+${key}` as ShortcutKey; - } + return `${modifierKey}+${key}` as ShortcutKey; + } - // no modifier key here then we do not do anything while on input - if (isDOMElement(target) && isTypableElement(target)) return null; + // no modifier key here then we do not do anything while on input + if (isDOMElement(target) && isTypableElement(target)) return null; - // single key while not input - return `${key}` as ShortcutKey; + // single key while not input + return `${key}` as ShortcutKey; } function getPressedKey(ev: KeyboardEvent): string | null { - // Sometimes the property code is not available on the KeyboardEvent object - const key = (ev.key ?? "").toLowerCase(); + // Sometimes the property code is not available on the KeyboardEvent object + const key = (ev.key ?? "").toLowerCase(); - // Check arrow keys - if (key.startsWith("arrow")) { - return key.slice(5); - } + // Check arrow keys + if (key.startsWith("arrow")) { + return key.slice(5); + } - // Check for special keys - if (key === "tab") return "tab"; - if (key === " " || key === "space") return "space"; - if (key === "home") return "home"; - if (key === "end") return "end"; - if (key === "delete") return "delete"; - if (key === "backspace") return "backspace"; + // Check for special keys + if (key === "tab") return "tab"; + if (key === " " || key === "space") return "space"; + if (key === "home") return "home"; + if (key === "end") return "end"; + if (key === "delete") return "delete"; + if (key === "backspace") return "backspace"; - // Check letter keys - const isLetter = key.length === 1 && key >= "a" && key <= "z"; - if (isLetter) return key; + // Check letter keys + const isLetter = key.length === 1 && key >= "a" && key <= "z"; + if (isLetter) return key; - // Check if number keys - const isDigit = key.length === 1 && key >= "0" && key <= "9"; - if (isDigit) return key; + // Check if number keys + const isDigit = key.length === 1 && key >= "0" && key <= "9"; + if (isDigit) return key; - // Check if slash, period or enter - if (key === "/" || key === "." || key === "enter") return key; + // Check if slash, period or enter + if (key === "/" || key === "." || key === "enter") return key; - // If no other cases match, this is not a valid key - return null; + // If no other cases match, this is not a valid key + return null; } function getActiveModifier(ev: KeyboardEvent): string | null { - const modifierKeys = { - ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, - alt: ev.altKey, - shift: ev.shiftKey, - }; + const modifierKeys = { + ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, + alt: ev.altKey, + shift: ev.shiftKey, + }; - // active modifier: ctrl | alt | ctrl+alt | ctrl+shift | ctrl+alt+shift | alt+shift - // modiferKeys object's keys are sorted to match the above order - const activeModifier = Object.keys(modifierKeys) - .filter((key) => modifierKeys[key as keyof typeof modifierKeys]) - .join("+"); + // active modifier: ctrl | alt | ctrl+alt | ctrl+shift | ctrl+alt+shift | alt+shift + // modiferKeys object's keys are sorted to match the above order + const activeModifier = Object.keys(modifierKeys) + .filter((key) => modifierKeys[key as keyof typeof modifierKeys]) + .join("+"); - return activeModifier === "" ? null : activeModifier; + return activeModifier === "" ? null : activeModifier; } diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index 305316c8..c4ee5f3e 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -6,288 +6,284 @@ import { generateUUID } from "@/lib/utils"; export type MediaType = "image" | "video" | "audio"; export interface MediaItem { - id: string; - name: string; - type: MediaType; - file: File; - url?: string; // Object URL for preview - thumbnailUrl?: string; // For video thumbnails - duration?: number; // For video/audio duration - width?: number; // For video/image width - height?: number; // For video/image height - fps?: number; // For video frame rate - // Text-specific properties - content?: string; // Text content - fontSize?: number; // Font size - fontFamily?: string; // Font family - color?: string; // Text color - backgroundColor?: string; // Background color - textAlign?: "left" | "center" | "right"; // Text alignment + id: string; + name: string; + type: MediaType; + file: File; + url?: string; // Object URL for preview + thumbnailUrl?: string; // For video thumbnails + duration?: number; // For video/audio duration + width?: number; // For video/image width + height?: number; // For video/image height + fps?: number; // For video frame rate + // Text-specific properties + content?: string; // Text content + fontSize?: number; // Font size + fontFamily?: string; // Font family + color?: string; // Text color + backgroundColor?: string; // Background color + textAlign?: "left" | "center" | "right"; // Text alignment } interface MediaStore { - mediaItems: MediaItem[]; - isLoading: boolean; + mediaItems: MediaItem[]; + isLoading: boolean; - // Actions - now require projectId - addMediaItem: ( - projectId: string, - item: Omit<MediaItem, "id">, - ) => Promise<void>; - removeMediaItem: (projectId: string, id: string) => Promise<void>; - loadProjectMedia: (projectId: string) => Promise<void>; - clearProjectMedia: (projectId: string) => Promise<void>; - clearAllMedia: () => void; // Clear local state only + // Actions - now require projectId + addMediaItem: ( + projectId: string, + item: Omit<MediaItem, "id"> + ) => Promise<void>; + removeMediaItem: (projectId: string, id: string) => Promise<void>; + loadProjectMedia: (projectId: string) => Promise<void>; + clearProjectMedia: (projectId: string) => Promise<void>; + clearAllMedia: () => void; // Clear local state only } // Helper function to determine file type export const getFileType = (file: File): MediaType | null => { - const { type } = file; + const { type } = file; - if (type.startsWith("image/")) { - return "image"; - } - if (type.startsWith("video/")) { - return "video"; - } - if (type.startsWith("audio/")) { - return "audio"; - } + if (type.startsWith("image/")) { + return "image"; + } + if (type.startsWith("video/")) { + return "video"; + } + if (type.startsWith("audio/")) { + return "audio"; + } - return null; + return null; }; // Helper function to get image dimensions export const getImageDimensions = ( - file: File, + file: File ): Promise<{ width: number; height: number }> => { - return new Promise((resolve, reject) => { - const img = new window.Image(); + return new Promise((resolve, reject) => { + const img = new window.Image(); - img.addEventListener("load", () => { - const width = img.naturalWidth; - const height = img.naturalHeight; - resolve({ width, height }); - img.remove(); - }); + img.addEventListener("load", () => { + const width = img.naturalWidth; + const height = img.naturalHeight; + resolve({ width, height }); + img.remove(); + }); - img.addEventListener("error", () => { - reject(new Error("Could not load image")); - img.remove(); - }); + img.addEventListener("error", () => { + reject(new Error("Could not load image")); + img.remove(); + }); - img.src = URL.createObjectURL(file); - }); + img.src = URL.createObjectURL(file); + }); }; // Helper function to generate video thumbnail and get dimensions export const generateVideoThumbnail = ( - file: File, + file: File ): Promise<{ thumbnailUrl: string; width: number; height: number }> => { - return new Promise((resolve, reject) => { - const video = document.createElement("video") as HTMLVideoElement; - const canvas = document.createElement("canvas") as HTMLCanvasElement; - const ctx = canvas.getContext("2d"); + return new Promise((resolve, reject) => { + const video = document.createElement("video") as HTMLVideoElement; + const canvas = document.createElement("canvas") as HTMLCanvasElement; + const ctx = canvas.getContext("2d"); - if (!ctx) { - reject(new Error("Could not get canvas context")); - return; - } + if (!ctx) { + reject(new Error("Could not get canvas context")); + return; + } - video.addEventListener("loadedmetadata", () => { - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; + video.addEventListener("loadedmetadata", () => { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; - // Seek to 1 second or 10% of duration, whichever is smaller - video.currentTime = Math.min(1, video.duration * 0.1); - }); + // Seek to 1 second or 10% of duration, whichever is smaller + video.currentTime = Math.min(1, video.duration * 0.1); + }); - video.addEventListener("seeked", () => { - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); - const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8); - const width = video.videoWidth; - const height = video.videoHeight; + video.addEventListener("seeked", () => { + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8); + const width = video.videoWidth; + const height = video.videoHeight; - resolve({ thumbnailUrl, width, height }); + resolve({ thumbnailUrl, width, height }); - // Cleanup - video.remove(); - canvas.remove(); - }); + // Cleanup + video.remove(); + canvas.remove(); + }); - video.addEventListener("error", () => { - reject(new Error("Could not load video")); - video.remove(); - canvas.remove(); - }); + video.addEventListener("error", () => { + reject(new Error("Could not load video")); + video.remove(); + canvas.remove(); + }); - video.src = URL.createObjectURL(file); - video.load(); - }); + video.src = URL.createObjectURL(file); + video.load(); + }); }; // Helper function to get media duration export const getMediaDuration = (file: File): Promise<number> => { - return new Promise((resolve, reject) => { - const element = document.createElement( - file.type.startsWith("video/") ? "video" : "audio", - ) as HTMLVideoElement; + return new Promise((resolve, reject) => { + const element = document.createElement( + file.type.startsWith("video/") ? "video" : "audio" + ) as HTMLVideoElement; - element.addEventListener("loadedmetadata", () => { - resolve(element.duration); - element.remove(); - }); + element.addEventListener("loadedmetadata", () => { + resolve(element.duration); + element.remove(); + }); - element.addEventListener("error", () => { - reject(new Error("Could not load media")); - element.remove(); - }); + element.addEventListener("error", () => { + reject(new Error("Could not load media")); + element.remove(); + }); - element.src = URL.createObjectURL(file); - element.load(); - }); + element.src = URL.createObjectURL(file); + element.load(); + }); }; // Helper to get aspect ratio from MediaItem export const getMediaAspectRatio = (item: MediaItem): number => { - if (item.width && item.height) { - return item.width / item.height; - } - return 16 / 9; // Default aspect ratio + if (item.width && item.height) { + return item.width / item.height; + } + return 16 / 9; // Default aspect ratio }; export const useMediaStore = create<MediaStore>((set, get) => ({ - mediaItems: [], - isLoading: false, + mediaItems: [], + isLoading: false, - addMediaItem: async (projectId, item) => { - const newItem: MediaItem = { - ...item, - id: generateUUID(), - }; + addMediaItem: async (projectId, item) => { + const newItem: MediaItem = { + ...item, + id: generateUUID(), + }; - // Add to local state immediately for UI responsiveness - set((state) => ({ - mediaItems: [...state.mediaItems, newItem], - })); + // Add to local state immediately for UI responsiveness + set((state) => ({ + mediaItems: [...state.mediaItems, newItem], + })); - // Save to persistent storage in background - try { - await storageService.saveMediaItem(projectId, newItem); - } catch (error) { - console.error("Failed to save media item:", error); - // Remove from local state if save failed - set((state) => ({ - mediaItems: state.mediaItems.filter((media) => media.id !== newItem.id), - })); - } - }, + // Save to persistent storage in background + try { + await storageService.saveMediaItem(projectId, newItem); + } catch (error) { + console.error("Failed to save media item:", error); + // Remove from local state if save failed + set((state) => ({ + mediaItems: state.mediaItems.filter((media) => media.id !== newItem.id), + })); + } + }, - removeMediaItem: async (projectId, id: string) => { - const state = get(); - const item = state.mediaItems.find((media) => media.id === id); + removeMediaItem: async (projectId, id: string) => { + const state = get(); + const item = state.mediaItems.find((media) => media.id === id); - // Cleanup object URLs to prevent memory leaks - if (item && item.url) { - URL.revokeObjectURL(item.url); - if (item.thumbnailUrl) { - URL.revokeObjectURL(item.thumbnailUrl); - } - } + // Cleanup object URLs to prevent memory leaks + if (item && item.url) { + URL.revokeObjectURL(item.url); + if (item.thumbnailUrl) { + URL.revokeObjectURL(item.thumbnailUrl); + } + } - // Remove from local state immediately - set((state) => ({ - mediaItems: state.mediaItems.filter((media) => media.id !== id), - })); + // Remove from local state immediately + set((state) => ({ + mediaItems: state.mediaItems.filter((media) => media.id !== id), + })); - // Remove from persistent storage - try { - await storageService.deleteMediaItem(projectId, id); - } catch (error) { - console.error("Failed to delete media item:", error); - } - }, + // Remove from persistent storage + try { + await storageService.deleteMediaItem(projectId, id); + } catch (error) { + console.error("Failed to delete media item:", error); + } + }, - loadProjectMedia: async (projectId) => { - set({ isLoading: true }); + loadProjectMedia: async (projectId) => { + set({ isLoading: true }); - try { - const mediaItems = await storageService.loadAllMediaItems(projectId); + try { + const mediaItems = await storageService.loadAllMediaItems(projectId); - // Regenerate thumbnails for video items - const updatedMediaItems = await Promise.all( - mediaItems.map(async (item) => { - if (item.type === "video" && item.file) { - try { - const { thumbnailUrl, width, height } = - await generateVideoThumbnail(item.file); - return { - ...item, - thumbnailUrl, - width: width || item.width, - height: height || item.height, - }; - } catch (error) { - console.error( - `Failed to regenerate thumbnail for video ${item.id}:`, - error, - ); - return item; - } - } - return item; - }), - ); + // Regenerate thumbnails for video items + const updatedMediaItems = await Promise.all( + mediaItems.map(async (item) => { + if (item.type === "video" && item.file) { + try { + const { thumbnailUrl, width, height } = await generateVideoThumbnail(item.file); + return { + ...item, + thumbnailUrl, + width: width || item.width, + height: height || item.height + }; + } catch (error) { + console.error(`Failed to regenerate thumbnail for video ${item.id}:`, error); + return item; + } + } + return item; + }) + ); - set({ mediaItems: updatedMediaItems }); - } catch (error) { - console.error("Failed to load media items:", error); - } finally { - set({ isLoading: false }); - } - }, + set({ mediaItems: updatedMediaItems }); + } catch (error) { + console.error("Failed to load media items:", error); + } finally { + set({ isLoading: false }); + } + }, - clearProjectMedia: async (projectId) => { - const state = get(); + clearProjectMedia: async (projectId) => { + const state = get(); - // Cleanup all object URLs - state.mediaItems.forEach((item) => { - if (item.url) { - URL.revokeObjectURL(item.url); - } - if (item.thumbnailUrl) { - URL.revokeObjectURL(item.thumbnailUrl); - } - }); + // Cleanup all object URLs + state.mediaItems.forEach((item) => { + if (item.url) { + URL.revokeObjectURL(item.url); + } + if (item.thumbnailUrl) { + URL.revokeObjectURL(item.thumbnailUrl); + } + }); - // Clear local state - set({ mediaItems: [] }); + // Clear local state + set({ mediaItems: [] }); - // Clear persistent storage - try { - const mediaIds = state.mediaItems.map((item) => item.id); - await Promise.all( - mediaIds.map((id) => storageService.deleteMediaItem(projectId, id)), - ); - } catch (error) { - console.error("Failed to clear media items from storage:", error); - } - }, + // Clear persistent storage + try { + const mediaIds = state.mediaItems.map((item) => item.id); + await Promise.all( + mediaIds.map((id) => storageService.deleteMediaItem(projectId, id)) + ); + } catch (error) { + console.error("Failed to clear media items from storage:", error); + } + }, - clearAllMedia: () => { - const state = get(); + clearAllMedia: () => { + const state = get(); - // Cleanup all object URLs - state.mediaItems.forEach((item) => { - if (item.url) { - URL.revokeObjectURL(item.url); - } - if (item.thumbnailUrl) { - URL.revokeObjectURL(item.thumbnailUrl); - } - }); + // Cleanup all object URLs + state.mediaItems.forEach((item) => { + if (item.url) { + URL.revokeObjectURL(item.url); + } + if (item.thumbnailUrl) { + URL.revokeObjectURL(item.thumbnailUrl); + } + }); - // Clear local state - set({ mediaItems: [] }); - }, + // Clear local state + set({ mediaItems: [] }); + }, })); diff --git a/apps/web/src/stores/panel-store.ts b/apps/web/src/stores/panel-store.ts index 8360ad57..450b6a6e 100644 --- a/apps/web/src/stores/panel-store.ts +++ b/apps/web/src/stores/panel-store.ts @@ -1,45 +1,45 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; - -const DEFAULT_PANEL_SIZES = { - toolsPanel: 45, - previewPanel: 75, - propertiesPanel: 20, - mainContent: 70, - timeline: 30, -} as const; - -interface PanelState { - // Panel sizes as percentages - toolsPanel: number; - previewPanel: number; - propertiesPanel: number; - mainContent: number; - timeline: number; - - // Actions - setToolsPanel: (size: number) => void; - setPreviewPanel: (size: number) => void; - setPropertiesPanel: (size: number) => void; - setMainContent: (size: number) => void; - setTimeline: (size: number) => void; -} - -export const usePanelStore = create<PanelState>()( - persist( - (set) => ({ - // Default sizes - optimized for responsiveness - ...DEFAULT_PANEL_SIZES, - - // Actions - setToolsPanel: (size) => set({ toolsPanel: size }), - setPreviewPanel: (size) => set({ previewPanel: size }), - setPropertiesPanel: (size) => set({ propertiesPanel: size }), - setMainContent: (size) => set({ mainContent: size }), - setTimeline: (size) => set({ timeline: size }), - }), - { - name: "panel-sizes", - }, - ), -); +import { create } from "zustand";
+import { persist } from "zustand/middleware";
+
+const DEFAULT_PANEL_SIZES = {
+ toolsPanel: 45,
+ previewPanel: 75,
+ propertiesPanel: 20,
+ mainContent: 70,
+ timeline: 30,
+} as const;
+
+interface PanelState {
+ // Panel sizes as percentages
+ toolsPanel: number;
+ previewPanel: number;
+ propertiesPanel: number;
+ mainContent: number;
+ timeline: number;
+
+ // Actions
+ setToolsPanel: (size: number) => void;
+ setPreviewPanel: (size: number) => void;
+ setPropertiesPanel: (size: number) => void;
+ setMainContent: (size: number) => void;
+ setTimeline: (size: number) => void;
+}
+
+export const usePanelStore = create<PanelState>()(
+ persist(
+ (set) => ({
+ // Default sizes - optimized for responsiveness
+ ...DEFAULT_PANEL_SIZES,
+
+ // Actions
+ setToolsPanel: (size) => set({ toolsPanel: size }),
+ setPreviewPanel: (size) => set({ previewPanel: size }),
+ setPropertiesPanel: (size) => set({ propertiesPanel: size }),
+ setMainContent: (size) => set({ mainContent: size }),
+ setTimeline: (size) => set({ timeline: size }),
+ }),
+ {
+ name: "panel-sizes",
+ }
+ )
+);
diff --git a/apps/web/src/stores/playback-store.ts b/apps/web/src/stores/playback-store.ts index 3ef1d95c..543865f9 100644 --- a/apps/web/src/stores/playback-store.ts +++ b/apps/web/src/stores/playback-store.ts @@ -2,133 +2,133 @@ import { create } from "zustand"; import type { PlaybackState, PlaybackControls } from "@/types/playback"; interface PlaybackStore extends PlaybackState, PlaybackControls { - setDuration: (duration: number) => void; - setCurrentTime: (time: number) => void; + setDuration: (duration: number) => void; + setCurrentTime: (time: number) => void; } let playbackTimer: number | null = null; const startTimer = (store: () => PlaybackStore) => { - if (playbackTimer) cancelAnimationFrame(playbackTimer); + if (playbackTimer) cancelAnimationFrame(playbackTimer); - // Use requestAnimationFrame for smoother updates - const updateTime = () => { - const state = store(); - if (state.isPlaying && state.currentTime < state.duration) { - const now = performance.now(); - const delta = (now - lastUpdate) / 1000; // Convert to seconds - lastUpdate = now; + // Use requestAnimationFrame for smoother updates + const updateTime = () => { + const state = store(); + if (state.isPlaying && state.currentTime < state.duration) { + const now = performance.now(); + const delta = (now - lastUpdate) / 1000; // Convert to seconds + lastUpdate = now; - const newTime = state.currentTime + delta * state.speed; - if (newTime >= state.duration) { - // When video completes, pause and reset playhead to start - state.pause(); - state.setCurrentTime(0); - // Notify video elements to sync with reset - window.dispatchEvent( - new CustomEvent("playback-seek", { detail: { time: 0 } }), - ); - } else { - state.setCurrentTime(newTime); - // Notify video elements to sync - window.dispatchEvent( - new CustomEvent("playback-update", { detail: { time: newTime } }), - ); - } - } - playbackTimer = requestAnimationFrame(updateTime); - }; + const newTime = state.currentTime + delta * state.speed; + if (newTime >= state.duration) { + // When video completes, pause and reset playhead to start + state.pause(); + state.setCurrentTime(0); + // Notify video elements to sync with reset + window.dispatchEvent( + new CustomEvent("playback-seek", { detail: { time: 0 } }) + ); + } else { + state.setCurrentTime(newTime); + // Notify video elements to sync + window.dispatchEvent( + new CustomEvent("playback-update", { detail: { time: newTime } }) + ); + } + } + playbackTimer = requestAnimationFrame(updateTime); + }; - let lastUpdate = performance.now(); - playbackTimer = requestAnimationFrame(updateTime); + let lastUpdate = performance.now(); + playbackTimer = requestAnimationFrame(updateTime); }; const stopTimer = () => { - if (playbackTimer) { - cancelAnimationFrame(playbackTimer); - playbackTimer = null; - } + if (playbackTimer) { + cancelAnimationFrame(playbackTimer); + playbackTimer = null; + } }; export const usePlaybackStore = create<PlaybackStore>((set, get) => ({ - isPlaying: false, - currentTime: 0, - duration: 0, - volume: 1, - muted: false, - previousVolume: 1, - speed: 1.0, + isPlaying: false, + currentTime: 0, + duration: 0, + volume: 1, + muted: false, + previousVolume: 1, + speed: 1.0, - play: () => { - set({ isPlaying: true }); - startTimer(get); - }, + play: () => { + set({ isPlaying: true }); + startTimer(get); + }, - pause: () => { - set({ isPlaying: false }); - stopTimer(); - }, + pause: () => { + set({ isPlaying: false }); + stopTimer(); + }, - toggle: () => { - const { isPlaying } = get(); - if (isPlaying) { - get().pause(); - } else { - get().play(); - } - }, + toggle: () => { + const { isPlaying } = get(); + if (isPlaying) { + get().pause(); + } else { + get().play(); + } + }, - seek: (time: number) => { - const { duration } = get(); - const clampedTime = Math.max(0, Math.min(duration, time)); - set({ currentTime: clampedTime }); + seek: (time: number) => { + const { duration } = get(); + const clampedTime = Math.max(0, Math.min(duration, time)); + set({ currentTime: clampedTime }); - const event = new CustomEvent("playback-seek", { - detail: { time: clampedTime }, - }); - window.dispatchEvent(event); - }, + const event = new CustomEvent("playback-seek", { + detail: { time: clampedTime }, + }); + window.dispatchEvent(event); + }, - setVolume: (volume: number) => - set((state) => ({ - volume: Math.max(0, Math.min(1, volume)), - muted: volume === 0, - previousVolume: volume > 0 ? volume : state.previousVolume, - })), + setVolume: (volume: number) => + set((state) => ({ + volume: Math.max(0, Math.min(1, volume)), + muted: volume === 0, + previousVolume: volume > 0 ? volume : state.previousVolume, + })), - setSpeed: (speed: number) => { - const newSpeed = Math.max(0.1, Math.min(2.0, speed)); - set({ speed: newSpeed }); + setSpeed: (speed: number) => { + const newSpeed = Math.max(0.1, Math.min(2.0, speed)); + set({ speed: newSpeed }); - const event = new CustomEvent("playback-speed", { - detail: { speed: newSpeed }, - }); - window.dispatchEvent(event); - }, + const event = new CustomEvent("playback-speed", { + detail: { speed: newSpeed }, + }); + window.dispatchEvent(event); + }, - setDuration: (duration: number) => set({ duration }), - setCurrentTime: (time: number) => set({ currentTime: time }), + setDuration: (duration: number) => set({ duration }), + setCurrentTime: (time: number) => set({ currentTime: time }), - mute: () => { - const { volume, previousVolume } = get(); - set({ - muted: true, - previousVolume: volume > 0 ? volume : previousVolume, - volume: 0, - }); - }, + mute: () => { + const { volume, previousVolume } = get(); + set({ + muted: true, + previousVolume: volume > 0 ? volume : previousVolume, + volume: 0, + }); + }, - unmute: () => { - const { previousVolume } = get(); - set({ muted: false, volume: previousVolume ?? 1 }); - }, + unmute: () => { + const { previousVolume } = get(); + set({ muted: false, volume: previousVolume ?? 1 }); + }, - toggleMute: () => { - const { muted } = get(); - if (muted) { - get().unmute(); - } else { - get().mute(); - } - }, + toggleMute: () => { + const { muted } = get(); + if (muted) { + get().unmute(); + } else { + get().mute(); + } + }, })); diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts index 36ab3503..a8833982 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -1,361 +1,361 @@ -import { TProject } from "@/types/project"; -import { create } from "zustand"; -import { storageService } from "@/lib/storage/storage-service"; -import { toast } from "sonner"; -import { useMediaStore } from "./media-store"; -import { useTimelineStore } from "./timeline-store"; -import { generateUUID } from "@/lib/utils"; - -interface ProjectStore { - activeProject: TProject | null; - savedProjects: TProject[]; - isLoading: boolean; - isInitialized: boolean; - - // Actions - createNewProject: (name: string) => Promise<string>; - loadProject: (id: string) => Promise<void>; - saveCurrentProject: () => Promise<void>; - loadAllProjects: () => Promise<void>; - deleteProject: (id: string) => Promise<void>; - closeProject: () => void; - renameProject: (projectId: string, name: string) => Promise<void>; - duplicateProject: (projectId: string) => Promise<string>; - updateProjectBackground: (backgroundColor: string) => Promise<void>; - updateBackgroundType: ( - type: "color" | "blur", - options?: { backgroundColor?: string; blurIntensity?: number }, - ) => Promise<void>; - updateProjectFps: (fps: number) => Promise<void>; - - getFilteredAndSortedProjects: ( - searchQuery: string, - sortOption: string, - ) => TProject[]; -} - -export const useProjectStore = create<ProjectStore>((set, get) => ({ - activeProject: null, - savedProjects: [], - isLoading: true, - isInitialized: false, - - createNewProject: async (name: string) => { - const newProject: TProject = { - id: generateUUID(), - name, - thumbnail: "", - createdAt: new Date(), - updatedAt: new Date(), - backgroundColor: "#000000", - backgroundType: "color", - blurIntensity: 8, - }; - - set({ activeProject: newProject }); - - try { - await storageService.saveProject(newProject); - // Reload all projects to update the list - await get().loadAllProjects(); - return newProject.id; - } catch (error) { - toast.error("Failed to save new project"); - throw error; - } - }, - - loadProject: async (id: string) => { - if (!get().isInitialized) { - set({ isLoading: true }); - } - - // Clear media and timeline immediately to prevent flickering when switching projects - const mediaStore = useMediaStore.getState(); - const timelineStore = useTimelineStore.getState(); - mediaStore.clearAllMedia(); - timelineStore.clearTimeline(); - - try { - const project = await storageService.loadProject(id); - if (project) { - set({ activeProject: project }); - - // Load project-specific data in parallel - await Promise.all([ - mediaStore.loadProjectMedia(id), - timelineStore.loadProjectTimeline(id), - ]); - } else { - throw new Error(`Project with id ${id} not found`); - } - } catch (error) { - console.error("Failed to load project:", error); - throw error; // Re-throw so the editor page can handle it - } finally { - set({ isLoading: false }); - } - }, - - saveCurrentProject: async () => { - const { activeProject } = get(); - if (!activeProject) return; - - try { - // Save project metadata and timeline data in parallel - const timelineStore = useTimelineStore.getState(); - await Promise.all([ - storageService.saveProject(activeProject), - timelineStore.saveProjectTimeline(activeProject.id), - ]); - await get().loadAllProjects(); // Refresh the list - } catch (error) { - console.error("Failed to save project:", error); - } - }, - - loadAllProjects: async () => { - if (!get().isInitialized) { - set({ isLoading: true }); - } - - try { - const projects = await storageService.loadAllProjects(); - set({ savedProjects: projects }); - } catch (error) { - console.error("Failed to load projects:", error); - } finally { - set({ isLoading: false, isInitialized: true }); - } - }, - - deleteProject: async (id: string) => { - try { - // Delete project data in parallel - await Promise.all([ - storageService.deleteProjectMedia(id), - storageService.deleteProjectTimeline(id), - storageService.deleteProject(id), - ]); - await get().loadAllProjects(); // Refresh the list - - // If we deleted the active project, close it and clear data - const { activeProject } = get(); - if (activeProject?.id === id) { - set({ activeProject: null }); - const mediaStore = useMediaStore.getState(); - const timelineStore = useTimelineStore.getState(); - mediaStore.clearAllMedia(); - timelineStore.clearTimeline(); - } - } catch (error) { - console.error("Failed to delete project:", error); - } - }, - - closeProject: () => { - set({ activeProject: null }); - - // Clear data from stores when closing project - const mediaStore = useMediaStore.getState(); - const timelineStore = useTimelineStore.getState(); - mediaStore.clearAllMedia(); - timelineStore.clearTimeline(); - }, - - renameProject: async (id: string, name: string) => { - const { savedProjects } = get(); - - // Find the project to rename - const projectToRename = savedProjects.find((p) => p.id === id); - if (!projectToRename) { - toast.error("Project not found", { - description: "Please try again", - }); - return; - } - - const updatedProject = { - ...projectToRename, - name, - updatedAt: new Date(), - }; - - try { - // Save to storage - await storageService.saveProject(updatedProject); - - await get().loadAllProjects(); - - // Update activeProject if it's the same project - const { activeProject } = get(); - if (activeProject?.id === id) { - set({ activeProject: updatedProject }); - } - } catch (error) { - console.error("Failed to rename project:", error); - toast.error("Failed to rename project", { - description: - error instanceof Error ? error.message : "Please try again", - }); - } - }, - - duplicateProject: async (projectId: string) => { - try { - const project = await storageService.loadProject(projectId); - if (!project) { - toast.error("Project not found", { - description: "Please try again", - }); - throw new Error("Project not found"); - } - - const { savedProjects } = get(); - - // Extract the base name (remove any existing numbering) - const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/); - const baseName = numberMatch ? numberMatch[2] : project.name; - const existingNumbers: number[] = []; - - // Check for pattern "(number) baseName" in existing projects - savedProjects.forEach((p) => { - const match = p.name.match(/^\((\d+)\)\s+(.+)$/); - if (match && match[2] === baseName) { - existingNumbers.push(parseInt(match[1], 10)); - } - }); - - const nextNumber = - existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1; - - const newProject: TProject = { - id: generateUUID(), - name: `(${nextNumber}) ${baseName}`, - thumbnail: project.thumbnail, - createdAt: new Date(), - updatedAt: new Date(), - }; - - await storageService.saveProject(newProject); - await get().loadAllProjects(); - return newProject.id; - } catch (error) { - console.error("Failed to duplicate project:", error); - toast.error("Failed to duplicate project", { - description: - error instanceof Error ? error.message : "Please try again", - }); - throw error; - } - }, - - updateProjectBackground: async (backgroundColor: string) => { - const { activeProject } = get(); - if (!activeProject) return; - - const updatedProject = { - ...activeProject, - backgroundColor, - updatedAt: new Date(), - }; - - try { - await storageService.saveProject(updatedProject); - set({ activeProject: updatedProject }); - await get().loadAllProjects(); // Refresh the list - } catch (error) { - console.error("Failed to update project background:", error); - toast.error("Failed to update background", { - description: "Please try again", - }); - } - }, - - updateBackgroundType: async ( - type: "color" | "blur", - options?: { backgroundColor?: string; blurIntensity?: number }, - ) => { - const { activeProject } = get(); - if (!activeProject) return; - - const updatedProject = { - ...activeProject, - backgroundType: type, - ...(options?.backgroundColor && { - backgroundColor: options.backgroundColor, - }), - ...(options?.blurIntensity && { blurIntensity: options.blurIntensity }), - updatedAt: new Date(), - }; - - try { - await storageService.saveProject(updatedProject); - set({ activeProject: updatedProject }); - await get().loadAllProjects(); // Refresh the list - } catch (error) { - console.error("Failed to update background type:", error); - toast.error("Failed to update background", { - description: "Please try again", - }); - } - }, - - updateProjectFps: async (fps: number) => { - const { activeProject } = get(); - if (!activeProject) return; - - const updatedProject = { - ...activeProject, - fps, - updatedAt: new Date(), - }; - - try { - await storageService.saveProject(updatedProject); - set({ activeProject: updatedProject }); - await get().loadAllProjects(); // Refresh the list - } catch (error) { - console.error("Failed to update project FPS:", error); - toast.error("Failed to update project FPS", { - description: "Please try again", - }); - } - }, - - getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => { - const { savedProjects } = get(); - - // Filter projects by search query - const filteredProjects = savedProjects.filter((project) => - project.name.toLowerCase().includes(searchQuery.toLowerCase()), - ); - - // Sort filtered projects - const sortedProjects = [...filteredProjects].sort((a, b) => { - const [key, order] = sortOption.split("-"); - - if (key !== "createdAt" && key !== "name") { - console.warn(`Invalid sort key: ${key}`); - return 0; - } - - const aValue = a[key]; - const bValue = b[key]; - - if (aValue === undefined || bValue === undefined) return 0; - - if (order === "asc") { - if (aValue < bValue) return -1; - if (aValue > bValue) return 1; - return 0; - } else { - if (aValue > bValue) return -1; - if (aValue < bValue) return 1; - return 0; - } - }); - - return sortedProjects; - }, -})); +import { TProject } from "@/types/project";
+import { create } from "zustand";
+import { storageService } from "@/lib/storage/storage-service";
+import { toast } from "sonner";
+import { useMediaStore } from "./media-store";
+import { useTimelineStore } from "./timeline-store";
+import { generateUUID } from "@/lib/utils";
+
+interface ProjectStore {
+ activeProject: TProject | null;
+ savedProjects: TProject[];
+ isLoading: boolean;
+ isInitialized: boolean;
+
+ // Actions
+ createNewProject: (name: string) => Promise<string>;
+ loadProject: (id: string) => Promise<void>;
+ saveCurrentProject: () => Promise<void>;
+ loadAllProjects: () => Promise<void>;
+ deleteProject: (id: string) => Promise<void>;
+ closeProject: () => void;
+ renameProject: (projectId: string, name: string) => Promise<void>;
+ duplicateProject: (projectId: string) => Promise<string>;
+ updateProjectBackground: (backgroundColor: string) => Promise<void>;
+ updateBackgroundType: (
+ type: "color" | "blur",
+ options?: { backgroundColor?: string; blurIntensity?: number }
+ ) => Promise<void>;
+ updateProjectFps: (fps: number) => Promise<void>;
+
+ getFilteredAndSortedProjects: (
+ searchQuery: string,
+ sortOption: string
+ ) => TProject[];
+}
+
+export const useProjectStore = create<ProjectStore>((set, get) => ({
+ activeProject: null,
+ savedProjects: [],
+ isLoading: true,
+ isInitialized: false,
+
+ createNewProject: async (name: string) => {
+ const newProject: TProject = {
+ id: generateUUID(),
+ name,
+ thumbnail: "",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ backgroundColor: "#000000",
+ backgroundType: "color",
+ blurIntensity: 8,
+ };
+
+ set({ activeProject: newProject });
+
+ try {
+ await storageService.saveProject(newProject);
+ // Reload all projects to update the list
+ await get().loadAllProjects();
+ return newProject.id;
+ } catch (error) {
+ toast.error("Failed to save new project");
+ throw error;
+ }
+ },
+
+ loadProject: async (id: string) => {
+ if (!get().isInitialized) {
+ set({ isLoading: true });
+ }
+
+ // Clear media and timeline immediately to prevent flickering when switching projects
+ const mediaStore = useMediaStore.getState();
+ const timelineStore = useTimelineStore.getState();
+ mediaStore.clearAllMedia();
+ timelineStore.clearTimeline();
+
+ try {
+ const project = await storageService.loadProject(id);
+ if (project) {
+ set({ activeProject: project });
+
+ // Load project-specific data in parallel
+ await Promise.all([
+ mediaStore.loadProjectMedia(id),
+ timelineStore.loadProjectTimeline(id),
+ ]);
+ } else {
+ throw new Error(`Project with id ${id} not found`);
+ }
+ } catch (error) {
+ console.error("Failed to load project:", error);
+ throw error; // Re-throw so the editor page can handle it
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ saveCurrentProject: async () => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ try {
+ // Save project metadata and timeline data in parallel
+ const timelineStore = useTimelineStore.getState();
+ await Promise.all([
+ storageService.saveProject(activeProject),
+ timelineStore.saveProjectTimeline(activeProject.id),
+ ]);
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to save project:", error);
+ }
+ },
+
+ loadAllProjects: async () => {
+ if (!get().isInitialized) {
+ set({ isLoading: true });
+ }
+
+ try {
+ const projects = await storageService.loadAllProjects();
+ set({ savedProjects: projects });
+ } catch (error) {
+ console.error("Failed to load projects:", error);
+ } finally {
+ set({ isLoading: false, isInitialized: true });
+ }
+ },
+
+ deleteProject: async (id: string) => {
+ try {
+ // Delete project data in parallel
+ await Promise.all([
+ storageService.deleteProjectMedia(id),
+ storageService.deleteProjectTimeline(id),
+ storageService.deleteProject(id),
+ ]);
+ await get().loadAllProjects(); // Refresh the list
+
+ // If we deleted the active project, close it and clear data
+ const { activeProject } = get();
+ if (activeProject?.id === id) {
+ set({ activeProject: null });
+ const mediaStore = useMediaStore.getState();
+ const timelineStore = useTimelineStore.getState();
+ mediaStore.clearAllMedia();
+ timelineStore.clearTimeline();
+ }
+ } catch (error) {
+ console.error("Failed to delete project:", error);
+ }
+ },
+
+ closeProject: () => {
+ set({ activeProject: null });
+
+ // Clear data from stores when closing project
+ const mediaStore = useMediaStore.getState();
+ const timelineStore = useTimelineStore.getState();
+ mediaStore.clearAllMedia();
+ timelineStore.clearTimeline();
+ },
+
+ renameProject: async (id: string, name: string) => {
+ const { savedProjects } = get();
+
+ // Find the project to rename
+ const projectToRename = savedProjects.find((p) => p.id === id);
+ if (!projectToRename) {
+ toast.error("Project not found", {
+ description: "Please try again",
+ });
+ return;
+ }
+
+ const updatedProject = {
+ ...projectToRename,
+ name,
+ updatedAt: new Date(),
+ };
+
+ try {
+ // Save to storage
+ await storageService.saveProject(updatedProject);
+
+ await get().loadAllProjects();
+
+ // Update activeProject if it's the same project
+ const { activeProject } = get();
+ if (activeProject?.id === id) {
+ set({ activeProject: updatedProject });
+ }
+ } catch (error) {
+ console.error("Failed to rename project:", error);
+ toast.error("Failed to rename project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ }
+ },
+
+ duplicateProject: async (projectId: string) => {
+ try {
+ const project = await storageService.loadProject(projectId);
+ if (!project) {
+ toast.error("Project not found", {
+ description: "Please try again",
+ });
+ throw new Error("Project not found");
+ }
+
+ const { savedProjects } = get();
+
+ // Extract the base name (remove any existing numbering)
+ const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
+ const baseName = numberMatch ? numberMatch[2] : project.name;
+ const existingNumbers: number[] = [];
+
+ // Check for pattern "(number) baseName" in existing projects
+ savedProjects.forEach((p) => {
+ const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
+ if (match && match[2] === baseName) {
+ existingNumbers.push(parseInt(match[1], 10));
+ }
+ });
+
+ const nextNumber =
+ existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
+
+ const newProject: TProject = {
+ id: generateUUID(),
+ name: `(${nextNumber}) ${baseName}`,
+ thumbnail: project.thumbnail,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+
+ await storageService.saveProject(newProject);
+ await get().loadAllProjects();
+ return newProject.id;
+ } catch (error) {
+ console.error("Failed to duplicate project:", error);
+ toast.error("Failed to duplicate project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ throw error;
+ }
+ },
+
+ updateProjectBackground: async (backgroundColor: string) => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ const updatedProject = {
+ ...activeProject,
+ backgroundColor,
+ updatedAt: new Date(),
+ };
+
+ try {
+ await storageService.saveProject(updatedProject);
+ set({ activeProject: updatedProject });
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to update project background:", error);
+ toast.error("Failed to update background", {
+ description: "Please try again",
+ });
+ }
+ },
+
+ updateBackgroundType: async (
+ type: "color" | "blur",
+ options?: { backgroundColor?: string; blurIntensity?: number }
+ ) => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ const updatedProject = {
+ ...activeProject,
+ backgroundType: type,
+ ...(options?.backgroundColor && {
+ backgroundColor: options.backgroundColor,
+ }),
+ ...(options?.blurIntensity && { blurIntensity: options.blurIntensity }),
+ updatedAt: new Date(),
+ };
+
+ try {
+ await storageService.saveProject(updatedProject);
+ set({ activeProject: updatedProject });
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to update background type:", error);
+ toast.error("Failed to update background", {
+ description: "Please try again",
+ });
+ }
+ },
+
+ updateProjectFps: async (fps: number) => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ const updatedProject = {
+ ...activeProject,
+ fps,
+ updatedAt: new Date(),
+ };
+
+ try {
+ await storageService.saveProject(updatedProject);
+ set({ activeProject: updatedProject });
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to update project FPS:", error);
+ toast.error("Failed to update project FPS", {
+ description: "Please try again",
+ });
+ }
+ },
+
+ getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
+ const { savedProjects } = get();
+
+ // Filter projects by search query
+ const filteredProjects = savedProjects.filter((project) =>
+ project.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ // Sort filtered projects
+ const sortedProjects = [...filteredProjects].sort((a, b) => {
+ const [key, order] = sortOption.split("-");
+
+ if (key !== "createdAt" && key !== "name") {
+ console.warn(`Invalid sort key: ${key}`);
+ return 0;
+ }
+
+ const aValue = a[key];
+ const bValue = b[key];
+
+ if (aValue === undefined || bValue === undefined) return 0;
+
+ if (order === "asc") {
+ if (aValue < bValue) return -1;
+ if (aValue > bValue) return 1;
+ return 0;
+ } else {
+ if (aValue > bValue) return -1;
+ if (aValue < bValue) return 1;
+ return 0;
+ }
+ });
+
+ return sortedProjects;
+ },
+}));
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 25a56943..ca4da916 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -1,27 +1,28 @@ +import { toast } from "sonner"; import { create } from "zustand"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { storageService } from "@/lib/storage/storage-service"; +import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline"; +import { generateUUID } from "@/lib/utils"; import { - TrackType, - TimelineElement, - CreateTimelineElement, - TimelineTrack, - TextElement, - DragData, - sortTracksByOrder, + type CreateTimelineElement, + type DragData, ensureMainTrack, + sortTracksByOrder, + type TextElement, + type TimelineElement, + type TimelineTrack, + type TrackType, validateElementTrackCompatibility, } from "@/types/timeline"; import { useEditorStore } from "./editor-store"; import { - useMediaStore, getMediaAspectRatio, type MediaItem, + type MediaType, + useMediaStore, } from "./media-store"; -import { storageService } from "@/lib/storage/storage-service"; import { useProjectStore } from "./project-store"; -import { generateUUID } from "@/lib/utils"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { toast } from "sonner"; -import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline"; // Helper function to manage element naming with suffixes const getElementNameWithSuffix = ( @@ -782,7 +783,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { // For ripple editing, we need to move elements that come after the moved element const currentElementStart = currentElement.startTime; - const currentElementEnd = + const _currentElementEnd = currentElement.startTime + (currentElement.duration - currentElement.trimStart - @@ -1072,7 +1073,16 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { if (!fileType) return false; // Process the new media file - let mediaData: any = { + const mediaData: { + name: string; + type: MediaType; + file: File; + url: string; + duration?: number; + thumbnailUrl?: string; + width?: number; + height?: number; + } = { name: newFile.name, type: fileType, file: newFile, diff --git a/apps/web/src/types/editor.ts b/apps/web/src/types/editor.ts index d09e1912..dce7ac50 100644 --- a/apps/web/src/types/editor.ts +++ b/apps/web/src/types/editor.ts @@ -1,12 +1,12 @@ -export type BackgroundType = "blur" | "mirror" | "color"; - -export interface CanvasSize { - width: number; - height: number; -} - -export interface CanvasPreset { - name: string; - width: number; - height: number; -} +export type BackgroundType = "blur" | "mirror" | "color";
+
+export interface CanvasSize {
+ width: number;
+ height: number;
+}
+
+export interface CanvasPreset {
+ name: string;
+ width: number;
+ height: number;
+}
diff --git a/apps/web/src/types/keybinding.ts b/apps/web/src/types/keybinding.ts index c66a04dd..d97c88da 100644 --- a/apps/web/src/types/keybinding.ts +++ b/apps/web/src/types/keybinding.ts @@ -5,69 +5,23 @@ import { ActionWithOptionalArgs } from "@/constants/actions"; * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!) */ export type ModifierKeys = - | "ctrl" - | "alt" - | "shift" - | "ctrl+shift" - | "alt+shift" - | "ctrl+alt" - | "ctrl+alt+shift"; + | "ctrl" + | "alt" + | "shift" + | "ctrl+shift" + | "alt+shift" + | "ctrl+alt" + | "ctrl+alt+shift"; /* eslint-disable prettier/prettier */ // prettier-ignore export type Key = - | "a" - | "b" - | "c" - | "d" - | "e" - | "f" - | "g" - | "h" - | "i" - | "j" - | "k" - | "l" - | "m" - | "n" - | "o" - | "p" - | "q" - | "r" - | "s" - | "t" - | "u" - | "v" - | "w" - | "x" - | "y" - | "z" - | "0" - | "1" - | "2" - | "3" - | "4" - | "5" - | "6" - | "7" - | "8" - | "9" - | "up" - | "down" - | "left" - | "right" - | "/" - | "?" - | "." - | "enter" - | "tab" - | "space" - | "escape" - | "esc" - | "backspace" - | "delete" - | "home" - | "end"; + | "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" + | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" + | "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3" + | "4" | "5" | "6" | "7" | "8" | "9" | "up" | "down" | "left" + | "right" | "/" | "?" | "." | "enter" | "tab" | "space" + | "escape" | "esc" | "backspace" | "delete" | "home" | "end" /* eslint-enable */ export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`; @@ -77,5 +31,5 @@ export type SingleCharacterShortcutKey = `${Key}`; export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey; export type KeybindingConfig = { - [key in ShortcutKey]?: ActionWithOptionalArgs; + [key in ShortcutKey]?: ActionWithOptionalArgs; }; diff --git a/apps/web/src/types/playback.ts b/apps/web/src/types/playback.ts index 18c1e099..8758ea03 100644 --- a/apps/web/src/types/playback.ts +++ b/apps/web/src/types/playback.ts @@ -1,21 +1,21 @@ export interface PlaybackState { - isPlaying: boolean; - currentTime: number; - duration: number; - volume: number; - speed: number; - muted: boolean; - previousVolume?: number; + isPlaying: boolean; + currentTime: number; + duration: number; + volume: number; + speed: number; + muted: boolean; + previousVolume?: number; } export interface PlaybackControls { - play: () => void; - pause: () => void; - seek: (time: number) => void; - setVolume: (volume: number) => void; - setSpeed: (speed: number) => void; - toggle: () => void; - mute: () => void; - unmute: () => void; - toggleMute: () => void; + play: () => void; + pause: () => void; + seek: (time: number) => void; + setVolume: (volume: number) => void; + setSpeed: (speed: number) => void; + toggle: () => void; + mute: () => void; + unmute: () => void; + toggleMute: () => void; } diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts index b3ab1332..58d37b03 100644 --- a/apps/web/src/types/project.ts +++ b/apps/web/src/types/project.ts @@ -1,12 +1,12 @@ export interface TProject { - id: string; - name: string; - thumbnail: string; - createdAt: Date; - updatedAt: Date; - mediaItems?: string[]; - backgroundColor?: string; - backgroundType?: "color" | "blur"; - blurIntensity?: number; // in pixels (4, 8, 18) - fps?: number; + id: string; + name: string; + thumbnail: string; + createdAt: Date; + updatedAt: Date; + mediaItems?: string[]; + backgroundColor?: string; + backgroundType?: "color" | "blur"; + blurIntensity?: number; // in pixels (4, 8, 18) + fps?: number; } diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index e45908d6..17dbb5d1 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -5,36 +5,36 @@ export type TrackType = "media" | "text" | "audio"; // Base element properties interface BaseTimelineElement { - id: string; - name: string; - duration: number; - startTime: number; - trimStart: number; - trimEnd: number; + id: string; + name: string; + duration: number; + startTime: number; + trimStart: number; + trimEnd: number; } // Media element that references MediaStore export interface MediaElement extends BaseTimelineElement { - type: "media"; - mediaId: string; + type: "media"; + mediaId: string; } // Text element with embedded text data export interface TextElement extends BaseTimelineElement { - type: "text"; - content: string; - fontSize: number; - fontFamily: string; - color: string; - backgroundColor: string; - textAlign: "left" | "center" | "right"; - fontWeight: "normal" | "bold"; - fontStyle: "normal" | "italic"; - textDecoration: "none" | "underline" | "line-through"; - x: number; // Position relative to canvas center - y: number; // Position relative to canvas center - rotation: number; // in degrees - opacity: number; // 0-1 + type: "text"; + content: string; + fontSize: number; + fontFamily: string; + color: string; + backgroundColor: string; + textAlign: "left" | "center" | "right"; + fontWeight: "normal" | "bold"; + fontStyle: "normal" | "italic"; + textDecoration: "none" | "underline" | "line-through"; + x: number; // Position relative to canvas center + y: number; // Position relative to canvas center + rotation: number; // in degrees + opacity: number; // 0-1 } // Typed timeline elements @@ -46,112 +46,112 @@ export type CreateTextElement = Omit<TextElement, "id">; export type CreateTimelineElement = CreateMediaElement | CreateTextElement; export interface TimelineElementProps { - element: TimelineElement; - track: TimelineTrack; - zoomLevel: number; - isSelected: boolean; - onElementMouseDown: (e: React.MouseEvent, element: TimelineElement) => void; - onElementClick: (e: React.MouseEvent, element: TimelineElement) => void; + element: TimelineElement; + track: TimelineTrack; + zoomLevel: number; + isSelected: boolean; + onElementMouseDown: (e: React.MouseEvent, element: TimelineElement) => void; + onElementClick: (e: React.MouseEvent, element: TimelineElement) => void; } export interface ResizeState { - elementId: string; - side: "left" | "right"; - startX: number; - initialTrimStart: number; - initialTrimEnd: number; + elementId: string; + side: "left" | "right"; + startX: number; + initialTrimStart: number; + initialTrimEnd: number; } // Drag data types for type-safe drag and drop export interface MediaItemDragData { - id: string; - type: MediaType; - name: string; + id: string; + type: MediaType; + name: string; } export interface TextItemDragData { - id: string; - type: "text"; - name: string; - content: string; + id: string; + type: "text"; + name: string; + content: string; } export type DragData = MediaItemDragData | TextItemDragData; export interface TimelineTrack { - id: string; - name: string; - type: TrackType; - elements: TimelineElement[]; - muted?: boolean; - isMain?: boolean; + id: string; + name: string; + type: TrackType; + elements: TimelineElement[]; + muted?: boolean; + isMain?: boolean; } export function sortTracksByOrder(tracks: TimelineTrack[]): TimelineTrack[] { - return [...tracks].sort((a, b) => { - // Audio tracks always go to bottom - if (a.type === "audio" && b.type !== "audio") return 1; - if (b.type === "audio" && a.type !== "audio") return -1; + return [...tracks].sort((a, b) => { + // Audio tracks always go to bottom + if (a.type === "audio" && b.type !== "audio") return 1; + if (b.type === "audio" && a.type !== "audio") return -1; - // Main track goes above audio but below other tracks - if (a.isMain && !b.isMain && b.type !== "audio") return 1; - if (b.isMain && !a.isMain && a.type !== "audio") return -1; + // Main track goes above audio but below other tracks + if (a.isMain && !b.isMain && b.type !== "audio") return 1; + if (b.isMain && !a.isMain && a.type !== "audio") return -1; - // Within same category, maintain creation order - return 0; - }); + // Within same category, maintain creation order + return 0; + }); } export function getMainTrack(tracks: TimelineTrack[]): TimelineTrack | null { - return tracks.find((track) => track.isMain) || null; + return tracks.find((track) => track.isMain) || null; } export function ensureMainTrack(tracks: TimelineTrack[]): TimelineTrack[] { - const hasMainTrack = tracks.some((track) => track.isMain); + const hasMainTrack = tracks.some((track) => track.isMain); - if (!hasMainTrack) { - // Create main track if it doesn't exist - const mainTrack: TimelineTrack = { - id: generateUUID(), - name: "Main Track", - type: "media", - elements: [], - muted: false, - isMain: true, - }; - return [mainTrack, ...tracks]; - } + if (!hasMainTrack) { + // Create main track if it doesn't exist + const mainTrack: TimelineTrack = { + id: generateUUID(), + name: "Main Track", + type: "media", + elements: [], + muted: false, + isMain: true, + }; + return [mainTrack, ...tracks]; + } - return tracks; + return tracks; } // Timeline validation utilities export function canElementGoOnTrack( - elementType: "text" | "media", - trackType: TrackType, + elementType: "text" | "media", + trackType: TrackType ): boolean { - if (elementType === "text") { - return trackType === "text"; - } else if (elementType === "media") { - return trackType === "media" || trackType === "audio"; - } - return false; + if (elementType === "text") { + return trackType === "text"; + } else if (elementType === "media") { + return trackType === "media" || trackType === "audio"; + } + return false; } export function validateElementTrackCompatibility( - element: { type: "text" | "media" }, - track: { type: TrackType }, + element: { type: "text" | "media" }, + track: { type: TrackType } ): { isValid: boolean; errorMessage?: string } { - const isValid = canElementGoOnTrack(element.type, track.type); + const isValid = canElementGoOnTrack(element.type, track.type); - if (!isValid) { - const errorMessage = - element.type === "text" - ? "Text elements can only be placed on text tracks" - : "Media elements can only be placed on media or audio tracks"; + if (!isValid) { + const errorMessage = + element.type === "text" + ? "Text elements can only be placed on text tracks" + : "Media elements can only be placed on media or audio tracks"; - return { isValid: false, errorMessage }; - } + return { isValid: false, errorMessage }; + } - return { isValid: true }; + return { isValid: true }; } diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index fbbf0e53..049add76 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -1,143 +1,143 @@ import type { Config } from "tailwindcss"; export default { - darkMode: ["class"], - content: [ - "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", - "./src/components/**/*.{js,ts,jsx,tsx,mdx}", - "./src/app/**/*.{js,ts,jsx,tsx,mdx}", - "./src/lib/**/*.{js,ts,jsx,tsx,mdx}", - "./src/constants/**/*.{js,ts,jsx,tsx,mdx}", - ], - theme: { - extend: { - screens: { - xs: "480px", - }, - fontSize: { - base: "0.95rem", - xs: "0.80rem", - }, - fontFamily: { - sans: ["var(--font-inter)", "sans-serif"], - }, - colors: { - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - chart: { - "1": "hsl(var(--chart-1))", - "2": "hsl(var(--chart-2))", - "3": "hsl(var(--chart-3))", - "4": "hsl(var(--chart-4))", - "5": "hsl(var(--chart-5))", - }, - sidebar: { - DEFAULT: "hsl(var(--sidebar-background))", - foreground: "hsl(var(--sidebar-foreground))", - primary: "hsl(var(--sidebar-primary))", - "primary-foreground": "hsl(var(--sidebar-primary-foreground))", - accent: "hsl(var(--sidebar-accent))", - "accent-foreground": "hsl(var(--sidebar-accent-foreground))", - border: "hsl(var(--sidebar-border))", - ring: "hsl(var(--sidebar-ring))", - }, - panel: { - DEFAULT: "hsl(var(--panel-background))", - accent: "hsl(var(--panel-accent))", - }, - }, - borderRadius: { - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 8px)", - }, - keyframes: { - "accordion-down": { - from: { - height: "0", - }, - to: { - height: "var(--radix-accordion-content-height)", - }, - }, - "accordion-up": { - from: { - height: "var(--radix-accordion-content-height)", - }, - to: { - height: "0", - }, - }, - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - }, - }, - }, - plugins: [ - require("tailwindcss-animate"), - function ({ - addUtilities, - }: { - addUtilities: (utilities: Record<string, any>) => void; - }) { - addUtilities({ - ".scrollbar-hidden": { - "-ms-overflow-style": "none", - "scrollbar-width": "none", - "&::-webkit-scrollbar": { - display: "none", - }, - }, - ".scrollbar-x-hidden": { - "-ms-overflow-style": "none", - "scrollbar-width": "none", - "&::-webkit-scrollbar:horizontal": { - display: "none", - }, - }, - ".scrollbar-y-hidden": { - "-ms-overflow-style": "none", - "scrollbar-width": "none", - "&::-webkit-scrollbar:vertical": { - display: "none", - }, - }, - }); - }, - ], - future: { - hoverOnlyWhenSupported: true, - }, + darkMode: ["class"], + content: [ + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + "./src/lib/**/*.{js,ts,jsx,tsx,mdx}", + "./src/constants/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + screens: { + xs: "480px", + }, + fontSize: { + base: "0.95rem", + xs: "0.80rem", + }, + fontFamily: { + sans: ["var(--font-inter)", "sans-serif"], + }, + colors: { + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + chart: { + "1": "hsl(var(--chart-1))", + "2": "hsl(var(--chart-2))", + "3": "hsl(var(--chart-3))", + "4": "hsl(var(--chart-4))", + "5": "hsl(var(--chart-5))", + }, + sidebar: { + DEFAULT: "hsl(var(--sidebar-background))", + foreground: "hsl(var(--sidebar-foreground))", + primary: "hsl(var(--sidebar-primary))", + "primary-foreground": "hsl(var(--sidebar-primary-foreground))", + accent: "hsl(var(--sidebar-accent))", + "accent-foreground": "hsl(var(--sidebar-accent-foreground))", + border: "hsl(var(--sidebar-border))", + ring: "hsl(var(--sidebar-ring))", + }, + panel: { + DEFAULT: "hsl(var(--panel-background))", + accent: "hsl(var(--panel-accent))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 8px)", + }, + keyframes: { + "accordion-down": { + from: { + height: "0", + }, + to: { + height: "var(--radix-accordion-content-height)", + }, + }, + "accordion-up": { + from: { + height: "var(--radix-accordion-content-height)", + }, + to: { + height: "0", + }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [ + require("tailwindcss-animate"), + function ({ + addUtilities, + }: { + addUtilities: (utilities: Record<string, any>) => void; + }) { + addUtilities({ + ".scrollbar-hidden": { + "-ms-overflow-style": "none", + "scrollbar-width": "none", + "&::-webkit-scrollbar": { + display: "none", + }, + }, + ".scrollbar-x-hidden": { + "-ms-overflow-style": "none", + "scrollbar-width": "none", + "&::-webkit-scrollbar:horizontal": { + display: "none", + }, + }, + ".scrollbar-y-hidden": { + "-ms-overflow-style": "none", + "scrollbar-width": "none", + "&::-webkit-scrollbar:vertical": { + display: "none", + }, + }, + }); + }, + ], + future: { + hoverOnlyWhenSupported: true, + }, } satisfies Config; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 9b1b05fc..e291637a 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,35 +1,43 @@ { - "compilerOptions": { - "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./src/*"] - }, - "forceConsistentCasingInFileNames": true - }, - "include": [ - "**/*.ts", - "**/*.tsx", - "apps/web/.next/types/**/*.ts", - "next-env.d.ts", - ".next/types/**/*.ts", - "src/types/**/*.d.ts" - ], - "exclude": ["node_modules"] + "compilerOptions": { + "target": "ES2017", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./src/*" + ] + }, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "**/*.ts", + "**/*.tsx", + "apps/web/.next/types/**/*.ts", + "next-env.d.ts", + ".next/types/**/*.ts", + "src/types/**/*.d.ts" + ], + "exclude": [ + "node_modules" + ] } |
