diff --git a/README.md b/README.md index f63a0fe1..2bb115e3 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,24 @@ This is a big React app, hastily designed, that's doing a lot of expensive CPU/G - Careful with setTimeout chains/setInterval. Make sure there is a way to clean them up on hot reloads. useEffect is a good way to do this. - If the app slows down a bunch, use the browser profiler to identify where it's spending time. If it's spending time in the garbage collector/cycle collector, it's likely a memory leak issue. At the time of writing, running locally with devtools open the app should use about 1GB of memory. +### Deploying + +We host Conjurer on Vercel. Vercel does not allow you to deploy an organization repo like `sotsf/conjurer` on their free hobby tier. So instead, we deploy `brollin/conjurer`. It is an exact copy of the `sotsf/conjurer` repo, + +To deploy, all you have to do is to push a new commit to the `main` branch of the `brollin/conjurer` repo. First you will need to be a collaborator on the `brollin/conjurer` repo, so just ask Ben for that. + +For ease, you can clone `sotsf/conjurer`. Then you can set up a new remote called `prod` to point at `brollin/conjurer`: + +``` +git remote add prod ssh://git@github.com/brollin/conjurer.git +``` + +And then to deploy would just look like: + +``` +git remote add prod ssh://git@github.com/brollin/conjurer.git +``` + ## Todos To dos are captured as issues. Feel free to poke around the open issues, ask questions, and open a PR if you feel so inclined. diff --git a/migrations/0001_purple_captain_midlands.sql b/migrations/0001_purple_captain_midlands.sql new file mode 100644 index 00000000..bc4580ce --- /dev/null +++ b/migrations/0001_purple_captain_midlands.sql @@ -0,0 +1,22 @@ +CREATE TABLE `playlist_experiences` ( + `id` integer PRIMARY KEY NOT NULL, + `playlist_id` integer NOT NULL, + `experience_id` integer NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + FOREIGN KEY (`playlist_id`) REFERENCES `playlists`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`experience_id`) REFERENCES `experiences`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `playlist_experience_index` ON `playlist_experiences` (`playlist_id`,`experience_id`);--> statement-breakpoint +CREATE TABLE `playlists` ( + `id` integer PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `user_id` integer NOT NULL, + `is_locked` integer DEFAULT false NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE INDEX `playlist_user_index` ON `playlists` (`user_id`); \ No newline at end of file diff --git a/migrations/0002_sweet_maria_hill.sql b/migrations/0002_sweet_maria_hill.sql new file mode 100644 index 00000000..8d27b398 --- /dev/null +++ b/migrations/0002_sweet_maria_hill.sql @@ -0,0 +1,5 @@ +DROP TABLE `playlist_experiences`;--> statement-breakpoint +DROP INDEX IF EXISTS `playlist_user_index`;--> statement-breakpoint +ALTER TABLE `playlists` ADD `description` text DEFAULT '' NOT NULL;--> statement-breakpoint +ALTER TABLE `playlists` ADD `orderedExperienceIds` text DEFAULT '[]' NOT NULL;--> statement-breakpoint +CREATE INDEX `user_id_index` ON `playlists` (`user_id`); \ No newline at end of file diff --git a/migrations/meta/0001_snapshot.json b/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..27e69cff --- /dev/null +++ b/migrations/meta/0001_snapshot.json @@ -0,0 +1,459 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "fb406078-b8a1-4f20-813f-9c5e0275ffae", + "prevId": "1185bae5-5133-4d1b-bb63-d0daf1e9a784", + "tables": { + "experiences": { + "name": "experiences", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "song_id": { + "name": "song_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inprogress'" + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "experiences_name_unique": { + "name": "experiences_name_unique", + "columns": [ + "name" + ], + "isUnique": true + }, + "status_index": { + "name": "status_index", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "playlist_experiences": { + "name": "playlist_experiences", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "playlist_id": { + "name": "playlist_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "experience_id": { + "name": "experience_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "playlist_experience_index": { + "name": "playlist_experience_index", + "columns": [ + "playlist_id", + "experience_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "playlist_experiences_playlist_id_playlists_id_fk": { + "name": "playlist_experiences_playlist_id_playlists_id_fk", + "tableFrom": "playlist_experiences", + "tableTo": "playlists", + "columnsFrom": [ + "playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "playlist_experiences_experience_id_experiences_id_fk": { + "name": "playlist_experiences_experience_id_experiences_id_fk", + "tableFrom": "playlist_experiences", + "tableTo": "experiences", + "columnsFrom": [ + "experience_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "playlists": { + "name": "playlists", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_locked": { + "name": "is_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "playlist_user_index": { + "name": "playlist_user_index", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "playlists_user_id_users_id_fk": { + "name": "playlists_user_id_users_id_fk", + "tableFrom": "playlists", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "songs": { + "name": "songs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artist": { + "name": "artist", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "song_name_artist_index": { + "name": "song_name_artist_index", + "columns": [ + "artist", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users_to_experiences": { + "name": "users_to_experiences", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "experience_id": { + "name": "experience_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "user_experience_index": { + "name": "user_experience_index", + "columns": [ + "user_id", + "experience_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "users_to_experiences_user_id_users_id_fk": { + "name": "users_to_experiences_user_id_users_id_fk", + "tableFrom": "users_to_experiences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "users_to_experiences_experience_id_experiences_id_fk": { + "name": "users_to_experiences_experience_id_experiences_id_fk", + "tableFrom": "users_to_experiences", + "tableTo": "experiences", + "columnsFrom": [ + "experience_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/0002_snapshot.json b/migrations/meta/0002_snapshot.json new file mode 100644 index 00000000..a515114d --- /dev/null +++ b/migrations/meta/0002_snapshot.json @@ -0,0 +1,393 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "365e6651-acc8-4433-9258-368dfcde3d15", + "prevId": "fb406078-b8a1-4f20-813f-9c5e0275ffae", + "tables": { + "experiences": { + "name": "experiences", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "song_id": { + "name": "song_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inprogress'" + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "experiences_name_unique": { + "name": "experiences_name_unique", + "columns": [ + "name" + ], + "isUnique": true + }, + "status_index": { + "name": "status_index", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "playlists": { + "name": "playlists", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_locked": { + "name": "is_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "orderedExperienceIds": { + "name": "orderedExperienceIds", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "user_id_index": { + "name": "user_id_index", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "playlists_user_id_users_id_fk": { + "name": "playlists_user_id_users_id_fk", + "tableFrom": "playlists", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "songs": { + "name": "songs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "artist": { + "name": "artist", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "song_name_artist_index": { + "name": "song_name_artist_index", + "columns": [ + "artist", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users_to_experiences": { + "name": "users_to_experiences", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "experience_id": { + "name": "experience_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "user_experience_index": { + "name": "user_experience_index", + "columns": [ + "user_id", + "experience_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "users_to_experiences_user_id_users_id_fk": { + "name": "users_to_experiences_user_id_users_id_fk", + "tableFrom": "users_to_experiences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "users_to_experiences_experience_id_experiences_id_fk": { + "name": "users_to_experiences_experience_id_experiences_id_fk", + "tableFrom": "users_to_experiences", + "tableTo": "experiences", + "columnsFrom": [ + "experience_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 919be37f..003423cd 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1729754659573, "tag": "0000_stormy_molecule_man", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1730544614288, + "tag": "0001_purple_captain_midlands", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1730598821462, + "tag": "0002_sweet_maria_hill", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index d4e13abe..37ab5b8b 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "db:prod:backup": "src/scripts/backupDatabase.sh", "generateCanopy": "ts-node --project tsconfig.script.json src/scripts/generateCanopy.ts", "tursoMigration": "src/scripts/tursoMigration.sh", - "downloadCloudAudio": "ts-node --project tsconfig.script.json src/scripts/downloadCloudAssets.ts", + "downloadCloudAudio": "ts-node --project tsconfig.script.json src/scripts/downloadCloudAudio.ts", "unityTestServer": "ts-node --project tsconfig.script.json src/scripts/unityTestServer.ts", "controllerServer": "ts-node --project tsconfig.script.json src/scripts/controllerServer.ts", "generatePattern": "ts-node --project tsconfig.script.json src/scripts/generatePattern.ts" @@ -61,6 +61,7 @@ "react-dom": "^18.3.1", "react-draggable": "^4.4.6", "react-icons": "^4.11.0", + "react-resizable-panels": "^2.1.6", "recharts": "^2.8.0", "splines": "jonathanlurie/splines", "three": "^0.157.0", diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index c0801ea6..da5865a4 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -26,7 +26,7 @@ export const POST = async (req: Request, res: Response) => { process.cwd(), `public/${LOCAL_ASSET_DIRECTORY}${AUDIO_ASSET_PREFIX}${filename}` ), - buffer + new Uint8Array(buffer) ); return NextResponse.json({ Message: "Success", status: 201 }); } catch (error) { diff --git a/src/components/AddPatternButton.tsx b/src/components/AddPatternButton.tsx index 5f385d83..616787b4 100644 --- a/src/components/AddPatternButton.tsx +++ b/src/components/AddPatternButton.tsx @@ -8,9 +8,8 @@ export const AddPatternButton = observer(function AddPatternButton() { const store = useStore(); const { uiStore } = store; - const placement = { [uiStore.horizontalLayout ? "right" : "left"]: 0 }; return ( - + {!isPending && users - .filter(({ username }) => username !== user) + .filter((user) => user.username !== store.username) .map((user) => ( - - - - - ); -}); diff --git a/src/components/Menu/MenuBar.tsx b/src/components/Menu/MenuBar.tsx index 3381ba51..1a8bb189 100644 --- a/src/components/Menu/MenuBar.tsx +++ b/src/components/Menu/MenuBar.tsx @@ -7,7 +7,9 @@ import { MenuButton, MenuDivider, MenuItem, + MenuItemOption, MenuList, + MenuOptionGroup, Modal, ModalBody, ModalCloseButton, @@ -26,6 +28,8 @@ import { OpenExperienceModal } from "@/src/components/Menu/OpenExperienceModal"; import { SaveExperienceModal } from "@/src/components/Menu/SaveExperienceModal"; import { KeyboardShortcuts } from "@/src/components/KeyboardShortcuts"; import { useSaveExperience } from "@/src/hooks/experience"; +import { DisplayMode } from "@/src/types/UIStore"; +import { action } from "mobx"; export const MenuBar = observer(function MenuBar() { const store = useStore(); @@ -39,6 +43,9 @@ export const MenuBar = observer(function MenuBar() { onClose: onCloseKeyboardShortcuts, } = useDisclosure(); + // Don't show the menu bar if there's no experience loaded yet + if (!store.experienceName) return null; + return ( {store.experienceName} - {store.context !== "viewer" && + {store.context === "experienceEditor" && !store.hasSaved && !store.experienceId && ( not yet saved )} - {store.context !== "viewer" && store.hasSaved && ( + {store.context === "experienceEditor" && store.hasSaved && ( - {store.experienceLastSavedAt - ? `last saved at ${Intl.DateTimeFormat("en", { - hour: "numeric", - minute: "numeric", - hour12: true, - }).format(store.experienceLastSavedAt)}` - : "not yet saved"} + {`last saved at ${Intl.DateTimeFormat("en", { + hour: "numeric", + minute: "numeric", + hour12: true, + }).format(store.experienceLastSavedAt)}`} )} {process.env.NEXT_PUBLIC_NODE_ENV !== "production" && ( @@ -143,14 +148,18 @@ export const MenuBar = observer(function MenuBar() { File - } - command="⌘N" - onClick={experienceStore.loadEmptyExperience} - > - New experience - - + {store.context === "experienceEditor" && ( + <> + } + command="⌘N" + onClick={experienceStore.loadEmptyExperience} + > + New experience + + + + )} } command="⌘O" @@ -158,21 +167,25 @@ export const MenuBar = observer(function MenuBar() { > Open... - - } - command="⌘S" - onClick={() => saveExperience()} - > - Save - - } - command="⌘⇧S" - onClick={uiStore.attemptShowSaveExperienceModal} - > - Save as... - + {store.context === "experienceEditor" && ( + <> + + } + command="⌘S" + onClick={() => saveExperience()} + > + Save + + } + command="⌘⇧S" + onClick={uiStore.attemptShowSaveExperienceModal} + > + Save as... + + + )} @@ -210,23 +223,94 @@ export const MenuBar = observer(function MenuBar() { _hover={{ bg: "gray.500" }} _focus={{ boxShadow: "outline" }} > - Go to + View - - Playground - - - Controller - - - Portal - - - Beat Mapper - + {store.context === "experienceEditor" && ( + <> + + + Horizontal + + Vertical + + + + )} + + (uiStore.renderTargetSize = parseInt(value as string)) + )} + > + 256 x 256 + 512 x 512 + 1024 x 1024 + + + (uiStore.displayMode = value as DisplayMode) + )} + > + Canopy + + Cartesian space + + + Canopy space + + + + + Show performance overlay + + {process.env.NEXT_PUBLIC_NODE_ENV !== "production" && ( + + + Tools + + + + Transmit data to canopy + + + + )} About Conjurer - - Keyboard shortcuts - + {store.context === "experienceEditor" && ( + + Keyboard shortcuts + + )} (uiStore.showingOpenExperienceModal = false)); @@ -39,33 +40,40 @@ export const OpenExperienceModal = observer(function OpenExperienceModal() { onClose={onClose} isOpen={uiStore.showingOpenExperienceModal} isCentered + size="4xl" > - Open experience {isPending && } + Open experience{" "} + {(isPending || isRefetching || isLoadingNewExperience) && ( + + )} - {!isPending && experiences.length === 0 && ( - {user} has no saved experiences yet! + {!isPending && experiencesAndUsers.length === 0 && ( + + {username} has no saved experiences yet! + )} + setViewingAllExperiences(e.target.checked)} + > + View all experiences + {!isPending && ( - - {experiences.map((experience) => ( - - ))} - + { + setIsLoadingNewExperience(true); + await experienceStore.load(experience.name); + setIsLoadingNewExperience(false); + onClose(); + })} + /> )} diff --git a/src/components/Menu/SaveExperienceModal.tsx b/src/components/Menu/SaveExperienceModal.tsx index 415f3ecb..f0c257f3 100644 --- a/src/components/Menu/SaveExperienceModal.tsx +++ b/src/components/Menu/SaveExperienceModal.tsx @@ -21,14 +21,14 @@ import { useSaveExperience } from "@/src/hooks/experience"; export const SaveExperienceModal = observer(function SaveExperienceModal() { const store = useStore(); - const { uiStore, user, usingLocalData } = store; + const { uiStore, username, usingLocalData } = store; const { isPending, isError, data: experiences, - } = trpc.experience.listExperiences.useQuery( - { username: user, usingLocalData }, + } = trpc.experience.listExperiencesForUser.useQuery( + { username, usingLocalData }, { enabled: uiStore.showingSaveExperienceModal } ); @@ -85,7 +85,7 @@ export const SaveExperienceModal = observer(function SaveExperienceModal() { Save experience as... - {user ? ( + {username ? ( isPending ? ( ) : ( diff --git a/src/components/NewVariationButtons.tsx b/src/components/ParameterVariations/NewVariationButtons.tsx similarity index 100% rename from src/components/NewVariationButtons.tsx rename to src/components/ParameterVariations/NewVariationButtons.tsx diff --git a/src/components/ParameterVariations.tsx b/src/components/ParameterVariations/ParameterVariations.tsx similarity index 92% rename from src/components/ParameterVariations.tsx rename to src/components/ParameterVariations/ParameterVariations.tsx index 3a890e4c..48be737b 100644 --- a/src/components/ParameterVariations.tsx +++ b/src/components/ParameterVariations/ParameterVariations.tsx @@ -8,14 +8,14 @@ import { OnDragEndResponder, } from "@hello-pangea/dnd"; import { Fragment } from "react"; -import { reorder } from "@/src/utils/algorithm"; +import { reorder } from "@/src/utils/array"; import { Block } from "@/src/types/Block"; import { action } from "mobx"; -import { VariationBound } from "@/src/components/VariationBound"; -import { NewVariationButtons } from "@/src/components/NewVariationButtons"; +import { VariationBound } from "@/src/components/ParameterVariations/VariationBound"; +import { NewVariationButtons } from "@/src/components/ParameterVariations/NewVariationButtons"; import { observer } from "mobx-react-lite"; import { useStore } from "@/src/types/StoreContext"; -import { VariationHandle } from "@/src/components/VariationHandle"; +import { VariationHandle } from "@/src/components/ParameterVariations/VariationHandle"; type ParameterVariationsProps = { uniformName: string; diff --git a/src/components/VariationBound.tsx b/src/components/ParameterVariations/VariationBound.tsx similarity index 100% rename from src/components/VariationBound.tsx rename to src/components/ParameterVariations/VariationBound.tsx diff --git a/src/components/VariationHandle.tsx b/src/components/ParameterVariations/VariationHandle.tsx similarity index 100% rename from src/components/VariationHandle.tsx rename to src/components/ParameterVariations/VariationHandle.tsx diff --git a/src/components/PatternPlayground/PatternPlayground.tsx b/src/components/PatternPlayground/PatternPlayground.tsx index 405fc4af..4165f06c 100644 --- a/src/components/PatternPlayground/PatternPlayground.tsx +++ b/src/components/PatternPlayground/PatternPlayground.tsx @@ -135,8 +135,10 @@ export const PatternPlayground = observer(function PatternPlayground() { {/* */} - {["playground", "default"].includes(context) && } - {context === "default" && ( + {["playground", "experienceEditor"].includes(context) && ( + + )} + {context === "experienceEditor" && ( - ))} - + {!isPending && experiencesAndUsers.length === 0 && ( + + {username} has no saved experiences yet! + + )} + setViewingAllExperiences(e.target.checked)} + > + View all experiences + + {!isPending && ( + { + savePlaylist({ + ...playlist, + orderedExperienceIds: [ + ...playlist.orderedExperienceIds, + experience.id!, + ], + }); + onClose(); + })} + /> + )} diff --git a/src/components/PlaylistEditor/PlaylistEditor.tsx b/src/components/PlaylistEditor/PlaylistEditor.tsx index 7296772b..e2b5dac1 100644 --- a/src/components/PlaylistEditor/PlaylistEditor.tsx +++ b/src/components/PlaylistEditor/PlaylistEditor.tsx @@ -1,13 +1,11 @@ import { Button, - Checkbox, - Editable, - EditableInput, - EditablePreview, + ButtonGroup, HStack, Table, TableContainer, Tbody, + Td, Text, Th, Thead, @@ -18,79 +16,162 @@ import { useStore } from "@/src/types/StoreContext"; import { observer } from "mobx-react-lite"; import { PlaylistItem } from "@/src/components/PlaylistEditor/PlaylistItem"; import { MdOutlinePlaylistAdd } from "react-icons/md"; -import { FaRegClipboard } from "react-icons/fa"; import { action } from "mobx"; import { AddExperienceModal } from "@/src/components/PlaylistEditor/AddExperienceModal"; +import { BiShuffle } from "react-icons/bi"; +import { ImLoop } from "react-icons/im"; +import { trpc } from "@/src/utils/trpc"; +import { PlaylistNameEditable } from "@/src/components/PlaylistEditor/PlaylistNameEditable"; +import { useEffect } from "react"; +import { FaPlus } from "react-icons/fa"; +import { useRouter } from "next/router"; export const PlaylistEditor = observer(function PlaylistEditor() { const store = useStore(); - const { playlistStore, uiStore } = store; - const { experienceNames } = playlistStore; + const { username, usingLocalData, playlistStore, uiStore } = store; + const { selectedPlaylist } = playlistStore; - const isEditable = store.context !== "viewer"; + const isEditable = + !!store.username && store.username === selectedPlaylist?.user.username; - return ( - <> - (playlistStore.name = value))} - fontSize={20} - fontWeight="bold" - textAlign="center" - isDisabled={!isEditable} - > - - - + const { isPending, isError, data } = trpc.playlist.getPlaylist.useQuery( + { + usingLocalData, + username, + id: selectedPlaylist?.id!, + }, + { + enabled: selectedPlaylist !== null, + } + ); + + useEffect(() => { + if (!data?.experiencesAndUsers.length || store.experienceName) return; + // once experiences are fetched, load the first experience in the playlist + store.experienceStore.load(data.experiencesAndUsers[0].experience.name); + }, [data?.experiencesAndUsers]); + + const router = useRouter(); - - + + + + + + + + + {playlist.description && ( + + + {playlist.description} + {/* TODO: make this editable */} + + + )} + + + {playlist.user.username} • {experiencesAndUsers.length} experiences + + - + - + + + + - {experienceNames.map((experienceName, index) => ( - - - - ))} + {experiencesAndUsers.length === 0 ? ( + <> + + + + + + ) : ( + experiencesAndUsers.map(({ experience, user }, index) => ( + + + + )) + )}
#Experience nameExperienceAuthorSong
- + No experiences added yet! +
+ {isEditable && ( <> - - )} - - - - - {experienceName} - - - - + {editable && ( <> @@ -120,9 +89,16 @@ export const PlaylistItem = observer(function PlaylistItem({ height={4} _hover={{ color: "blue.500" }} icon={} - onClick={action(() => - playlistStore.reorderExperience(index, -1) - )} + onClick={() => + savePlaylist({ + ...playlist, + orderedExperienceIds: reorder( + playlist.orderedExperienceIds, + index, + index - 1 + ), + }) + } /> )} {index < playlistLength - 1 && ( @@ -133,20 +109,119 @@ export const PlaylistItem = observer(function PlaylistItem({ height={4} _hover={{ color: "blue.500" }} icon={} - onClick={action(() => - playlistStore.reorderExperience(index, 1) - )} + onClick={() => + savePlaylist({ + ...playlist, + orderedExperienceIds: reorder( + playlist.orderedExperienceIds, + index, + index + 1 + ), + }) + } /> )} + + )} + setMousingOver(true)} + onPointerLeave={() => setMousingOver(false)} + > + {isSelectedExperience && loadingExperience ? ( + + ) : isSelectedExperience ? ( + + {store.playing ? ( + + ) : ( + + )} + + } + onClick={store.playing ? onPauseClick : onPlayClick} + /> + ) : mousingOver ? ( + + + + } + onClick={onPlayClick} + /> + ) : ( + + )} + + + + + + + {experience.name} + + + + + {user.username} + + + + + {experience.song.artist} - {experience.song.name} + + + + + + {store.username === user.username && ( + <> + } + onClick={action(() => + router.push(`/experience/${experience.name}`) + )} + /> + + )} + {editable && ( + <> } - onClick={action(() => playlistStore.removeExperience(index))} + icon={} + onClick={() => { + savePlaylist({ + ...playlist, + orderedExperienceIds: playlist.orderedExperienceIds.filter( + (id) => id !== experience.id + ), + }); + }} /> )} diff --git a/src/components/PlaylistEditor/PlaylistLibrary.tsx b/src/components/PlaylistEditor/PlaylistLibrary.tsx new file mode 100644 index 00000000..3e208b13 --- /dev/null +++ b/src/components/PlaylistEditor/PlaylistLibrary.tsx @@ -0,0 +1,87 @@ +import { Button, HStack, Text, VStack } from "@chakra-ui/react"; +import { useStore } from "@/src/types/StoreContext"; +import { observer } from "mobx-react-lite"; +import { FaPlus } from "react-icons/fa"; +import { runInAction } from "mobx"; +import { trpc } from "@/src/utils/trpc"; +import { SelectablePlaylist } from "@/src/components/PlaylistEditor/SelectablePlaylist"; +import { useEffect } from "react"; + +export const PlaylistLibrary = observer(function PlaylistLibrary() { + const store = useStore(); + const { username, usingLocalData, playlistStore } = store; + + const isEditable = !!store.username; + + const utils = trpc.useUtils(); + const createPlaylist = trpc.playlist.savePlaylist.useMutation(); + + const { + isPending, + isError, + data: playlists, + } = trpc.playlist.listPlaylistsForUser.useQuery({ + usingLocalData, + username, + }); + + useEffect(() => { + if (!playlists || playlists.length === 0 || playlistStore.selectedPlaylist) + return; + runInAction(() => { + playlistStore.selectedPlaylist = playlists[0]; + }); + }, [playlists]); + + if (isPending || isError) return null; + + return ( + + + + Conjurer + + {isEditable && ( + + )} + + + + + + {playlists.map((playlist) => ( + + ))} + + + ); +}); diff --git a/src/components/PlaylistEditor/PlaylistNameEditable.tsx b/src/components/PlaylistEditor/PlaylistNameEditable.tsx new file mode 100644 index 00000000..8ee09964 --- /dev/null +++ b/src/components/PlaylistEditor/PlaylistNameEditable.tsx @@ -0,0 +1,38 @@ +import { Editable, EditableInput, EditablePreview } from "@chakra-ui/react"; +import { observer } from "mobx-react-lite"; +import { useState } from "react"; +import { Playlist } from "@/src/types/Playlist"; +import { useSavePlaylist } from "@/src/hooks/playlist"; + +export const PlaylistNameEditable = observer(function PlaylistNameEditable({ + playlist, + isEditable, +}: { + playlist: Playlist; + isEditable: boolean; +}) { + const [playlistName, setPlaylistName] = useState(playlist.name); + + const { savePlaylist } = useSavePlaylist(); + + return ( + setPlaylistName(value)} + onSubmit={() => + savePlaylist({ + ...playlist, + name: playlistName, + }) + } + fontSize={20} + fontWeight="bold" + textAlign="center" + isDisabled={!isEditable} + > + + + + ); +}); diff --git a/src/components/PlaylistEditor/SelectablePlaylist.tsx b/src/components/PlaylistEditor/SelectablePlaylist.tsx new file mode 100644 index 00000000..8169e16f --- /dev/null +++ b/src/components/PlaylistEditor/SelectablePlaylist.tsx @@ -0,0 +1,82 @@ +import { HStack, IconButton, Text, VStack } from "@chakra-ui/react"; +import { useStore } from "@/src/types/StoreContext"; +import { observer } from "mobx-react-lite"; +import { FaTrashAlt } from "react-icons/fa"; +import { action, runInAction } from "mobx"; +import { trpc } from "@/src/utils/trpc"; +import { Playlist } from "@/src/types/Playlist"; + +export const SelectablePlaylist = observer(function SelectablePlaylist({ + playlist, +}: { + playlist: Playlist; +}) { + const store = useStore(); + const { username, usingLocalData, playlistStore } = store; + + const isEditable = store.username === playlist.user.username; + + const isSelected = playlistStore.selectedPlaylist?.id === playlist.id; + + const utils = trpc.useUtils(); + const deletePlaylist = trpc.playlist.deletePlaylist.useMutation(); + + return ( + (playlistStore.selectedPlaylist = playlist))} + bgColor={isSelected ? "gray.600" : undefined} + _hover={{ + bgColor: isSelected ? "gray.500" : "gray.600", + transition: "background-color 0.2s", + }} + > + + + {playlist.name} + + + + {playlist.user.username} • {playlist.orderedExperienceIds.length}{" "} + experiences + + + + + {isEditable && ( + } + onClick={action(async () => { + if ( + !confirm( + "Are you sure you want to delete this playlist? This will permanently cast the playlist into the fires of Mount Doom." + ) + ) + return; + + await deletePlaylist.mutateAsync({ + username, + usingLocalData, + id: playlist.id, + }); + utils.playlist.listPlaylistsForUser.invalidate(); + + if (playlistStore.selectedPlaylist?.id === playlist.id) { + runInAction(() => { + playlistStore.selectedPlaylist = null; + }); + } + })} + /> + )} + + ); +}); diff --git a/src/components/RoleSelector.tsx b/src/components/RoleSelector.tsx new file mode 100644 index 00000000..4ca5c2f3 --- /dev/null +++ b/src/components/RoleSelector.tsx @@ -0,0 +1,47 @@ +import { Button, Menu, MenuButton, MenuItem, MenuList } from "@chakra-ui/react"; +import { FaCaretDown } from "react-icons/fa"; +import { useStore } from "@/src/types/StoreContext"; +import { action } from "mobx"; +import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; + +export const RoleSelector = observer(function RoleSelector() { + const store = useStore(); + const router = useRouter(); + return ( + + } + > + Role: {store.roleText} + + + { + store.role = "emcee"; + router.push("/"); + })} + > + Emcee + + { + store.role = "experience creator"; + router.push(`/experience/${store.experienceName}`); + })} + > + Experience creator + + + + ); +}); diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx deleted file mode 100644 index 99e8c33f..00000000 --- a/src/components/Timeline.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { observer } from "mobx-react-lite"; -import { Box, HStack, VStack } from "@chakra-ui/react"; -import { useStore } from "@/src/types/StoreContext"; -import { PlayHead } from "@/src/components/PlayHead"; -import { useRef } from "react"; -import { useWheelZooming } from "@/src/hooks/wheelZooming"; -import { WavesurferWaveform } from "@/src/components/Wavesurfer/WavesurferWaveform"; -import { MAX_TIME } from "@/src/utils/time"; -import { TimelineLayer } from "@/src/components/TimelineLayer"; -import { TimerReadout } from "@/src/components/TimerReadout"; -import { MarkerEditorModal } from "@/src/components/MarkerEditorModal"; -import { TimerControls } from "@/src/components/TimerControls"; -import { BeatGridOverlay } from "@/src/components/BeatGridOverlay"; - -export const Timeline = observer(function Timeline() { - const store = useStore(); - const { uiStore, embeddedViewer } = store; - const timelineRef = useRef(null); - - useWheelZooming(timelineRef.current); - - return ( - - - - - {!embeddedViewer && } - - - - - - {store.context !== "viewer" && ( - - - - {store.layers.map((layer, index) => ( - - ))} - - )} - - ); -}); diff --git a/src/components/Timeline/Timeline.tsx b/src/components/Timeline/Timeline.tsx new file mode 100644 index 00000000..5406ccd6 --- /dev/null +++ b/src/components/Timeline/Timeline.tsx @@ -0,0 +1,27 @@ +import { observer } from "mobx-react-lite"; +import { Box } from "@chakra-ui/react"; +import { useStore } from "@/src/types/StoreContext"; +import { useRef } from "react"; +import { useWheelZooming } from "@/src/hooks/wheelZooming"; +import { TimerAndWaveform } from "@/src/components/Timeline/TimerAndWaveform"; +import { TimelineLayerStack } from "@/src/components/Timeline/TimelineLayerStack"; + +export const Timeline = observer(function Timeline() { + const store = useStore(); + const timelineRef = useRef(null); + + useWheelZooming(timelineRef.current); + + return ( + + + {store.context !== "viewer" && } + + ); +}); diff --git a/src/components/TimelineLayer.tsx b/src/components/Timeline/TimelineLayer.tsx similarity index 94% rename from src/components/TimelineLayer.tsx rename to src/components/Timeline/TimelineLayer.tsx index 95f4bf00..a14ec4fe 100644 --- a/src/components/TimelineLayer.tsx +++ b/src/components/Timeline/TimelineLayer.tsx @@ -1,12 +1,12 @@ import { observer } from "mobx-react-lite"; -import { TimelineBlockStack } from "@/src/components/TimelineBlockStack"; import { useStore } from "@/src/types/StoreContext"; import { Box, HStack } from "@chakra-ui/react"; import { MAX_TIME } from "@/src/utils/time"; import { Layer } from "@/src/types/Layer"; import { action } from "mobx"; import { useRef } from "react"; -import { TimelineLayerHeader } from "@/src/components/TimelineLayerHeader"; +import { TimelineLayerHeader } from "@/src/components/Timeline/TimelineLayerHeader"; +import { TimelineBlockStack } from "@/src/components/TimelineBlockStack/TimelineBlockStack"; type TimelineLayerProps = { index: number; diff --git a/src/components/TimelineLayerHeader.tsx b/src/components/Timeline/TimelineLayerHeader.tsx similarity index 100% rename from src/components/TimelineLayerHeader.tsx rename to src/components/Timeline/TimelineLayerHeader.tsx diff --git a/src/components/Timeline/TimelineLayerStack.tsx b/src/components/Timeline/TimelineLayerStack.tsx new file mode 100644 index 00000000..aec8b21f --- /dev/null +++ b/src/components/Timeline/TimelineLayerStack.tsx @@ -0,0 +1,20 @@ +import { observer } from "mobx-react-lite"; +import { VStack } from "@chakra-ui/react"; +import { useStore } from "@/src/types/StoreContext"; +import { PlayHead } from "@/src/components/PlayHead"; +import { TimelineLayer } from "@/src/components/Timeline/TimelineLayer"; +import { BeatGridOverlay } from "@/src/components/BeatGridOverlay"; + +export const TimelineLayerStack = observer(function TimelineLayerStack() { + const store = useStore(); + + return ( + + + + {store.layers.map((layer, index) => ( + + ))} + + ); +}); diff --git a/src/components/Timeline/TimerAndWaveform.tsx b/src/components/Timeline/TimerAndWaveform.tsx new file mode 100644 index 00000000..b9c8f73f --- /dev/null +++ b/src/components/Timeline/TimerAndWaveform.tsx @@ -0,0 +1,46 @@ +import { observer } from "mobx-react-lite"; +import { HStack, VStack } from "@chakra-ui/react"; +import { useStore } from "@/src/types/StoreContext"; +import { WavesurferWaveform } from "@/src/components/Wavesurfer/WavesurferWaveform"; +import { MAX_TIME } from "@/src/utils/time"; +import { TimerReadout } from "@/src/components/Timeline/TimerReadout"; +import { TimerControls } from "@/src/components/Timeline/TimerControls"; + +export const TimerAndWaveform = observer(function TimerAndWaveform() { + const store = useStore(); + const { uiStore, embeddedViewer } = store; + + const width = uiStore.canTimelineZoom + ? uiStore.timeToXPixels(MAX_TIME) + : "100%"; + + return ( + + + + {!embeddedViewer && } + + + + + ); +}); diff --git a/src/components/TimerControls.tsx b/src/components/Timeline/TimerControls.tsx similarity index 100% rename from src/components/TimerControls.tsx rename to src/components/Timeline/TimerControls.tsx diff --git a/src/components/TimerReadout.tsx b/src/components/Timeline/TimerReadout.tsx similarity index 100% rename from src/components/TimerReadout.tsx rename to src/components/Timeline/TimerReadout.tsx diff --git a/src/components/AddEffectButton.tsx b/src/components/TimelineBlockStack/AddEffectButton.tsx similarity index 95% rename from src/components/AddEffectButton.tsx rename to src/components/TimelineBlockStack/AddEffectButton.tsx index 8ff31ac6..0a9f9301 100644 --- a/src/components/AddEffectButton.tsx +++ b/src/components/TimelineBlockStack/AddEffectButton.tsx @@ -13,7 +13,7 @@ import { import { action } from "mobx"; import { FiPlusSquare } from "react-icons/fi"; import { playgroundEffects } from "@/src/effects/effects"; -import { HeaderRepeat } from "@/src/components/HeaderRepeat"; +import { HeaderRepeat } from "@/src/components/TimelineBlockStack/HeaderRepeat"; import { observer } from "mobx-react-lite"; type Props = { diff --git a/src/components/HeaderRepeat.tsx b/src/components/TimelineBlockStack/HeaderRepeat.tsx similarity index 100% rename from src/components/HeaderRepeat.tsx rename to src/components/TimelineBlockStack/HeaderRepeat.tsx diff --git a/src/components/ParameterValue.tsx b/src/components/TimelineBlockStack/ParameterValue.tsx similarity index 100% rename from src/components/ParameterValue.tsx rename to src/components/TimelineBlockStack/ParameterValue.tsx diff --git a/src/components/ParameterView.tsx b/src/components/TimelineBlockStack/ParameterView.tsx similarity index 90% rename from src/components/ParameterView.tsx rename to src/components/TimelineBlockStack/ParameterView.tsx index 8490aeb4..6ef1be2b 100644 --- a/src/components/ParameterView.tsx +++ b/src/components/TimelineBlockStack/ParameterView.tsx @@ -3,12 +3,12 @@ import { Box, Button, HStack, Text } from "@chakra-ui/react"; import { useState } from "react"; import { BsCaretDown, BsCaretUp } from "react-icons/bs"; import { Block } from "@/src/types/Block"; -import { NewVariationButtons } from "@/src/components/NewVariationButtons"; -import { ParameterVariations } from "@/src/components/ParameterVariations"; +import { NewVariationButtons } from "@/src/components/ParameterVariations/NewVariationButtons"; import { observer } from "mobx-react-lite"; -import { ParameterValue } from "@/src/components/ParameterValue"; +import { ParameterValue } from "@/src/components/TimelineBlockStack/ParameterValue"; import { useStore } from "@/src/types/StoreContext"; -import { HeaderRepeat } from "@/src/components/HeaderRepeat"; +import { HeaderRepeat } from "@/src/components/TimelineBlockStack/HeaderRepeat"; +import { ParameterVariations } from "@/src/components/ParameterVariations/ParameterVariations"; type ParameterProps = { uniformName: string; diff --git a/src/components/ParametersList.tsx b/src/components/TimelineBlockStack/ParametersList.tsx similarity index 92% rename from src/components/ParametersList.tsx rename to src/components/TimelineBlockStack/ParametersList.tsx index b138feae..9b7156aa 100644 --- a/src/components/ParametersList.tsx +++ b/src/components/TimelineBlockStack/ParametersList.tsx @@ -1,6 +1,6 @@ import { Block } from "@/src/types/Block"; import { ExtraParams } from "@/src/types/PatternParams"; -import { ParameterView } from "@/src/components/ParameterView"; +import { ParameterView } from "@/src/components/TimelineBlockStack/ParameterView"; import { VStack } from "@chakra-ui/react"; import { memo } from "react"; diff --git a/src/components/PatternOrEffectBlock.tsx b/src/components/TimelineBlockStack/PatternOrEffectBlock.tsx similarity index 85% rename from src/components/PatternOrEffectBlock.tsx rename to src/components/TimelineBlockStack/PatternOrEffectBlock.tsx index 9f794442..340debb9 100644 --- a/src/components/PatternOrEffectBlock.tsx +++ b/src/components/TimelineBlockStack/PatternOrEffectBlock.tsx @@ -5,13 +5,11 @@ import { observer } from "mobx-react-lite"; import { MouseEvent as ReactMouseEvent, useState } from "react"; import { MdDragIndicator } from "react-icons/md"; import { BsArrowsCollapse, BsArrowsExpand } from "react-icons/bs"; -import { ParametersList } from "@/src/components/ParametersList"; import { RxCaretDown, RxCaretUp } from "react-icons/rx"; import { FaTrashAlt } from "react-icons/fa"; -import { HeaderRepeat } from "@/src/components/HeaderRepeat"; -import { ImLoop } from "react-icons/im"; -import { useStore } from "@/src/types/StoreContext"; -import { PatternTimingModal } from "./PatternTimingModal"; +import { HeaderRepeat } from "@/src/components/TimelineBlockStack/HeaderRepeat"; +import { PatternTimingModal } from "@/src/components/TimelineBlockStack/PatternTimingModal"; +import { ParametersList } from "@/src/components/TimelineBlockStack/ParametersList"; type Props = { block: Block; @@ -26,7 +24,6 @@ export const PatternOrEffectBlock = observer(function PatternOrEffectBlock({ isSelected, effectIndex = -1, }: Props) { - const { audioStore } = useStore(); const [expandMode, setExpandMode] = useState<"expanded" | "collapsed">( "collapsed" ); @@ -65,18 +62,6 @@ export const PatternOrEffectBlock = observer(function PatternOrEffectBlock({ > {isEffect ? "Effect" : "Pattern"}: {block.pattern.name} - } - onClick={(e) => { - audioStore.loopAudio(block.startTime, block.endTime); - e.stopPropagation(); - }} - /> { + audioStore.selectedSong = newSong; + }); onClose(); }); diff --git a/src/components/ViewerPage.tsx b/src/components/ViewerPage.tsx index 0f7ef45b..c2ccb9cf 100644 --- a/src/components/ViewerPage.tsx +++ b/src/components/ViewerPage.tsx @@ -4,7 +4,6 @@ import { Display } from "@/src/components/Display"; import { lazy, memo, useEffect, useRef } from "react"; import { useStore } from "@/src/types/StoreContext"; import { KeyboardControls } from "@/src/components/KeyboardControls"; -import { PlaylistDrawer } from "@/src/components/PlaylistDrawer"; const PortalNarrativeModal = lazy( () => import("@/src/components/PortalNarrativeModal") @@ -28,7 +27,6 @@ export const ViewerPage = memo(function ViewerPage({ return ( - {portalNarrative && } { // Can't be run on the server, so we need to use dynamic imports const [ { default: WaveSurfer }, { default: TimelinePlugin }, - { default: RegionsPlugin }, { default: MinimapPlugin }, ] = await Promise.all([ import("wavesurfer.js"), import("wavesurfer.js/dist/plugins/timeline"), - import("wavesurfer.js/dist/plugins/regions"), import("wavesurfer.js/dist/plugins/minimap"), ]); return { WaveSurfer, TimelinePlugin, - RegionsPlugin, MinimapPlugin, }; }; @@ -43,23 +36,19 @@ const DEFAULT_WAVESURFER_OPTIONS: Partial = { waveColor: "#ddd", progressColor: "#0178FF", cursorColor: "#FF0000FF", - height: 60, hideScrollbar: true, - fillParent: false, autoScroll: false, autoCenter: false, interact: true, }; const DEFAULT_TIMELINE_OPTIONS: TimelinePluginOptions = { - height: 60, insertPosition: "beforebegin", timeInterval: 0.25, - primaryLabelInterval: 5, - secondaryLabelInterval: 1, style: { fontSize: "14px", color: "#000000", + zIndex: "100", }, }; @@ -96,12 +85,10 @@ export const WavesurferWaveform = observer(function WavesurferWaveform() { const wavesurferConstructors = useRef<{ WaveSurfer: typeof WaveSurfer | null; TimelinePlugin: typeof TimelinePlugin | null; - RegionsPlugin: typeof RegionsPlugin | null; MinimapPlugin: typeof MinimapPlugin | null; }>({ WaveSurfer: null, TimelinePlugin: null, - RegionsPlugin: null, MinimapPlugin: null, }); @@ -114,18 +101,12 @@ export const WavesurferWaveform = observer(function WavesurferWaveform() { const cloneCanvas = useCloneCanvas(clonedWaveformRef); - const timelinePluginOptions = useMemo( - () => ({ - ...DEFAULT_TIMELINE_OPTIONS, - ...(store.context === "viewer" - ? { - primaryLabelInterval: 15, - secondaryLabelInterval: 0, - } - : {}), - }), - [store.context] - ); + const timelinePluginOptions = { + ...DEFAULT_TIMELINE_OPTIONS, + height: uiStore.canTimelineZoom ? 60 : 80, + primaryLabelInterval: uiStore.canTimelineZoom ? 5 : 30, + secondaryLabelInterval: uiStore.canTimelineZoom ? 1 : 0, + }; // initialize wavesurfer useEffect(() => { @@ -136,7 +117,7 @@ export const WavesurferWaveform = observer(function WavesurferWaveform() { setLoading(true); // Lazy load all wave surfer dependencies - const { WaveSurfer, TimelinePlugin, RegionsPlugin, MinimapPlugin } = + const { WaveSurfer, TimelinePlugin, MinimapPlugin } = (wavesurferConstructors.current = await importWavesurferConstructors()); // Instantiate timeline plugin @@ -144,9 +125,6 @@ export const WavesurferWaveform = observer(function WavesurferWaveform() { timelinePluginOptions )); - // Instantiate regions plugin - const regionsPlugin = (audioStore.regionsPlugin = RegionsPlugin.create()); - // Instantiate minimap plugin const minimapPlugin = (audioStore.minimapPlugin = MinimapPlugin.create({ ...DEFAULT_MINIMAP_OPTIONS, @@ -160,8 +138,12 @@ export const WavesurferWaveform = observer(function WavesurferWaveform() { const options: WaveSurferOptions = { ...DEFAULT_WAVESURFER_OPTIONS, container: waveformRef.current!, - minPxPerSec: uiStore.pixelsPerSecond, - plugins: [timelinePlugin, regionsPlugin, minimapPlugin], + height: uiStore.canTimelineZoom ? 60 : 80, + fillParent: !uiStore.canTimelineZoom, + minPxPerSec: uiStore.canTimelineZoom + ? uiStore.pixelsPerSecond + : undefined, + plugins: [timelinePlugin, minimapPlugin], media: audioRef.current!, }; const wavesurfer = WaveSurfer.create(options); @@ -180,27 +162,10 @@ export const WavesurferWaveform = observer(function WavesurferWaveform() { wavesurfer.on("ready", () => { ready.current = true; - if ( - store.context !== "viewer" && - audioStore.initialRegions.length > 0 - ) { - regionsPlugin.clearRegions(); - audioStore.initialRegions.forEach((region) => { - regionsPlugin.addRegion(region.withNewContentElement()); - }); - } - regionsPlugin.on( - "region-double-clicked", - action((region: RegionParams) => { - // only edit regions that have content - if (!region.content) return; - uiStore.showingMarkerEditorModal = true; - uiStore.markerToEdit = region; - }) - ); + if (audioStore.audioMuted) wavesurfer.setMuted(true); - wavesurfer.zoom(uiStore.pixelsPerSecond); + uiStore.canTimelineZoom && wavesurfer.zoom(uiStore.pixelsPerSecond); wavesurfer.seekTo(0); const audioBuffer = wavesurfer.getDecodedData(); @@ -215,7 +180,8 @@ export const WavesurferWaveform = observer(function WavesurferWaveform() { "finish", action(() => { audioStore.audioState = "paused"; - if (playlistStore.autoplay) playlistStore.playNextExperience(); + if (store.context === "playlistEditor") + playlistStore.playNextExperience(); }) ); @@ -316,87 +282,6 @@ export const WavesurferWaveform = observer(function WavesurferWaveform() { cloneCanvas(); }, [audioStore, audioStore.wavesurfer, audioStore.selectedSong, uiStore.pixelsPerSecond, cloneCanvas, timelinePluginOptions, embeddedViewer]); - // on loop toggle - useEffect(() => { - if (!didInitialize.current || !ready.current) return; - - let disableDragSelection = () => {}; - const toggleLoopingMode = action(async () => { - if (!didInitialize.current || !audioStore.regionsPlugin) return; - - const regionsPlugin = audioStore.regionsPlugin; - if (!audioStore.loopingAudio) { - regionsPlugin.unAll(); - regionsPlugin - .getRegions() - // remove the looped region, if any. looped regions will not have content - .forEach((region) => !region.content && region.remove()); - audioStore.loopRegion = null; - return; - } - - disableDragSelection = regionsPlugin.enableDragSelection({ - color: loopRegionColor, - }); - - regionsPlugin.on( - "region-created", - action((newRegion: RegionParams) => { - regionsPlugin.getRegions().forEach( - (region) => - // remove the last looped region, if any. looped regions will not have content - region !== newRegion && !region.content && region.remove() - ); - audioStore.loopRegion = newRegion; - if (!audioStore.wavesurfer) return; - audioStore.setTimeWithCursor(Math.max(0, newRegion.start)); - }) - ); - regionsPlugin.on( - "region-updated", - action((region: RegionParams) => { - audioStore.loopRegion = region; - if (!audioStore.wavesurfer) return; - audioStore.setTimeWithCursor(Math.max(0, region.start)); - }) - ); - }); - toggleLoopingMode(); - return disableDragSelection; - }, [audioStore, audioStore.loopingAudio]); - - // on marker mode toggle - useEffect(() => { - if (!didInitialize.current || !ready.current) return; - - let disableCreateByClick = () => {}; - const toggleMarkingMode = action(async () => { - const { wavesurfer, regionsPlugin } = audioStore; - if ( - !didInitialize.current || - !audioStore.regionsPlugin || - !wavesurfer || - !regionsPlugin - ) - return; - - if (!audioStore.markingAudio) return; - - disableCreateByClick = wavesurfer.on( - "interaction", - action((newTime: number) => { - uiStore.showingMarkerEditorModal = true; - uiStore.markerToEdit = { - id: generateId(), - start: newTime, - }; - }) - ); - }); - toggleMarkingMode(); - return disableCreateByClick; - }, [uiStore, audioStore, audioStore.markingAudio]); - // on audio state change useEffect(() => { if (!didInitialize.current || !ready.current) return; @@ -416,7 +301,8 @@ export const WavesurferWaveform = observer(function WavesurferWaveform() { // on zoom change useEffect(() => { if (!audioStore.wavesurfer || !ready.current) return; - audioStore.wavesurfer.zoom(uiStore.pixelsPerSecond); + uiStore.canTimelineZoom && + audioStore.wavesurfer.zoom(uiStore.pixelsPerSecond); cloneCanvas(); }, [cloneCanvas, uiStore.pixelsPerSecond, audioStore.wavesurfer]); @@ -429,7 +315,7 @@ export const WavesurferWaveform = observer(function WavesurferWaveform() { audioStore.wavesurfer.seekTo(clamp(progress, 0, 1)); }, [audioStore.lastCursor, audioStore.wavesurfer]); - return ( + return uiStore.canTimelineZoom ? ( )} + ) : ( + +