diff --git a/README.md b/README.md index 6acb8533c..93b92c752 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,10 @@ This [turborepo](https://turborepo.org/) uses [pnpm](https://pnpm.io/) as a pack ### Packages +- [engine](packages/engine): Extensions of [Lattice](https://github.com/lattice-engine/lattice) - [eslint-config-custom](packages/eslint-config-custom): custom eslint config used throughout the repo - [gltf-extension](packages/gltf-extension): [glTF-Transform](https://github.com/donmccurdy/glTF-Transform) extensions used by the client -- [react-client](packages/react-client): React components and hooks used by the client engine +- [protocol](packages/protocol): Extensions of [The Wired Protocol](https://github.com/wired-protocol/spec) - [tsconfig](packages/tsconfig): tsconfigs used throughout the repo ### Utilities diff --git a/apps/client/app/(navbar)/CreateCard.tsx b/apps/client/app/(navbar)/CreateCard.tsx index f74b5c655..394b1a2a2 100644 --- a/apps/client/app/(navbar)/CreateCard.tsx +++ b/apps/client/app/(navbar)/CreateCard.tsx @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; import { env } from "@/src/env.mjs"; -import { getUserSession } from "@/src/server/auth/getUserSession"; +import { getSession } from "@/src/server/auth/getSession"; import { db } from "@/src/server/db/drizzle"; import { world, worldModel } from "@/src/server/db/schema"; import { nanoidShort } from "@/src/server/nanoid"; @@ -17,7 +17,7 @@ export const preferredRegion = "iad1"; export async function createWorld() { "use server"; - const session = await getUserSession(); + const session = await getSession(); if (!session) return; const publicId = nanoidShort(); diff --git a/apps/client/app/(navbar)/SignInButton.tsx b/apps/client/app/(navbar)/SignInButton.tsx index b0281ea0b..9ac697dbf 100644 --- a/apps/client/app/(navbar)/SignInButton.tsx +++ b/apps/client/app/(navbar)/SignInButton.tsx @@ -27,7 +27,7 @@ export default function SignInButton({ loading }: Props) { - - ); -} diff --git a/apps/client/src/play/ui/editor/Tools.tsx b/apps/client/src/play/ui/editor/Tools.tsx index 7b314e04e..87e06b30d 100644 --- a/apps/client/src/play/ui/editor/Tools.tsx +++ b/apps/client/src/play/ui/editor/Tools.tsx @@ -3,7 +3,7 @@ import { BiMove } from "react-icons/bi"; import { CgArrowsExpandUpRight } from "react-icons/cg"; import { MdSync } from "react-icons/md"; -import { usePlayStore } from "@/app/play/store"; +import { usePlayStore } from "@/app/play/playStore"; import { Tool } from "@/app/play/types"; import Tooltip from "@/src/ui/Tooltip"; diff --git a/apps/client/src/play/ui/editor/WorldPage.tsx b/apps/client/src/play/ui/editor/WorldPage.tsx deleted file mode 100644 index 28c7c9a0e..000000000 --- a/apps/client/src/play/ui/editor/WorldPage.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { usePlayStore } from "@/app/play/store"; -import ImageInput from "@/src/ui/ImageInput"; -import TextAreaDark from "@/src/ui/TextAreaDark"; -import TextFieldDark from "@/src/ui/TextFieldDark"; - -import { useSave } from "../../hooks/useSave"; -import PanelPage from "./PanelPage"; - -export default function WorldPage() { - const worldId = usePlayStore((state) => state.worldId); - const image = usePlayStore((state) => state.metadata.info?.image); - const title = usePlayStore((state) => state.metadata.info?.title); - const description = usePlayStore((state) => state.metadata.info?.description); - - const { save, saving } = useSave(); - - const placeholder = - worldId.type === "id" ? `World ${worldId.value.slice(0, 6)}` : ""; - - return ( -
- - { - const file = e.target.files?.[0]; - if (!file) return; - - const reader = new FileReader(); - - reader.onload = (e) => { - const image = e.target?.result as string; - - usePlayStore.setState((prev) => ({ - metadata: { - ...prev.metadata, - info: { - ...prev.metadata.info, - image, - }, - }, - })); - }; - - reader.readAsDataURL(file); - }} - dark - className="h-40 w-full rounded-lg object-cover" - /> - - { - usePlayStore.setState((prev) => ({ - metadata: { - ...prev.metadata, - info: { - ...prev.metadata.info, - title: e.target.value, - }, - }, - })); - }} - /> - - { - usePlayStore.setState((prev) => ({ - metadata: { - ...prev.metadata, - info: { - ...prev.metadata.info, - description: e.target.value, - }, - }, - })); - }} - className="max-h-40" - /> - - - -
- ); -} diff --git a/apps/client/src/play/ui/editor/hooks/useTreeArrayValue.ts b/apps/client/src/play/ui/editor/hooks/useTreeArrayValue.ts new file mode 100644 index 000000000..5ae4f3892 --- /dev/null +++ b/apps/client/src/play/ui/editor/hooks/useTreeArrayValue.ts @@ -0,0 +1,22 @@ +import { TreeItem, useSceneStore } from "@unavi/engine"; + +type ArrayKey = Exclude< + T, + { + [K in T]: TreeItem[K] extends Array ? never : K; + }[T] +>; + +type Keys = ArrayKey; + +export function useTreeArrayValue( + id: bigint | undefined, + key: T, + index: U +): TreeItem[T][U] | undefined { + const value = useSceneStore((state) => + id ? state.items.get(id)?.[key][index] : undefined + ); + + return value; +} diff --git a/apps/client/src/play/ui/editor/hooks/useTreeItem.ts b/apps/client/src/play/ui/editor/hooks/useTreeItem.ts new file mode 100644 index 000000000..c0dece577 --- /dev/null +++ b/apps/client/src/play/ui/editor/hooks/useTreeItem.ts @@ -0,0 +1,13 @@ +import { useSceneStore } from "@unavi/engine"; +import { useMemo } from "react"; + +export function useTreeItem(id: bigint | undefined) { + const items = useSceneStore((state) => state.items); + + const item = useMemo(() => { + if (!id) return undefined; + return items.get(id); + }, [id, items]); + + return item; +} diff --git a/apps/client/src/play/ui/editor/hooks/useTreeValue.ts b/apps/client/src/play/ui/editor/hooks/useTreeValue.ts new file mode 100644 index 000000000..f4be27ab8 --- /dev/null +++ b/apps/client/src/play/ui/editor/hooks/useTreeValue.ts @@ -0,0 +1,12 @@ +import { TreeItem, useSceneStore } from "@unavi/engine"; + +export function useTreeValue( + id: bigint | undefined, + key: T +): TreeItem[T] | undefined { + const value = useSceneStore((state) => + id ? state.items.get(id)?.[key] : undefined + ); + + return value; +} diff --git a/apps/client/src/play/ui/editor/AddPage.tsx b/apps/client/src/play/ui/editor/pages/Add/AddPage.tsx similarity index 64% rename from apps/client/src/play/ui/editor/AddPage.tsx rename to apps/client/src/play/ui/editor/pages/Add/AddPage.tsx index 79b41003d..558bd92cf 100644 --- a/apps/client/src/play/ui/editor/AddPage.tsx +++ b/apps/client/src/play/ui/editor/pages/Add/AddPage.tsx @@ -1,3 +1,4 @@ +import { useSceneStore } from "@unavi/engine"; import { BiBox, BiCircle, @@ -7,12 +8,32 @@ import { BiText, } from "react-icons/bi"; +import { usePlayStore } from "@/app/play/playStore"; import { LeftPanelPage } from "@/app/play/types"; -import { addBox } from "../../actions/addBox"; -import { addCylinder } from "../../actions/addCylinder"; -import { addSphere } from "../../actions/addSphere"; -import PanelPage from "./PanelPage"; +import { addBox } from "../../../../actions/addBox"; +import { addCylinder } from "../../../../actions/addCylinder"; +import { addSphere } from "../../../../actions/addSphere"; +import PanelPage from "../PanelPage"; + +function wrapAdd(fn: () => string) { + return () => { + const name = fn(); + + setTimeout(() => { + const { items } = useSceneStore.getState(); + + for (const [, item] of items) { + if (item.name === name) { + useSceneStore.setState({ selectedId: item.id }); + break; + } + } + + usePlayStore.setState({ leftPage: LeftPanelPage.Scene }); + }, 40); + }; +} export default function AddPage() { return ( @@ -30,13 +51,13 @@ export default function AddPage() { - + - + - + diff --git a/apps/client/src/play/ui/editor/pages/Inspect/InspectPage.tsx b/apps/client/src/play/ui/editor/pages/Inspect/InspectPage.tsx new file mode 100644 index 000000000..69592a782 --- /dev/null +++ b/apps/client/src/play/ui/editor/pages/Inspect/InspectPage.tsx @@ -0,0 +1,45 @@ +import { editNode } from "@/src/play/actions/editNode"; +import TextFieldDark from "@/src/ui/TextFieldDark"; + +import { useTreeValue } from "../../hooks/useTreeValue"; +import { getDisplayName } from "../../utils/getDisplayName"; +import PanelPage from "../PanelPage"; +import Rotation from "./Rotation"; +import Scale from "./Scale"; +import Translation from "./Translation"; + +interface Props { + id: bigint; +} + +export default function InspectPage({ id }: Props) { + const name = useTreeValue(id, "name"); + const locked = useTreeValue(id, "locked"); + + const displayName = getDisplayName(name, id); + + return ( + + { + if (!name) { + return; + } + + editNode({ + name: e.target.value, + target: name, + }); + }} + /> + + + + + + ); +} diff --git a/apps/client/src/play/ui/editor/pages/Inspect/Rotation.tsx b/apps/client/src/play/ui/editor/pages/Inspect/Rotation.tsx new file mode 100644 index 000000000..fddb6f9f2 --- /dev/null +++ b/apps/client/src/play/ui/editor/pages/Inspect/Rotation.tsx @@ -0,0 +1,127 @@ +import { Euler, Quaternion } from "three"; + +import { editNode } from "@/src/play/actions/editNode"; +import TextFieldDark from "@/src/ui/TextFieldDark"; + +import { useTreeArrayValue } from "../../hooks/useTreeArrayValue"; +import { useTreeValue } from "../../hooks/useTreeValue"; + +const STEP = 1; + +const euler = new Euler(); +const quat = new Quaternion(); + +interface Props { + id: bigint; +} + +export default function Rotation({ id }: Props) { + const name = useTreeValue(id, "name"); + const locked = useTreeValue(id, "locked"); + const rawX = useTreeArrayValue(id, "rotation", 0); + const rawY = useTreeArrayValue(id, "rotation", 1); + const rawZ = useTreeArrayValue(id, "rotation", 2); + const rawW = useTreeArrayValue(id, "rotation", 3); + + if ( + !name || + rawX === undefined || + rawY === undefined || + rawZ === undefined || + rawW === undefined + ) { + return null; + } + + euler.setFromQuaternion(quat.set(rawX, rawY, rawZ, rawW)); + + const x = round(toDegrees(euler.x)); + const y = round(toDegrees(euler.y)); + const z = round(toDegrees(euler.z)); + + return ( +
+
Rotation
+ +
+ { + quat.setFromEuler( + euler.set( + toRadians(Number(e.target.value)), + toRadians(y), + toRadians(z) + ) + ); + + editNode({ + rotation: [quat.x, quat.y, quat.z, quat.w], + target: name, + }); + }} + /> + { + quat.setFromEuler( + euler.set( + toRadians(x), + toRadians(Number(e.target.value)), + toRadians(z) + ) + ); + + editNode({ + rotation: [quat.x, quat.y, quat.z, quat.w], + target: name, + }); + }} + /> + { + quat.setFromEuler( + euler.set( + toRadians(x), + toRadians(y), + toRadians(Number(e.target.value)) + ) + ); + + editNode({ + rotation: [quat.x, quat.y, quat.z, quat.w], + target: name, + }); + }} + /> +
+
+ ); +} + +const PRECISION = 1000; + +function round(num: number) { + return Math.round(num * PRECISION) / PRECISION; +} + +function toRadians(degrees: number) { + return (degrees * Math.PI) / 180; +} + +function toDegrees(radians: number) { + return (radians * 180) / Math.PI; +} diff --git a/apps/client/src/play/ui/editor/pages/Inspect/Scale.tsx b/apps/client/src/play/ui/editor/pages/Inspect/Scale.tsx new file mode 100644 index 000000000..84e5982f3 --- /dev/null +++ b/apps/client/src/play/ui/editor/pages/Inspect/Scale.tsx @@ -0,0 +1,81 @@ +import { editNode } from "@/src/play/actions/editNode"; +import TextFieldDark from "@/src/ui/TextFieldDark"; + +import { useTreeArrayValue } from "../../hooks/useTreeArrayValue"; +import { useTreeValue } from "../../hooks/useTreeValue"; + +const STEP = 0.01; + +interface Props { + id: bigint; +} + +export default function Scale({ id }: Props) { + const name = useTreeValue(id, "name"); + const locked = useTreeValue(id, "locked"); + const rawX = useTreeArrayValue(id, "scale", 0); + const rawY = useTreeArrayValue(id, "scale", 1); + const rawZ = useTreeArrayValue(id, "scale", 2); + + if (!name || rawX === undefined || rawY === undefined || rawZ === undefined) { + return null; + } + + const x = round(rawX); + const y = round(rawY); + const z = round(rawZ); + + return ( +
+
Scale
+ +
+ { + editNode({ + scale: [Number(e.target.value), y, z], + target: name, + }); + }} + /> + { + editNode({ + scale: [x, Number(e.target.value), z], + target: name, + }); + }} + /> + { + editNode({ + scale: [x, y, Number(e.target.value)], + target: name, + }); + }} + /> +
+
+ ); +} + +const PRECISION = 1000; + +function round(num: number) { + return Math.round(num * PRECISION) / PRECISION; +} diff --git a/apps/client/src/play/ui/editor/pages/Inspect/Translation.tsx b/apps/client/src/play/ui/editor/pages/Inspect/Translation.tsx new file mode 100644 index 000000000..0dca7edcc --- /dev/null +++ b/apps/client/src/play/ui/editor/pages/Inspect/Translation.tsx @@ -0,0 +1,81 @@ +import { editNode } from "@/src/play/actions/editNode"; +import TextFieldDark from "@/src/ui/TextFieldDark"; + +import { useTreeArrayValue } from "../../hooks/useTreeArrayValue"; +import { useTreeValue } from "../../hooks/useTreeValue"; + +const STEP = 0.1; + +interface Props { + id: bigint; +} + +export default function Translation({ id }: Props) { + const name = useTreeValue(id, "name"); + const locked = useTreeValue(id, "locked"); + const rawX = useTreeArrayValue(id, "translation", 0); + const rawY = useTreeArrayValue(id, "translation", 1); + const rawZ = useTreeArrayValue(id, "translation", 2); + + if (!name || rawX === undefined || rawY === undefined || rawZ === undefined) { + return null; + } + + const x = round(rawX); + const y = round(rawY); + const z = round(rawZ); + + return ( +
+
Translation
+ +
+ { + editNode({ + target: name, + translation: [Number(e.target.value), y, z], + }); + }} + /> + { + editNode({ + target: name, + translation: [x, Number(e.target.value), z], + }); + }} + /> + { + editNode({ + target: name, + translation: [x, y, Number(e.target.value)], + }); + }} + /> +
+
+ ); +} + +const PRECISION = 1000; + +function round(num: number) { + return Math.round(num * PRECISION) / PRECISION; +} diff --git a/apps/client/src/play/ui/editor/Left.tsx b/apps/client/src/play/ui/editor/pages/Left.tsx similarity index 65% rename from apps/client/src/play/ui/editor/Left.tsx rename to apps/client/src/play/ui/editor/pages/Left.tsx index d9bda64ca..d72c1193f 100644 --- a/apps/client/src/play/ui/editor/Left.tsx +++ b/apps/client/src/play/ui/editor/pages/Left.tsx @@ -1,15 +1,15 @@ -import { usePlayStore } from "@/app/play/store"; +import { usePlayStore } from "@/app/play/playStore"; import { LeftPanelPage } from "@/app/play/types"; -import AddPage from "./AddPage"; -import ScenePage from "./ScenePage"; +import AddPage from "./Add/AddPage"; +import ScenePage from "./Scene/ScenePage"; export default function Left() { const page = usePlayStore((state) => state.leftPage); return (
-
+
{page === LeftPanelPage.Add ? ( ) : page === LeftPanelPage.Scene ? ( diff --git a/apps/client/src/play/ui/editor/PanelPage.tsx b/apps/client/src/play/ui/editor/pages/PanelPage.tsx similarity index 51% rename from apps/client/src/play/ui/editor/PanelPage.tsx rename to apps/client/src/play/ui/editor/pages/PanelPage.tsx index 2789d1dc0..0bfe744a6 100644 --- a/apps/client/src/play/ui/editor/PanelPage.tsx +++ b/apps/client/src/play/ui/editor/pages/PanelPage.tsx @@ -1,11 +1,12 @@ import { MdArrowBack } from "react-icons/md"; -import { usePlayStore } from "@/app/play/store"; +import { usePlayStore } from "@/app/play/playStore"; import { LeftPanelPage, RightPanelPage } from "@/app/play/types"; interface PropsBase { children: React.ReactNode; title: string; + onBack?: () => void; } interface PropsNone extends PropsBase { @@ -30,8 +31,13 @@ export default function PanelPage({ title, side, parentPage, + onBack, }: Props) { function handleBack() { + if (onBack) { + onBack(); + } + if (!side) return; if (side === "left") { @@ -41,21 +47,28 @@ export default function PanelPage({ } } + const showBack = side !== undefined || onBack !== undefined; + return ( -
-
- {!side ? ( -
- ) : ( - - )} +
+
+
+ {showBack ? ( + + ) : null} +
-

{title}

+

+ {title} +

{children} diff --git a/apps/client/src/play/ui/editor/pages/Right.tsx b/apps/client/src/play/ui/editor/pages/Right.tsx new file mode 100644 index 000000000..05fe2ad2f --- /dev/null +++ b/apps/client/src/play/ui/editor/pages/Right.tsx @@ -0,0 +1,16 @@ +import { useSceneStore } from "@unavi/engine"; + +import InspectPage from "./Inspect/InspectPage"; +import WorldPage from "./World/WorldPage"; + +export default function Right() { + const selectedId = useSceneStore((state) => state.selectedId); + + return ( +
+
+ {selectedId ? : } +
+
+ ); +} diff --git a/apps/client/src/play/ui/editor/pages/Scene/ScenePage.tsx b/apps/client/src/play/ui/editor/pages/Scene/ScenePage.tsx new file mode 100644 index 000000000..ce26d5283 --- /dev/null +++ b/apps/client/src/play/ui/editor/pages/Scene/ScenePage.tsx @@ -0,0 +1,42 @@ +import { useSceneStore } from "@unavi/engine"; + +import { usePlayStore } from "@/app/play/playStore"; +import { LeftPanelPage } from "@/app/play/types"; + +import { useTreeValue } from "../../hooks/useTreeValue"; +import { getDisplayName } from "../../utils/getDisplayName"; +import PanelPage from "../PanelPage"; +import SceneTree from "./SceneTree"; + +export default function ScenePage() { + const rootId = useSceneStore((state) => state.rootId); + const sceneTreeId = useSceneStore((state) => state.sceneTreeId); + const usedId = sceneTreeId || rootId; + + const parentId = useTreeValue(usedId, "parentId"); + const name = useTreeValue(usedId, "name"); + + if (usedId === undefined) { + return null; + } + + const handleBack = parentId + ? () => useSceneStore.setState({ sceneTreeId: parentId }) + : undefined; + + const displayName = + usedId === rootId ? "Scene" : getDisplayName(name, usedId); + + return ( + + + + + + ); +} diff --git a/apps/client/src/play/ui/editor/pages/Scene/SceneTree.tsx b/apps/client/src/play/ui/editor/pages/Scene/SceneTree.tsx new file mode 100644 index 000000000..8eb012351 --- /dev/null +++ b/apps/client/src/play/ui/editor/pages/Scene/SceneTree.tsx @@ -0,0 +1,24 @@ +import { useSceneStore } from "@unavi/engine"; + +import { useTreeValue } from "../../hooks/useTreeValue"; +import TreeItem from "./TreeItem"; + +interface Props { + rootId: bigint; +} + +export default function SceneTree({ rootId }: Props) { + const childrenIds = useTreeValue(rootId, "childrenIds"); + + function clearSelected() { + useSceneStore.setState({ selectedId: undefined }); + } + + return ( +
+ {childrenIds?.map((id) => ( + + ))} +
+ ); +} diff --git a/apps/client/src/play/ui/editor/pages/Scene/TreeItem.tsx b/apps/client/src/play/ui/editor/pages/Scene/TreeItem.tsx new file mode 100644 index 000000000..a97639be6 --- /dev/null +++ b/apps/client/src/play/ui/editor/pages/Scene/TreeItem.tsx @@ -0,0 +1,72 @@ +import { useSceneStore } from "@unavi/engine"; +import { IoMdExpand, IoMdLock, IoMdUnlock } from "react-icons/io"; + +import { editNode } from "@/src/play/actions/editNode"; +import Tooltip from "@/src/ui/Tooltip"; + +import { useTreeValue } from "../../hooks/useTreeValue"; + +interface Props { + id: bigint; +} + +export default function TreeItem({ id }: Props) { + const selectedId = useSceneStore((state) => state.selectedId); + const name = useTreeValue(id, "name"); + const locked = useTreeValue(id, "locked"); + + function select(e: React.MouseEvent) { + e.stopPropagation(); + useSceneStore.setState({ selectedId: id }); + } + + function expand(e: React.MouseEvent) { + e.stopPropagation(); + useSceneStore.setState({ sceneTreeId: id }); + } + + function toggleLock(e: React.MouseEvent) { + e.stopPropagation(); + if (!name) return; + editNode({ extras: { locked: !locked }, target: name }); + } + + const isSelected = selectedId === id; + + return ( +
+ + +
+ + + + + + + +
+
+ ); +} diff --git a/apps/client/src/play/ui/editor/pages/World/WorldPage.tsx b/apps/client/src/play/ui/editor/pages/World/WorldPage.tsx new file mode 100644 index 000000000..7015a7f98 --- /dev/null +++ b/apps/client/src/play/ui/editor/pages/World/WorldPage.tsx @@ -0,0 +1,101 @@ +import { usePlayStore } from "@/app/play/playStore"; +import ImageInput from "@/src/ui/ImageInput"; +import TextAreaDark from "@/src/ui/TextAreaDark"; +import TextFieldDark from "@/src/ui/TextFieldDark"; +import { cropImage } from "@/src/utils/cropImage"; + +import { useSave } from "../../../../hooks/useSave"; +import PanelPage from "../PanelPage"; + +export default function WorldPage() { + const worldId = usePlayStore((state) => state.worldId); + const image = usePlayStore((state) => state.metadata.info?.image); + const title = usePlayStore((state) => state.metadata.info?.title); + const description = usePlayStore((state) => state.metadata.info?.description); + + const { save, saving } = useSave(); + + const placeholder = + worldId.type === "id" ? `World ${worldId.value.slice(0, 6)}` : ""; + + return ( + + { + const file = e.target.files?.[0]; + if (!file) return; + + const fileURL = URL.createObjectURL(file); + const cropped = await cropImage(fileURL); + const croppedURL = URL.createObjectURL(cropped); + + usePlayStore.setState((prev) => ({ + metadata: { + ...prev.metadata, + info: { + ...prev.metadata.info, + image: croppedURL, + }, + }, + })); + + URL.revokeObjectURL(fileURL); + }} + dark + className="h-40 w-full rounded-lg object-cover" + /> + + { + usePlayStore.setState((prev) => ({ + metadata: { + ...prev.metadata, + info: { + ...prev.metadata.info, + title: e.target.value, + }, + }, + })); + }} + /> + + { + usePlayStore.setState((prev) => ({ + metadata: { + ...prev.metadata, + info: { + ...prev.metadata.info, + description: e.target.value, + }, + }, + })); + }} + className="max-h-40" + /> + +
+ +
+
+ ); +} diff --git a/apps/client/src/play/ui/editor/utils/getDisplayName.ts b/apps/client/src/play/ui/editor/utils/getDisplayName.ts new file mode 100644 index 000000000..277c13f50 --- /dev/null +++ b/apps/client/src/play/ui/editor/utils/getDisplayName.ts @@ -0,0 +1,3 @@ +export function getDisplayName(value: string | undefined, id: bigint) { + return value || `(${id})`; +} diff --git a/apps/client/src/play/utils/setAvatar.ts b/apps/client/src/play/utils/setAvatar.ts index 2b56b01fe..c7ee8f11f 100644 --- a/apps/client/src/play/utils/setAvatar.ts +++ b/apps/client/src/play/utils/setAvatar.ts @@ -1,7 +1,7 @@ -import { useClientStore } from "@unavi/react-client"; +import { useClientStore } from "@unavi/engine"; import { getTempUpload } from "@/app/api/temp/helper"; -import { usePlayStore } from "@/app/play/store"; +import { usePlayStore } from "@/app/play/playStore"; import { cdnURL, S3Path } from "@/src/utils/s3Paths"; import { LocalStorageKey } from "../constants"; diff --git a/apps/client/src/server/auth/constants.ts b/apps/client/src/server/auth/constants.ts new file mode 100644 index 000000000..aa985f292 --- /dev/null +++ b/apps/client/src/server/auth/constants.ts @@ -0,0 +1 @@ +export const SESSION_COOKIE_NAME = "auth_session"; diff --git a/apps/client/src/server/auth/getSession.ts b/apps/client/src/server/auth/getSession.ts index 4a525a291..4cd17b2b8 100644 --- a/apps/client/src/server/auth/getSession.ts +++ b/apps/client/src/server/auth/getSession.ts @@ -1,6 +1,6 @@ -import { SESSION_COOKIE_NAME } from "lucia-auth"; import { cookies } from "next/headers"; +import { SESSION_COOKIE_NAME } from "./constants"; import { auth } from "./lucia"; /** @@ -10,13 +10,20 @@ export async function getSession() { const cookie = cookies().get(SESSION_COOKIE_NAME); if (!cookie) return null; - // TODO: Add CSRF protection? - // https://lucia-auth.com/basics/sessions#get-session-from-requests - try { const session = await auth.validateSession(cookie.value); + + // Expiration extended, update cookie + if (session.fresh) { + const sessionCookie = auth.createSessionCookie(session); + cookies().set(SESSION_COOKIE_NAME, sessionCookie.value); + } + return session; } catch { + // Invalid session, remove cookie + cookies().delete(SESSION_COOKIE_NAME); + return null; } } diff --git a/apps/client/src/server/auth/getUserSession.ts b/apps/client/src/server/auth/getUserSession.ts deleted file mode 100644 index 916438d59..000000000 --- a/apps/client/src/server/auth/getUserSession.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { SESSION_COOKIE_NAME } from "lucia-auth"; -import { cookies } from "next/headers"; - -import { auth } from "./lucia"; - -/** - * Get the authentication session + user - */ -export async function getUserSession() { - const cookie = cookies().get(SESSION_COOKIE_NAME); - if (!cookie) return null; - - // TODO: Add CSRF protection? - // https://lucia-auth.com/basics/sessions#get-session-from-requests - - try { - const res = await auth.validateSessionUser(cookie.value); - return res; - } catch { - return null; - } -} diff --git a/apps/client/src/server/auth/lucia.ts b/apps/client/src/server/auth/lucia.ts index 9a6df60d4..8e9cf99cb 100644 --- a/apps/client/src/server/auth/lucia.ts +++ b/apps/client/src/server/auth/lucia.ts @@ -1,11 +1,35 @@ -import "lucia-auth/polyfill/node"; +import "lucia/polyfill/node"; -import lucia from "lucia-auth"; -import { nextjs } from "lucia-auth/middleware"; +import { lucia } from "lucia"; +import { nextjs } from "lucia/middleware"; import { env } from "@/src/env.mjs"; +import { + AUTH_KEY_TABLE_NAME, + AUTH_SESSION_TABLE_NAME, + AUTH_USER_TABLE_NAME, +} from "../db/constants"; import { mysql2Connection, planetscaleConnection } from "../db/drizzle"; +import { SESSION_COOKIE_NAME } from "./constants"; + +async function getPlanetscaleAdapter() { + const { planetscale } = await import("@lucia-auth/adapter-mysql"); + return planetscale(planetscaleConnection, { + key: AUTH_KEY_TABLE_NAME, + session: AUTH_SESSION_TABLE_NAME, + user: AUTH_USER_TABLE_NAME, + }); +} + +async function getMysql2Adapter() { + const { mysql2 } = await import("@lucia-auth/adapter-mysql"); + return mysql2(mysql2Connection, { + key: AUTH_KEY_TABLE_NAME, + session: AUTH_SESSION_TABLE_NAME, + user: AUTH_USER_TABLE_NAME, + }); +} export const luciaEnv = process.env.NODE_ENV === "development" ? "DEV" : "PROD"; @@ -16,24 +40,17 @@ const adapter = env.PLANETSCALE export const auth = lucia({ adapter, env: luciaEnv, - middleware: nextjs(), - transformDatabaseUser: (userData) => { + getUserAttributes: (data) => { return { - address: userData.address, - userId: userData.id, - username: userData.username, + address: data.address, + username: data.username, }; }, + middleware: nextjs(), + sessionCookie: { + expires: false, + name: SESSION_COOKIE_NAME, + }, }); export type Auth = typeof auth; - -async function getPlanetscaleAdapter() { - const { planetscale } = await import("@lucia-auth/adapter-mysql"); - return planetscale(planetscaleConnection); -} - -async function getMysql2Adapter() { - const { mysql2 } = await import("@lucia-auth/adapter-mysql"); - return mysql2(mysql2Connection); -} diff --git a/apps/client/src/server/auth/types.ts b/apps/client/src/server/auth/types.ts index 8819a1f55..49e464c4b 100644 --- a/apps/client/src/server/auth/types.ts +++ b/apps/client/src/server/auth/types.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export enum AuthMethod { - Ethereum = "Ethereum", + Ethereum = "ethereum", } export const EthereumAuthSchema = z.object({ diff --git a/apps/client/src/server/db/constants.ts b/apps/client/src/server/db/constants.ts index 4043f7118..9018273f9 100644 --- a/apps/client/src/server/db/constants.ts +++ b/apps/client/src/server/db/constants.ts @@ -1,5 +1,9 @@ import { NANOID_LENGTH, NANOID_SHORT_LENGTH } from "../nanoid"; +export const AUTH_USER_TABLE_NAME = "auth_user"; +export const AUTH_KEY_TABLE_NAME = "auth_key"; +export const AUTH_SESSION_TABLE_NAME = "auth_session"; + export const WORLD_ID_LENGTH = NANOID_SHORT_LENGTH; export const WORLD_TITLE_LENGTH = 80; export const WORLD_DESCRIPTION_LENGTH = 1200; diff --git a/apps/client/src/server/db/schema.ts b/apps/client/src/server/db/schema.ts index f200099b8..55f5d295e 100644 --- a/apps/client/src/server/db/schema.ts +++ b/apps/client/src/server/db/schema.ts @@ -1,7 +1,6 @@ import { relations } from "drizzle-orm"; import { bigint, - boolean, char, mysqlTable, serial, @@ -11,6 +10,9 @@ import { } from "drizzle-orm/mysql-core"; import { + AUTH_KEY_TABLE_NAME, + AUTH_SESSION_TABLE_NAME, + AUTH_USER_TABLE_NAME, ETH_ADDRESS_LENGTH, ETH_AUTH_ID_LENGTH, ETH_AUTH_NONCE_LENGTH, @@ -75,11 +77,18 @@ export const profile = mysqlTable("profile", { }); // Auth -export const user = mysqlTable("auth_user", { - address: char("address", { length: ETH_ADDRESS_LENGTH }), - id: varchar("id", { length: USER_ID_LENGTH }).primaryKey(), - username: varchar("username", { length: MAX_USERNAME_LENGTH }).notNull(), -}); +export const user = mysqlTable( + AUTH_USER_TABLE_NAME, + { + address: char("address", { length: ETH_ADDRESS_LENGTH }), + id: varchar("id", { length: USER_ID_LENGTH }).primaryKey(), + username: varchar("username", { length: MAX_USERNAME_LENGTH }).notNull(), + }, + (table) => ({ + addressIndex: uniqueIndex("address").on(table.address), + usernameIndex: uniqueIndex("username").on(table.username), + }) +); export const userRelations = relations(user, ({ one, many }) => ({ profile: one(profile, { @@ -89,18 +98,16 @@ export const userRelations = relations(user, ({ one, many }) => ({ worlds: many(world), })); -export const session = mysqlTable("auth_session", { +export const session = mysqlTable(AUTH_SESSION_TABLE_NAME, { activeExpires: bigint("active_expires", { mode: "number" }).notNull(), id: varchar("id", { length: 128 }).primaryKey(), idleExpires: bigint("idle_expires", { mode: "number" }).notNull(), userId: varchar("user_id", { length: USER_ID_LENGTH }).notNull(), }); -export const key = mysqlTable("auth_key", { - expires: bigint("expires", { mode: "number" }), +export const key = mysqlTable(AUTH_KEY_TABLE_NAME, { hashedPassword: varchar("hashed_password", { length: 255 }), id: varchar("id", { length: 255 }).primaryKey(), - primaryKey: boolean("primary_key").notNull(), userId: varchar("user_id", { length: USER_ID_LENGTH }).notNull(), }); diff --git a/apps/client/src/server/helpers/fetchAuthors.ts b/apps/client/src/server/helpers/fetchAuthors.ts new file mode 100644 index 000000000..6ef4e47fe --- /dev/null +++ b/apps/client/src/server/helpers/fetchAuthors.ts @@ -0,0 +1,28 @@ +import { WorldMetadata } from "@wired-protocol/types"; + +import { fetchUserProfile, UserProfile } from "./fetchUserProfile"; + +export async function fetchAuthors(metadata: WorldMetadata) { + if (!metadata.info?.authors) return []; + + const profiles: UserProfile[] = []; + + await Promise.all( + metadata.info.authors.map(async (author) => { + const profile = await fetchUserProfile(author); + + if (!profile) { + profiles.push({ + home: "", + metadata: { name: author }, + username: "", + }); + return; + } + + profiles.push(profile); + }) + ); + + return profiles; +} diff --git a/apps/client/src/server/helpers/genUsername.ts b/apps/client/src/server/helpers/genUsername.ts new file mode 100644 index 000000000..9dc5b5157 --- /dev/null +++ b/apps/client/src/server/helpers/genUsername.ts @@ -0,0 +1,6 @@ +import { nanoidShort } from "../nanoid"; + +// TODO: Better username generation +export function genUsername() { + return nanoidShort(); +} diff --git a/apps/client/src/server/helpers/generateWorldMetadata.ts b/apps/client/src/server/helpers/generateWorldMetadata.ts new file mode 100644 index 000000000..33965a75b --- /dev/null +++ b/apps/client/src/server/helpers/generateWorldMetadata.ts @@ -0,0 +1,50 @@ +import { Metadata } from "next"; + +import { baseMetadata } from "@/app/metadata"; +import { env } from "@/src/env.mjs"; +import { parseWorldId } from "@/src/utils/parseWorldId"; + +import { fetchAuthors } from "./fetchAuthors"; +import { fetchWorld } from "./fetchWorld"; + +export async function generateWorldMetadata(id: string): Promise { + const worldId = parseWorldId(id); + + const found = await fetchWorld(worldId); + if (!found?.metadata) return {}; + + const profiles = await fetchAuthors(found.metadata); + + const displayId = worldId.value.slice(0, 6); + const title = found.metadata.info?.title || `World ${displayId}`; + + const description = found.metadata.info?.description || ""; + const image = found.metadata.info?.image; + + return { + authors: + profiles.length > 0 + ? profiles.map(({ username, metadata }) => ({ + name: metadata.name || username, + url: username + ? `${env.NEXT_PUBLIC_DEPLOYED_URL}/@${username}` + : undefined, + })) + : undefined, + description, + openGraph: { + ...baseMetadata.openGraph, + description, + images: image ? [{ url: image }] : undefined, + title, + }, + title, + twitter: { + ...baseMetadata.twitter, + card: image ? "summary_large_image" : "summary", + description, + images: image ? [image] : undefined, + title, + }, + }; +} diff --git a/apps/client/src/ui/Button.tsx b/apps/client/src/ui/Button.tsx index 9decb8c6b..c987d9a69 100644 --- a/apps/client/src/ui/Button.tsx +++ b/apps/client/src/ui/Button.tsx @@ -8,7 +8,7 @@ const Button = forwardRef(