diff --git a/.github/workflows/mirror-2-dev-db.yml b/.github/workflows/mirror-2-dev-db.yml
new file mode 100644
index 00000000..1cc081a4
--- /dev/null
+++ b/.github/workflows/mirror-2-dev-db.yml
@@ -0,0 +1,31 @@
+name: Deploy Migrations to Dev
+
+on:
+ push:
+ branches: [dev]
+ paths:
+ - 'mirror-2/**'
+ pull_request:
+ branches: [dev]
+ paths:
+ - 'mirror-2/**'
+ workflow_dispatch:
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+
+ env:
+ SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
+ SUPABASE_DB_PASSWORD: ${{ secrets.DEV_DB_PASSWORD }}
+ SUPABASE_PROJECT_ID: ${{ secrets.DEV_PROJECT_ID }}
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - uses: supabase/setup-cli@v1
+ with:
+ version: latest
+
+ - run: supabase link --project-ref $SUPABASE_PROJECT_ID
+ - run: supabase db push
diff --git a/LICENSE.txt b/LICENSE.txt
index 3c2b95a1..d6cb58e8 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -1,3 +1,5 @@
+The below licenses apply to everything in this repository with the exception of the /mirror-2 folder; for the mirror-2 folder, see /mirror-2/LICENSE.txt.
+
MIT License
Copyright (c) 2022-present The Mirror Megaverse Inc.
diff --git a/mirror-2/.env.example b/mirror-2/.env.example
new file mode 100644
index 00000000..07783698
--- /dev/null
+++ b/mirror-2/.env.example
@@ -0,0 +1,8 @@
+# Update these with your Supabase details from your project settings > API
+# https://app.supabase.com/project/_/settings/api
+NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
+NEXT_PUBLIC_SUPABASE_ANON_KEY=SUPABASE_CLIENT_API_KEY
+NEXT_PUBLIC_APP_NAME="The Mirror" # only use The Mirror or Reflekt
+NEXT_PUBLIC_DISCORD_INVITE_URL=https://themirror.space/discord
+NEXT_PUBLIC_VERSION_NAME="JavaScript"
+NEXT_PUBLIC_AMPLITUDE_PUBLIC_KEY=
diff --git a/mirror-2/.gitignore b/mirror-2/.gitignore
new file mode 100644
index 00000000..00bba9bb
--- /dev/null
+++ b/mirror-2/.gitignore
@@ -0,0 +1,37 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+.yarn/install-state.gz
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env*.local
+.env
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/mirror-2/.prettierignore b/mirror-2/.prettierignore
new file mode 100644
index 00000000..a10c0d6c
--- /dev/null
+++ b/mirror-2/.prettierignore
@@ -0,0 +1,6 @@
+# Ignore artifacts:
+build
+coverage
+dist
+# ignore JS for now because of vanilla JS file imports might mess up some text replacement. Can revisit in the future but not high priority
+*.js
diff --git a/mirror-2/.prettierrc b/mirror-2/.prettierrc
new file mode 100644
index 00000000..ffca9292
--- /dev/null
+++ b/mirror-2/.prettierrc
@@ -0,0 +1,6 @@
+{
+ "singleQuote": true,
+ "trailingComma": "none",
+ "semi": false,
+ "endOfLine": "auto"
+}
diff --git a/mirror-2/.vscode/launch.json b/mirror-2/.vscode/launch.json
new file mode 100644
index 00000000..d184594a
--- /dev/null
+++ b/mirror-2/.vscode/launch.json
@@ -0,0 +1,16 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Next.js: debug full stack",
+ "type": "node-terminal",
+ "request": "launch",
+ "command": "yarn dev",
+ "serverReadyAction": {
+ "pattern": "- Local:.+(https?://.+)",
+ "uriFormat": "%s",
+ "action": "debugWithChrome"
+ }
+ }
+ ]
+}
diff --git a/mirror-2/.vscode/settings.json b/mirror-2/.vscode/settings.json
new file mode 100644
index 00000000..68adb827
--- /dev/null
+++ b/mirror-2/.vscode/settings.json
@@ -0,0 +1,23 @@
+{
+ "deno.enablePaths": ["supabase/functions"],
+ "deno.lint": true,
+ "deno.unstable": [
+ "bare-node-builtins",
+ "byonm",
+ "sloppy-imports",
+ "unsafe-proto",
+ "webgpu",
+ "broadcast-channel",
+ "worker-options",
+ "cron",
+ "kv",
+ "ffi",
+ "fs",
+ "http",
+ "net"
+ ],
+ "[typescript]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "typescript.tsdk": "node_modules/typescript/lib"
+}
diff --git a/mirror-2/LICENSE.txt b/mirror-2/LICENSE.txt
new file mode 100644
index 00000000..f3078b4f
--- /dev/null
+++ b/mirror-2/LICENSE.txt
@@ -0,0 +1,46 @@
+RevShare: A Source Available License
+
+Copyright (c) 2024-Present The Mirror Megaverse Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, subject to the following conditions:
+
+1. Revenue Sharing Requirement
+If any revenue is generated through the use of the Software, including but not limited to sales, in-game transactions, or any other form of monetization, the user agrees to pay a fee to the owner of the Software (“Company”). The fee structure and payment requirements are detailed on the Company’s website, and users are responsible for staying updated on any changes to these terms. The fee is 15% as of Sept 2024 and subject to change (the website is the source of truth for this fee if the amount in this license differs).
+
+2. Payment in Fiat Currency
+If revenue is generated in fiat currency (e.g., USD, EUR), the user agrees to remit payments to the Company based on the fee structure listed on the Company’s website. Failure to comply with these payment terms will result in the revocation of the license.
+
+3. Payment in Cryptocurrency and Royalties for Non-Fungible Tokens
+Cryptocurrency integrations are exclusive to the Reflekt version of the Software, a separate fork of The Mirror. In contrast, The Mirror version of the Software does not support cryptocurrency usage. This structure is designed to give developers the flexibility to choose between traditional web2 approaches and blockchain-integrated games.
+
+Direct Cryptocurrency Transactions: If revenue is generated directly through the Software in the form of cryptocurrency (e.g., in-game transactions, purchases, or other forms of monetization), the user agrees to pay a 10% fee on all such transactions. Payments must be made to the Company’s wallet address as specified on its website. Users are responsible for ensuring accurate and timely payments to the listed address.
+
+Non-Fungible Token Royalties: If the Software is used to issue or mint Non-Fungible Tokens (NFTs), the user agrees to pay a 10% royalty on all subsequent sales or transfers of the NFT, regardless of the marketplace or platform where the transaction occurs. If the NFT was minted outside of the platform and a transaction takes place using the Software, the user agrees to pay the same percent fee via direct cryptocurrency transfer to the company's wallet.
+
+4. Warranty Disclaimer
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+By using the Software, you agree to the terms outlined above. These terms are subject to change due to early versions of the Software and the team is open to feedback!
+
+---
+The below copyright notice and MIT license is included for compliance with the MIT license of the PlayCanvas Engine: https://github.com/playcanvas/engine
+
+Copyright (c) 2011-2024 PlayCanvas Ltd.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/mirror-2/README.md b/mirror-2/README.md
new file mode 100644
index 00000000..5a07d3cb
--- /dev/null
+++ b/mirror-2/README.md
@@ -0,0 +1 @@
+Alea iacta est.
diff --git a/mirror-2/actions/auth.ts b/mirror-2/actions/auth.ts
new file mode 100644
index 00000000..5a9928a1
--- /dev/null
+++ b/mirror-2/actions/auth.ts
@@ -0,0 +1,124 @@
+'use server'
+
+import { createServerClient } from '@/utils/supabase/server'
+import { encodedRedirect } from '@/utils/utils'
+import { headers } from 'next/headers'
+import { redirect } from 'next/navigation'
+
+export const createAccountAction = async (formData: FormData) => {
+ const email = formData.get('email')?.toString()
+ const password = formData.get('password')?.toString()
+ const supabase = createServerClient()
+ const origin = headers().get('origin')
+
+ if (!email || !password) {
+ return { error: 'Email and password are required' }
+ }
+
+ const { error } = await supabase.auth.signUp({
+ email,
+ password,
+ options: {
+ emailRedirectTo: `${origin}/auth/callback`
+ }
+ })
+
+ if (error) {
+ console.error(error.code + ' ' + error.message)
+ return encodedRedirect('error', '/create-account', error.message)
+ } else {
+ return encodedRedirect(
+ 'success',
+ '/create-account',
+ 'Thanks for signing up! Please check your email for a verification link.'
+ )
+ }
+}
+
+export const loginAction = async (formData: FormData) => {
+ const email = formData.get('email') as string
+ const password = formData.get('password') as string
+ const supabase = createServerClient()
+
+ const { error } = await supabase.auth.signInWithPassword({
+ email,
+ password
+ })
+
+ if (error) {
+ return encodedRedirect('error', '/login', error.message)
+ }
+
+ return redirect('/home')
+}
+
+export const forgotPasswordAction = async (formData: FormData) => {
+ const email = formData.get('email')?.toString()
+ const supabase = createServerClient()
+ const origin = headers().get('origin')
+ const callbackUrl = formData.get('callbackUrl')?.toString()
+
+ if (!email) {
+ return encodedRedirect('error', '/forgot-password', 'Email is required')
+ }
+
+ const { error } = await supabase.auth.resetPasswordForEmail(email, {
+ redirectTo: `${origin}/auth/callback?redirect_to=/protected/reset-password`
+ })
+
+ if (error) {
+ console.error(error.message)
+ return encodedRedirect(
+ 'error',
+ '/forgot-password',
+ 'Could not reset password'
+ )
+ }
+
+ if (callbackUrl) {
+ return redirect(callbackUrl)
+ }
+
+ return encodedRedirect(
+ 'success',
+ '/forgot-password',
+ 'Check your email for a link to reset your password.'
+ )
+}
+
+export const resetPasswordAction = async (formData: FormData) => {
+ const supabase = createServerClient()
+
+ const password = formData.get('password') as string
+ const confirmPassword = formData.get('confirmPassword') as string
+
+ if (!password || !confirmPassword) {
+ encodedRedirect(
+ 'error',
+ '/protected/reset-password',
+ 'Password and confirm password are required'
+ )
+ }
+
+ if (password !== confirmPassword) {
+ encodedRedirect(
+ 'error',
+ '/protected/reset-password',
+ 'Passwords do not match'
+ )
+ }
+
+ const { error } = await supabase.auth.updateUser({
+ password: password
+ })
+
+ if (error) {
+ encodedRedirect(
+ 'error',
+ '/protected/reset-password',
+ 'Password update failed'
+ )
+ }
+
+ encodedRedirect('success', '/protected/reset-password', 'Password updated')
+}
diff --git a/mirror-2/actions/name-generator.ts b/mirror-2/actions/name-generator.ts
new file mode 100644
index 00000000..fe7dcb61
--- /dev/null
+++ b/mirror-2/actions/name-generator.ts
@@ -0,0 +1,34 @@
+'use server'
+import {
+ uniqueNamesGenerator,
+ Config,
+ adjectives,
+ colors,
+ animals
+} from 'unique-names-generator'
+
+const randomName: string = uniqueNamesGenerator({
+ dictionaries: [adjectives, animals]
+})
+
+export async function generateSpaceName() {
+ const customConfig: Config = {
+ dictionaries: [adjectives, animals],
+ separator: ' ',
+ length: 2,
+ style: 'capital'
+ }
+
+ return uniqueNamesGenerator(customConfig)
+}
+
+export async function generateSceneName() {
+ const customConfig: Config = {
+ dictionaries: [adjectives],
+ separator: ' ',
+ length: 1,
+ style: 'capital'
+ }
+
+ return uniqueNamesGenerator(customConfig)
+}
diff --git a/mirror-2/ampli.json b/mirror-2/ampli.json
new file mode 100644
index 00000000..f1cce44f
--- /dev/null
+++ b/mirror-2/ampli.json
@@ -0,0 +1,14 @@
+{
+ "Zone": "us",
+ "OrgId": "308710",
+ "WorkspaceId": "a2da908b-27c1-4013-b214-f7ce5531bbe7",
+ "SourceId": "b077387d-b743-4f18-bd57-94effffd3ad1",
+ "Runtime": "browser:typescript-ampli-v2",
+ "Platform": "Browser",
+ "Language": "TypeScript",
+ "SDK": "@amplitude/analytics-browser@^1.0",
+ "Branch": "main",
+ "Version": "1.0.0",
+ "VersionId": "84e8930d-d0e7-4c92-b6c6-629ff838c9a6",
+ "Path": "./src/ampli"
+}
\ No newline at end of file
diff --git a/mirror-2/app/(auth-pages)/create-account/page.tsx b/mirror-2/app/(auth-pages)/create-account/page.tsx
new file mode 100644
index 00000000..ad893381
--- /dev/null
+++ b/mirror-2/app/(auth-pages)/create-account/page.tsx
@@ -0,0 +1,73 @@
+'use client'
+import { createAccountAction, loginAction } from '@/actions/auth'
+import { FormMessage, Message } from '@/components/form-message'
+import { SubmitButton } from '@/components/submit-button'
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardContent,
+ CardFooter
+} from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { useRedirectToHomeIfSignedIn } from '@/hooks/auth'
+import { AppLogoImageMedium } from '@/lib/theme-service'
+import Link from 'next/link'
+
+export default function CreateAccount({
+ searchParams
+}: {
+ searchParams: Message
+}) {
+ useRedirectToHomeIfSignedIn()
+ return (
+
+ )
+}
diff --git a/mirror-2/app/(auth-pages)/forgot-password/page.tsx b/mirror-2/app/(auth-pages)/forgot-password/page.tsx
new file mode 100644
index 00000000..09938fff
--- /dev/null
+++ b/mirror-2/app/(auth-pages)/forgot-password/page.tsx
@@ -0,0 +1,38 @@
+import { forgotPasswordAction } from '@/actions/auth'
+import { FormMessage, Message } from '@/components/form-message'
+import { SubmitButton } from '@/components/submit-button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import Link from 'next/link'
+import { SmtpMessage } from '../smtp-message'
+
+export default function ForgotPassword({
+ searchParams
+}: {
+ searchParams: Message
+}) {
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/mirror-2/app/(auth-pages)/layout.tsx b/mirror-2/app/(auth-pages)/layout.tsx
new file mode 100644
index 00000000..74e01480
--- /dev/null
+++ b/mirror-2/app/(auth-pages)/layout.tsx
@@ -0,0 +1,11 @@
+export default async function Layout({
+ children
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/mirror-2/app/(auth-pages)/login/page.tsx b/mirror-2/app/(auth-pages)/login/page.tsx
new file mode 100644
index 00000000..c7014e02
--- /dev/null
+++ b/mirror-2/app/(auth-pages)/login/page.tsx
@@ -0,0 +1,123 @@
+'use client'
+import { loginAction } from '@/actions/auth'
+import { FormMessage, Message } from '@/components/form-message'
+import { SubmitButton } from '@/components/submit-button'
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardContent,
+ CardFooter
+} from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { useRedirectToHomeIfSignedIn } from '@/hooks/auth'
+import { AppLogoImageMedium } from '@/lib/theme-service'
+import Link from 'next/link'
+import { useRef } from 'react'
+
+const isDevelopment = process.env.NODE_ENV === 'development'
+
+export default function Login({ searchParams }: { searchParams: Message }) {
+ useRedirectToHomeIfSignedIn()
+
+ // References for email and password fields
+ const emailRef = useRef(null)
+ const passwordRef = useRef(null)
+
+ // Function to simulate login with specific user
+ const handleDevLoginWithUser = (userEmail: string) => {
+ if (emailRef.current && passwordRef.current) {
+ emailRef.current.value = userEmail
+ passwordRef.current.value = 'password' // Default password for all dev users
+ }
+
+ // Simulate form submission by calling formAction
+ // Since the SubmitButton uses formAction, this will trigger loginAction
+ ;(document.getElementById('login-form') as HTMLFormElement)?.requestSubmit()
+ }
+
+ return (
+
+ )
+}
diff --git a/mirror-2/app/(auth-pages)/smtp-message.tsx b/mirror-2/app/(auth-pages)/smtp-message.tsx
new file mode 100644
index 00000000..f5f46633
--- /dev/null
+++ b/mirror-2/app/(auth-pages)/smtp-message.tsx
@@ -0,0 +1,25 @@
+import { ArrowUpRight, InfoIcon } from 'lucide-react'
+import Link from 'next/link'
+
+export function SmtpMessage() {
+ return (
+
+
+
+
+ Note: Emails are rate limited. Enable Custom SMTP to
+ increase the rate limit.
+
+
+
+}
diff --git a/mirror-2/app/space/[spaceId]/build/layout.tsx b/mirror-2/app/space/[spaceId]/build/layout.tsx
new file mode 100644
index 00000000..21727082
--- /dev/null
+++ b/mirror-2/app/space/[spaceId]/build/layout.tsx
@@ -0,0 +1,44 @@
+"use client"
+import InnerControlBar from "@/app/space/[spaceId]/build/controlBar/inner-control-bar"
+import Inspector from "@/app/space/[spaceId]/build/inspector/inspector"
+import { Sidebar } from "@/app/space/[spaceId]/build/sidebar"
+import { TopNavbar } from "@/app/space/[spaceId]/build/top-navbar"
+import SpaceViewport from "@/components/engine/space-viewport"
+import { useParams } from "next/navigation"
+
+export default function Layout({ children, params }: {
+ children: React.ReactNode,
+ spaceViewport: React.ReactNode,
+ params: { spaceId: string }
+}) {
+ const spaceId: number = parseInt(params.spaceId, 10) // Use parseInt for safer conversion
+ return (
+
+
+ {/*
*/}
+
+ {/* Sidebar with fixed width */}
+
+
+
+
+ {/* Inner control bar takes flexible space */}
+
+
+
+
+ {/* Space viewport (main content) */}
+
+
+
+
+ {/* Instead of a div wrapping here, passing in className so that this component can be server compoonent; the Inspector has to use a hook for checking if entity selected */}
+
+
+
+ {/* Children for additional content */}
+ {children}
+
+
+ );
+}
diff --git a/mirror-2/app/space/[spaceId]/build/page.tsx b/mirror-2/app/space/[spaceId]/build/page.tsx
new file mode 100644
index 00000000..79a8aeb4
--- /dev/null
+++ b/mirror-2/app/space/[spaceId]/build/page.tsx
@@ -0,0 +1,38 @@
+"use client"
+
+import { useAppDispatch, useAppSelector } from "@/hooks/hooks"
+import { selectCurrentScene, setCurrentScene } from "@/state/local.slice"
+import { useGetAllScenesQuery } from "@/state/api/scenes"
+import { useGetSingleSpaceQuery } from "@/state/api/spaces"
+
+import { useParams } from "next/navigation"
+import { useEffect } from "react"
+import dynamic from "next/dynamic"
+import { store } from "@/state/store"
+
+
+// blank page since we're using the parallel routes for spaceViewport, controlBar, etc.
+export default function Page() {
+ const currentScene = useAppSelector(selectCurrentScene);
+ const params = useParams<{ spaceId: string }>()
+ const spaceId: number = parseInt(params.spaceId, 10) // Use parseInt for safer conversion
+
+ const { data: space, error } = useGetSingleSpaceQuery(spaceId)
+ const { data: scenes, isLoading: isScenesLoading } = useGetAllScenesQuery(spaceId)
+
+ // after successful query, update the current scene to the first in the space.scenes array
+ const dispatch = useAppDispatch();
+ useEffect(() => {
+ // if no current Scene, set it to the first scene
+ if (scenes && scenes?.length > 0 && scenes[0]) {
+ if (!currentScene?.id) {
+ console.log("setting current scene to first scene", scenes[0])
+ dispatch(setCurrentScene(scenes[0]))
+ }
+ } else {
+ console.log('No scenes to set', scenes, currentScene)
+ }
+ }, [space, scenes])
+
+ return null
+}
diff --git a/mirror-2/app/space/[spaceId]/build/sidebar.tsx b/mirror-2/app/space/[spaceId]/build/sidebar.tsx
new file mode 100644
index 00000000..337b0ae8
--- /dev/null
+++ b/mirror-2/app/space/[spaceId]/build/sidebar.tsx
@@ -0,0 +1,10 @@
+"use client"
+import ControlBar from "@/app/space/[spaceId]/build/controlBar/control-bar";
+
+export function Sidebar() {
+ return (
+
+
+
+ )
+}
diff --git a/mirror-2/app/space/[spaceId]/build/top-navbar.tsx b/mirror-2/app/space/[spaceId]/build/top-navbar.tsx
new file mode 100644
index 00000000..38e3d3b7
--- /dev/null
+++ b/mirror-2/app/space/[spaceId]/build/top-navbar.tsx
@@ -0,0 +1,61 @@
+"use client"
+import { EditableSpaceName } from "@/components/editable-space-name";
+import { ThemeSwitcher } from "@/components/theme-switcher";
+import AccountDropdownMenu from "@/components/ui/account-dropdown-menu";
+import { Button } from "@/components/ui/button";
+import { useAppSelector } from "@/hooks/hooks";
+import { AppLogoImageSmall } from "@/lib/theme-service";
+import { selectLocalUser } from "@/state/local.slice";
+import { Play } from "lucide-react";
+import Link from "next/link";
+import { useParams } from "next/navigation";
+import { useEffect, useState } from "react";
+
+export function TopNavbar() {
+ const localUserState = useAppSelector(selectLocalUser)
+ const [hasMounted, setHasMounted] = useState(false);
+ const params = useParams<{ spaceId: string }>()
+ const spaceId: number = parseInt(params.spaceId, 10) // Use parseInt for safer conversion
+
+ // Check if the component is fully mounted (client-side)
+ useEffect(() => {
+ setHasMounted(true);
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {hasMounted &&
+ <>
+
+ {!localUserState?.id && }
+ >
+ }
+
+
+ );
+}
diff --git a/mirror-2/app/space/[spaceId]/play/layout.tsx b/mirror-2/app/space/[spaceId]/play/layout.tsx
new file mode 100644
index 00000000..7428b669
--- /dev/null
+++ b/mirror-2/app/space/[spaceId]/play/layout.tsx
@@ -0,0 +1,4 @@
+// separating this out for server-side vs client
+export default function PlayLayout({ children }) {
+ return children
+}
diff --git a/mirror-2/app/space/[spaceId]/play/page.tsx b/mirror-2/app/space/[spaceId]/play/page.tsx
new file mode 100644
index 00000000..a36bffd9
--- /dev/null
+++ b/mirror-2/app/space/[spaceId]/play/page.tsx
@@ -0,0 +1,14 @@
+import SpaceViewport from '@/components/engine/space-viewport'
+
+export default function PlayPage() {
+ console.log('play')
+ debugger
+ return (
+ <>
+
+ >
+ )
+}
diff --git a/mirror-2/app/space/new/page.tsx b/mirror-2/app/space/new/page.tsx
new file mode 100644
index 00000000..22e303e0
--- /dev/null
+++ b/mirror-2/app/space/new/page.tsx
@@ -0,0 +1,51 @@
+'use client'
+import { ProgressIndeterminate } from '@/components/ui/progress-indeterminate'
+import { Skeleton } from '@/components/ui/skeleton'
+
+import { useRouter } from 'next/navigation'
+import { useCreateSpaceMutation } from '@/state/api/spaces'
+import { useEffect, useState } from 'react'
+
+// Note: with React 19 this will annoying run twice with strict mode. Not sure about solution and I don't want to disable strict mode. https://stackoverflow.com/questions/72238175/why-useeffect-running-twice-and-how-to-handle-it-well-in-react#comment139336889_78443665
+export default function NewSpacePage() {
+ const [createSpace] = useCreateSpaceMutation()
+ const [started, setStarted] = useState(false)
+ const router = useRouter()
+ useEffect(() => {
+ async function create() {
+ const { data, error } = await createSpace({})
+ if (error) {
+ console.error(error)
+ return
+ }
+ // navigate to the space
+ router.replace(`/space/${data.id}/build`)
+ }
+ if (!started) {
+ setStarted(true)
+ create()
+ }
+ }, [])
+ return (
+
+ {/* Top Menu Bar */}
+
+
+
+
+
+ {/* Sidebar (20% of the width) */}
+
+
+
+
+ {/* Main content area (80% of the width) */}
+
+
+
+
+
+
+
+ )
+}
diff --git a/mirror-2/components.json b/mirror-2/components.json
new file mode 100644
index 00000000..d77a3f82
--- /dev/null
+++ b/mirror-2/components.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.ts",
+ "css": "app/globals.css",
+ "baseColor": "slate",
+ "cssVariables": false,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ }
+}
\ No newline at end of file
diff --git a/mirror-2/components/editable-space-name.tsx b/mirror-2/components/editable-space-name.tsx
new file mode 100644
index 00000000..879f8f7f
--- /dev/null
+++ b/mirror-2/components/editable-space-name.tsx
@@ -0,0 +1,90 @@
+'use client'
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ useGetSingleSpaceQuery,
+ useUpdateSpaceMutation
+} from '@/state/api/spaces'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useParams } from 'next/navigation'
+import { useEffect } from 'react'
+import { useForm } from 'react-hook-form'
+import { z } from 'zod'
+
+const formSchema = z.object({
+ name: z.string().min(3)
+})
+
+export function EditableSpaceName() {
+ const params = useParams<{ spaceId: string }>()
+ const spaceId: number = parseInt(params.spaceId, 10) // Use parseInt for safer conversion
+
+ const {
+ data: space,
+ isLoading,
+ isSuccess,
+ error
+ } = useGetSingleSpaceQuery(spaceId)
+ const [updateSpace] = useUpdateSpaceMutation()
+
+ // define the form
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ mode: 'onBlur',
+ defaultValues: {
+ name: space?.name || ''
+ }
+ // errors: error TODO add error handling here
+ })
+ // 2. Define a submit handler.
+ async function onSubmit(values: z.infer) {
+ // update the space name
+ await updateSpace({ id: space.id, updateData: { name: values.name } })
+ }
+
+ // Reset the form values when the space data is fetched
+ useEffect(() => {
+ if (space && isSuccess) {
+ form.reset({
+ name: space.name || '' // Set the form value once space.name is available
+ })
+ }
+ }, [space, isSuccess, form]) // Only run this effect when space or isLoading changes
+
+ return isLoading ? (
+
+ ) : (
+
+
+ )
+}
diff --git a/mirror-2/components/engine/__start-custom__.js b/mirror-2/components/engine/__start-custom__.js
new file mode 100644
index 00000000..a1605358
--- /dev/null
+++ b/mirror-2/components/engine/__start-custom__.js
@@ -0,0 +1,775 @@
+// Custom from the boilerplate from PC; it's recommended on docs to modify this, so ts-nocheck since it comes as JS file for initial boilerplate. https://developer.playcanvas.com/user-manual/publishing/web/communicating-webpage/
+import * as pc from 'playcanvas'
+
+// Shared Lib
+export var CANVAS_ID = 'application-canvas';
+
+// Needed as we will have edge cases for particular versions of iOS
+// returns null if not iOS
+var getIosVersion = function () {
+ if (/iP(hone|od|ad)/.test(navigator.platform)) {
+ var v = navigator.appVersion.match(/OS (\d+)_(\d+)_?(\d+)?/);
+ var version = [
+ parseInt(v[1], 10),
+ parseInt(v[2], 10),
+ parseInt(v[3] || 0, 10),
+ ];
+ return version;
+ }
+
+ return null;
+};
+
+var lastWindowHeight
+var lastWindowWidth
+var windowSizeChangeIntervalHandler = null;
+
+/**
+ * this was var pcBoostrap = ... but putting it in a function because of ssr issues
+ */
+function getPcBootstrap() {
+ return {
+ reflowHandler: null,
+ iosVersion: getIosVersion(),
+
+ createCanvas: function () {
+ var canvas = document.createElement('canvas');
+ canvas.setAttribute('id', CANVAS_ID);
+ canvas.setAttribute('tabindex', 0);
+
+ // Disable I-bar cursor on click+drag
+ canvas.onselectstart = function () {
+ return false;
+ };
+
+ // Disable long-touch select on iOS devices
+ canvas.style['-webkit-user-select'] = 'none';
+ canvas.className = "transition-opacity duration-750 opacity-0"
+ // document.body.appendChild(canvas);
+ document.getElementById('direct-container').appendChild(canvas);
+
+ setTimeout(() => {
+ canvas.classList.add('opacity-100'); // This will smoothly transition to visible
+ canvas.classList.remove('opacity-0'); // This will smoothly transition to visible
+ }, 50); // A slight delay to ensure the DOM is updated before applying the transition
+
+ return canvas;
+ },
+
+ resizeCanvas: function (app, canvas) {
+ // change to __start__ script here
+ var fillMode = app._fillMode;
+
+ canvas.style.width = '';
+ canvas.style.height = '';
+ if (fillMode === pc.FILLMODE_NONE) {
+ // our change for build mode (see below too)
+ const canvasContainer = document.getElementById('build-container')
+ app.resizeCanvas(canvasContainer.offsetWidth, canvasContainer.offsetHeight);
+ } else {
+ // non-custom behavior
+ app.resizeCanvas(canvas.width, canvas.height);
+ }
+
+
+ if (fillMode === pc.FILLMODE_NONE || fillMode === pc.FILLMODE_KEEP_ASPECT) {
+ if (
+ (fillMode === pc.FILLMODE_NONE &&
+ canvas.clientHeight < window.innerHeight) ||
+ canvas.clientWidth / canvas.clientHeight >=
+ window.innerWidth / window.innerHeight
+ ) {
+ // old line here for posterity
+ // canvas.style.marginTop = Math.floor((window.innerHeight - canvas.clientHeight) / 2) + 'px';
+ const canvasContainer = document.getElementById('build-container')
+ canvas.style.marginTop = canvasContainer.offsetTop + 'px'
+ canvas.style.marginLeft = canvasContainer.offsetLeft + 'px'
+
+ } else {
+ canvas.style.marginTop = '';
+ }
+ }
+
+ lastWindowHeight = window.innerHeight;
+ lastWindowWidth = window.innerWidth;
+
+ // Work around when in landscape to work on iOS 12 otherwise
+ // the content is under the URL bar at the top
+ if (this.iosVersion && this.iosVersion[0] <= 12) {
+ window.scrollTo(0, 0);
+ }
+ },
+
+ reflow: function (app, canvas) {
+ this.resizeCanvas(app, canvas);
+
+ // Poll for size changes as the window inner height can change after the resize event for iOS
+ // Have one tab only, and rotate from portrait -> landscape -> portrait
+ if (windowSizeChangeIntervalHandler === null) {
+ windowSizeChangeIntervalHandler = setInterval(
+ function () {
+ if (
+ lastWindowHeight !== window.innerHeight ||
+ lastWindowWidth !== window.innerWidth
+ ) {
+ this.resizeCanvas(app, canvas);
+ }
+ }.bind(this),
+ 100
+ );
+
+ // Don't want to do this all the time so stop polling after some short time
+ setTimeout(function () {
+ if (!!windowSizeChangeIntervalHandler) {
+ clearInterval(windowSizeChangeIntervalHandler);
+ windowSizeChangeIntervalHandler = null;
+ }
+ }, 2000);
+ }
+ },
+ };
+}
+
+// Expose the reflow to users so that they can override the existing
+// reflow logic if need be
+// window.pcBootstrap = pcBootstrap;
+// })();
+
+// (function () {
+// template varants
+var LTC_MAT_1 = [];
+var LTC_MAT_2 = [];
+
+var app;
+var canvas;
+
+export function getApp() {
+ return app
+}
+
+function initCSS() {
+ if (document.head.querySelector) {
+ // css media query for aspect ratio changes
+ // TODO: Change these from private properties
+ var css = `@media screen and (min-aspect-ratio: ${app._width}/${app._height}) {
+ #application-canvas.fill-mode-KEEP_ASPECT {
+ width: auto;
+ height: 100%;
+ margin: 0 auto;
+ }
+ }`;
+ document.getElementById('import-style').innerHTML += css; // Replace with getElementById for 'import-style'
+
+ }
+
+ // Configure resolution and resize event
+ if (canvas.classList) {
+ canvas.classList.add(`fill-mode-${app.fillMode}`);
+ }
+}
+
+function displayError(html) {
+ var div = document.createElement('div');
+ div.innerHTML = `