summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/web/components.json40
-rw-r--r--apps/web/drizzle.config.ts38
-rw-r--r--apps/web/migrations/meta/0000_snapshot.json624
-rw-r--r--apps/web/migrations/meta/0001_snapshot.json700
-rw-r--r--apps/web/migrations/meta/0002_snapshot.json702
-rw-r--r--apps/web/migrations/meta/_journal.json52
-rw-r--r--apps/web/next.config.ts36
-rw-r--r--apps/web/package.json146
-rw-r--r--apps/web/postcss.config.mjs6
-rw-r--r--apps/web/public/ffmpeg/ffmpeg-core.js33
-rw-r--r--apps/web/public/manifest.json86
-rw-r--r--apps/web/src/app/(auth)/login/page.tsx248
-rw-r--r--apps/web/src/app/(auth)/signup/page.tsx282
-rw-r--r--apps/web/src/app/api/auth/[...all]/route.ts8
-rw-r--r--apps/web/src/app/api/health/route.ts2
-rw-r--r--apps/web/src/app/api/waitlist/route.ts188
-rw-r--r--apps/web/src/app/api/waitlist/token/route.ts75
-rw-r--r--apps/web/src/app/contributors/page.tsx554
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx222
-rw-r--r--apps/web/src/app/globals.css158
-rw-r--r--apps/web/src/app/layout.tsx68
-rw-r--r--apps/web/src/app/metadata.ts158
-rw-r--r--apps/web/src/app/page.tsx14
-rw-r--r--apps/web/src/app/privacy/page.tsx506
-rw-r--r--apps/web/src/app/projects/page.tsx1086
-rw-r--r--apps/web/src/app/roadmap/page.tsx494
-rw-r--r--apps/web/src/app/terms/page.tsx634
-rw-r--r--apps/web/src/app/why-not-capcut/page.tsx382
-rw-r--r--apps/web/src/components/background-settings.tsx368
-rw-r--r--apps/web/src/components/delete-project-dialog.tsx118
-rw-r--r--apps/web/src/components/editor-header.tsx136
-rw-r--r--apps/web/src/components/editor-provider.tsx106
-rw-r--r--apps/web/src/components/editor/audio-waveform.tsx196
-rw-r--r--apps/web/src/components/editor/media-panel/drag-overlay.tsx114
-rw-r--r--apps/web/src/components/editor/media-panel/index.tsx110
-rw-r--r--apps/web/src/components/editor/media-panel/store.ts146
-rw-r--r--apps/web/src/components/editor/media-panel/tabbar.tsx248
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx596
-rw-r--r--apps/web/src/components/editor/media-panel/views/text.tsx106
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx846
-rw-r--r--apps/web/src/components/editor/properties-panel/audio-properties.tsx10
-rw-r--r--apps/web/src/components/editor/properties-panel/index.tsx166
-rw-r--r--apps/web/src/components/editor/properties-panel/media-properties.tsx10
-rw-r--r--apps/web/src/components/editor/properties-panel/property-item.tsx94
-rw-r--r--apps/web/src/components/editor/properties-panel/text-properties.tsx152
-rw-r--r--apps/web/src/components/editor/selection-box.tsx116
-rw-r--r--apps/web/src/components/editor/snap-indicator.tsx76
-rw-r--r--apps/web/src/components/editor/speed-control.tsx72
-rw-r--r--apps/web/src/components/editor/timeline-element.tsx664
-rw-r--r--apps/web/src/components/editor/timeline-playhead.tsx232
-rw-r--r--apps/web/src/components/editor/timeline-track.tsx2302
-rw-r--r--apps/web/src/components/editor/timeline.tsx1973
-rw-r--r--apps/web/src/components/footer.tsx246
-rw-r--r--apps/web/src/components/header-base.tsx82
-rw-r--r--apps/web/src/components/header.tsx140
-rw-r--r--apps/web/src/components/icons.tsx201
-rw-r--r--apps/web/src/components/keyboard-shortcuts-help.tsx421
-rw-r--r--apps/web/src/components/landing/handlebars.tsx274
-rw-r--r--apps/web/src/components/landing/hero.tsx336
-rw-r--r--apps/web/src/components/onboarding.tsx58
-rw-r--r--apps/web/src/components/rename-project-dialog.tsx146
-rw-r--r--apps/web/src/components/storage-provider.tsx160
-rw-r--r--apps/web/src/components/ui/accordion.tsx62
-rw-r--r--apps/web/src/components/ui/alert-dialog.tsx170
-rw-r--r--apps/web/src/components/ui/alert.tsx70
-rw-r--r--apps/web/src/components/ui/aspect-ratio.tsx8
-rw-r--r--apps/web/src/components/ui/audio-player.tsx254
-rw-r--r--apps/web/src/components/ui/avatar.tsx54
-rw-r--r--apps/web/src/components/ui/badge.tsx44
-rw-r--r--apps/web/src/components/ui/breadcrumb.tsx144
-rw-r--r--apps/web/src/components/ui/button.tsx83
-rw-r--r--apps/web/src/components/ui/calendar.tsx110
-rw-r--r--apps/web/src/components/ui/card.tsx91
-rw-r--r--apps/web/src/components/ui/carousel.tsx384
-rw-r--r--apps/web/src/components/ui/chart.tsx588
-rw-r--r--apps/web/src/components/ui/checkbox.tsx32
-rw-r--r--apps/web/src/components/ui/collapsible.tsx12
-rw-r--r--apps/web/src/components/ui/command.tsx186
-rw-r--r--apps/web/src/components/ui/context-menu.tsx288
-rw-r--r--apps/web/src/components/ui/dialog.tsx168
-rw-r--r--apps/web/src/components/ui/draggable-item.tsx320
-rw-r--r--apps/web/src/components/ui/drawer.tsx136
-rw-r--r--apps/web/src/components/ui/dropdown-menu.tsx318
-rw-r--r--apps/web/src/components/ui/font-picker.tsx80
-rw-r--r--apps/web/src/components/ui/form.tsx216
-rw-r--r--apps/web/src/components/ui/hover-card.tsx24
-rw-r--r--apps/web/src/components/ui/image-timeline-treatment.tsx204
-rw-r--r--apps/web/src/components/ui/input-otp.tsx90
-rw-r--r--apps/web/src/components/ui/input.tsx82
-rw-r--r--apps/web/src/components/ui/label.tsx18
-rw-r--r--apps/web/src/components/ui/menubar.tsx322
-rw-r--r--apps/web/src/components/ui/navigation-menu.tsx164
-rw-r--r--apps/web/src/components/ui/pagination.tsx144
-rw-r--r--apps/web/src/components/ui/phone-input.tsx334
-rw-r--r--apps/web/src/components/ui/popover.tsx28
-rw-r--r--apps/web/src/components/ui/progress.tsx30
-rw-r--r--apps/web/src/components/ui/radio-group.tsx50
-rw-r--r--apps/web/src/components/ui/resizable.tsx40
-rw-r--r--apps/web/src/components/ui/scroll-area.tsx60
-rw-r--r--apps/web/src/components/ui/select.tsx246
-rw-r--r--apps/web/src/components/ui/separator.tsx36
-rw-r--r--apps/web/src/components/ui/sheet.tsx172
-rw-r--r--apps/web/src/components/ui/sidebar.tsx1186
-rw-r--r--apps/web/src/components/ui/skeleton.tsx16
-rw-r--r--apps/web/src/components/ui/slider.tsx30
-rw-r--r--apps/web/src/components/ui/sonner.tsx50
-rw-r--r--apps/web/src/components/ui/sponsor-button.tsx57
-rw-r--r--apps/web/src/components/ui/switch.tsx32
-rw-r--r--apps/web/src/components/ui/table.tsx148
-rw-r--r--apps/web/src/components/ui/tabs.tsx60
-rw-r--r--apps/web/src/components/ui/textarea.tsx24
-rw-r--r--apps/web/src/components/ui/toast.tsx158
-rw-r--r--apps/web/src/components/ui/toaster.tsx52
-rw-r--r--apps/web/src/components/ui/toggle-group.tsx68
-rw-r--r--apps/web/src/components/ui/toggle.tsx54
-rw-r--r--apps/web/src/components/ui/tooltip.tsx26
-rw-r--r--apps/web/src/components/ui/video-player.tsx214
-rw-r--r--apps/web/src/constants/actions.ts300
-rw-r--r--apps/web/src/constants/font-constants.ts158
-rw-r--r--apps/web/src/constants/timeline-constants.ts110
-rw-r--r--apps/web/src/data/colors.ts488
-rw-r--r--apps/web/src/env.ts38
-rw-r--r--apps/web/src/hooks/auth/useLogin.ts92
-rw-r--r--apps/web/src/hooks/auth/useSignUp.ts102
-rw-r--r--apps/web/src/hooks/use-aspect-ratio.ts184
-rw-r--r--apps/web/src/hooks/use-drag-drop.ts170
-rw-r--r--apps/web/src/hooks/use-editor-actions.ts254
-rw-r--r--apps/web/src/hooks/use-keybinding-conflicts.ts80
-rw-r--r--apps/web/src/hooks/use-keybindings.ts68
-rw-r--r--apps/web/src/hooks/use-keyboard-shortcuts-help.ts206
-rw-r--r--apps/web/src/hooks/use-mobile.tsx28
-rw-r--r--apps/web/src/hooks/use-playback-controls.ts158
-rw-r--r--apps/web/src/hooks/use-selection-box.ts398
-rw-r--r--apps/web/src/hooks/use-timeline-element-resize.ts330
-rw-r--r--apps/web/src/hooks/use-timeline-playhead.ts314
-rw-r--r--apps/web/src/hooks/use-timeline-snapping.ts292
-rw-r--r--apps/web/src/hooks/use-timeline-zoom.ts108
-rw-r--r--apps/web/src/hooks/use-toast.ts256
-rw-r--r--apps/web/src/lib/fetch-github-stars.ts48
-rw-r--r--apps/web/src/lib/ffmpeg-utils.ts431
-rw-r--r--apps/web/src/lib/font-config.ts78
-rw-r--r--apps/web/src/lib/media-processing.ts162
-rw-r--r--apps/web/src/lib/rate-limit.ts32
-rw-r--r--apps/web/src/lib/storage/indexeddb-adapter.ts178
-rw-r--r--apps/web/src/lib/storage/opfs-adapter.ts114
-rw-r--r--apps/web/src/lib/storage/storage-service.ts440
-rw-r--r--apps/web/src/lib/storage/types.ts54
-rw-r--r--apps/web/src/lib/time.ts50
-rw-r--r--apps/web/src/lib/timeline.ts108
-rw-r--r--apps/web/src/lib/utils.ts84
-rw-r--r--apps/web/src/lib/waitlist.ts26
-rw-r--r--apps/web/src/middleware.ts96
-rw-r--r--apps/web/src/stores/editor-store.ts210
-rw-r--r--apps/web/src/stores/keybindings-store.ts360
-rw-r--r--apps/web/src/stores/media-store.ts438
-rw-r--r--apps/web/src/stores/panel-store.ts90
-rw-r--r--apps/web/src/stores/playback-store.ts206
-rw-r--r--apps/web/src/stores/project-store.ts722
-rw-r--r--apps/web/src/stores/timeline-store.ts2438
-rw-r--r--apps/web/src/types/editor.ts24
-rw-r--r--apps/web/src/types/keybinding.ts74
-rw-r--r--apps/web/src/types/playback.ts32
-rw-r--r--apps/web/src/types/project.ts20
-rw-r--r--apps/web/src/types/timeline.ts180
-rw-r--r--apps/web/tailwind.config.ts280
-rw-r--r--apps/web/tsconfig.json74
166 files changed, 18844 insertions, 18939 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 4dd15ee7..7ac08107 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -7,601 +7,601 @@ import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
- ChevronLeft,
- Plus,
- Calendar,
- MoreHorizontal,
- Video,
- Loader2,
- X,
- Trash2,
- Search,
+ ChevronLeft,
+ Plus,
+ Calendar,
+ MoreHorizontal,
+ Video,
+ Loader2,
+ X,
+ Trash2,
+ Search,
} from "lucide-react";
import { TProject } from "@/types/project";
import Image from "next/image";
import {
- DropdownMenu,
- DropdownMenuItem,
- DropdownMenuContent,
- DropdownMenuTrigger,
- DropdownMenuSeparator,
+ DropdownMenu,
+ DropdownMenuItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+ DropdownMenuSeparator,
} 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 {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
} from "@/components/ui/select";
export default function ProjectsPage() {
- const {
- createNewProject,
- savedProjects,
- isLoading,
- isInitialized,
- deleteProject,
- getFilteredAndSortedProjects,
- } = useProjectStore();
- const router = useRouter();
- const [isSelectionMode, setIsSelectionMode] = useState(false);
- const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
- new Set(),
- );
- const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
- const [searchQuery, setSearchQuery] = useState("");
- const [sortOption, setSortOption] = useState("createdAt-desc");
+ const {
+ createNewProject,
+ savedProjects,
+ isLoading,
+ isInitialized,
+ deleteProject,
+ getFilteredAndSortedProjects,
+ } = useProjectStore();
+ const router = useRouter();
+ const [isSelectionMode, setIsSelectionMode] = useState(false);
+ const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
+ new Set()
+ );
+ const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [sortOption, setSortOption] = useState("createdAt-desc");
- const handleCreateProject = async () => {
- const projectId = await createNewProject("New Project");
- console.log("projectId", projectId);
- router.push(`/editor/${projectId}`);
- };
+ const handleCreateProject = async () => {
+ const projectId = await createNewProject("New Project");
+ console.log("projectId", projectId);
+ router.push(`/editor/${projectId}`);
+ };
- const handleSelectProject = (projectId: string, checked: boolean) => {
- const newSelected = new Set(selectedProjects);
- if (checked) {
- newSelected.add(projectId);
- } else {
- newSelected.delete(projectId);
- }
- setSelectedProjects(newSelected);
- };
+ const handleSelectProject = (projectId: string, checked: boolean) => {
+ const newSelected = new Set(selectedProjects);
+ if (checked) {
+ newSelected.add(projectId);
+ } else {
+ newSelected.delete(projectId);
+ }
+ setSelectedProjects(newSelected);
+ };
- const handleSelectAll = (checked: boolean) => {
- if (checked) {
- setSelectedProjects(new Set(sortedProjects.map((p) => p.id)));
- } else {
- setSelectedProjects(new Set());
- }
- };
+ const handleSelectAll = (checked: boolean) => {
+ if (checked) {
+ setSelectedProjects(new Set(sortedProjects.map((p) => p.id)));
+ } else {
+ setSelectedProjects(new Set());
+ }
+ };
- const handleCancelSelection = () => {
- setIsSelectionMode(false);
- setSelectedProjects(new Set());
- };
+ const handleCancelSelection = () => {
+ setIsSelectionMode(false);
+ setSelectedProjects(new Set());
+ };
- const handleBulkDelete = async () => {
- await Promise.all(
- Array.from(selectedProjects).map((projectId) => deleteProject(projectId)),
- );
- setSelectedProjects(new Set());
- setIsSelectionMode(false);
- setIsBulkDeleteDialogOpen(false);
- };
+ const handleBulkDelete = async () => {
+ await Promise.all(
+ Array.from(selectedProjects).map((projectId) => deleteProject(projectId))
+ );
+ setSelectedProjects(new Set());
+ setIsSelectionMode(false);
+ setIsBulkDeleteDialogOpen(false);
+ };
- const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption);
+ const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption);
- const allSelected =
- sortedProjects.length > 0 &&
- selectedProjects.size === sortedProjects.length;
- const someSelected =
- selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length;
+ const allSelected =
+ sortedProjects.length > 0 &&
+ selectedProjects.size === sortedProjects.length;
+ const someSelected =
+ selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length;
- return (
- <div className="min-h-screen bg-background">
- <div className="pt-6 px-6 flex items-center justify-between w-full h-16">
- <Link
- href="/"
- className="flex items-center gap-1 hover:text-muted-foreground transition-colors"
- >
- <ChevronLeft className="!size-5 shrink-0" />
- <span className="text-sm font-medium">Back</span>
- </Link>
- <div className="block md:hidden">
- {isSelectionMode ? (
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={handleCancelSelection}
- >
- <X className="!size-4" />
- Cancel
- </Button>
- {selectedProjects.size > 0 && (
- <Button
- variant="destructive"
- size="sm"
- onClick={() => setIsBulkDeleteDialogOpen(true)}
- >
- <Trash2 className="!size-4" />
- Delete ({selectedProjects.size})
- </Button>
- )}
- </div>
- ) : (
- <CreateButton onClick={handleCreateProject} />
- )}
- </div>
- </div>
- <main className="max-w-6xl mx-auto px-6 pt-6 pb-6">
- <div className="mb-8 flex items-center justify-between">
- <div className="flex flex-col gap-3">
- <h1 className="text-2xl md:text-3xl font-bold tracking-tight">
- Your Projects
- </h1>
- <p className="text-muted-foreground">
- {savedProjects.length}{" "}
- {savedProjects.length === 1 ? "project" : "projects"}
- {isSelectionMode && selectedProjects.size > 0 && (
- <span className="ml-2 text-primary">
- • {selectedProjects.size} selected
- </span>
- )}
- </p>
- </div>
- <div className="hidden md:block">
- {isSelectionMode ? (
- <div className="flex items-center gap-2">
- <Button variant="outline" onClick={handleCancelSelection}>
- <X className="!size-4" />
- Cancel
- </Button>
- {selectedProjects.size > 0 && (
- <Button
- variant="destructive"
- onClick={() => setIsBulkDeleteDialogOpen(true)}
- >
- <Trash2 className="!size-4" />
- Delete Selected ({selectedProjects.size})
- </Button>
- )}
- </div>
- ) : (
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- onClick={() => setIsSelectionMode(true)}
- disabled={savedProjects.length === 0}
- >
- Select Projects
- </Button>
- <CreateButton onClick={handleCreateProject} />
- </div>
- )}
- </div>
- </div>
+ return (
+ <div className="min-h-screen bg-background">
+ <div className="pt-6 px-6 flex items-center justify-between w-full h-16">
+ <Link
+ href="/"
+ className="flex items-center gap-1 hover:text-muted-foreground transition-colors"
+ >
+ <ChevronLeft className="!size-5 shrink-0" />
+ <span className="text-sm font-medium">Back</span>
+ </Link>
+ <div className="block md:hidden">
+ {isSelectionMode ? (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleCancelSelection}
+ >
+ <X className="!size-4" />
+ Cancel
+ </Button>
+ {selectedProjects.size > 0 && (
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={() => setIsBulkDeleteDialogOpen(true)}
+ >
+ <Trash2 className="!size-4" />
+ Delete ({selectedProjects.size})
+ </Button>
+ )}
+ </div>
+ ) : (
+ <CreateButton onClick={handleCreateProject} />
+ )}
+ </div>
+ </div>
+ <main className="max-w-6xl mx-auto px-6 pt-6 pb-6">
+ <div className="mb-8 flex items-center justify-between">
+ <div className="flex flex-col gap-3">
+ <h1 className="text-2xl md:text-3xl font-bold tracking-tight">
+ Your Projects
+ </h1>
+ <p className="text-muted-foreground">
+ {savedProjects.length}{" "}
+ {savedProjects.length === 1 ? "project" : "projects"}
+ {isSelectionMode && selectedProjects.size > 0 && (
+ <span className="ml-2 text-primary">
+ • {selectedProjects.size} selected
+ </span>
+ )}
+ </p>
+ </div>
+ <div className="hidden md:block">
+ {isSelectionMode ? (
+ <div className="flex items-center gap-2">
+ <Button variant="outline" onClick={handleCancelSelection}>
+ <X className="!size-4" />
+ Cancel
+ </Button>
+ {selectedProjects.size > 0 && (
+ <Button
+ variant="destructive"
+ onClick={() => setIsBulkDeleteDialogOpen(true)}
+ >
+ <Trash2 className="!size-4" />
+ Delete Selected ({selectedProjects.size})
+ </Button>
+ )}
+ </div>
+ ) : (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ onClick={() => setIsSelectionMode(true)}
+ disabled={savedProjects.length === 0}
+ >
+ Select Projects
+ </Button>
+ <CreateButton onClick={handleCreateProject} />
+ </div>
+ )}
+ </div>
+ </div>
- <div className="mb-4 flex items-center justify-between gap-4">
- <div className="flex-1 max-w-72">
- <Input
- placeholder="Search projects..."
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
- />
- </div>
- <Select value={sortOption} onValueChange={setSortOption}>
- <SelectTrigger className="w-[180px]">
- <SelectValue placeholder="Sort by" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="createdAt-desc">Newest to Oldest</SelectItem>
- <SelectItem value="createdAt-asc">Oldest to Newest</SelectItem>
- <SelectItem value="name-asc">Name (A-Z)</SelectItem>
- <SelectItem value="name-desc">Name (Z-A)</SelectItem>
- </SelectContent>
- </Select>
- </div>
+ <div className="mb-4 flex items-center justify-between gap-4">
+ <div className="flex-1 max-w-72">
+ <Input
+ placeholder="Search projects..."
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ </div>
+ <Select value={sortOption} onValueChange={setSortOption}>
+ <SelectTrigger className="w-[180px]">
+ <SelectValue placeholder="Sort by" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="createdAt-desc">Newest to Oldest</SelectItem>
+ <SelectItem value="createdAt-asc">Oldest to Newest</SelectItem>
+ <SelectItem value="name-asc">Name (A-Z)</SelectItem>
+ <SelectItem value="name-desc">Name (Z-A)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
- {isSelectionMode && sortedProjects.length > 0 && (
- <div className="mb-6 p-4 bg-muted/30 rounded-lg border">
- <div className="flex items-center gap-3">
- <Checkbox
- checked={someSelected ? "indeterminate" : allSelected}
- onCheckedChange={(value) => handleSelectAll(!!value)}
- />
- <span className="text-sm font-medium">
- {allSelected ? "Deselect All" : "Select All"}
- </span>
- <span className="text-sm text-muted-foreground">
- ({selectedProjects.size} of {sortedProjects.length} selected)
- </span>
- </div>
- </div>
- )}
+ {isSelectionMode && sortedProjects.length > 0 && (
+ <div className="mb-6 p-4 bg-muted/30 rounded-lg border">
+ <div className="flex items-center gap-3">
+ <Checkbox
+ checked={someSelected ? "indeterminate" : allSelected}
+ onCheckedChange={(value) => handleSelectAll(!!value)}
+ />
+ <span className="text-sm font-medium">
+ {allSelected ? "Deselect All" : "Select All"}
+ </span>
+ <span className="text-sm text-muted-foreground">
+ ({selectedProjects.size} of {sortedProjects.length} selected)
+ </span>
+ </div>
+ </div>
+ )}
- {isLoading || !isInitialized ? (
- <div className="flex items-center justify-center py-16">
- <Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
- </div>
- ) : savedProjects.length === 0 ? (
- <NoProjects onCreateProject={handleCreateProject} />
- ) : sortedProjects.length === 0 ? (
- <NoResults
- searchQuery={searchQuery}
- onClearSearch={() => setSearchQuery("")}
- />
- ) : (
- <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
- {sortedProjects.map((project) => (
- <ProjectCard
- key={project.id}
- project={project}
- isSelectionMode={isSelectionMode}
- isSelected={selectedProjects.has(project.id)}
- onSelect={handleSelectProject}
- />
- ))}
- </div>
- )}
- </main>
+ {isLoading || !isInitialized ? (
+ <div className="flex items-center justify-center py-16">
+ <Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
+ </div>
+ ) : savedProjects.length === 0 ? (
+ <NoProjects onCreateProject={handleCreateProject} />
+ ) : sortedProjects.length === 0 ? (
+ <NoResults
+ searchQuery={searchQuery}
+ onClearSearch={() => setSearchQuery("")}
+ />
+ ) : (
+ <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
+ {sortedProjects.map((project) => (
+ <ProjectCard
+ key={project.id}
+ project={project}
+ isSelectionMode={isSelectionMode}
+ isSelected={selectedProjects.has(project.id)}
+ onSelect={handleSelectProject}
+ />
+ ))}
+ </div>
+ )}
+ </main>
- <DeleteProjectDialog
- isOpen={isBulkDeleteDialogOpen}
- onOpenChange={setIsBulkDeleteDialogOpen}
- onConfirm={handleBulkDelete}
- />
- </div>
- );
+ <DeleteProjectDialog
+ isOpen={isBulkDeleteDialogOpen}
+ onOpenChange={setIsBulkDeleteDialogOpen}
+ onConfirm={handleBulkDelete}
+ />
+ </div>
+ );
}
interface ProjectCardProps {
- project: TProject;
- isSelectionMode?: boolean;
- isSelected?: boolean;
- onSelect?: (projectId: string, checked: boolean) => void;
+ project: TProject;
+ isSelectionMode?: boolean;
+ isSelected?: boolean;
+ onSelect?: (projectId: string, checked: boolean) => void;
}
function ProjectCard({
- project,
- isSelectionMode = false,
- isSelected = false,
- onSelect,
+ project,
+ isSelectionMode = false,
+ isSelected = false,
+ onSelect,
}: ProjectCardProps) {
- const [isDropdownOpen, setIsDropdownOpen] = useState(false);
- const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
- const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
- const { deleteProject, renameProject, duplicateProject } = useProjectStore();
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
+ const { deleteProject, renameProject, duplicateProject } = useProjectStore();
- const formatDate = (date: Date): string => {
- return date.toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- });
- };
+ const formatDate = (date: Date): string => {
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ };
- const handleDeleteProject = async () => {
- await deleteProject(project.id);
- setIsDropdownOpen(false);
- };
+ const handleDeleteProject = async () => {
+ await deleteProject(project.id);
+ setIsDropdownOpen(false);
+ };
- const handleRenameProject = async (newName: string) => {
- await renameProject(project.id, newName);
- setIsRenameDialogOpen(false);
- };
+ const handleRenameProject = async (newName: string) => {
+ await renameProject(project.id, newName);
+ setIsRenameDialogOpen(false);
+ };
- const handleDuplicateProject = async () => {
- setIsDropdownOpen(false);
- await duplicateProject(project.id);
- };
+ const handleDuplicateProject = async () => {
+ setIsDropdownOpen(false);
+ await duplicateProject(project.id);
+ };
- const handleCardClick = (e: React.MouseEvent) => {
- if (isSelectionMode) {
- e.preventDefault();
- onSelect?.(project.id, !isSelected);
- }
- };
+ const handleCardClick = (e: React.MouseEvent) => {
+ if (isSelectionMode) {
+ e.preventDefault();
+ onSelect?.(project.id, !isSelected);
+ }
+ };
- return (
- <>
- {isSelectionMode ? (
- <div onClick={handleCardClick} className="block group cursor-pointer">
- <Card
- className={`overflow-hidden bg-background border-none p-0 transition-all ${
- isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
- }`}
- >
- <div
- className={`relative aspect-square bg-muted transition-opacity ${
- isDropdownOpen
- ? "opacity-65"
- : "opacity-100 group-hover:opacity-65"
- }`}
- >
- {/* Selection checkbox */}
- {isSelectionMode && (
- <div className="absolute top-3 left-3 z-10">
- <div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm border flex items-center justify-center">
- <Checkbox
- checked={isSelected}
- onCheckedChange={(checked) =>
- onSelect?.(project.id, checked as boolean)
- }
- onClick={(e) => e.stopPropagation()}
- className="w-4 h-4"
- />
- </div>
- </div>
- )}
+ return (
+ <>
+ {isSelectionMode ? (
+ <div onClick={handleCardClick} className="block group cursor-pointer">
+ <Card
+ className={`overflow-hidden bg-background border-none p-0 transition-all ${
+ isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
+ }`}
+ >
+ <div
+ className={`relative aspect-square bg-muted transition-opacity ${
+ isDropdownOpen
+ ? "opacity-65"
+ : "opacity-100 group-hover:opacity-65"
+ }`}
+ >
+ {/* Selection checkbox */}
+ {isSelectionMode && (
+ <div className="absolute top-3 left-3 z-10">
+ <div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm border flex items-center justify-center">
+ <Checkbox
+ checked={isSelected}
+ onCheckedChange={(checked) =>
+ onSelect?.(project.id, checked as boolean)
+ }
+ onClick={(e) => e.stopPropagation()}
+ className="w-4 h-4"
+ />
+ </div>
+ </div>
+ )}
- {/* Thumbnail preview or placeholder */}
- <div className="absolute inset-0">
- {project.thumbnail ? (
- <Image
- src={project.thumbnail}
- alt="Project thumbnail"
- fill
- className="object-cover"
- />
- ) : (
- <div className="w-full h-full bg-muted/50 flex items-center justify-center">
- <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" />
- </div>
- )}
- </div>
- </div>
+ {/* Thumbnail preview or placeholder */}
+ <div className="absolute inset-0">
+ {project.thumbnail ? (
+ <Image
+ src={project.thumbnail}
+ alt="Project thumbnail"
+ fill
+ className="object-cover"
+ />
+ ) : (
+ <div className="w-full h-full bg-muted/50 flex items-center justify-center">
+ <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" />
+ </div>
+ )}
+ </div>
+ </div>
- <CardContent className="px-0 pt-5 flex flex-col gap-1">
- <div className="flex items-start justify-between">
- <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
- {project.name}
- </h3>
- {!isSelectionMode && (
- <DropdownMenu
- open={isDropdownOpen}
- onOpenChange={setIsDropdownOpen}
- >
- <DropdownMenuTrigger asChild>
- <Button
- variant="text"
- size="sm"
- className={`size-6 p-0 transition-all shrink-0 ml-2 ${
- isDropdownOpen
- ? "opacity-100"
- : "opacity-0 group-hover:opacity-100"
- }`}
- onClick={(e) => e.preventDefault()}
- >
- <MoreHorizontal />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent
- align="end"
- onCloseAutoFocus={(e) => {
- e.preventDefault();
- e.stopPropagation();
- }}
- >
- <DropdownMenuItem
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDropdownOpen(false);
- setIsRenameDialogOpen(true);
- }}
- >
- Rename
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- handleDuplicateProject();
- }}
- >
- Duplicate
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem
- variant="destructive"
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDropdownOpen(false);
- setIsDeleteDialogOpen(true);
- }}
- >
- Delete
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )}
- </div>
+ <CardContent className="px-0 pt-5 flex flex-col gap-1">
+ <div className="flex items-start justify-between">
+ <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
+ {project.name}
+ </h3>
+ {!isSelectionMode && (
+ <DropdownMenu
+ open={isDropdownOpen}
+ onOpenChange={setIsDropdownOpen}
+ >
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="text"
+ size="sm"
+ className={`size-6 p-0 transition-all shrink-0 ml-2 ${
+ isDropdownOpen
+ ? "opacity-100"
+ : "opacity-0 group-hover:opacity-100"
+ }`}
+ onClick={(e) => e.preventDefault()}
+ >
+ <MoreHorizontal />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent
+ align="end"
+ onCloseAutoFocus={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+ <DropdownMenuItem
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDropdownOpen(false);
+ setIsRenameDialogOpen(true);
+ }}
+ >
+ Rename
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ handleDuplicateProject();
+ }}
+ >
+ Duplicate
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ variant="destructive"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDropdownOpen(false);
+ setIsDeleteDialogOpen(true);
+ }}
+ >
+ Delete
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )}
+ </div>
- <div className="space-y-1">
- <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
- <Calendar className="!size-4" />
- <span>Created {formatDate(project.createdAt)}</span>
- </div>
- </div>
- </CardContent>
- </Card>
- </div>
- ) : (
- <Link href={`/editor/${project.id}`} className="block group">
- <Card
- className={`overflow-hidden bg-background border-none p-0 transition-all ${
- isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
- }`}
- >
- <div
- className={`relative aspect-square bg-muted transition-opacity ${
- isDropdownOpen
- ? "opacity-65"
- : "opacity-100 group-hover:opacity-65"
- }`}
- >
- {/* Thumbnail preview or placeholder */}
- <div className="absolute inset-0">
- {project.thumbnail ? (
- <Image
- src={project.thumbnail}
- alt="Project thumbnail"
- fill
- className="object-cover"
- />
- ) : (
- <div className="w-full h-full bg-muted/50 flex items-center justify-center">
- <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" />
- </div>
- )}
- </div>
- </div>
+ <div className="space-y-1">
+ <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
+ <Calendar className="!size-4" />
+ <span>Created {formatDate(project.createdAt)}</span>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ ) : (
+ <Link href={`/editor/${project.id}`} className="block group">
+ <Card
+ className={`overflow-hidden bg-background border-none p-0 transition-all ${
+ isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
+ }`}
+ >
+ <div
+ className={`relative aspect-square bg-muted transition-opacity ${
+ isDropdownOpen
+ ? "opacity-65"
+ : "opacity-100 group-hover:opacity-65"
+ }`}
+ >
+ {/* Thumbnail preview or placeholder */}
+ <div className="absolute inset-0">
+ {project.thumbnail ? (
+ <Image
+ src={project.thumbnail}
+ alt="Project thumbnail"
+ fill
+ className="object-cover"
+ />
+ ) : (
+ <div className="w-full h-full bg-muted/50 flex items-center justify-center">
+ <Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" />
+ </div>
+ )}
+ </div>
+ </div>
- <CardContent className="px-0 pt-5 flex flex-col gap-1">
- <div className="flex items-start justify-between">
- <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
- {project.name}
- </h3>
- <DropdownMenu
- open={isDropdownOpen}
- onOpenChange={setIsDropdownOpen}
- >
- <DropdownMenuTrigger asChild>
- <Button
- variant="text"
- size="sm"
- className={`size-6 p-0 transition-all shrink-0 ml-2 ${
- isDropdownOpen
- ? "opacity-100"
- : "opacity-0 group-hover:opacity-100"
- }`}
- onClick={(e) => e.preventDefault()}
- >
- <MoreHorizontal />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent
- align="end"
- onCloseAutoFocus={(e) => {
- e.preventDefault();
- e.stopPropagation();
- }}
- >
- <DropdownMenuItem
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDropdownOpen(false);
- setIsRenameDialogOpen(true);
- }}
- >
- Rename
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- handleDuplicateProject();
- }}
- >
- Duplicate
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem
- variant="destructive"
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setIsDropdownOpen(false);
- setIsDeleteDialogOpen(true);
- }}
- >
- Delete
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
+ <CardContent className="px-0 pt-5 flex flex-col gap-1">
+ <div className="flex items-start justify-between">
+ <h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
+ {project.name}
+ </h3>
+ <DropdownMenu
+ open={isDropdownOpen}
+ onOpenChange={setIsDropdownOpen}
+ >
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="text"
+ size="sm"
+ className={`size-6 p-0 transition-all shrink-0 ml-2 ${
+ isDropdownOpen
+ ? "opacity-100"
+ : "opacity-0 group-hover:opacity-100"
+ }`}
+ onClick={(e) => e.preventDefault()}
+ >
+ <MoreHorizontal />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent
+ align="end"
+ onCloseAutoFocus={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+ <DropdownMenuItem
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDropdownOpen(false);
+ setIsRenameDialogOpen(true);
+ }}
+ >
+ Rename
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ handleDuplicateProject();
+ }}
+ >
+ Duplicate
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ variant="destructive"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDropdownOpen(false);
+ setIsDeleteDialogOpen(true);
+ }}
+ >
+ Delete
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
- <div className="space-y-1">
- <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
- <Calendar className="!size-4" />
- <span>Created {formatDate(project.createdAt)}</span>
- </div>
- </div>
- </CardContent>
- </Card>
- </Link>
- )}
- <DeleteProjectDialog
- isOpen={isDeleteDialogOpen}
- onOpenChange={setIsDeleteDialogOpen}
- onConfirm={handleDeleteProject}
- projectName={project.name}
- />
- <RenameProjectDialog
- isOpen={isRenameDialogOpen}
- onOpenChange={setIsRenameDialogOpen}
- onConfirm={handleRenameProject}
- projectName={project.name}
- />
- </>
- );
+ <div className="space-y-1">
+ <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
+ <Calendar className="!size-4" />
+ <span>Created {formatDate(project.createdAt)}</span>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </Link>
+ )}
+ <DeleteProjectDialog
+ isOpen={isDeleteDialogOpen}
+ onOpenChange={setIsDeleteDialogOpen}
+ onConfirm={handleDeleteProject}
+ projectName={project.name}
+ />
+ <RenameProjectDialog
+ isOpen={isRenameDialogOpen}
+ onOpenChange={setIsRenameDialogOpen}
+ onConfirm={handleRenameProject}
+ projectName={project.name}
+ />
+ </>
+ );
}
function CreateButton({ onClick }: { onClick?: () => void }) {
- return (
- <Button className="flex" onClick={onClick}>
- <Plus className="!size-4" />
- <span className="text-sm font-medium">New project</span>
- </Button>
- );
+ return (
+ <Button className="flex" onClick={onClick}>
+ <Plus className="!size-4" />
+ <span className="text-sm font-medium">New project</span>
+ </Button>
+ );
}
function NoProjects({ onCreateProject }: { onCreateProject: () => void }) {
- return (
- <div className="flex flex-col items-center justify-center py-16 text-center">
- <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
- <Video className="h-8 w-8 text-muted-foreground" />
- </div>
- <h3 className="text-lg font-medium mb-2">No projects yet</h3>
- <p className="text-muted-foreground mb-6 max-w-md">
- Start creating your first video project. Import media, edit, and export
- professional videos.
- </p>
- <Button size="lg" className="gap-2" onClick={onCreateProject}>
- <Plus className="h-4 w-4" />
- Create Your First Project
- </Button>
- </div>
- );
+ return (
+ <div className="flex flex-col items-center justify-center py-16 text-center">
+ <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
+ <Video className="h-8 w-8 text-muted-foreground" />
+ </div>
+ <h3 className="text-lg font-medium mb-2">No projects yet</h3>
+ <p className="text-muted-foreground mb-6 max-w-md">
+ Start creating your first video project. Import media, edit, and export
+ professional videos.
+ </p>
+ <Button size="lg" className="gap-2" onClick={onCreateProject}>
+ <Plus className="h-4 w-4" />
+ Create Your First Project
+ </Button>
+ </div>
+ );
}
function NoResults({
- searchQuery,
- onClearSearch,
+ searchQuery,
+ onClearSearch,
}: {
- searchQuery: string;
- onClearSearch: () => void;
+ searchQuery: string;
+ onClearSearch: () => void;
}) {
- return (
- <div className="flex flex-col items-center justify-center py-16 text-center">
- <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
- <Search className="h-8 w-8 text-muted-foreground" />
- </div>
- <h3 className="text-lg font-medium mb-2">No results found</h3>
- <p className="text-muted-foreground mb-6 max-w-md">
- Your search for "{searchQuery}" did not return any results.
- </p>
- <Button onClick={onClearSearch} variant="outline">
- Clear Search
- </Button>
- </div>
- );
+ return (
+ <div className="flex flex-col items-center justify-center py-16 text-center">
+ <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
+ <Search className="h-8 w-8 text-muted-foreground" />
+ </div>
+ <h3 className="text-lg font-medium mb-2">No results found</h3>
+ <p className="text-muted-foreground mb-6 max-w-md">
+ Your search for "{searchQuery}" did not return any results.
+ </p>
+ <Button onClick={onClearSearch} variant="outline">
+ Clear Search
+ </Button>
+ </div>
+ );
}
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 6e48f7cd..1fcfc60e 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -1,20 +1,20 @@
import { create } from "zustand";
import {
- TrackType,
- TimelineElement,
- CreateTimelineElement,
- TimelineTrack,
- TextElement,
- DragData,
- sortTracksByOrder,
- ensureMainTrack,
- validateElementTrackCompatibility,
+ TrackType,
+ TimelineElement,
+ CreateTimelineElement,
+ TimelineTrack,
+ TextElement,
+ DragData,
+ sortTracksByOrder,
+ ensureMainTrack,
+ validateElementTrackCompatibility,
} from "@/types/timeline";
import { useEditorStore } from "./editor-store";
import {
- useMediaStore,
- getMediaAspectRatio,
- type MediaItem,
+ useMediaStore,
+ getMediaAspectRatio,
+ type MediaItem,
} from "./media-store";
import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store";
@@ -25,1396 +25,1394 @@ import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
// Helper function to manage element naming with suffixes
const getElementNameWithSuffix = (
- originalName: string,
- suffix: string,
+ originalName: string,
+ suffix: string
): string => {
- // Remove existing suffixes to prevent accumulation
- const baseName = originalName
- .replace(/ \(left\)$/, "")
- .replace(/ \(right\)$/, "")
- .replace(/ \(audio\)$/, "")
- .replace(/ \(split \d+\)$/, "");
+ // Remove existing suffixes to prevent accumulation
+ const baseName = originalName
+ .replace(/ \(left\)$/, "")
+ .replace(/ \(right\)$/, "")
+ .replace(/ \(audio\)$/, "")
+ .replace(/ \(split \d+\)$/, "");
- return `${baseName} (${suffix})`;
+ return `${baseName} (${suffix})`;
};
interface TimelineStore {
- // Private track storage
- _tracks: TimelineTrack[];
- history: TimelineTrack[][];
- redoStack: TimelineTrack[][];
+ // Private track storage
+ _tracks: TimelineTrack[];
+ history: TimelineTrack[][];
+ redoStack: TimelineTrack[][];
- // Always returns properly ordered tracks with main track ensured
- tracks: TimelineTrack[];
+ // Always returns properly ordered tracks with main track ensured
+ tracks: TimelineTrack[];
- // Manual method if you need to force recomputation
- getSortedTracks: () => TimelineTrack[];
+ // Manual method if you need to force recomputation
+ getSortedTracks: () => TimelineTrack[];
- // Snapping settings
- snappingEnabled: boolean;
+ // Snapping settings
+ snappingEnabled: boolean;
- // Snapping actions
- toggleSnapping: () => void;
+ // Snapping actions
+ toggleSnapping: () => void;
- // Ripple editing mode
- rippleEditingEnabled: boolean;
- toggleRippleEditing: () => void;
+ // Ripple editing mode
+ rippleEditingEnabled: boolean;
+ toggleRippleEditing: () => void;
- // Multi-selection
- selectedElements: { trackId: string; elementId: string }[];
- selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
- deselectElement: (trackId: string, elementId: string) => void;
- clearSelectedElements: () => void;
- setSelectedElements: (
- elements: { trackId: string; elementId: string }[],
- ) => void;
+ // Multi-selection
+ selectedElements: { trackId: string; elementId: string }[];
+ selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
+ deselectElement: (trackId: string, elementId: string) => void;
+ clearSelectedElements: () => void;
+ setSelectedElements: (
+ elements: { trackId: string; elementId: string }[]
+ ) => void;
- // Drag state
- dragState: {
- isDragging: boolean;
- elementId: string | null;
- trackId: string | null;
- startMouseX: number;
- startElementTime: number;
- clickOffsetTime: number;
- currentTime: number;
- };
- setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
- startDrag: (
- elementId: string,
- trackId: string,
- startMouseX: number,
- startElementTime: number,
- clickOffsetTime: number,
- ) => void;
- updateDragTime: (currentTime: number) => void;
- endDrag: () => void;
+ // Drag state
+ dragState: {
+ isDragging: boolean;
+ elementId: string | null;
+ trackId: string | null;
+ startMouseX: number;
+ startElementTime: number;
+ clickOffsetTime: number;
+ currentTime: number;
+ };
+ setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
+ startDrag: (
+ elementId: string,
+ trackId: string,
+ startMouseX: number,
+ startElementTime: number,
+ clickOffsetTime: number
+ ) => void;
+ updateDragTime: (currentTime: number) => void;
+ endDrag: () => void;
- // Actions
- addTrack: (type: TrackType) => string;
- insertTrackAt: (type: TrackType, index: number) => string;
- removeTrack: (trackId: string) => void;
- removeTrackWithRipple: (trackId: string) => void;
- addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
- removeElementFromTrack: (trackId: string, elementId: string) => void;
- moveElementToTrack: (
- fromTrackId: string,
- toTrackId: string,
- elementId: string,
- ) => void;
- updateElementTrim: (
- trackId: string,
- elementId: string,
- trimStart: number,
- trimEnd: number,
- ) => void;
- updateElementDuration: (
- trackId: string,
- elementId: string,
- duration: number,
- ) => void;
- updateElementStartTime: (
- trackId: string,
- elementId: string,
- startTime: number,
- ) => void;
- toggleTrackMute: (trackId: string) => void;
+ // Actions
+ addTrack: (type: TrackType) => string;
+ insertTrackAt: (type: TrackType, index: number) => string;
+ removeTrack: (trackId: string) => void;
+ removeTrackWithRipple: (trackId: string) => void;
+ addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
+ removeElementFromTrack: (trackId: string, elementId: string) => void;
+ moveElementToTrack: (
+ fromTrackId: string,
+ toTrackId: string,
+ elementId: string
+ ) => void;
+ updateElementTrim: (
+ trackId: string,
+ elementId: string,
+ trimStart: number,
+ trimEnd: number
+ ) => void;
+ updateElementDuration: (
+ trackId: string,
+ elementId: string,
+ duration: number
+ ) => void;
+ updateElementStartTime: (
+ trackId: string,
+ elementId: string,
+ startTime: number
+ ) => void;
+ toggleTrackMute: (trackId: string) => void;
- // Split operations for elements
- splitElement: (
- trackId: string,
- elementId: string,
- splitTime: number,
- ) => string | null;
- splitAndKeepLeft: (
- trackId: string,
- elementId: string,
- splitTime: number,
- ) => void;
- splitAndKeepRight: (
- trackId: string,
- elementId: string,
- splitTime: number,
- ) => void;
- separateAudio: (trackId: string, elementId: string) => string | null;
+ // Split operations for elements
+ splitElement: (
+ trackId: string,
+ elementId: string,
+ splitTime: number
+ ) => string | null;
+ splitAndKeepLeft: (
+ trackId: string,
+ elementId: string,
+ splitTime: number
+ ) => void;
+ splitAndKeepRight: (
+ trackId: string,
+ elementId: string,
+ splitTime: number
+ ) => void;
+ separateAudio: (trackId: string, elementId: string) => string | null;
- // Replace media for an element
- replaceElementMedia: (
- trackId: string,
- elementId: string,
- newFile: File,
- ) => Promise<boolean>;
+ // Replace media for an element
+ replaceElementMedia: (
+ trackId: string,
+ elementId: string,
+ newFile: File
+ ) => Promise<boolean>;
- // Ripple editing functions
- updateElementStartTimeWithRipple: (
- trackId: string,
- elementId: string,
- newStartTime: number,
- ) => void;
- removeElementFromTrackWithRipple: (
- trackId: string,
- elementId: string,
- ) => void;
+ // Ripple editing functions
+ updateElementStartTimeWithRipple: (
+ trackId: string,
+ elementId: string,
+ newStartTime: number
+ ) => void;
+ removeElementFromTrackWithRipple: (
+ trackId: string,
+ elementId: string
+ ) => void;
- // Computed values
- getTotalDuration: () => number;
+ // Computed values
+ getTotalDuration: () => number;
- // History actions
- undo: () => void;
- redo: () => void;
- pushHistory: () => void;
+ // History actions
+ undo: () => void;
+ redo: () => void;
+ pushHistory: () => void;
- // Persistence actions
- loadProjectTimeline: (projectId: string) => Promise<void>;
- saveProjectTimeline: (projectId: string) => Promise<void>;
- clearTimeline: () => void;
- updateTextElement: (
- trackId: string,
- elementId: string,
- updates: Partial<
- Pick<
- TextElement,
- | "content"
- | "fontSize"
- | "fontFamily"
- | "color"
- | "backgroundColor"
- | "textAlign"
- | "fontWeight"
- | "fontStyle"
- | "textDecoration"
- | "x"
- | "y"
- | "rotation"
- | "opacity"
- >
- >,
- ) => void;
- checkElementOverlap: (
- trackId: string,
- startTime: number,
- duration: number,
- excludeElementId?: string,
- ) => boolean;
- findOrCreateTrack: (trackType: TrackType) => string;
- addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean;
- addTextAtTime: (item: TextElement, currentTime?: number) => boolean;
- addMediaToNewTrack: (item: MediaItem) => boolean;
- addTextToNewTrack: (item: TextElement | DragData) => boolean;
+ // Persistence actions
+ loadProjectTimeline: (projectId: string) => Promise<void>;
+ saveProjectTimeline: (projectId: string) => Promise<void>;
+ clearTimeline: () => void;
+ updateTextElement: (
+ trackId: string,
+ elementId: string,
+ updates: Partial<
+ Pick<
+ TextElement,
+ | "content"
+ | "fontSize"
+ | "fontFamily"
+ | "color"
+ | "backgroundColor"
+ | "textAlign"
+ | "fontWeight"
+ | "fontStyle"
+ | "textDecoration"
+ | "x"
+ | "y"
+ | "rotation"
+ | "opacity"
+ >
+ >
+ ) => void;
+ checkElementOverlap: (
+ trackId: string,
+ startTime: number,
+ duration: number,
+ excludeElementId?: string
+ ) => boolean;
+ findOrCreateTrack: (trackType: TrackType) => string;
+ addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean;
+ addTextAtTime: (item: TextElement, currentTime?: number) => boolean;
+ addMediaToNewTrack: (item: MediaItem) => boolean;
+ addTextToNewTrack: (item: TextElement | DragData) => boolean;
}
export const useTimelineStore = create<TimelineStore>((set, get) => {
- // Helper to update tracks and maintain ordering
- const updateTracks = (newTracks: TimelineTrack[]) => {
- const tracksWithMain = ensureMainTrack(newTracks);
- const sortedTracks = sortTracksByOrder(tracksWithMain);
- set({
- _tracks: tracksWithMain,
- tracks: sortedTracks,
- });
- };
+ // Helper to update tracks and maintain ordering
+ const updateTracks = (newTracks: TimelineTrack[]) => {
+ const tracksWithMain = ensureMainTrack(newTracks);
+ const sortedTracks = sortTracksByOrder(tracksWithMain);
+ set({
+ _tracks: tracksWithMain,
+ tracks: sortedTracks,
+ });
+ };
- // Helper to auto-save timeline changes
- const autoSaveTimeline = async () => {
- const activeProject = useProjectStore.getState().activeProject;
- if (activeProject) {
- try {
- await storageService.saveTimeline(activeProject.id, get()._tracks);
- } catch (error) {
- console.error("Failed to auto-save timeline:", error);
- }
- }
- };
+ // Helper to auto-save timeline changes
+ const autoSaveTimeline = async () => {
+ const activeProject = useProjectStore.getState().activeProject;
+ if (activeProject) {
+ try {
+ await storageService.saveTimeline(activeProject.id, get()._tracks);
+ } catch (error) {
+ console.error("Failed to auto-save timeline:", error);
+ }
+ }
+ };
- // Helper to update tracks and auto-save
- const updateTracksAndSave = (newTracks: TimelineTrack[]) => {
- updateTracks(newTracks);
- // Auto-save in background
- setTimeout(autoSaveTimeline, 100);
- };
+ // Helper to update tracks and auto-save
+ const updateTracksAndSave = (newTracks: TimelineTrack[]) => {
+ updateTracks(newTracks);
+ // Auto-save in background
+ setTimeout(autoSaveTimeline, 100);
+ };
- // Initialize with proper track ordering
- const initialTracks = ensureMainTrack([]);
- const sortedInitialTracks = sortTracksByOrder(initialTracks);
+ // Initialize with proper track ordering
+ const initialTracks = ensureMainTrack([]);
+ const sortedInitialTracks = sortTracksByOrder(initialTracks);
- return {
- _tracks: initialTracks,
- tracks: sortedInitialTracks,
- history: [],
- redoStack: [],
- selectedElements: [],
- rippleEditingEnabled: false,
+ return {
+ _tracks: initialTracks,
+ tracks: sortedInitialTracks,
+ history: [],
+ redoStack: [],
+ selectedElements: [],
+ rippleEditingEnabled: false,
- // Snapping settings defaults
- snappingEnabled: true,
+ // Snapping settings defaults
+ snappingEnabled: true,
- getSortedTracks: () => {
- const { _tracks } = get();
- const tracksWithMain = ensureMainTrack(_tracks);
- return sortTracksByOrder(tracksWithMain);
- },
+ getSortedTracks: () => {
+ const { _tracks } = get();
+ const tracksWithMain = ensureMainTrack(_tracks);
+ return sortTracksByOrder(tracksWithMain);
+ },
- pushHistory: () => {
- const { _tracks, history } = get();
- set({
- history: [...history, JSON.parse(JSON.stringify(_tracks))],
- redoStack: [],
- });
- },
+ pushHistory: () => {
+ const { _tracks, history } = get();
+ set({
+ history: [...history, JSON.parse(JSON.stringify(_tracks))],
+ redoStack: [],
+ });
+ },
- undo: () => {
- const { history, redoStack, _tracks } = get();
- if (history.length === 0) return;
- const prev = history[history.length - 1];
- updateTracksAndSave(prev);
- set({
- history: history.slice(0, -1),
- redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))],
- });
- },
+ undo: () => {
+ const { history, redoStack, _tracks } = get();
+ if (history.length === 0) return;
+ const prev = history[history.length - 1];
+ updateTracksAndSave(prev);
+ set({
+ history: history.slice(0, -1),
+ redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))],
+ });
+ },
- selectElement: (trackId, elementId, multi = false) => {
- set((state) => {
- const exists = state.selectedElements.some(
- (c) => c.trackId === trackId && c.elementId === elementId,
- );
- if (multi) {
- return exists
- ? {
- selectedElements: state.selectedElements.filter(
- (c) => !(c.trackId === trackId && c.elementId === elementId),
- ),
- }
- : {
- selectedElements: [
- ...state.selectedElements,
- { trackId, elementId },
- ],
- };
- } else {
- return { selectedElements: [{ trackId, elementId }] };
- }
- });
- },
+ selectElement: (trackId, elementId, multi = false) => {
+ set((state) => {
+ const exists = state.selectedElements.some(
+ (c) => c.trackId === trackId && c.elementId === elementId
+ );
+ if (multi) {
+ return exists
+ ? {
+ selectedElements: state.selectedElements.filter(
+ (c) => !(c.trackId === trackId && c.elementId === elementId)
+ ),
+ }
+ : {
+ selectedElements: [
+ ...state.selectedElements,
+ { trackId, elementId },
+ ],
+ };
+ } else {
+ return { selectedElements: [{ trackId, elementId }] };
+ }
+ });
+ },
- deselectElement: (trackId, elementId) => {
- set((state) => ({
- selectedElements: state.selectedElements.filter(
- (c) => !(c.trackId === trackId && c.elementId === elementId),
- ),
- }));
- },
+ deselectElement: (trackId, elementId) => {
+ set((state) => ({
+ selectedElements: state.selectedElements.filter(
+ (c) => !(c.trackId === trackId && c.elementId === elementId)
+ ),
+ }));
+ },
- clearSelectedElements: () => {
- set({ selectedElements: [] });
- },
+ clearSelectedElements: () => {
+ set({ selectedElements: [] });
+ },
- setSelectedElements: (elements) => set({ selectedElements: elements }),
+ setSelectedElements: (elements) => set({ selectedElements: elements }),
- addTrack: (type) => {
- get().pushHistory();
+ addTrack: (type) => {
+ get().pushHistory();
- // Generate proper track name based on type
- const trackName =
- type === "media"
- ? "Media Track"
- : type === "text"
- ? "Text Track"
- : type === "audio"
- ? "Audio Track"
- : "Track";
+ // Generate proper track name based on type
+ const trackName =
+ type === "media"
+ ? "Media Track"
+ : type === "text"
+ ? "Text Track"
+ : type === "audio"
+ ? "Audio Track"
+ : "Track";
- const newTrack: TimelineTrack = {
- id: generateUUID(),
- name: trackName,
- type,
- elements: [],
- muted: false,
- };
+ const newTrack: TimelineTrack = {
+ id: generateUUID(),
+ name: trackName,
+ type,
+ elements: [],
+ muted: false,
+ };
- updateTracksAndSave([...get()._tracks, newTrack]);
- return newTrack.id;
- },
+ updateTracksAndSave([...get()._tracks, newTrack]);
+ return newTrack.id;
+ },
- insertTrackAt: (type, index) => {
- get().pushHistory();
+ insertTrackAt: (type, index) => {
+ get().pushHistory();
- // Generate proper track name based on type
- const trackName =
- type === "media"
- ? "Media Track"
- : type === "text"
- ? "Text Track"
- : type === "audio"
- ? "Audio Track"
- : "Track";
+ // Generate proper track name based on type
+ const trackName =
+ type === "media"
+ ? "Media Track"
+ : type === "text"
+ ? "Text Track"
+ : type === "audio"
+ ? "Audio Track"
+ : "Track";
- const newTrack: TimelineTrack = {
- id: generateUUID(),
- name: trackName,
- type,
- elements: [],
- muted: false,
- };
+ const newTrack: TimelineTrack = {
+ id: generateUUID(),
+ name: trackName,
+ type,
+ elements: [],
+ muted: false,
+ };
- const newTracks = [...get()._tracks];
- newTracks.splice(index, 0, newTrack);
- updateTracksAndSave(newTracks);
- return newTrack.id;
- },
+ const newTracks = [...get()._tracks];
+ newTracks.splice(index, 0, newTrack);
+ updateTracksAndSave(newTracks);
+ return newTrack.id;
+ },
- removeTrack: (trackId) => {
- const { rippleEditingEnabled } = get();
+ removeTrack: (trackId) => {
+ const { rippleEditingEnabled } = get();
- if (rippleEditingEnabled) {
- get().removeTrackWithRipple(trackId);
- } else {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.filter((track) => track.id !== trackId),
- );
- }
- },
+ if (rippleEditingEnabled) {
+ get().removeTrackWithRipple(trackId);
+ } else {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.filter((track) => track.id !== trackId)
+ );
+ }
+ },
- removeTrackWithRipple: (trackId) => {
- const { _tracks } = get();
- const trackToRemove = _tracks.find((t) => t.id === trackId);
+ removeTrackWithRipple: (trackId) => {
+ const { _tracks } = get();
+ const trackToRemove = _tracks.find((t) => t.id === trackId);
- if (!trackToRemove) return;
+ if (!trackToRemove) return;
- get().pushHistory();
+ get().pushHistory();
- // If track has no elements, just remove it normally
- if (trackToRemove.elements.length === 0) {
- updateTracksAndSave(_tracks.filter((track) => track.id !== trackId));
- return;
- }
+ // If track has no elements, just remove it normally
+ if (trackToRemove.elements.length === 0) {
+ updateTracksAndSave(_tracks.filter((track) => track.id !== trackId));
+ return;
+ }
- // Find all the time ranges occupied by elements in the track being removed
- const occupiedRanges = trackToRemove.elements.map((element) => ({
- startTime: element.startTime,
- endTime:
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd),
- }));
+ // Find all the time ranges occupied by elements in the track being removed
+ const occupiedRanges = trackToRemove.elements.map((element) => ({
+ startTime: element.startTime,
+ endTime:
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd),
+ }));
- // Sort ranges by start time
- occupiedRanges.sort((a, b) => a.startTime - b.startTime);
+ // Sort ranges by start time
+ occupiedRanges.sort((a, b) => a.startTime - b.startTime);
- // Merge overlapping ranges to get consolidated gaps
- const mergedRanges: Array<{
- startTime: number;
- endTime: number;
- duration: number;
- }> = [];
+ // Merge overlapping ranges to get consolidated gaps
+ const mergedRanges: Array<{
+ startTime: number;
+ endTime: number;
+ duration: number;
+ }> = [];
- for (const range of occupiedRanges) {
- if (mergedRanges.length === 0) {
- mergedRanges.push({
- startTime: range.startTime,
- endTime: range.endTime,
- duration: range.endTime - range.startTime,
- });
- } else {
- const lastRange = mergedRanges[mergedRanges.length - 1];
- if (range.startTime <= lastRange.endTime) {
- // Overlapping or adjacent ranges, merge them
- lastRange.endTime = Math.max(lastRange.endTime, range.endTime);
- lastRange.duration = lastRange.endTime - lastRange.startTime;
- } else {
- // Non-overlapping range, add as new
- mergedRanges.push({
- startTime: range.startTime,
- endTime: range.endTime,
- duration: range.endTime - range.startTime,
- });
- }
- }
- }
+ for (const range of occupiedRanges) {
+ if (mergedRanges.length === 0) {
+ mergedRanges.push({
+ startTime: range.startTime,
+ endTime: range.endTime,
+ duration: range.endTime - range.startTime,
+ });
+ } else {
+ const lastRange = mergedRanges[mergedRanges.length - 1];
+ if (range.startTime <= lastRange.endTime) {
+ // Overlapping or adjacent ranges, merge them
+ lastRange.endTime = Math.max(lastRange.endTime, range.endTime);
+ lastRange.duration = lastRange.endTime - lastRange.startTime;
+ } else {
+ // Non-overlapping range, add as new
+ mergedRanges.push({
+ startTime: range.startTime,
+ endTime: range.endTime,
+ duration: range.endTime - range.startTime,
+ });
+ }
+ }
+ }
- // Remove the track and apply ripple effects to remaining tracks
- const updatedTracks = _tracks
- .filter((track) => track.id !== trackId)
- .map((track) => {
- const updatedElements = track.elements.map((element) => {
- let newStartTime = element.startTime;
+ // Remove the track and apply ripple effects to remaining tracks
+ const updatedTracks = _tracks
+ .filter((track) => track.id !== trackId)
+ .map((track) => {
+ const updatedElements = track.elements.map((element) => {
+ let newStartTime = element.startTime;
- // Process gaps from right to left (latest to earliest) to avoid cumulative shifts
- for (let i = mergedRanges.length - 1; i >= 0; i--) {
- const gap = mergedRanges[i];
- // If this element starts after the gap, shift it left by the gap duration
- if (newStartTime >= gap.endTime) {
- newStartTime -= gap.duration;
- }
- }
+ // Process gaps from right to left (latest to earliest) to avoid cumulative shifts
+ for (let i = mergedRanges.length - 1; i >= 0; i--) {
+ const gap = mergedRanges[i];
+ // If this element starts after the gap, shift it left by the gap duration
+ if (newStartTime >= gap.endTime) {
+ newStartTime -= gap.duration;
+ }
+ }
- return {
- ...element,
- startTime: Math.max(0, newStartTime),
- };
- });
+ return {
+ ...element,
+ startTime: Math.max(0, newStartTime),
+ };
+ });
- // Check for overlaps and resolve them if necessary
- const hasOverlaps = checkElementOverlaps(updatedElements);
- if (hasOverlaps) {
- const resolvedElements = resolveElementOverlaps(updatedElements);
- return { ...track, elements: resolvedElements };
- }
+ // Check for overlaps and resolve them if necessary
+ const hasOverlaps = checkElementOverlaps(updatedElements);
+ if (hasOverlaps) {
+ const resolvedElements = resolveElementOverlaps(updatedElements);
+ return { ...track, elements: resolvedElements };
+ }
- return { ...track, elements: updatedElements };
- });
+ return { ...track, elements: updatedElements };
+ });
- updateTracksAndSave(updatedTracks);
- },
+ updateTracksAndSave(updatedTracks);
+ },
- addElementToTrack: (trackId, elementData) => {
- get().pushHistory();
+ addElementToTrack: (trackId, elementData) => {
+ get().pushHistory();
- // Validate element type matches track type
- const track = get()._tracks.find((t) => t.id === trackId);
- if (!track) {
- console.error("Track not found:", trackId);
- return;
- }
+ // Validate element type matches track type
+ const track = get()._tracks.find((t) => t.id === trackId);
+ if (!track) {
+ console.error("Track not found:", trackId);
+ return;
+ }
- // Use utility function for validation
- const validation = validateElementTrackCompatibility(elementData, track);
- if (!validation.isValid) {
- console.error(validation.errorMessage);
- return;
- }
+ // Use utility function for validation
+ const validation = validateElementTrackCompatibility(elementData, track);
+ if (!validation.isValid) {
+ console.error(validation.errorMessage);
+ return;
+ }
- // For media elements, validate mediaId exists
- if (elementData.type === "media" && !elementData.mediaId) {
- console.error("Media element must have mediaId");
- return;
- }
+ // For media elements, validate mediaId exists
+ if (elementData.type === "media" && !elementData.mediaId) {
+ console.error("Media element must have mediaId");
+ return;
+ }
- // For text elements, validate required text properties
- if (elementData.type === "text" && !elementData.content) {
- console.error("Text element must have content");
- return;
- }
+ // For text elements, validate required text properties
+ if (elementData.type === "text" && !elementData.content) {
+ console.error("Text element must have content");
+ return;
+ }
- // Check if this is the first element being added to the timeline
- const currentState = get();
- const totalElementsInTimeline = currentState._tracks.reduce(
- (total, track) => total + track.elements.length,
- 0,
- );
- const isFirstElement = totalElementsInTimeline === 0;
+ // Check if this is the first element being added to the timeline
+ const currentState = get();
+ const totalElementsInTimeline = currentState._tracks.reduce(
+ (total, track) => total + track.elements.length,
+ 0
+ );
+ const isFirstElement = totalElementsInTimeline === 0;
- const newElement: TimelineElement = {
- ...elementData,
- id: generateUUID(),
- startTime: elementData.startTime || 0,
- trimStart: 0,
- trimEnd: 0,
- } as TimelineElement; // Type assertion since we trust the caller passes valid data
+ const newElement: TimelineElement = {
+ ...elementData,
+ id: generateUUID(),
+ startTime: elementData.startTime || 0,
+ trimStart: 0,
+ trimEnd: 0,
+ } as TimelineElement; // Type assertion since we trust the caller passes valid data
- // If this is the first element and it's a media element, automatically set the project canvas size
- // to match the media's aspect ratio and FPS (for videos)
- if (isFirstElement && newElement.type === "media") {
- const mediaStore = useMediaStore.getState();
- const mediaItem = mediaStore.mediaItems.find(
- (item) => item.id === newElement.mediaId,
- );
+ // If this is the first element and it's a media element, automatically set the project canvas size
+ // to match the media's aspect ratio and FPS (for videos)
+ if (isFirstElement && newElement.type === "media") {
+ const mediaStore = useMediaStore.getState();
+ const mediaItem = mediaStore.mediaItems.find(
+ (item) => item.id === newElement.mediaId
+ );
- if (
- mediaItem &&
- (mediaItem.type === "image" || mediaItem.type === "video")
- ) {
- const editorStore = useEditorStore.getState();
- editorStore.setCanvasSizeFromAspectRatio(
- getMediaAspectRatio(mediaItem),
- );
- }
+ if (
+ mediaItem &&
+ (mediaItem.type === "image" || mediaItem.type === "video")
+ ) {
+ const editorStore = useEditorStore.getState();
+ editorStore.setCanvasSizeFromAspectRatio(
+ getMediaAspectRatio(mediaItem)
+ );
+ }
- // Set project FPS from the first video element
- if (mediaItem && mediaItem.type === "video" && mediaItem.fps) {
- const projectStore = useProjectStore.getState();
- if (projectStore.activeProject) {
- projectStore.updateProjectFps(mediaItem.fps);
- }
- }
- }
+ // Set project FPS from the first video element
+ if (mediaItem && mediaItem.type === "video" && mediaItem.fps) {
+ const projectStore = useProjectStore.getState();
+ if (projectStore.activeProject) {
+ projectStore.updateProjectFps(mediaItem.fps);
+ }
+ }
+ }
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? { ...track, elements: [...track.elements, newElement] }
- : track,
- ),
- );
- },
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? { ...track, elements: [...track.elements, newElement] }
+ : track
+ )
+ );
+ },
- removeElementFromTrack: (trackId, elementId) => {
- const { rippleEditingEnabled } = get();
+ removeElementFromTrack: (trackId, elementId) => {
+ const { rippleEditingEnabled } = get();
- if (rippleEditingEnabled) {
- get().removeElementFromTrackWithRipple(trackId, elementId);
- } else {
- get().pushHistory();
- updateTracksAndSave(
- get()
- ._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.filter(
- (element) => element.id !== elementId,
- ),
- }
- : track,
- )
- .filter((track) => track.elements.length > 0),
- );
- }
- },
+ if (rippleEditingEnabled) {
+ get().removeElementFromTrackWithRipple(trackId, elementId);
+ } else {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()
+ ._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.filter(
+ (element) => element.id !== elementId
+ ),
+ }
+ : track
+ )
+ .filter((track) => track.elements.length > 0)
+ );
+ }
+ },
- removeElementFromTrackWithRipple: (trackId, elementId) => {
- const { _tracks, rippleEditingEnabled } = get();
+ removeElementFromTrackWithRipple: (trackId, elementId) => {
+ const { _tracks, rippleEditingEnabled } = get();
- if (!rippleEditingEnabled) {
- // If ripple editing is disabled, use regular removal
- get().removeElementFromTrack(trackId, elementId);
- return;
- }
+ if (!rippleEditingEnabled) {
+ // If ripple editing is disabled, use regular removal
+ get().removeElementFromTrack(trackId, elementId);
+ return;
+ }
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
- if (!element || !track) return;
+ if (!element || !track) return;
- get().pushHistory();
+ get().pushHistory();
- const elementStartTime = element.startTime;
- const elementDuration =
- element.duration - element.trimStart - element.trimEnd;
- const elementEndTime = elementStartTime + elementDuration;
+ const elementStartTime = element.startTime;
+ const elementDuration =
+ element.duration - element.trimStart - element.trimEnd;
+ const elementEndTime = elementStartTime + elementDuration;
- // Remove the element and shift all elements that come after it
- const updatedTracks = _tracks
- .map((currentTrack) => {
- // Only apply ripple effects to the same track unless multi-track ripple is enabled
- const shouldApplyRipple = currentTrack.id === trackId;
+ // Remove the element and shift all elements that come after it
+ const updatedTracks = _tracks
+ .map((currentTrack) => {
+ // Only apply ripple effects to the same track unless multi-track ripple is enabled
+ const shouldApplyRipple = currentTrack.id === trackId;
- const updatedElements = currentTrack.elements
- .filter((currentElement) => {
- // Remove the target element
- if (
- currentElement.id === elementId &&
- currentTrack.id === trackId
- ) {
- return false;
- }
- return true;
- })
- .map((currentElement) => {
- // Only apply ripple effects if we should process this track
- if (!shouldApplyRipple) {
- return currentElement;
- }
+ const updatedElements = currentTrack.elements
+ .filter((currentElement) => {
+ // Remove the target element
+ if (
+ currentElement.id === elementId &&
+ currentTrack.id === trackId
+ ) {
+ return false;
+ }
+ return true;
+ })
+ .map((currentElement) => {
+ // Only apply ripple effects if we should process this track
+ if (!shouldApplyRipple) {
+ return currentElement;
+ }
- // Shift elements that start after the removed element
- if (currentElement.startTime >= elementEndTime) {
- return {
- ...currentElement,
- startTime: Math.max(
- 0,
- currentElement.startTime - elementDuration,
- ),
- };
- }
- return currentElement;
- });
+ // Shift elements that start after the removed element
+ if (currentElement.startTime >= elementEndTime) {
+ return {
+ ...currentElement,
+ startTime: Math.max(
+ 0,
+ currentElement.startTime - elementDuration
+ ),
+ };
+ }
+ return currentElement;
+ });
- // Check for overlaps and resolve them if necessary
- const hasOverlaps = checkElementOverlaps(updatedElements);
- if (hasOverlaps) {
- // Resolve overlaps by adjusting element positions
- const resolvedElements = resolveElementOverlaps(updatedElements);
- return { ...currentTrack, elements: resolvedElements };
- }
+ // Check for overlaps and resolve them if necessary
+ const hasOverlaps = checkElementOverlaps(updatedElements);
+ if (hasOverlaps) {
+ // Resolve overlaps by adjusting element positions
+ const resolvedElements = resolveElementOverlaps(updatedElements);
+ return { ...currentTrack, elements: resolvedElements };
+ }
- return { ...currentTrack, elements: updatedElements };
- })
- .filter((track) => track.elements.length > 0 || track.isMain);
+ return { ...currentTrack, elements: updatedElements };
+ })
+ .filter((track) => track.elements.length > 0 || track.isMain);
- updateTracksAndSave(updatedTracks);
- },
+ updateTracksAndSave(updatedTracks);
+ },
- moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
- get().pushHistory();
+ moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
+ get().pushHistory();
- const fromTrack = get()._tracks.find((track) => track.id === fromTrackId);
- const toTrack = get()._tracks.find((track) => track.id === toTrackId);
- const elementToMove = fromTrack?.elements.find(
- (element) => element.id === elementId,
- );
+ const fromTrack = get()._tracks.find((track) => track.id === fromTrackId);
+ const toTrack = get()._tracks.find((track) => track.id === toTrackId);
+ const elementToMove = fromTrack?.elements.find(
+ (element) => element.id === elementId
+ );
- if (!elementToMove || !toTrack) return;
+ if (!elementToMove || !toTrack) return;
- // Validate element type compatibility with target track
- const validation = validateElementTrackCompatibility(
- elementToMove,
- toTrack,
- );
- if (!validation.isValid) {
- console.error(validation.errorMessage);
- return;
- }
+ // Validate element type compatibility with target track
+ const validation = validateElementTrackCompatibility(
+ elementToMove,
+ toTrack
+ );
+ if (!validation.isValid) {
+ console.error(validation.errorMessage);
+ return;
+ }
- const newTracks = get()
- ._tracks.map((track) => {
- if (track.id === fromTrackId) {
- return {
- ...track,
- elements: track.elements.filter(
- (element) => element.id !== elementId,
- ),
- };
- } else if (track.id === toTrackId) {
- return {
- ...track,
- elements: [...track.elements, elementToMove],
- };
- }
- return track;
- })
- .filter((track) => track.elements.length > 0);
+ const newTracks = get()
+ ._tracks.map((track) => {
+ if (track.id === fromTrackId) {
+ return {
+ ...track,
+ elements: track.elements.filter(
+ (element) => element.id !== elementId
+ ),
+ };
+ } else if (track.id === toTrackId) {
+ return {
+ ...track,
+ elements: [...track.elements, elementToMove],
+ };
+ }
+ return track;
+ })
+ .filter((track) => track.elements.length > 0);
- updateTracksAndSave(newTracks);
- },
+ updateTracksAndSave(newTracks);
+ },
- updateElementTrim: (trackId, elementId, trimStart, trimEnd) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId
- ? { ...element, trimStart, trimEnd }
- : element,
- ),
- }
- : track,
- ),
- );
- },
+ updateElementTrim: (trackId, elementId, trimStart, trimEnd) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId
+ ? { ...element, trimStart, trimEnd }
+ : element
+ ),
+ }
+ : track
+ )
+ );
+ },
- updateElementDuration: (trackId, elementId, duration) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId ? { ...element, duration } : element,
- ),
- }
- : track,
- ),
- );
- },
+ updateElementDuration: (trackId, elementId, duration) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId ? { ...element, duration } : element
+ ),
+ }
+ : track
+ )
+ );
+ },
- updateElementStartTime: (trackId, elementId, startTime) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId
- ? { ...element, startTime }
- : element,
- ),
- }
- : track,
- ),
- );
- },
+ updateElementStartTime: (trackId, elementId, startTime) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId ? { ...element, startTime } : element
+ ),
+ }
+ : track
+ )
+ );
+ },
- updateElementStartTimeWithRipple: (trackId, elementId, newStartTime) => {
- const { _tracks, rippleEditingEnabled } = get();
+ updateElementStartTimeWithRipple: (trackId, elementId, newStartTime) => {
+ const { _tracks, rippleEditingEnabled } = get();
- if (!rippleEditingEnabled) {
- // If ripple editing is disabled, use regular update
- get().updateElementStartTime(trackId, elementId, newStartTime);
- return;
- }
+ if (!rippleEditingEnabled) {
+ // If ripple editing is disabled, use regular update
+ get().updateElementStartTime(trackId, elementId, newStartTime);
+ return;
+ }
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
- if (!element || !track) return;
+ if (!element || !track) return;
- get().pushHistory();
+ get().pushHistory();
- const oldStartTime = element.startTime;
- const oldEndTime =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
- const newEndTime =
- newStartTime + (element.duration - element.trimStart - element.trimEnd);
- const timeDelta = newStartTime - oldStartTime;
+ const oldStartTime = element.startTime;
+ const oldEndTime =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+ const newEndTime =
+ newStartTime + (element.duration - element.trimStart - element.trimEnd);
+ const timeDelta = newStartTime - oldStartTime;
- // Update tracks based on multi-track ripple setting
- const updatedTracks = _tracks.map((currentTrack) => {
- // Only apply ripple effects to the same track unless multi-track ripple is enabled
- const shouldApplyRipple = currentTrack.id === trackId;
+ // Update tracks based on multi-track ripple setting
+ const updatedTracks = _tracks.map((currentTrack) => {
+ // Only apply ripple effects to the same track unless multi-track ripple is enabled
+ const shouldApplyRipple = currentTrack.id === trackId;
- const updatedElements = currentTrack.elements.map((currentElement) => {
- if (currentElement.id === elementId && currentTrack.id === trackId) {
- // Update the moved element
- return { ...currentElement, startTime: newStartTime };
- }
+ const updatedElements = currentTrack.elements.map((currentElement) => {
+ if (currentElement.id === elementId && currentTrack.id === trackId) {
+ // Update the moved element
+ return { ...currentElement, startTime: newStartTime };
+ }
- // Only apply ripple effects if we should process this track
- if (!shouldApplyRipple) {
- return currentElement;
- }
+ // Only apply ripple effects if we should process this track
+ if (!shouldApplyRipple) {
+ return currentElement;
+ }
- // For ripple editing, we need to move elements that come after the moved element
- const currentElementStart = currentElement.startTime;
- const currentElementEnd =
- currentElement.startTime +
- (currentElement.duration -
- currentElement.trimStart -
- currentElement.trimEnd);
+ // For ripple editing, we need to move elements that come after the moved element
+ const currentElementStart = currentElement.startTime;
+ const currentElementEnd =
+ currentElement.startTime +
+ (currentElement.duration -
+ currentElement.trimStart -
+ currentElement.trimEnd);
- // If moving element to the right (positive delta)
- if (timeDelta > 0) {
- // Move elements that start after the original position of the moved element
- if (currentElementStart >= oldEndTime) {
- return {
- ...currentElement,
- startTime: currentElementStart + timeDelta,
- };
- }
- }
- // If moving element to the left (negative delta)
- else if (timeDelta < 0) {
- // Move elements that start after the new position of the moved element
- if (
- currentElementStart >= newEndTime &&
- currentElementStart >= oldStartTime
- ) {
- return {
- ...currentElement,
- startTime: Math.max(0, currentElementStart + timeDelta),
- };
- }
- }
+ // If moving element to the right (positive delta)
+ if (timeDelta > 0) {
+ // Move elements that start after the original position of the moved element
+ if (currentElementStart >= oldEndTime) {
+ return {
+ ...currentElement,
+ startTime: currentElementStart + timeDelta,
+ };
+ }
+ }
+ // If moving element to the left (negative delta)
+ else if (timeDelta < 0) {
+ // Move elements that start after the new position of the moved element
+ if (
+ currentElementStart >= newEndTime &&
+ currentElementStart >= oldStartTime
+ ) {
+ return {
+ ...currentElement,
+ startTime: Math.max(0, currentElementStart + timeDelta),
+ };
+ }
+ }
- return currentElement;
- });
+ return currentElement;
+ });
- // Check for overlaps and resolve them if necessary
- const hasOverlaps = checkElementOverlaps(updatedElements);
- if (hasOverlaps) {
- // Resolve overlaps by adjusting element positions
- const resolvedElements = resolveElementOverlaps(updatedElements);
- return { ...currentTrack, elements: resolvedElements };
- }
+ // Check for overlaps and resolve them if necessary
+ const hasOverlaps = checkElementOverlaps(updatedElements);
+ if (hasOverlaps) {
+ // Resolve overlaps by adjusting element positions
+ const resolvedElements = resolveElementOverlaps(updatedElements);
+ return { ...currentTrack, elements: resolvedElements };
+ }
- return { ...currentTrack, elements: updatedElements };
- });
+ return { ...currentTrack, elements: updatedElements };
+ });
- updateTracksAndSave(updatedTracks);
- },
+ updateTracksAndSave(updatedTracks);
+ },
- toggleTrackMute: (trackId) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId ? { ...track, muted: !track.muted } : track,
- ),
- );
- },
+ toggleTrackMute: (trackId) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId ? { ...track, muted: !track.muted } : track
+ )
+ );
+ },
- updateTextElement: (trackId, elementId, updates) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId && element.type === "text"
- ? { ...element, ...updates }
- : element,
- ),
- }
- : track,
- ),
- );
- },
+ updateTextElement: (trackId, elementId, updates) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId && element.type === "text"
+ ? { ...element, ...updates }
+ : element
+ ),
+ }
+ : track
+ )
+ );
+ },
- splitElement: (trackId, elementId, splitTime) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ splitElement: (trackId, elementId, splitTime) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return null;
+ if (!element) return null;
- 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 (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
- get().pushHistory();
+ get().pushHistory();
- const relativeTime = splitTime - element.startTime;
- const firstDuration = relativeTime;
- const secondDuration =
- element.duration - element.trimStart - element.trimEnd - relativeTime;
+ const relativeTime = splitTime - element.startTime;
+ const firstDuration = relativeTime;
+ const secondDuration =
+ element.duration - element.trimStart - element.trimEnd - relativeTime;
- const secondElementId = generateUUID();
+ const secondElementId = generateUUID();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.flatMap((c) =>
- c.id === elementId
- ? [
- {
- ...c,
- trimEnd: c.trimEnd + secondDuration,
- name: getElementNameWithSuffix(c.name, "left"),
- },
- {
- ...c,
- id: secondElementId,
- startTime: splitTime,
- trimStart: c.trimStart + firstDuration,
- name: getElementNameWithSuffix(c.name, "right"),
- },
- ]
- : [c],
- ),
- }
- : track,
- ),
- );
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.flatMap((c) =>
+ c.id === elementId
+ ? [
+ {
+ ...c,
+ trimEnd: c.trimEnd + secondDuration,
+ name: getElementNameWithSuffix(c.name, "left"),
+ },
+ {
+ ...c,
+ id: secondElementId,
+ startTime: splitTime,
+ trimStart: c.trimStart + firstDuration,
+ name: getElementNameWithSuffix(c.name, "right"),
+ },
+ ]
+ : [c]
+ ),
+ }
+ : track
+ )
+ );
- return secondElementId;
- },
+ return secondElementId;
+ },
- // Split element and keep only the left portion
- splitAndKeepLeft: (trackId, elementId, splitTime) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ // Split element and keep only the left portion
+ splitAndKeepLeft: (trackId, elementId, splitTime) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.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 (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
- get().pushHistory();
+ get().pushHistory();
- const relativeTime = splitTime - element.startTime;
- const durationToRemove =
- element.duration - element.trimStart - element.trimEnd - relativeTime;
+ const relativeTime = splitTime - element.startTime;
+ const durationToRemove =
+ element.duration - element.trimStart - element.trimEnd - relativeTime;
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((c) =>
- c.id === elementId
- ? {
- ...c,
- trimEnd: c.trimEnd + durationToRemove,
- name: getElementNameWithSuffix(c.name, "left"),
- }
- : c,
- ),
- }
- : track,
- ),
- );
- },
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((c) =>
+ c.id === elementId
+ ? {
+ ...c,
+ trimEnd: c.trimEnd + durationToRemove,
+ name: getElementNameWithSuffix(c.name, "left"),
+ }
+ : c
+ ),
+ }
+ : track
+ )
+ );
+ },
- // Split element and keep only the right portion
- splitAndKeepRight: (trackId, elementId, splitTime) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ // Split element and keep only the right portion
+ splitAndKeepRight: (trackId, elementId, splitTime) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.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 (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
- get().pushHistory();
+ get().pushHistory();
- const relativeTime = splitTime - element.startTime;
+ const relativeTime = splitTime - element.startTime;
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((c) =>
- c.id === elementId
- ? {
- ...c,
- startTime: splitTime,
- trimStart: c.trimStart + relativeTime,
- name: getElementNameWithSuffix(c.name, "right"),
- }
- : c,
- ),
- }
- : track,
- ),
- );
- },
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((c) =>
+ c.id === elementId
+ ? {
+ ...c,
+ startTime: splitTime,
+ trimStart: c.trimStart + relativeTime,
+ name: getElementNameWithSuffix(c.name, "right"),
+ }
+ : c
+ ),
+ }
+ : track
+ )
+ );
+ },
- // Extract audio from video element to an audio track
- separateAudio: (trackId, elementId) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ // Extract audio from video element to an audio track
+ separateAudio: (trackId, elementId) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!element || track?.type !== "media") return null;
+ if (!element || track?.type !== "media") return null;
- get().pushHistory();
+ get().pushHistory();
- // Find existing audio track or prepare to create one
- const existingAudioTrack = _tracks.find((t) => t.type === "audio");
- const audioElementId = generateUUID();
+ // Find existing audio track or prepare to create one
+ const existingAudioTrack = _tracks.find((t) => t.type === "audio");
+ const audioElementId = generateUUID();
- if (existingAudioTrack) {
- // Add audio element to existing audio track
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === existingAudioTrack.id
- ? {
- ...track,
- elements: [
- ...track.elements,
- {
- ...element,
- id: audioElementId,
- name: getElementNameWithSuffix(element.name, "audio"),
- },
- ],
- }
- : track,
- ),
- );
- } else {
- // Create new audio track with the audio element in a single atomic update
- const newAudioTrack: TimelineTrack = {
- id: generateUUID(),
- name: "Audio Track",
- type: "audio",
- elements: [
- {
- ...element,
- id: audioElementId,
- name: getElementNameWithSuffix(element.name, "audio"),
- },
- ],
- muted: false,
- };
+ if (existingAudioTrack) {
+ // Add audio element to existing audio track
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === existingAudioTrack.id
+ ? {
+ ...track,
+ elements: [
+ ...track.elements,
+ {
+ ...element,
+ id: audioElementId,
+ name: getElementNameWithSuffix(element.name, "audio"),
+ },
+ ],
+ }
+ : track
+ )
+ );
+ } else {
+ // Create new audio track with the audio element in a single atomic update
+ const newAudioTrack: TimelineTrack = {
+ id: generateUUID(),
+ name: "Audio Track",
+ type: "audio",
+ elements: [
+ {
+ ...element,
+ id: audioElementId,
+ name: getElementNameWithSuffix(element.name, "audio"),
+ },
+ ],
+ muted: false,
+ };
- updateTracksAndSave([...get()._tracks, newAudioTrack]);
- }
+ updateTracksAndSave([...get()._tracks, newAudioTrack]);
+ }
- return audioElementId;
- },
+ return audioElementId;
+ },
- // Replace media for an element
- replaceElementMedia: async (trackId, elementId, newFile) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ // Replace media for an element
+ replaceElementMedia: async (trackId, elementId, newFile) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!element || element.type !== "media") return false;
+ if (!element || element.type !== "media") return false;
- try {
- const mediaStore = useMediaStore.getState();
- const projectStore = useProjectStore.getState();
+ try {
+ const mediaStore = useMediaStore.getState();
+ const projectStore = useProjectStore.getState();
- if (!projectStore.activeProject) return false;
+ if (!projectStore.activeProject) return false;
- // Import required media processing functions
- const {
- getFileType,
- getImageDimensions,
- generateVideoThumbnail,
- getMediaDuration,
- } = await import("./media-store");
+ // Import required media processing functions
+ const {
+ getFileType,
+ getImageDimensions,
+ generateVideoThumbnail,
+ getMediaDuration,
+ } = await import("./media-store");
- const fileType = getFileType(newFile);
- if (!fileType) return false;
+ const fileType = getFileType(newFile);
+ if (!fileType) return false;
- // Process the new media file
- let mediaData: any = {
- name: newFile.name,
- type: fileType,
- file: newFile,
- url: URL.createObjectURL(newFile),
- };
+ // Process the new media file
+ let mediaData: any = {
+ name: newFile.name,
+ type: fileType,
+ file: newFile,
+ url: URL.createObjectURL(newFile),
+ };
- // Get media-specific metadata
- if (fileType === "image") {
- const { width, height } = await getImageDimensions(newFile);
- mediaData.width = width;
- mediaData.height = height;
- } else if (fileType === "video") {
- const [duration, { thumbnailUrl, width, height }] = await Promise.all(
- [getMediaDuration(newFile), generateVideoThumbnail(newFile)],
- );
- mediaData.duration = duration;
- mediaData.thumbnailUrl = thumbnailUrl;
- mediaData.width = width;
- mediaData.height = height;
- } else if (fileType === "audio") {
- mediaData.duration = await getMediaDuration(newFile);
- }
+ // Get media-specific metadata
+ if (fileType === "image") {
+ const { width, height } = await getImageDimensions(newFile);
+ mediaData.width = width;
+ mediaData.height = height;
+ } else if (fileType === "video") {
+ const [duration, { thumbnailUrl, width, height }] = await Promise.all(
+ [getMediaDuration(newFile), generateVideoThumbnail(newFile)]
+ );
+ mediaData.duration = duration;
+ mediaData.thumbnailUrl = thumbnailUrl;
+ mediaData.width = width;
+ mediaData.height = height;
+ } else if (fileType === "audio") {
+ mediaData.duration = await getMediaDuration(newFile);
+ }
- // Add new media item to store
- await mediaStore.addMediaItem(projectStore.activeProject.id, mediaData);
+ // Add new media item to store
+ await mediaStore.addMediaItem(projectStore.activeProject.id, mediaData);
- // Find the newly created media item
- const newMediaItem = mediaStore.mediaItems.find(
- (item) => item.file === newFile,
- );
+ // Find the newly created media item
+ const newMediaItem = mediaStore.mediaItems.find(
+ (item) => item.file === newFile
+ );
- if (!newMediaItem) return false;
+ if (!newMediaItem) return false;
- get().pushHistory();
+ get().pushHistory();
- // Update the timeline element to reference the new media
- updateTracksAndSave(
- _tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((c) =>
- c.id === elementId
- ? {
- ...c,
- mediaId: newMediaItem.id,
- name: newMediaItem.name,
- // Update duration if the new media has a different duration
- duration: newMediaItem.duration || c.duration,
- }
- : c,
- ),
- }
- : track,
- ),
- );
+ // Update the timeline element to reference the new media
+ updateTracksAndSave(
+ _tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((c) =>
+ c.id === elementId
+ ? {
+ ...c,
+ mediaId: newMediaItem.id,
+ name: newMediaItem.name,
+ // Update duration if the new media has a different duration
+ duration: newMediaItem.duration || c.duration,
+ }
+ : c
+ ),
+ }
+ : track
+ )
+ );
- return true;
- } catch (error) {
- console.log(
- JSON.stringify({
- error: "Failed to replace element media",
- details: error,
- }),
- );
- return false;
- }
- },
+ return true;
+ } catch (error) {
+ console.log(
+ JSON.stringify({
+ error: "Failed to replace element media",
+ details: error,
+ })
+ );
+ return false;
+ }
+ },
- getTotalDuration: () => {
- const { _tracks } = get();
- if (_tracks.length === 0) return 0;
+ getTotalDuration: () => {
+ const { _tracks } = get();
+ if (_tracks.length === 0) return 0;
- const trackEndTimes = _tracks.map((track) =>
- track.elements.reduce((maxEnd, element) => {
- const elementEnd =
- element.startTime +
- element.duration -
- element.trimStart -
- element.trimEnd;
- return Math.max(maxEnd, elementEnd);
- }, 0),
- );
+ const trackEndTimes = _tracks.map((track) =>
+ track.elements.reduce((maxEnd, element) => {
+ const elementEnd =
+ element.startTime +
+ element.duration -
+ element.trimStart -
+ element.trimEnd;
+ return Math.max(maxEnd, elementEnd);
+ }, 0)
+ );
- return Math.max(...trackEndTimes, 0);
- },
+ return Math.max(...trackEndTimes, 0);
+ },
- redo: () => {
- const { redoStack } = get();
- if (redoStack.length === 0) return;
- const next = redoStack[redoStack.length - 1];
- updateTracksAndSave(next);
- set({ redoStack: redoStack.slice(0, -1) });
- },
+ redo: () => {
+ const { redoStack } = get();
+ if (redoStack.length === 0) return;
+ const next = redoStack[redoStack.length - 1];
+ updateTracksAndSave(next);
+ set({ redoStack: redoStack.slice(0, -1) });
+ },
- dragState: {
- isDragging: false,
- elementId: null,
- trackId: null,
- startMouseX: 0,
- startElementTime: 0,
- clickOffsetTime: 0,
- currentTime: 0,
- },
+ dragState: {
+ isDragging: false,
+ elementId: null,
+ trackId: null,
+ startMouseX: 0,
+ startElementTime: 0,
+ clickOffsetTime: 0,
+ currentTime: 0,
+ },
- setDragState: (dragState) =>
- set((state) => ({
- dragState: { ...state.dragState, ...dragState },
- })),
+ setDragState: (dragState) =>
+ set((state) => ({
+ dragState: { ...state.dragState, ...dragState },
+ })),
- startDrag: (
- elementId,
- trackId,
- startMouseX,
- startElementTime,
- clickOffsetTime,
- ) => {
- set({
- dragState: {
- isDragging: true,
- elementId,
- trackId,
- startMouseX,
- startElementTime,
- clickOffsetTime,
- currentTime: startElementTime,
- },
- });
- },
+ startDrag: (
+ elementId,
+ trackId,
+ startMouseX,
+ startElementTime,
+ clickOffsetTime
+ ) => {
+ set({
+ dragState: {
+ isDragging: true,
+ elementId,
+ trackId,
+ startMouseX,
+ startElementTime,
+ clickOffsetTime,
+ currentTime: startElementTime,
+ },
+ });
+ },
- updateDragTime: (currentTime) => {
- set((state) => ({
- dragState: {
- ...state.dragState,
- currentTime,
- },
- }));
- },
+ updateDragTime: (currentTime) => {
+ set((state) => ({
+ dragState: {
+ ...state.dragState,
+ currentTime,
+ },
+ }));
+ },
- endDrag: () => {
- set({
- dragState: {
- isDragging: false,
- elementId: null,
- trackId: null,
- startMouseX: 0,
- startElementTime: 0,
- clickOffsetTime: 0,
- currentTime: 0,
- },
- });
- },
+ endDrag: () => {
+ set({
+ dragState: {
+ isDragging: false,
+ elementId: null,
+ trackId: null,
+ startMouseX: 0,
+ startElementTime: 0,
+ clickOffsetTime: 0,
+ currentTime: 0,
+ },
+ });
+ },
- // Persistence methods
- loadProjectTimeline: async (projectId) => {
- try {
- const tracks = await storageService.loadTimeline(projectId);
- if (tracks) {
- updateTracks(tracks);
- } else {
- // No timeline saved yet, initialize with default
- const defaultTracks = ensureMainTrack([]);
- updateTracks(defaultTracks);
- }
- // Clear history when loading a project
- set({ history: [], redoStack: [] });
- } catch (error) {
- console.error("Failed to load timeline:", error);
- // Initialize with default on error
- const defaultTracks = ensureMainTrack([]);
- updateTracks(defaultTracks);
- set({ history: [], redoStack: [] });
- }
- },
+ // Persistence methods
+ loadProjectTimeline: async (projectId) => {
+ try {
+ const tracks = await storageService.loadTimeline(projectId);
+ if (tracks) {
+ updateTracks(tracks);
+ } else {
+ // No timeline saved yet, initialize with default
+ const defaultTracks = ensureMainTrack([]);
+ updateTracks(defaultTracks);
+ }
+ // Clear history when loading a project
+ set({ history: [], redoStack: [] });
+ } catch (error) {
+ console.error("Failed to load timeline:", error);
+ // Initialize with default on error
+ const defaultTracks = ensureMainTrack([]);
+ updateTracks(defaultTracks);
+ set({ history: [], redoStack: [] });
+ }
+ },
- saveProjectTimeline: async (projectId) => {
- try {
- await storageService.saveTimeline(projectId, get()._tracks);
- } catch (error) {
- console.error("Failed to save timeline:", error);
- }
- },
+ saveProjectTimeline: async (projectId) => {
+ try {
+ await storageService.saveTimeline(projectId, get()._tracks);
+ } catch (error) {
+ console.error("Failed to save timeline:", error);
+ }
+ },
- clearTimeline: () => {
- const defaultTracks = ensureMainTrack([]);
- updateTracks(defaultTracks);
- set({ history: [], redoStack: [], selectedElements: [] });
- },
+ clearTimeline: () => {
+ const defaultTracks = ensureMainTrack([]);
+ updateTracks(defaultTracks);
+ set({ history: [], redoStack: [], selectedElements: [] });
+ },
- // Snapping actions
- toggleSnapping: () => {
- set((state) => ({ snappingEnabled: !state.snappingEnabled }));
- },
+ // Snapping actions
+ toggleSnapping: () => {
+ set((state) => ({ snappingEnabled: !state.snappingEnabled }));
+ },
- // Ripple editing functions
- toggleRippleEditing: () => {
- set((state) => ({
- rippleEditingEnabled: !state.rippleEditingEnabled,
- }));
- },
+ // Ripple editing functions
+ toggleRippleEditing: () => {
+ set((state) => ({
+ rippleEditingEnabled: !state.rippleEditingEnabled,
+ }));
+ },
- checkElementOverlap: (trackId, startTime, duration, excludeElementId) => {
- const track = get()._tracks.find((t) => t.id === trackId);
- if (!track) return false;
+ checkElementOverlap: (trackId, startTime, duration, excludeElementId) => {
+ const track = get()._tracks.find((t) => t.id === trackId);
+ if (!track) return false;
- const overlap = track.elements.some((element) => {
- const elementEnd =
- element.startTime +
- element.duration -
- element.trimStart -
- element.trimEnd;
+ const overlap = track.elements.some((element) => {
+ const elementEnd =
+ element.startTime +
+ element.duration -
+ element.trimStart -
+ element.trimEnd;
- if (element.id === excludeElementId) {
- return false;
- }
+ if (element.id === excludeElementId) {
+ return false;
+ }
- return (
- (startTime >= element.startTime && startTime < elementEnd) ||
- (startTime + duration > element.startTime &&
- startTime + duration <= elementEnd) ||
- (startTime < element.startTime && startTime + duration > elementEnd)
- );
- });
- return overlap;
- },
+ return (
+ (startTime >= element.startTime && startTime < elementEnd) ||
+ (startTime + duration > element.startTime &&
+ startTime + duration <= elementEnd) ||
+ (startTime < element.startTime && startTime + duration > elementEnd)
+ );
+ });
+ return overlap;
+ },
- findOrCreateTrack: (trackType) => {
- // Always create new text track to allow multiple text elements
- if (trackType === "text") {
- return get().addTrack(trackType);
- }
+ findOrCreateTrack: (trackType) => {
+ // Always create new text track to allow multiple text elements
+ if (trackType === "text") {
+ return get().addTrack(trackType);
+ }
- const existingTrack = get()._tracks.find((t) => t.type === trackType);
- if (existingTrack) {
- return existingTrack.id;
- }
+ const existingTrack = get()._tracks.find((t) => t.type === trackType);
+ if (existingTrack) {
+ return existingTrack.id;
+ }
- return get().addTrack(trackType);
- },
+ return get().addTrack(trackType);
+ },
- addMediaAtTime: (item, currentTime = 0) => {
- const trackType = item.type === "audio" ? "audio" : "media";
- const targetTrackId = get().findOrCreateTrack(trackType);
+ addMediaAtTime: (item, currentTime = 0) => {
+ const trackType = item.type === "audio" ? "audio" : "media";
+ const targetTrackId = get().findOrCreateTrack(trackType);
- const duration =
- item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
+ const duration =
+ item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
- if (get().checkElementOverlap(targetTrackId, currentTime, duration)) {
- toast.error(
- "Cannot place element here - it would overlap with existing elements",
- );
- return false;
- }
+ if (get().checkElementOverlap(targetTrackId, currentTime, duration)) {
+ toast.error(
+ "Cannot place element here - it would overlap with existing elements"
+ );
+ return false;
+ }
- get().addElementToTrack(targetTrackId, {
- type: "media",
- mediaId: item.id,
- name: item.name,
- duration,
- startTime: currentTime,
- trimStart: 0,
- trimEnd: 0,
- });
- return true;
- },
+ get().addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: item.id,
+ name: item.name,
+ duration,
+ startTime: currentTime,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ return true;
+ },
- addTextAtTime: (item, currentTime = 0) => {
- const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
+ addTextAtTime: (item, currentTime = 0) => {
+ const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
- get().addElementToTrack(targetTrackId, {
- type: "text",
- name: item.name || "Text",
- content: item.content || "Default Text",
- duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: currentTime,
- trimStart: 0,
- trimEnd: 0,
- fontSize: item.fontSize || 48,
- fontFamily: item.fontFamily || "Arial",
- color: item.color || "#ffffff",
- backgroundColor: item.backgroundColor || "transparent",
- textAlign: item.textAlign || "center",
- fontWeight: item.fontWeight || "normal",
- fontStyle: item.fontStyle || "normal",
- textDecoration: item.textDecoration || "none",
- x: item.x || 0,
- y: item.y || 0,
- rotation: item.rotation || 0,
- opacity: item.opacity !== undefined ? item.opacity : 1,
- });
- return true;
- },
+ get().addElementToTrack(targetTrackId, {
+ type: "text",
+ name: item.name || "Text",
+ content: item.content || "Default Text",
+ duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: currentTime,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: item.fontSize || 48,
+ fontFamily: item.fontFamily || "Arial",
+ color: item.color || "#ffffff",
+ backgroundColor: item.backgroundColor || "transparent",
+ textAlign: item.textAlign || "center",
+ fontWeight: item.fontWeight || "normal",
+ fontStyle: item.fontStyle || "normal",
+ textDecoration: item.textDecoration || "none",
+ x: item.x || 0,
+ y: item.y || 0,
+ rotation: item.rotation || 0,
+ opacity: item.opacity !== undefined ? item.opacity : 1,
+ });
+ return true;
+ },
- addMediaToNewTrack: (item) => {
- const trackType = item.type === "audio" ? "audio" : "media";
- const targetTrackId = get().findOrCreateTrack(trackType);
+ addMediaToNewTrack: (item) => {
+ const trackType = item.type === "audio" ? "audio" : "media";
+ const targetTrackId = get().findOrCreateTrack(trackType);
- get().addElementToTrack(targetTrackId, {
- type: "media",
- mediaId: item.id,
- name: item.name,
- duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- return true;
- },
+ get().addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: item.id,
+ name: item.name,
+ duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ return true;
+ },
- addTextToNewTrack: (item) => {
- const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
+ addTextToNewTrack: (item) => {
+ const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
- get().addElementToTrack(targetTrackId, {
- type: "text",
- name: item.name || "Text",
- content:
- ("content" in item ? item.content : "Default Text") || "Default Text",
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- fontSize: ("fontSize" in item ? item.fontSize : 48) || 48,
- fontFamily:
- ("fontFamily" in item ? item.fontFamily : "Arial") || "Arial",
- color: ("color" in item ? item.color : "#ffffff") || "#ffffff",
- backgroundColor:
- ("backgroundColor" in item ? item.backgroundColor : "transparent") ||
- "transparent",
- textAlign:
- ("textAlign" in item ? item.textAlign : "center") || "center",
- fontWeight:
- ("fontWeight" in item ? item.fontWeight : "normal") || "normal",
- fontStyle:
- ("fontStyle" in item ? item.fontStyle : "normal") || "normal",
- textDecoration:
- ("textDecoration" in item ? item.textDecoration : "none") || "none",
- x: ("x" in item ? item.x : 0) || 0,
- y: ("y" in item ? item.y : 0) || 0,
- rotation: ("rotation" in item ? item.rotation : 0) || 0,
- opacity:
- "opacity" in item && item.opacity !== undefined ? item.opacity : 1,
- });
- return true;
- },
- };
+ get().addElementToTrack(targetTrackId, {
+ type: "text",
+ name: item.name || "Text",
+ content:
+ ("content" in item ? item.content : "Default Text") || "Default Text",
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: ("fontSize" in item ? item.fontSize : 48) || 48,
+ fontFamily:
+ ("fontFamily" in item ? item.fontFamily : "Arial") || "Arial",
+ color: ("color" in item ? item.color : "#ffffff") || "#ffffff",
+ backgroundColor:
+ ("backgroundColor" in item ? item.backgroundColor : "transparent") ||
+ "transparent",
+ textAlign:
+ ("textAlign" in item ? item.textAlign : "center") || "center",
+ fontWeight:
+ ("fontWeight" in item ? item.fontWeight : "normal") || "normal",
+ fontStyle:
+ ("fontStyle" in item ? item.fontStyle : "normal") || "normal",
+ textDecoration:
+ ("textDecoration" in item ? item.textDecoration : "none") || "none",
+ x: ("x" in item ? item.x : 0) || 0,
+ y: ("y" in item ? item.y : 0) || 0,
+ rotation: ("rotation" in item ? item.rotation : 0) || 0,
+ opacity:
+ "opacity" in item && item.opacity !== undefined ? item.opacity : 1,
+ });
+ return true;
+ },
+ };
});
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"
+ ]
}