diff --git a/src/components/ExperienceEditor/ExperienceEditorPage.tsx b/src/components/ExperienceEditor/ExperienceEditorPage.tsx index 6052042c..18e33e5c 100644 --- a/src/components/ExperienceEditor/ExperienceEditorPage.tsx +++ b/src/components/ExperienceEditor/ExperienceEditorPage.tsx @@ -13,15 +13,39 @@ import { LoadingOverlay } from "@/src/components/LoadingOverlay"; export const ExperienceEditorPage = observer(function ExperienceEditorPage() { const store = useStore(); - const { uiStore, experienceStore } = store; + const { uiStore, experienceStore, initializationState } = store; + const { loadingExperienceName } = experienceStore; const router = useRouter(); + + // Initialize the store with the experience name from the URL useEffect(() => { - if (store.initializedClientSide || !router.query.experienceName) return; + if (initializationState !== "uninitialized" || !router.isReady) return; store.initializeClientSide(router.query.experienceName as string); + }, [ + store, + initializationState, + experienceStore, + store.experienceName, + router.isReady, + router.query.experienceName, + ]); + + // Listen for url changes and load any new experience by name + useEffect(() => { + if ( + initializationState !== "initialized" || + !router.query.experienceName || + store.experienceName === router.query.experienceName || + loadingExperienceName === router.query.experienceName + ) + return; + experienceStore.load(router.query.experienceName as string); }, [ store, experienceStore, + initializationState, + loadingExperienceName, store.experienceName, router.query.experienceName, ]); diff --git a/src/components/ExperienceThumbnail.tsx b/src/components/ExperienceThumbnail.tsx index 0a1d5deb..9e01ce6b 100644 --- a/src/components/ExperienceThumbnail.tsx +++ b/src/components/ExperienceThumbnail.tsx @@ -6,13 +6,13 @@ import { FaCamera } from "react-icons/fa"; export const ExperienceThumbnail = memo(function ExperienceThumbnail({ thumbnailURL, onClick, - captureButton = false, + showCaptureButton = false, }: { thumbnailURL: string; onClick?: () => void; - captureButton?: boolean; + showCaptureButton?: boolean; }) { - if (!thumbnailURL && !captureButton) return null; + if (!thumbnailURL && !showCaptureButton) return null; return ( - {!thumbnailURL && captureButton && ( + {!thumbnailURL && showCaptureButton && ( diff --git a/src/components/KeyboardControls.tsx b/src/components/KeyboardControls.tsx index 08a43eae..f06ca9ed 100644 --- a/src/components/KeyboardControls.tsx +++ b/src/components/KeyboardControls.tsx @@ -2,6 +2,7 @@ import { useSaveExperience } from "@/src/hooks/experience"; import { useStore } from "@/src/types/StoreContext"; import { action } from "mobx"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; import { useEffect } from "react"; type Props = { @@ -14,6 +15,7 @@ export const KeyboardControls = observer(function KeyboardControls({ const store = useStore(); const { uiStore, experienceStore, audioStore } = store; + const router = useRouter(); const { saveExperience } = useSaveExperience(); useEffect(() => { @@ -25,7 +27,7 @@ export const KeyboardControls = observer(function KeyboardControls({ saveExperience(); e.preventDefault(); } else if (editMode && e.key === "n" && (e.ctrlKey || e.metaKey)) { - experienceStore.loadEmptyExperience(); + experienceStore.openEmptyExperience(router); e.preventDefault(); } @@ -95,7 +97,15 @@ export const KeyboardControls = observer(function KeyboardControls({ window.removeEventListener("copy", handleCopy); window.removeEventListener("paste", handlePaste); }; - }, [store, uiStore, experienceStore, audioStore, editMode, saveExperience]); + }, [ + store, + uiStore, + experienceStore, + audioStore, + editMode, + saveExperience, + router, + ]); return null; }); diff --git a/src/components/LoadingOverlay.tsx b/src/components/LoadingOverlay.tsx index 9c3a422e..f078de35 100644 --- a/src/components/LoadingOverlay.tsx +++ b/src/components/LoadingOverlay.tsx @@ -18,10 +18,14 @@ const contextMatchesRole = (context: Context, role: Role) => { export const LoadingOverlay = observer(function LoadingOverlay() { const store = useStore(); - const { context, role } = store; + const { context, role, experienceStore } = store; return ( {}} closeOnEsc={false} closeOnOverlayClick={false} diff --git a/src/components/LoginButton.tsx b/src/components/LoginButton.tsx index c192fb46..081bec20 100644 --- a/src/components/LoginButton.tsx +++ b/src/components/LoginButton.tsx @@ -21,11 +21,13 @@ import { observer } from "mobx-react-lite"; import { trpc } from "@/src/utils/trpc"; import { sanitize } from "@/src/utils/sanitize"; import { CONJURER_USER } from "@/src/types/User"; +import { useRouter } from "next/router"; export const LoginButton = observer(function LoginButton() { const store = useStore(); const { experienceStore, uiStore, userStore, usingLocalData } = store; + const router = useRouter(); const [newUsername, setNewUsername] = useState(""); const { @@ -80,7 +82,7 @@ export const LoginButton = observer(function LoginButton() { width="100%" onClick={action(() => { userStore.me = user; - experienceStore.loadEmptyExperience(); + experienceStore.openEmptyExperience(router); if (store.context === "experienceEditor") { uiStore.showingOpenExperienceModal = true; } @@ -110,7 +112,7 @@ export const LoginButton = observer(function LoginButton() { username: newUsername, }); userStore.me = newUser; - experienceStore.loadEmptyExperience(); + experienceStore.openEmptyExperience(router); onClose(); })} > diff --git a/src/components/Menu/MenuBar.tsx b/src/components/Menu/MenuBar.tsx index 5c9807d7..45bc00a5 100644 --- a/src/components/Menu/MenuBar.tsx +++ b/src/components/Menu/MenuBar.tsx @@ -33,11 +33,13 @@ import { action } from "mobx"; import { LatencyModal } from "@/src/components/LatencyModal/LatencyModal"; import { ExperienceThumbnail } from "@/src/components/ExperienceThumbnail"; import { ExperienceStatusIndicator } from "../ExperienceStatusIndicator"; +import { useRouter } from "next/router"; export const MenuBar = observer(function MenuBar() { const store = useStore(); const { audioStore, experienceStore, uiStore } = store; + const router = useRouter(); const { saveExperience } = useSaveExperience(); const { @@ -86,12 +88,11 @@ export const MenuBar = observer(function MenuBar() { - {/* TODO: disallow editing if you don't own this experience */} - {store.context === "experienceEditor" ? ( + {store.context === "experienceEditor" && store.canEditExperience ? ( (uiStore.capturingThumbnail = true))} - captureButton + showCaptureButton /> ) : ( @@ -169,7 +170,9 @@ export const MenuBar = observer(function MenuBar() { } command="⌘N" - onClick={experienceStore.loadEmptyExperience} + onClick={() => + experienceStore.openEmptyExperience(router) + } > New experience @@ -373,17 +376,16 @@ export const MenuBar = observer(function MenuBar() { py={0} variant="ghost" size="xs" + fontSize="xs" transition="all 0.2s" borderRadius="md" _hover={{ bg: "gray.500" }} _focus={{ boxShadow: "outline" }} > - - - + diff --git a/src/components/Menu/OpenExperienceModal.tsx b/src/components/Menu/OpenExperienceModal.tsx index 038569dd..55951497 100644 --- a/src/components/Menu/OpenExperienceModal.tsx +++ b/src/components/Menu/OpenExperienceModal.tsx @@ -17,20 +17,22 @@ import { action } from "mobx"; import { useState } from "react"; import { ExperiencesTable } from "@/src/components/ExperiencesTable/ExperiencesTable"; import { useExperiences } from "@/src/hooks/experiencesAndUsers"; +import { useRouter } from "next/router"; export const OpenExperienceModal = observer(function OpenExperienceModal() { const store = useStore(); - const { experienceStore, uiStore, userStore } = store; + const { uiStore, userStore, experienceStore } = store; const { username } = userStore; const [viewingAllExperiences, setViewingAllExperiences] = useState(false); - const [isLoadingNewExperience, setIsLoadingNewExperience] = useState(false); const { isPending, isError, isRefetching, experiences } = useExperiences({ username: viewingAllExperiences ? undefined : username, enabled: uiStore.showingOpenExperienceModal, }); + const router = useRouter(); + const onClose = action(() => (uiStore.showingOpenExperienceModal = false)); if (isError) return null; @@ -45,10 +47,7 @@ export const OpenExperienceModal = observer(function OpenExperienceModal() { - Open experience{" "} - {(isPending || isRefetching || isLoadingNewExperience) && ( - - )} + Open experience {(isPending || isRefetching) && } @@ -63,9 +62,7 @@ export const OpenExperienceModal = observer(function OpenExperienceModal() { { - setIsLoadingNewExperience(true); - await experienceStore.load(experience.name); - setIsLoadingNewExperience(false); + experienceStore.openExperience(router, experience.name); onClose(); })} /> diff --git a/src/components/PlaygroundPage.tsx b/src/components/PlaygroundPage.tsx index 95b5cd14..2dc44b04 100644 --- a/src/components/PlaygroundPage.tsx +++ b/src/components/PlaygroundPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { PatternPlayground } from "@/src/components/PatternPlayground/PatternPlayground"; import { useStore } from "@/src/types/StoreContext"; import { observer } from "mobx-react-lite"; @@ -10,15 +10,13 @@ import { LoginButton } from "@/src/components/LoginButton"; export const PlaygroundPage = observer(function PlaygroundPage() { const store = useStore(); - const initialized = useRef(false); useEffect(() => { - if (initialized.current) return; - initialized.current = true; + if (store.initializationState !== "uninitialized") return; store.initializeClientSide(); - }, [store]); + }, [store, store.initializationState]); return ( - store.initializedClientSide && ( + store.initializationState === "initialized" && ( <> } onClick={() => { store.role = "experienceCreator"; - router.push("/experience/untitled"); + experienceStore.openEmptyExperience(router); }} > New experience diff --git a/src/components/PlaylistEditor/PlaylistEditorPage.tsx b/src/components/PlaylistEditor/PlaylistEditorPage.tsx index 8ec14074..9e0035bf 100644 --- a/src/components/PlaylistEditor/PlaylistEditorPage.tsx +++ b/src/components/PlaylistEditor/PlaylistEditorPage.tsx @@ -15,9 +15,9 @@ export const PlaylistEditorPage = observer(function PlaylistEditorPage() { const { userStore } = store; useEffect(() => { - if (store.initializedClientSide) return; + if (store.initializationState !== "uninitialized") return; store.initializeClientSide(); - }, [store]); + }, [store, store.initializationState]); return ( diff --git a/src/components/PlaylistEditor/PlaylistItem.tsx b/src/components/PlaylistEditor/PlaylistItem.tsx index 3b161c6d..16ef81dc 100644 --- a/src/components/PlaylistEditor/PlaylistItem.tsx +++ b/src/components/PlaylistEditor/PlaylistItem.tsx @@ -166,7 +166,7 @@ export const PlaylistItem = observer(function PlaylistItem({ icon={} onClick={action(() => { store.role = "experienceCreator"; - router.push(`/experience/${experience.name}`); + experienceStore.openExperience(router, experience.name); })} /> {editable && ( diff --git a/src/components/RoleSelector.tsx b/src/components/RoleSelector.tsx index 499eee61..49a58f19 100644 --- a/src/components/RoleSelector.tsx +++ b/src/components/RoleSelector.tsx @@ -7,6 +7,7 @@ import { useRouter } from "next/router"; export const RoleSelector = observer(function RoleSelector() { const store = useStore(); + const { experienceStore } = store; const router = useRouter(); return ( @@ -36,7 +37,7 @@ export const RoleSelector = observer(function RoleSelector() { { store.role = "experienceCreator"; - router.push(`/experience/${store.experienceName || "untitled"}`); + experienceStore.openExperience(router, store.experienceName); })} > Experience Creator diff --git a/src/types/ExperienceStore.ts b/src/types/ExperienceStore.ts index 99e3161c..e9b0b904 100644 --- a/src/types/ExperienceStore.ts +++ b/src/types/ExperienceStore.ts @@ -3,12 +3,33 @@ import { trpcClient } from "@/src/utils/trpc"; import { Experience, EXPERIENCE_VERSION } from "@/src/types/Experience"; import { NO_SONG } from "@/src/types/Song"; import type { Store } from "@/src/types/Store"; +import { NextRouter } from "next/router"; export class ExperienceStore { + private _loadingExperienceName: string | null = null; + get loadingExperienceName() { + return this._loadingExperienceName; + } + set loadingExperienceName(value: string | null) { + this._loadingExperienceName = value; + } + constructor(readonly store: Store) { makeAutoObservable(this); } + // Open an experience by experience name + openExperience = (router: NextRouter, experienceName: string) => { + router.push(`/experience/${experienceName}`); + }; + + // Open an empty experience + openEmptyExperience = (router: NextRouter) => { + router.push("/experience/untitled"); + }; + + // This "load" method and subsequent load* methods are used internally to change experiences, and + // are not meant to be called directly. Instead use openExperience/openEmptyExperience. loadExperience = (experience: Experience) => { this.store.deserialize(experience); runInAction(() => { @@ -17,13 +38,31 @@ export class ExperienceStore { }); }; + loadEmptyExperience = () => { + this.store.deserialize({ + id: undefined, + user: this.store.userStore.me ?? { id: -1, username: "" }, + name: "untitled", + song: NO_SONG, + status: "inprogress", + version: EXPERIENCE_VERSION, + data: { layers: [{ patternBlocks: [] }, { patternBlocks: [] }] }, + thumbnailURL: "", + }); + + this.store.hasSaved = false; + this.store.experienceLastSavedAt = 0; + }; + load = async (experienceName: string) => { + this.loadingExperienceName = experienceName; const experience = await trpcClient.experience.getExperience.query({ experienceName, usingLocalData: this.store.usingLocalData, }); if (!experience) this.loadEmptyExperience(); else this.loadExperience(experience); + this.loadingExperienceName = null; }; loadById = async (experienceId: number) => { @@ -35,29 +74,6 @@ export class ExperienceStore { else this.loadExperience(experience); }; - loadEmptyExperience = () => { - this.store.deserialize({ - id: undefined, - user: this.store.userStore.me ?? { id: 0, username: "" }, - name: "untitled", - song: NO_SONG, - status: "inprogress", - version: EXPERIENCE_VERSION, - data: { layers: [{ patternBlocks: [] }, { patternBlocks: [] }] }, - thumbnailURL: "", - }); - - this.store.hasSaved = false; - this.store.experienceLastSavedAt = 0; - }; - - loadFromParams = () => { - const urlParams = new URLSearchParams(window.location.search); - const experience = urlParams.get("experience"); - if (experience) void this.load(experience); - return !!experience; - }; - stringifyExperience = (pretty: boolean = false): string => JSON.stringify( this.store.serialize(), diff --git a/src/types/Store.ts b/src/types/Store.ts index 5860568c..291560f9 100644 --- a/src/types/Store.ts +++ b/src/types/Store.ts @@ -19,6 +19,7 @@ import { NO_SONG } from "@/src/types/Song"; import { Context, Role } from "@/src/types/context"; import "@/src/utils/mobx"; import { UserStore } from "@/src/types/UserStore"; +import { User } from "@/src/types/User"; export type BlockSelection = { type: "block"; block: Block }; @@ -31,9 +32,9 @@ export type VariationSelection = { export type BlockOrVariation = BlockSelection | VariationSelection; -export class Store { - initializedClientSide = false; +type InitializationState = "uninitialized" | "initializing" | "initialized"; +export class Store { audioStore = new AudioStore(this); beatMapStore = new BeatMapStore(this); uiStore = new UIStore(this); @@ -47,6 +48,14 @@ export class Store { sendingData = false; viewerMode = false; + private _initializationState: InitializationState = "uninitialized"; + get initializationState() { + return this._initializationState; + } + set initializationState(value: InitializationState) { + this._initializationState = value; + } + _globalIntensity = 1; get globalIntensity(): number { return this._globalIntensity; @@ -121,7 +130,6 @@ export class Store { this._experienceName = value; if (this.context === "experienceEditor") { localStorage.setItem("experienceName", value); - window.history.pushState({}, "", `/experience/${value}`); } } @@ -132,6 +140,13 @@ export class Store { experienceStatus: ExperienceStatus = "inprogress"; experienceId: number | undefined = undefined; experienceThumbnailURL = ""; + experienceUser: User | undefined = undefined; + get canEditExperience() { + return ( + this.userStore.isAuthenticated && + this.experienceUser?.id === this.userStore.me?.id + ); + } get playing() { return this.audioStore.audioState !== "paused"; @@ -141,9 +156,9 @@ export class Store { makeAutoObservable(this); } - initializeClientSide = (initialExperienceName?: string) => { - if (this.initializedClientSide) return; - this.initializedClientSide = true; + initializeClientSide = async (initialExperienceName?: string) => { + if (this.initializationState !== "uninitialized") return; + this.initializationState = "initializing"; if (process.env.NEXT_PUBLIC_ENABLE_VOICE === "true") setupVoiceCommandWebsocket(this); @@ -178,14 +193,17 @@ export class Store { if (usingLocalData && process.env.NEXT_PUBLIC_NODE_ENV !== "production") this._usingLocalData = usingLocalData === "true"; + if (this.context === "playground") this.playgroundStore.initialize(); + this.uiStore.initialize(); + this.audioStore.initialize(); + // load experience from path parameter if provided (e.g. /experience/my-experience) - if (initialExperienceName) this.experienceStore.load(initialExperienceName); + if (initialExperienceName) + await this.experienceStore.load(initialExperienceName); else if (this.context !== "playlistEditor") this.experienceStore.loadEmptyExperience(); - if (this.context === "playground") this.playgroundStore.initialize(); - this.uiStore.initialize(); - this.audioStore.initialize(); + this.initializationState = "initialized"; }; toggleSendingData = () => { @@ -477,16 +495,19 @@ export class Store { } }; - serialize = (): Experience => ({ - id: this.experienceId, - name: this.experienceName, - user: this.userStore.me!, - song: this.audioStore.selectedSong, - status: this.experienceStatus, - version: this.experienceVersion, - data: { layers: this.layers.map((l) => l.serialize()) }, - thumbnailURL: this.experienceThumbnailURL, - }); + serialize = (): Experience => { + if (!this.userStore.me) throw new Error("User not authenticated"); + return { + id: this.experienceId, + name: this.experienceName, + user: this.userStore.me, + song: this.audioStore.selectedSong, + status: this.experienceStatus, + version: this.experienceVersion, + data: { layers: this.layers.map((l) => l.serialize()) }, + thumbnailURL: this.experienceThumbnailURL, + }; + }; deserialize = (experience: Experience) => { this.experienceId = experience.id; @@ -495,6 +516,7 @@ export class Store { this.experienceStatus = experience.status; this.experienceVersion = experience.version; this.experienceThumbnailURL = experience.thumbnailURL; + this.experienceUser = experience.user; this.layers = experience.data.layers.map((l: any) => Layer.deserialize(this, l), );