From 1178168a1007dca999e432d9c3a9e72aa29cf301 Mon Sep 17 00:00:00 2001
From: Aditya Mitra <55396651+aditya-mitra@users.noreply.github.com>
Date: Mon, 12 Aug 2024 17:40:06 +0530
Subject: [PATCH 01/29] fix tooltip css applied to all popups (#10938)
---
packages/ui/src/primitives/tailwind/Tooltip/index.tsx | 4 ++++
packages/ui/src/primitives/tailwind/Tooltip/tooltip.css | 6 ------
2 files changed, 4 insertions(+), 6 deletions(-)
diff --git a/packages/ui/src/primitives/tailwind/Tooltip/index.tsx b/packages/ui/src/primitives/tailwind/Tooltip/index.tsx
index 83266c9dca..fae7fa566d 100644
--- a/packages/ui/src/primitives/tailwind/Tooltip/index.tsx
+++ b/packages/ui/src/primitives/tailwind/Tooltip/index.tsx
@@ -44,6 +44,10 @@ const Tooltip = ({ title, titleClassName, content, children, className, ...rest
keepTooltipInside
repositionOnResize
arrow={false}
+ contentStyle={{
+ animation: 'expandFromCenter 0.3s cubic-bezier(0.38, 0.1, 0.36, 0.9) forwards',
+ transformOrigin: 'center'
+ }}
{...rest}
>
diff --git a/packages/ui/src/primitives/tailwind/Tooltip/tooltip.css b/packages/ui/src/primitives/tailwind/Tooltip/tooltip.css
index 552f7d5e98..be3c3454e5 100644
--- a/packages/ui/src/primitives/tailwind/Tooltip/tooltip.css
+++ b/packages/ui/src/primitives/tailwind/Tooltip/tooltip.css
@@ -12,9 +12,3 @@
opacity: 1;
}
}
-
-.popup-content {
- animation: expandFromCenter 0.3s cubic-bezier(0.38, 0.1, 0.36, 0.9) forwards;
- -webkit-animation: expandFromCenter 0.3s cubic-bezier(0.38, 0.1, 0.36, 0.9) forwards;
- transform-origin: center;
-}
From a2df55f740cf4b53b0242b032d6b238358d12fa7 Mon Sep 17 00:00:00 2001
From: dtlehrer <19999194+dtlehrer@users.noreply.github.com>
Date: Mon, 12 Aug 2024 10:12:12 -0500
Subject: [PATCH 02/29] prevent scene from disappearing when double-clicking on
a HierarchyTreeNode (#10932)
---
.../editor/panels/Hierarchy/container/index.tsx | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/packages/ui/src/components/editor/panels/Hierarchy/container/index.tsx b/packages/ui/src/components/editor/panels/Hierarchy/container/index.tsx
index ae728106b9..09252525c2 100644
--- a/packages/ui/src/components/editor/panels/Hierarchy/container/index.tsx
+++ b/packages/ui/src/components/editor/panels/Hierarchy/container/index.tsx
@@ -23,7 +23,12 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20
Ethereal Engine. All Rights Reserved.
*/
-import { getComponent, getMutableComponent, useOptionalComponent } from '@etherealengine/ecs/src/ComponentFunctions'
+import {
+ getComponent,
+ getMutableComponent,
+ getOptionalComponent,
+ useOptionalComponent
+} from '@etherealengine/ecs/src/ComponentFunctions'
import { AllFileTypes } from '@etherealengine/engine/src/assets/constants/fileTypes'
import { getMutableState, getState, none, useHookstate, useMutableState } from '@etherealengine/hyperflux'
import { NameComponent } from '@etherealengine/spatial/src/common/NameComponent'
@@ -276,9 +281,11 @@ function HierarchyPanelContents(props: { sceneURL: string; rootEntity: Entity; i
}
setPrevClickedNode(entity)
} else if (e.detail === 2) {
- const editorCameraState = getMutableComponent(Engine.instance.cameraEntity, CameraOrbitComponent)
- editorCameraState.focusedEntities.set([entity])
- editorCameraState.refocus.set(true)
+ if (entity && getOptionalComponent(entity, CameraOrbitComponent)) {
+ const editorCameraState = getMutableComponent(Engine.instance.cameraEntity, CameraOrbitComponent)
+ editorCameraState.focusedEntities.set([entity])
+ editorCameraState.refocus.set(true)
+ }
}
},
[prevClickedNode, entityHierarchy]
From b45db2accba362e4125185b58c3ff545f3020fae Mon Sep 17 00:00:00 2001
From: dtlehrer <19999194+dtlehrer@users.noreply.github.com>
Date: Mon, 12 Aug 2024 13:32:28 -0500
Subject: [PATCH 03/29] [IR-3324] studio: drag-n-drop folders (#10737)
* enable movement of folders via drag n drop
* update handleBrowserItemClick & include folders in multi-select
* pass tests?
* revert
* update moveFolderRecursively
* Update file-browser.class.ts
* fix s3.storage.isDirectory
* change s3.storage.isDirectory command prefix
* fix moveObject key - to work with directories and files
* add test cases for isDirectory, moveObject, and getIncrementalName
* add test case for moveFolderRecursively
* Update FileUtil.test.ts
* Update FileUtil.test.ts
* Update storageprovider.test.ts
* Update storageprovider.test.ts
* Update storageprovider.test.ts
* pass ui check
* Update index.tsx
* double-click always navigates to folder
* select folders on single click and navigate into them on double click
* don't allow folders to be dropped into self
* make local.storage.ts moveObject function work with directories
---------
Co-authored-by: Rahul Ghosh
Co-authored-by: Daniel Belmes <3631206+DanielBelmes@users.noreply.github.com>
---
.../server-core/src/media/FileUtil.test.ts | 34 ++++++++
packages/server-core/src/media/FileUtil.ts | 9 ++-
.../media/file-browser/file-browser.class.ts | 37 ++++++++-
.../media/storageprovider/local.storage.ts | 11 ++-
.../src/media/storageprovider/s3.storage.ts | 11 ++-
.../storageprovider/storageprovider.test.ts | 77 ++++++++++++++++---
.../editor/panels/Files/browserGrid/index.tsx | 8 +-
.../editor/panels/Files/container/index.tsx | 26 +++----
8 files changed, 171 insertions(+), 42 deletions(-)
diff --git a/packages/server-core/src/media/FileUtil.test.ts b/packages/server-core/src/media/FileUtil.test.ts
index 2effa93ac9..f77b319aff 100644
--- a/packages/server-core/src/media/FileUtil.test.ts
+++ b/packages/server-core/src/media/FileUtil.test.ts
@@ -157,6 +157,40 @@ describe('FileUtil functions', () => {
fs.rmdirSync(path.join(STORAGE_PATH, dirName))
fs.rmdirSync(path.join(STORAGE_PATH, dirName_1))
})
+
+ it('should handle singular and plural directory names correctly', async () => {
+ const singularDirName = 'testdir'
+ const pluralDirName = 'testdirs'
+
+ // ensure directories don't exist before starting
+ if (fs.existsSync(path.join(STORAGE_PATH, singularDirName))) {
+ fs.rmdirSync(path.join(STORAGE_PATH, singularDirName))
+ }
+ if (fs.existsSync(path.join(STORAGE_PATH, pluralDirName))) {
+ fs.rmdirSync(path.join(STORAGE_PATH, pluralDirName))
+ }
+
+ // create 'testdirs' directory
+ fs.mkdirSync(path.join(STORAGE_PATH, pluralDirName))
+
+ // try to create 'testdir' directory
+ let name = await getIncrementalName(singularDirName, TEST_DIR, store, true)
+ assert.equal(name, singularDirName, "Should return 'testdir' as it doesn't exist")
+
+ // create 'testdir' directory
+ fs.mkdirSync(path.join(STORAGE_PATH, singularDirName))
+
+ // try to create another 'testdir' directory
+ name = await getIncrementalName(singularDirName, TEST_DIR, store, true)
+ assert.equal(name, `${singularDirName}(1)`, "Should return 'testdir(1)' as 'testdir' already exists")
+
+ // try to create 'testdirs' directory
+ name = await getIncrementalName(pluralDirName, TEST_DIR, store, true)
+ assert.equal(name, `${pluralDirName}(1)`, "Should return 'testdirs(1)' as 'testdirs' already exists")
+
+ fs.rmdirSync(path.join(STORAGE_PATH, singularDirName))
+ fs.rmdirSync(path.join(STORAGE_PATH, pluralDirName))
+ })
})
after(() => {
diff --git a/packages/server-core/src/media/FileUtil.ts b/packages/server-core/src/media/FileUtil.ts
index 535cf90366..e82cc7032a 100644
--- a/packages/server-core/src/media/FileUtil.ts
+++ b/packages/server-core/src/media/FileUtil.ts
@@ -50,22 +50,23 @@ export const getIncrementalName = async function (
let filename = name
if (!(await store.doesExist(filename, directoryPath))) return filename
+ if (isDirectory && !(await store.isDirectory(filename, directoryPath))) return filename
let count = 1
if (isDirectory) {
- do {
+ while (await store.isDirectory(filename, directoryPath)) {
filename = `${name}(${count})`
count++
- } while (await store.doesExist(filename, directoryPath))
+ }
} else {
const extension = path.extname(name)
const baseName = path.basename(name, extension)
- do {
+ while (await store.doesExist(filename, directoryPath)) {
filename = `${baseName}(${count})${extension}`
count++
- } while (await store.doesExist(filename, directoryPath))
+ }
}
return filename
diff --git a/packages/server-core/src/media/file-browser/file-browser.class.ts b/packages/server-core/src/media/file-browser/file-browser.class.ts
index bcf3ca6902..f196369c3b 100755
--- a/packages/server-core/src/media/file-browser/file-browser.class.ts
+++ b/packages/server-core/src/media/file-browser/file-browser.class.ts
@@ -51,7 +51,7 @@ import config from '../../appconfig'
import { getContentType } from '../../util/fileUtils'
import { getIncrementalName } from '../FileUtil'
import { getStorageProvider } from '../storageprovider/storageprovider'
-import { StorageObjectInterface } from '../storageprovider/storageprovider.interface'
+import { StorageObjectInterface, StorageProviderInterface } from '../storageprovider/storageprovider.interface'
import { uploadStaticResource } from './file-helper'
export const projectsRootFolder = path.join(appRootPath.path, 'packages/projects')
@@ -221,7 +221,16 @@ export class FileBrowserService
const isDirectory = await storageProvider.isDirectory(oldName, oldDirectory)
const fileName = await getIncrementalName(newName, newDirectory, storageProvider, isDirectory)
- await storageProvider.moveObject(oldName, fileName, oldDirectory, newDirectory, data.isCopy)
+
+ if (isDirectory) {
+ await this.moveFolderRecursively(
+ storageProvider,
+ path.join(oldDirectory, oldName),
+ path.join(newDirectory, fileName)
+ )
+ } else {
+ await storageProvider.moveObject(oldName, fileName, oldDirectory, newDirectory, data.isCopy)
+ }
const staticResources = (await this.app.service(staticResourcePath).find({
query: {
@@ -265,6 +274,30 @@ export class FileBrowserService
return results
}
+ private async moveFolderRecursively(storageProvider: StorageProviderInterface, oldPath: string, newPath: string) {
+ const items = await storageProvider.listFolderContent(oldPath + '/')
+
+ for (const item of items) {
+ const oldItemPath = path.join(oldPath, item.name)
+ const newItemPath = path.join(newPath, item.name)
+
+ if (item.type === 'directory') {
+ await this.moveFolderRecursively(storageProvider, oldItemPath, newItemPath)
+ } else {
+ await storageProvider.moveObject(item.name, item.name, oldPath, newPath, false)
+ }
+ }
+
+ // move the folder itself
+ await storageProvider.moveObject(
+ path.basename(oldPath),
+ path.basename(newPath),
+ path.dirname(oldPath),
+ path.dirname(newPath),
+ false
+ )
+ }
+
/**
* Upload file
*/
diff --git a/packages/server-core/src/media/storageprovider/local.storage.ts b/packages/server-core/src/media/storageprovider/local.storage.ts
index cd335fd873..7e1859b462 100755
--- a/packages/server-core/src/media/storageprovider/local.storage.ts
+++ b/packages/server-core/src/media/storageprovider/local.storage.ts
@@ -39,7 +39,6 @@ import config from '../../appconfig'
import logger from '../../ServerLogger'
import { ServerMode, ServerState } from '../../ServerState'
import { getContentType } from '../../util/fileUtils'
-import { copyRecursiveSync } from '../FileUtil'
import {
BlobStore,
PutObjectParams,
@@ -423,7 +422,15 @@ export class LocalStorage implements StorageProviderInterface {
if (!fs.existsSync(path.dirname(newFilePath))) fs.mkdirSync(path.dirname(newFilePath), { recursive: true })
try {
- isCopy ? copyRecursiveSync(oldFilePath, newFilePath) : fs.renameSync(oldFilePath, newFilePath)
+ if (isCopy) {
+ if (fs.lstatSync(oldFilePath).isDirectory()) {
+ fs.mkdirSync(newFilePath)
+ } else {
+ fs.copyFileSync(oldFilePath, newFilePath)
+ }
+ } else {
+ fs.renameSync(oldFilePath, newFilePath)
+ }
} catch (err) {
return false
}
diff --git a/packages/server-core/src/media/storageprovider/s3.storage.ts b/packages/server-core/src/media/storageprovider/s3.storage.ts
index deecc2dfd8..11e34b9024 100755
--- a/packages/server-core/src/media/storageprovider/s3.storage.ts
+++ b/packages/server-core/src/media/storageprovider/s3.storage.ts
@@ -260,12 +260,12 @@ export class S3Provider implements StorageProviderInterface {
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-folders.htmlhow to
const command = new ListObjectsV2Command({
Bucket: this.bucket,
- Prefix: path.join(directoryPath, fileName),
+ Prefix: path.join(directoryPath, fileName, '/'),
MaxKeys: 1
})
try {
const response = await this.provider.send(command)
- return response?.Contents?.[0]?.Key?.endsWith('/') || false
+ return (response.Contents && response.Contents.length > 0) || false
} catch {
return false
}
@@ -740,13 +740,16 @@ export class S3Provider implements StorageProviderInterface {
* @param isCopy If true it will create a copy of object.
*/
async moveObject(oldName: string, newName: string, oldPath: string, newPath: string, isCopy = false) {
+ const isDirectory = await this.isDirectory(oldName, oldPath)
const oldFilePath = path.join(oldPath, oldName)
const newFilePath = path.join(newPath, newName)
- const listResponse = await this.listObjects(oldFilePath, true)
+ const listResponse = await this.listObjects(oldFilePath + (isDirectory ? '/' : ''), false)
const result = await Promise.all([
...listResponse.Contents.map(async (file) => {
- const key = path.join(newFilePath, file.Key.replace(oldFilePath, ''))
+ const relativePath = file.Key.replace(oldFilePath, '')
+ const key = newFilePath + relativePath
+
const input = {
Bucket: this.bucket,
CopySource: `/${this.bucket}/${file.Key}`,
diff --git a/packages/server-core/tests/storageprovider/storageprovider.test.ts b/packages/server-core/tests/storageprovider/storageprovider.test.ts
index bee09620d7..12100a0a54 100644
--- a/packages/server-core/tests/storageprovider/storageprovider.test.ts
+++ b/packages/server-core/tests/storageprovider/storageprovider.test.ts
@@ -58,9 +58,20 @@ describe('storageprovider', () => {
storageProviders.forEach((providerType) => {
describe(`tests for ${providerType.name}`, () => {
let provider
+ let testRootPath
+
+ const createTestDirectories = async () => {
+ testRootPath = path.join(process.cwd(), 'packages', 'server', 'upload', testFolderName)
+ await fs.ensureDir(testRootPath)
+ await fs.ensureDir(path.join(testRootPath, 'temp'))
+ await fs.ensureDir(path.join(testRootPath, 'temp2'))
+ await fs.ensureDir(path.join(testRootPath, 'testDirectory'))
+ }
+
before(async function () {
createEngine()
provider = new providerType()
+ await createTestDirectories()
await providerBeforeTest(provider, testFolderName, folderKeyTemp, folderKeyTemp2)
})
@@ -165,19 +176,27 @@ describe('storageprovider', () => {
})
it(`should put over 1000 objects in ${providerType.name}`, async function () {
- const promises: any[] = []
- for (let i = 0; i < 1010; i++) {
- const fileKey = path.join(testFolderName, `${i}-${testFileName}`)
- const data = Buffer.from([])
- promises.push(
- provider.putObject({
- Body: data,
- Key: fileKey,
- ContentType: getContentType(fileKey)
- })
- )
+ this.timeout(30000) // increase timeout to 30 seconds
+
+ const batchSize = 100
+ const totalObjects = 1010
+
+ for (let i = 0; i < totalObjects; i += batchSize) {
+ const promises: any[] = []
+ for (let j = i; j < Math.min(i + batchSize, totalObjects); j++) {
+ const fileKey = path.join(testFolderName, `${j}-${testFileName}`)
+ const data = Buffer.from([])
+ promises.push(
+ provider.putObject({
+ Body: data,
+ Key: fileKey,
+ ContentType: getContentType(fileKey)
+ })
+ )
+ }
+ await Promise.all(promises)
+ await new Promise((resolve) => setTimeout(resolve, 100)) // Add a small delay between batches
}
- await Promise.all(promises)
})
it(`should list over 1000 objects in ${providerType.name}`, async function () {
@@ -185,9 +204,43 @@ describe('storageprovider', () => {
assert(res.length > 1000)
})
+ it(`isDirectory: should correctly identify directories in ${providerType.name}`, async function () {
+ const dirName = 'testDirectory'
+ const dirPath = path.join(testRootPath, dirName)
+ const fileName = `testFile-${uuidv4()}.txt`
+ const filePath = path.join(dirPath, fileName)
+
+ // create a directory
+ await provider.putObject(
+ {
+ Key: dirPath,
+ Body: Buffer.from(''),
+ ContentType: 'application/x-directory'
+ },
+ { isDirectory: true }
+ )
+
+ // create a file inside the directory
+ await provider.putObject({
+ Body: Buffer.from('test content'),
+ Key: filePath,
+ ContentType: 'text/plain'
+ })
+
+ // test isDirectory
+ assert(await provider.isDirectory(dirName, testRootPath), 'Should identify directory')
+ assert(!(await provider.isDirectory(fileName, filePath)), 'Should not identify file as directory')
+ assert(
+ !(await provider.isDirectory('nonexistent', testFolderName)),
+ 'Should not identify non-existent path as directory'
+ )
+ })
+
after(async function () {
await destroyEngine()
await providerAfterTest(provider, testFolderName)
+ // clean up the test directory
+ await fs.remove(testRootPath)
})
})
})
diff --git a/packages/ui/src/components/editor/panels/Files/browserGrid/index.tsx b/packages/ui/src/components/editor/panels/Files/browserGrid/index.tsx
index 70c60946f2..a0bfc3f0be 100644
--- a/packages/ui/src/components/editor/panels/Files/browserGrid/index.tsx
+++ b/packages/ui/src/components/editor/panels/Files/browserGrid/index.tsx
@@ -212,6 +212,7 @@ type FileBrowserItemType = {
staticResourceModifiedDates: Record
isSelected: boolean
refreshDirectory: () => Promise
+ selectedFileKeys: string[]
}
function fileConsistsOfContentType(file: FileDataType, contentType: string): boolean {
@@ -240,7 +241,8 @@ export function FileBrowserItem({
isListView,
staticResourceModifiedDates,
isSelected,
- refreshDirectory
+ refreshDirectory,
+ selectedFileKeys
}: FileBrowserItemType) {
const { t } = useTranslation()
const [anchorEvent, setAnchorEvent] = React.useState>(undefined)
@@ -322,7 +324,9 @@ export function FileBrowserItem({
accept: [...SupportedFileTypes],
drop: (dropItem) => handleDropItemsOnPanel(dropItem, item),
canDrop: (dropItem: Record) =>
- item.isFolder && ('key' in dropItem || canDropItemOverFolder(item.key)),
+ item.isFolder &&
+ ('key' in dropItem || canDropItemOverFolder(item.key)) &&
+ !selectedFileKeys.includes(item.key),
collect: (monitor) => ({
isOver: monitor.canDrop() && monitor.isOver()
})
diff --git a/packages/ui/src/components/editor/panels/Files/container/index.tsx b/packages/ui/src/components/editor/panels/Files/container/index.tsx
index 3008bb79bc..3d34986aca 100644
--- a/packages/ui/src/components/editor/panels/Files/container/index.tsx
+++ b/packages/ui/src/components/editor/panels/Files/container/index.tsx
@@ -297,7 +297,10 @@ const FileBrowserContentPanel: React.FC = (props)
}
const onSelect = (event, params: FileDataType) => {
- if (params.type !== 'folder') {
+ if (params.isFolder && event.detail === 2) {
+ const newPath = `${selectedDirectory.value}${params.name}/`
+ changeDirectoryByPath(newPath)
+ } else {
props.onSelectionChanged({
resourceUrl: params.url,
name: params.name,
@@ -306,11 +309,6 @@ const FileBrowserContentPanel: React.FC = (props)
})
ClickPlacementState.setSelectedAsset(params.url)
- } else {
- if (event.detail === 2) {
- const newPath = `${selectedDirectory.value}${params.name}/`
- changeDirectoryByPath(newPath)
- }
}
}
@@ -324,7 +322,6 @@ const FileBrowserContentPanel: React.FC = (props)
selectedFileKeys?: string[]
) => {
if (isLoading) return
-
const destinationPath = dropOn?.isFolder ? `${dropOn.key}/` : selectedDirectory.value
if (selectedFileKeys && selectedFileKeys.length > 0) {
@@ -332,18 +329,14 @@ const FileBrowserContentPanel: React.FC = (props)
selectedFileKeys.map(async (fileKey) => {
const file = files.find((f) => f.key === fileKey)
if (file) {
- if (file.isFolder) {
- await fileService.create(`${destinationPath}${file.name}`)
- } else {
- const newName = `${file.name}${file.type ? '.' + file.type : ''}`
- await moveContent(file.fullName, newName, file.path, destinationPath, false)
- }
+ const newName = file.isFolder ? file.name : `${file.name}${file.type ? '.' + file.type : ''}`
+ await moveContent(file.fullName, newName, file.path, destinationPath, false)
}
})
)
} else if (isFileDataType(data)) {
if (dropOn?.isFolder) {
- const newName = `${data.name}${data.type ? '.' + data.type : ''}`
+ const newName = data.isFolder ? data.name : `${data.name}${data.type ? '.' + data.type : ''}`
await moveContent(data.fullName, newName, data.path, destinationPath, false)
}
} else {
@@ -571,8 +564,8 @@ const FileBrowserContentPanel: React.FC = (props)
item={file}
disableDnD={props.disableDnD}
projectName={projectName}
- onClick={(event, currentFile) => {
- handleFileBrowserItemClick(event, currentFile)
+ onClick={(event) => {
+ handleFileBrowserItemClick(event, file)
onSelect(event, file)
}}
onContextMenu={(event, currentFile) => {
@@ -637,6 +630,7 @@ const FileBrowserContentPanel: React.FC = (props)
staticResourceModifiedDates={staticResourceModifiedDates.value}
isSelected={fileProperties.value.some(({ key }) => key === file.key)}
refreshDirectory={refreshDirectory}
+ selectedFileKeys={fileProperties.value.map((file) => file.key)}
/>
))}
>
From 85de0d82c0e083d53e001fa94f6dcaa71c937d71 Mon Sep 17 00:00:00 2001
From: Michael Estes
Date: Mon, 12 Aug 2024 14:04:34 -0700
Subject: [PATCH 04/29] Component dependencies (#10916)
* WIP component dependencies for loading
* naming
* naming
* Race condition with model.scene not being reactive
* Missed check
* Logic update
* Scene loaded hooks
* use scene loaded hooks instead of progress
* Remove component dependencies
* remove
* UUID typing
* Don't need to do this in a use effect anymore
* remove console log
---
.../src/networking/AvatarSpawnSystem.tsx | 3 +-
.../src/systems/LoadingUISystem.tsx | 4 +-
.../hierarchy/HierarchyTreeWalker.ts | 6 +-
.../src/systems/ClickPlacementSystem.tsx | 7 +-
packages/engine/src/gltf/GLTFComponent.tsx | 156 +++++++++++++++---
.../components/ParticleSystemComponent.ts | 6 +-
.../components/VisualScriptComponent.tsx | 3 +-
.../common/functions/OnBeforeCompilePlugin.ts | 1 -
.../panels/Viewport/container/index.tsx | 6 +-
9 files changed, 154 insertions(+), 38 deletions(-)
diff --git a/packages/client-core/src/networking/AvatarSpawnSystem.tsx b/packages/client-core/src/networking/AvatarSpawnSystem.tsx
index 11a04785da..da6513dacd 100644
--- a/packages/client-core/src/networking/AvatarSpawnSystem.tsx
+++ b/packages/client-core/src/networking/AvatarSpawnSystem.tsx
@@ -34,7 +34,6 @@ import {
getComponent,
getOptionalComponent,
PresentationSystemGroup,
- useComponent,
useQuery,
UUIDComponent
} from '@etherealengine/ecs'
@@ -56,7 +55,7 @@ import { AuthState } from '../user/services/AuthService'
export const AvatarSpawnReactor = (props: { sceneEntity: Entity }) => {
if (!isClient) return null
const { sceneEntity } = props
- const gltfLoaded = useComponent(sceneEntity, GLTFComponent).progress.value === 100
+ const gltfLoaded = GLTFComponent.useSceneLoaded(sceneEntity)
const searchParams = useMutableState(SearchParamState)
const spawnAvatar = useHookstate(false)
diff --git a/packages/client-core/src/systems/LoadingUISystem.tsx b/packages/client-core/src/systems/LoadingUISystem.tsx
index d306f00d61..e44b4442f9 100755
--- a/packages/client-core/src/systems/LoadingUISystem.tsx
+++ b/packages/client-core/src/systems/LoadingUISystem.tsx
@@ -140,9 +140,9 @@ export const LoadingUISystemState = defineState({
const LoadingReactor = (props: { sceneEntity: Entity }) => {
const { sceneEntity } = props
- const gltfComponent = useComponent(props.sceneEntity, GLTFComponent)
+ const gltfComponent = useComponent(sceneEntity, GLTFComponent)
const loadingProgress = gltfComponent.progress.value
- const sceneLoaded = loadingProgress === 100
+ const sceneLoaded = GLTFComponent.useSceneLoaded(sceneEntity)
const locationState = useMutableState(LocationState)
const state = useMutableState(LoadingUISystemState)
diff --git a/packages/editor/src/components/hierarchy/HierarchyTreeWalker.ts b/packages/editor/src/components/hierarchy/HierarchyTreeWalker.ts
index 29b0d85a9d..307507df45 100644
--- a/packages/editor/src/components/hierarchy/HierarchyTreeWalker.ts
+++ b/packages/editor/src/components/hierarchy/HierarchyTreeWalker.ts
@@ -23,8 +23,8 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20
Ethereal Engine. All Rights Reserved.
*/
-import { ComponentType, getComponent, hasComponent } from '@etherealengine/ecs/src/ComponentFunctions'
-import { Entity } from '@etherealengine/ecs/src/Entity'
+import { getComponent, hasComponent } from '@etherealengine/ecs/src/ComponentFunctions'
+import { Entity, EntityUUID } from '@etherealengine/ecs/src/Entity'
import { entityExists } from '@etherealengine/ecs/src/EntityFunctions'
import { SourceComponent } from '@etherealengine/engine/src/scene/components/SourceComponent'
import { getState } from '@etherealengine/hyperflux'
@@ -68,7 +68,7 @@ function buildHierarchyTree(
sceneID: string,
showModelChildren: boolean
) {
- const uuid = node.extensions && (node.extensions[UUIDComponent.jsonID] as ComponentType)
+ const uuid = node.extensions && (node.extensions[UUIDComponent.jsonID] as EntityUUID)
const entity = UUIDComponent.getEntityByUUID(uuid!)
if (!entity || !entityExists(entity)) return
diff --git a/packages/editor/src/systems/ClickPlacementSystem.tsx b/packages/editor/src/systems/ClickPlacementSystem.tsx
index 475a25b9a5..db23567ede 100644
--- a/packages/editor/src/systems/ClickPlacementSystem.tsx
+++ b/packages/editor/src/systems/ClickPlacementSystem.tsx
@@ -36,7 +36,6 @@ import {
getOptionalComponent,
removeComponent,
setComponent,
- useComponent,
useOptionalComponent
} from '@etherealengine/ecs'
import { GLTFComponent } from '@etherealengine/engine/src/gltf/GLTFComponent'
@@ -115,7 +114,7 @@ const ClickPlacementReactor = (props: { parentEntity: Entity }) => {
const { parentEntity } = props
const clickState = useState(getMutableState(ClickPlacementState))
const editorState = useState(getMutableState(EditorHelperState))
- const gltfComponent = useComponent(parentEntity, GLTFComponent)
+ const sceneLoaded = GLTFComponent.useSceneLoaded(parentEntity)
const errors = useEntityErrors(clickState.placementEntity.value, ModelComponent)
// const renderers = defineQuery([RendererComponent])
@@ -132,7 +131,7 @@ const ClickPlacementReactor = (props: { parentEntity: Entity }) => {
// }, [editorState.placementMode])
useEffect(() => {
- if (gltfComponent.progress.value < 100) return
+ if (!sceneLoaded) return
if (editorState.placementMode.value === PlacementMode.CLICK) {
SelectionState.updateSelection([])
if (clickState.placementEntity.value) return
@@ -146,7 +145,7 @@ const ClickPlacementReactor = (props: { parentEntity: Entity }) => {
clickState.placementEntity.set(UndefinedEntity)
SelectionState.updateSelection(selectedEntities)
}
- }, [editorState.placementMode, gltfComponent.progress])
+ }, [editorState.placementMode, sceneLoaded])
useEffect(() => {
if (!clickState.placementEntity.value) return
diff --git a/packages/engine/src/gltf/GLTFComponent.tsx b/packages/engine/src/gltf/GLTFComponent.tsx
index c2803003e4..af991ac3ac 100644
--- a/packages/engine/src/gltf/GLTFComponent.tsx
+++ b/packages/engine/src/gltf/GLTFComponent.tsx
@@ -28,6 +28,8 @@ import React, { useEffect } from 'react'
import { parseStorageProviderURLs } from '@etherealengine/common/src/utils/parseSceneJSON'
import {
+ Component,
+ ComponentJSONIDMap,
defineComponent,
Entity,
EntityUUID,
@@ -44,13 +46,36 @@ import { dispatchAction, getState, useHookstate } from '@etherealengine/hyperflu
import { FileLoader } from '../assets/loaders/base/FileLoader'
import { BINARY_EXTENSION_HEADER_MAGIC, EXTENSIONS, GLTFBinaryExtension } from '../assets/loaders/gltf/GLTFExtensions'
-import { ModelComponent } from '../scene/components/ModelComponent'
import { SourceComponent } from '../scene/components/SourceComponent'
import { SceneJsonType } from '../scene/types/SceneTypes'
import { migrateSceneJSONToGLTF } from './convertJsonToGLTF'
import { GLTFDocumentState, GLTFSnapshotAction } from './GLTFDocumentState'
import { ResourcePendingComponent } from './ResourcePendingComponent'
+const loadDependencies = {
+ ['EE_model']: ['scene']
+} as Record
+
+type ComponentDependencies = Record
+
+const buildComponentDependencies = (json: GLTF.IGLTF) => {
+ const dependencies = {} as ComponentDependencies
+ if (!json.nodes) return dependencies
+ for (const node of json.nodes) {
+ if (!node.extensions || !node.extensions[UUIDComponent.jsonID]) continue
+ const uuid = node.extensions[UUIDComponent.jsonID] as EntityUUID
+ const extensions = Object.keys(node.extensions)
+ for (const extension of extensions) {
+ if (loadDependencies[extension]) {
+ if (!dependencies[uuid]) dependencies[uuid] = []
+ dependencies[uuid].push(ComponentJSONIDMap.get(extension)!)
+ }
+ }
+ }
+
+ return dependencies
+}
+
export const GLTFComponent = defineComponent({
name: 'GLTFComponent',
@@ -59,7 +84,8 @@ export const GLTFComponent = defineComponent({
src: '',
// internals
extensions: {},
- progress: 0
+ progress: 0,
+ dependencies: undefined as ComponentDependencies | undefined
}
},
@@ -67,41 +93,61 @@ export const GLTFComponent = defineComponent({
if (typeof json?.src === 'string') component.src.set(json.src)
},
+ useDependenciesLoaded(entity: Entity) {
+ const dependencies = useComponent(entity, GLTFComponent).dependencies
+ return !!(dependencies.value && !dependencies.keys?.length)
+ },
+
+ useSceneLoaded(entity: Entity) {
+ const gltfComponent = useComponent(entity, GLTFComponent)
+ const dependencies = gltfComponent.dependencies
+ const progress = gltfComponent.progress.value
+ return !!(dependencies.value && !dependencies.keys?.length) && progress === 100
+ },
+
+ isSceneLoaded(entity: Entity) {
+ const gltfComponent = getComponent(entity, GLTFComponent)
+ const dependencies = gltfComponent.dependencies
+ const progress = gltfComponent.progress
+ return !!(dependencies && !Object.keys(dependencies).length) && progress === 100
+ },
+
reactor: () => {
const entity = useEntityContext()
const gltfComponent = useComponent(entity, GLTFComponent)
+ const dependencies = gltfComponent.dependencies
useGLTFDocument(gltfComponent.src.value, entity)
const documentID = useComponent(entity, SourceComponent).value
- return
+ return (
+ <>
+
+ {dependencies.value && dependencies.keys?.length ? (
+
+ ) : null}
+ >
+ )
}
})
const ResourceReactor = (props: { documentID: string; entity: Entity }) => {
+ const dependenciesLoaded = GLTFComponent.useDependenciesLoaded(props.entity)
const resourceQuery = useQuery([SourceComponent, ResourcePendingComponent])
const sourceEntities = useHookstate(SourceComponent.entitiesBySourceState[props.documentID])
useEffect(() => {
if (getComponent(props.entity, GLTFComponent).progress === 100) return
if (!getState(GLTFDocumentState)[props.documentID]) return
- const document = getState(GLTFDocumentState)[props.documentID]
- const modelNodes = document.nodes?.filter((node) => !!node.extensions?.[ModelComponent.jsonID])
- if (modelNodes) {
- for (const node of modelNodes) {
- //check if an entity exists for this node, and has a model component
- const uuid = node.extensions![UUIDComponent.jsonID] as EntityUUID
- if (!UUIDComponent.entitiesByUUIDState[uuid]) return
- const entity = UUIDComponent.entitiesByUUIDState[uuid].value
- const model = getOptionalComponent(entity, ModelComponent)
- //ensure that model contents have been loaded into the scene
- if (!model?.scene) return
- }
- }
+
const entities = resourceQuery.filter((e) => getComponent(e, SourceComponent) === props.documentID)
if (!entities.length) {
- getMutableComponent(props.entity, GLTFComponent).progress.set(100)
+ if (dependenciesLoaded) getMutableComponent(props.entity, GLTFComponent).progress.set(100)
return
}
@@ -121,14 +167,83 @@ const ResourceReactor = (props: { documentID: string; entity: Entity }) => {
const progress = resources.reduce((acc, resource) => acc + resource.progress, 0)
const total = resources.reduce((acc, resource) => acc + resource.total, 0)
+ if (!total) return
- const percentage = total === 0 ? 100 : (progress / total) * 100
+ const percentage = Math.floor(Math.min((progress / total) * 100, dependenciesLoaded ? 100 : 99))
getMutableComponent(props.entity, GLTFComponent).progress.set(percentage)
- }, [resourceQuery, sourceEntities])
+ }, [resourceQuery, sourceEntities, dependenciesLoaded])
return null
}
+const ComponentReactor = (props: { gltfComponentEntity: Entity; entity: Entity; component: Component }) => {
+ const { gltfComponentEntity, entity, component } = props
+ const dependencies = loadDependencies[component.jsonID!]
+ const comp = useComponent(entity, component)
+
+ useEffect(() => {
+ const compValue = comp.value
+ for (const key of dependencies) {
+ if (!compValue[key]) return
+ }
+
+ // console.log(`All dependencies loaded for entity: ${entity} on component: ${component.jsonID}`)
+
+ const gltfComponent = getMutableComponent(gltfComponentEntity, GLTFComponent)
+ const uuid = getComponent(entity, UUIDComponent)
+ gltfComponent.dependencies.set((prev) => {
+ const dependencyArr = prev![uuid] as Component[]
+ const index = dependencyArr.findIndex((compItem) => compItem.jsonID === component.jsonID)
+ dependencyArr.splice(index, 1)
+ if (!dependencyArr.length) {
+ delete prev![uuid]
+ }
+ return prev
+ })
+ }, [...dependencies.map((key) => comp[key])])
+
+ return null
+}
+
+const DependencyEntryReactor = (props: { gltfComponentEntity: Entity; uuid: string; components: Component[] }) => {
+ const { gltfComponentEntity, uuid, components } = props
+ const entity = UUIDComponent.useEntityByUUID(uuid as EntityUUID) as Entity | undefined
+ return entity ? (
+ <>
+ {components.map((component) => {
+ return (
+
+ )
+ })}
+ >
+ ) : null
+}
+
+const DependencyReactor = (props: { gltfComponentEntity: Entity; dependencies: ComponentDependencies }) => {
+ const { gltfComponentEntity, dependencies } = props
+ const entries = Object.entries(dependencies)
+
+ return (
+ <>
+ {entries.map(([uuid, components]) => {
+ return (
+
+ )
+ })}
+ >
+ )
+}
+
const onError = (error: ErrorEvent) => {
// console.error(error)
}
@@ -187,6 +302,9 @@ const useGLTFDocument = (url: string, entity: Entity) => {
json = migrateSceneJSONToGLTF(json)
}
+ const dependencies = buildComponentDependencies(json)
+ state.dependencies.set(dependencies)
+
dispatchAction(
GLTFSnapshotAction.createSnapshot({
source: getComponent(entity, SourceComponent),
diff --git a/packages/engine/src/scene/components/ParticleSystemComponent.ts b/packages/engine/src/scene/components/ParticleSystemComponent.ts
index 374bffde30..d267efa9a1 100644
--- a/packages/engine/src/scene/components/ParticleSystemComponent.ts
+++ b/packages/engine/src/scene/components/ParticleSystemComponent.ts
@@ -868,7 +868,7 @@ export const ParticleSystemComponent = defineComponent({
const metadata = useHookstate({ textures: {}, geometries: {}, materials: {} } as ParticleSystemMetadata)
const sceneID = useOptionalComponent(entity, SourceComponent)?.value
const rootEntity = useHookstate(getMutableState(GLTFSourceState))[sceneID ?? ''].value
- const rootGLTF = useOptionalComponent(rootEntity, GLTFComponent)
+ const sceneLoaded = GLTFComponent.useSceneLoaded(rootEntity)
const refreshed = useHookstate(false)
const [geoDependency] = useGLTF(componentState.value.systemParameters.instancingGeometry!, entity, (url) => {
@@ -890,7 +890,7 @@ export const ParticleSystemComponent = defineComponent({
})
//@todo: this is a hack to make trail rendering mode work correctly. We need to find out why an additional snapshot is needed
useEffect(() => {
- if (rootGLTF?.value?.progress !== 100) return
+ if (!sceneLoaded) return
if (refreshed.value) return
//if (componentState.systemParameters.renderMode.value === RenderMode.Trail) {
@@ -898,7 +898,7 @@ export const ParticleSystemComponent = defineComponent({
dispatchAction(GLTFSnapshotAction.createSnapshot(snapshot))
//}
refreshed.set(true)
- }, [rootGLTF?.value?.progress])
+ }, [sceneLoaded])
useEffect(() => {
//add dud material
diff --git a/packages/engine/src/visualscript/components/VisualScriptComponent.tsx b/packages/engine/src/visualscript/components/VisualScriptComponent.tsx
index e82d5c73e3..4189e15dc6 100644
--- a/packages/engine/src/visualscript/components/VisualScriptComponent.tsx
+++ b/packages/engine/src/visualscript/components/VisualScriptComponent.tsx
@@ -111,8 +111,7 @@ export const VisualScriptComponent = defineComponent({
})
const LoadReactor = (props: { entity: Entity; gltfAncestor: Entity }) => {
- const gltfComponent = useComponent(props.gltfAncestor, GLTFComponent)
- const loaded = gltfComponent.progress.value === 100
+ const loaded = GLTFComponent.useSceneLoaded(props.gltfAncestor)
useEffect(() => {
setComponent(props.entity, VisualScriptComponent, { run: true })
diff --git a/packages/spatial/src/common/functions/OnBeforeCompilePlugin.ts b/packages/spatial/src/common/functions/OnBeforeCompilePlugin.ts
index 20e8dd1758..179f8d4aa5 100644
--- a/packages/spatial/src/common/functions/OnBeforeCompilePlugin.ts
+++ b/packages/spatial/src/common/functions/OnBeforeCompilePlugin.ts
@@ -58,7 +58,6 @@ export type PluginType = PluginObjectType | typeof Material.prototype.onBeforeCo
/**@deprecated Use setPlugin instead */
export function addOBCPlugin(material: Material, plugin: PluginType): void {
material.onBeforeCompile = plugin as any
- console.log(material.onBeforeCompile)
material.needsUpdate = true
}
diff --git a/packages/ui/src/components/editor/panels/Viewport/container/index.tsx b/packages/ui/src/components/editor/panels/Viewport/container/index.tsx
index e744cfb1b6..580bd920c3 100644
--- a/packages/ui/src/components/editor/panels/Viewport/container/index.tsx
+++ b/packages/ui/src/components/editor/panels/Viewport/container/index.tsx
@@ -123,6 +123,7 @@ const ViewportDnD = ({ children }: { children: React.ReactNode }) => {
const SceneLoadingProgress = ({ rootEntity }) => {
const { t } = useTranslation()
const progress = useComponent(rootEntity, GLTFComponent).progress.value
+ const loaded = GLTFComponent.useSceneLoaded(rootEntity)
const resourcePendingQuery = useQuery([ResourcePendingComponent])
const root = getComponent(rootEntity, SourceComponent)
const sceneModified = useHookstate(getMutableState(GLTFModifiedState)[root]).value
@@ -142,12 +143,13 @@ const SceneLoadingProgress = ({ rootEntity }) => {
}
}, [sceneModified])
- if (progress === 100) return null
+ if (loaded) return null
return (
)
@@ -183,8 +185,8 @@ const ViewPortPanelContainer = () => {
{sceneName.value ? : null}
{sceneName.value ? (
<>
- {rootEntity.value && }
+ {rootEntity.value && }
>
) : (
From 5755786b9cc5633739373b970eb11a9a21a19593 Mon Sep 17 00:00:00 2001
From: Appaji <52322531+CITIZENDOT@users.noreply.github.com>
Date: Tue, 13 Aug 2024 12:12:56 +0530
Subject: [PATCH 05/29] IR-3053: Add project-history service (#10736)
* Add project-history service
* Add license
* Add hooks to project service
* Pass actionIdentifier & fix resolver & hooks order
Import ProjectHistoryService in services.ts
* replace `isSuperAdmin` hook
* Fix errors
* Implement base UI
* Update table structure
And Add history hooks to remaining services
* add license
* remove userId in query
* Add User Interface for Project-History service
- Update AvatarImage's label parsing logic
* Parse URLs
* Add `updatedBy` column in `static-resource` table
* Add stored procedure and trigger for static-resource
* Fix typo
* Fix typo
* remove hook
* Add new line at the end
* `dropForeign` column in migration
* revert prop types in Text
* move schema and path to common files
* Move schema and path to common package
* Add `static-resource` & `location` triggers and stored procedures
Added triggers for all the operations (CREATE, UPDATE, DELETE)
Also added `updatedBy` columns to the both tables
* revert tsconfig
* Add `project` triggers and stored procedures
* Add `project-permission` triggers + stored procedures + updatedBy column
* fix CI/CD error
* fix CI/CD error
* fix errors
* move strings from multitenancy.json
* remove individual action types
* typo
* allow multipleStatements in knexfile
* improve ui
* Fixed enum
* fix tooltip value for permission actions
set actionDetail type to json
* change `CONCAT` to `JSON_OBJECT` to construct json strings
* remove unnecessary checks
* Revert "Fixed enum"
This reverts commit 7c0f76afc104bfca496172581596a5d446f8f9ab.
* add newline
* check user permissions before find query
* revert tsconfig
* Changed permissions
* permit only owner scope (and project:read)
* join avatarimage labels without comma
* move delete log operations to API write
* Fix scene & resource classification
---------
Co-authored-by: Liam Broza
Co-authored-by: hanzlamateen
---
packages/client-core/i18n/en/admin.json | 8 +-
.../components/project/ProjectHistory.tsx | 302 ++++++++++++++++++
.../project/ProjectHistoryModal.tsx | 45 +++
.../admin/components/project/ProjectTable.tsx | 12 +
.../src/social/services/LocationService.ts | 1 +
packages/common/src/schema.type.module.ts | 4 +
.../schemas/media/static-resource.schema.ts | 3 +
.../projects/project-history.schema.ts | 110 +++++++
.../projects/project-permission.schema.ts | 3 +
.../src/schemas/projects/project.schema.ts | 6 +-
.../src/schemas/social/location.schema.ts | 4 +
packages/server-core/knexfile.ts | 10 +-
.../migrations/20240731173405_updatedBy.ts | 59 ++++
.../static-resource/static-resource.hooks.ts | 33 +-
.../static-resource.resolvers.ts | 10 +-
packages/server-core/src/mysql.ts | 3 +-
.../20240730102300_project-history.ts | 86 +++++
...20240731180000_static_resource_triggers.ts | 51 +++
.../20240806175210_location_triggers.ts | 51 +++
.../20240806191009_project_triggers.ts | 48 +++
...40806192128_project-permission_triggers.ts | 51 +++
.../migrations/location_triggers.sql | 108 +++++++
.../project-permission_triggers.sql | 120 +++++++
.../migrations/project-triggers.sql | 46 +++
.../migrations/static_resource_triggers.sql | 142 ++++++++
.../project-history/project-history.class.ts | 43 +++
.../project-history/project-history.docs.ts | 44 +++
.../project-history/project-history.hooks.ts | 163 ++++++++++
.../project-history.resolvers.ts | 81 +++++
.../project-history/project-history.ts | 59 ++++
.../migrations/20240806192034_updatedBy.ts | 58 ++++
.../project-permission.hooks.ts | 27 +-
.../project-permission.resolvers.ts | 6 +
.../migrations/20240806170758_updatedBy.ts | 58 ++++
.../src/projects/project/project.resolvers.ts | 6 +
packages/server-core/src/projects/services.ts | 4 +-
.../src/social/location/location.hooks.ts | 24 +-
.../src/social/location/location.resolvers.ts | 6 +
.../migrations/20240806175038_updatedBy.ts | 58 ++++
.../editor/panels/Files/container/index.tsx | 1 +
.../primitives/tailwind/AvatarImage/index.tsx | 18 +-
.../ui/src/primitives/tailwind/Text/index.tsx | 6 +-
42 files changed, 1961 insertions(+), 17 deletions(-)
create mode 100644 packages/client-core/src/admin/components/project/ProjectHistory.tsx
create mode 100644 packages/client-core/src/admin/components/project/ProjectHistoryModal.tsx
create mode 100644 packages/common/src/schemas/projects/project-history.schema.ts
create mode 100644 packages/server-core/src/media/static-resource/migrations/20240731173405_updatedBy.ts
create mode 100644 packages/server-core/src/projects/project-history/migrations/20240730102300_project-history.ts
create mode 100644 packages/server-core/src/projects/project-history/migrations/20240731180000_static_resource_triggers.ts
create mode 100644 packages/server-core/src/projects/project-history/migrations/20240806175210_location_triggers.ts
create mode 100644 packages/server-core/src/projects/project-history/migrations/20240806191009_project_triggers.ts
create mode 100644 packages/server-core/src/projects/project-history/migrations/20240806192128_project-permission_triggers.ts
create mode 100644 packages/server-core/src/projects/project-history/migrations/location_triggers.sql
create mode 100644 packages/server-core/src/projects/project-history/migrations/project-permission_triggers.sql
create mode 100644 packages/server-core/src/projects/project-history/migrations/project-triggers.sql
create mode 100644 packages/server-core/src/projects/project-history/migrations/static_resource_triggers.sql
create mode 100644 packages/server-core/src/projects/project-history/project-history.class.ts
create mode 100644 packages/server-core/src/projects/project-history/project-history.docs.ts
create mode 100644 packages/server-core/src/projects/project-history/project-history.hooks.ts
create mode 100644 packages/server-core/src/projects/project-history/project-history.resolvers.ts
create mode 100644 packages/server-core/src/projects/project-history/project-history.ts
create mode 100644 packages/server-core/src/projects/project-permission/migrations/20240806192034_updatedBy.ts
create mode 100644 packages/server-core/src/projects/project/migrations/20240806170758_updatedBy.ts
create mode 100644 packages/server-core/src/social/location/migrations/20240806175038_updatedBy.ts
diff --git a/packages/client-core/i18n/en/admin.json b/packages/client-core/i18n/en/admin.json
index 98fef2a4d5..d40aa031d7 100755
--- a/packages/client-core/i18n/en/admin.json
+++ b/packages/client-core/i18n/en/admin.json
@@ -86,7 +86,9 @@
"lastUpdatedBy": "Last updated by user id: {{userId}} on {{updatedAt}}",
"fillRequiredFields": "Please fill all required field",
"fixErrorFields": "Please fix all errors",
- "logOut": "Log Out"
+ "logOut": "Log Out",
+ "newestFirst": "Newest First",
+ "oldestFirst": "Oldest First"
},
"analytics": {
"loading": "Loading analytics...",
@@ -214,8 +216,10 @@
"repo": "Repo",
"access": "Access",
"invalidateCache": "Invalidate Cache",
- "update": "Update"
+ "update": "Update",
+ "history": "History"
},
+ "projectHistory": "Project History",
"addProject": "Add Project",
"updateProject": "Update Project",
"downloadProject": "Download Project",
diff --git a/packages/client-core/src/admin/components/project/ProjectHistory.tsx b/packages/client-core/src/admin/components/project/ProjectHistory.tsx
new file mode 100644
index 0000000000..0dc57f958f
--- /dev/null
+++ b/packages/client-core/src/admin/components/project/ProjectHistory.tsx
@@ -0,0 +1,302 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+import { projectHistoryPath } from '@etherealengine/common/src/schema.type.module'
+import { ProjectHistoryType } from '@etherealengine/common/src/schemas/projects/project-history.schema'
+import { useFind } from '@etherealengine/spatial/src/common/functions/FeathersHooks'
+
+import { toDisplayDateTime } from '@etherealengine/common/src/utils/datetime-sql'
+import AvatarImage from '@etherealengine/ui/src/primitives/tailwind/AvatarImage'
+import Button from '@etherealengine/ui/src/primitives/tailwind/Button'
+import { TablePagination } from '@etherealengine/ui/src/primitives/tailwind/Table'
+import Text from '@etherealengine/ui/src/primitives/tailwind/Text'
+import Tooltip from '@etherealengine/ui/src/primitives/tailwind/Tooltip'
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import { FaSortAmountDown, FaSortAmountUpAlt } from 'react-icons/fa'
+
+const PROJECT_HISTORY_PAGE_LIMIT = 10
+
+const getRelativeURLFromProject = (projectName: string, url: string) => {
+ const prefix = `projects/${projectName}/`
+ if (url.startsWith(prefix)) {
+ return url.replace(prefix, '')
+ }
+ return url
+}
+
+const getResourceURL = (projectName: string, url: string, resourceType: 'resource' | 'scene') => {
+ const relativeURL = getRelativeURLFromProject(projectName, url)
+ const resourceURL =
+ resourceType === 'resource'
+ ? `/projects/${projectName}/${relativeURL}`
+ : `/studio?project=${projectName}&scenePath=${url}`
+ return {
+ relativeURL,
+ resourceURL
+ }
+}
+
+function HistoryLog({ projectHistory, projectName }: { projectHistory: ProjectHistoryType; projectName: string }) {
+ const { t } = useTranslation()
+
+ const RenderAction = () => {
+ if (projectHistory.action === 'LOCATION_PUBLISHED' || projectHistory.action === 'LOCATION_UNPUBLISHED') {
+ const actionDetail = JSON.parse(projectHistory.actionDetail) as {
+ locationName: string
+ sceneURL: string
+ sceneId: string
+ }
+
+ const verb = projectHistory.action === 'LOCATION_PUBLISHED' ? 'published' : 'unpublished'
+
+ const { relativeURL, resourceURL } = getResourceURL(projectName, actionDetail.sceneURL, 'scene')
+
+ return (
+ <>
+ {verb} the location
+
+ {verb === 'published' ? (
+
+
+ {actionDetail.locationName}
+
+
+ ) : (
+
+ {actionDetail.locationName}
+
+ )}
+
+ from the scene
+
+
+ {relativeURL}.
+
+ >
+ )
+ } else if (projectHistory.action === 'LOCATION_MODIFIED') {
+ const actionDetail = JSON.parse(projectHistory.actionDetail) as {
+ locationName: string
+ }
+
+ return (
+ <>
+ modified the location
+
+
+
+ {actionDetail.locationName}
+
+
+ >
+ )
+ } else if (projectHistory.action === 'PERMISSION_CREATED' || projectHistory.action === 'PERMISSION_REMOVED') {
+ const actionDetail = JSON.parse(projectHistory.actionDetail) as {
+ userName: string
+ userId: string
+ permissionType: string
+ }
+
+ const verb = projectHistory.action === 'PERMISSION_CREATED' ? 'added' : 'removed'
+
+ return (
+ <>
+ {verb} the
+ {actionDetail.permissionType}
+
+ access to
+
+
+ {actionDetail.userName}
+
+ >
+ )
+ } else if (projectHistory.action === 'PERMISSION_MODIFIED') {
+ const actionDetail = JSON.parse(projectHistory.actionDetail) as {
+ userName: string
+ userId: string
+ oldPermissionType: string
+ newPermissionType: string
+ }
+
+ return (
+ <>
+ updated the permission of the user
+
+ {actionDetail.userName}
+
+ from
+ {actionDetail.oldPermissionType}
+ to
+ {actionDetail.newPermissionType}
+ >
+ )
+ } else if (projectHistory.action === 'PROJECT_CREATED') {
+ return created the project
+ } else if (
+ projectHistory.action === 'RESOURCE_CREATED' ||
+ projectHistory.action === 'RESOURCE_REMOVED' ||
+ projectHistory.action === 'SCENE_CREATED' ||
+ projectHistory.action === 'SCENE_REMOVED'
+ ) {
+ const verb =
+ projectHistory.action === 'RESOURCE_CREATED' || projectHistory.action === 'SCENE_CREATED'
+ ? 'created'
+ : 'removed'
+ const object =
+ projectHistory.action === 'RESOURCE_CREATED' || projectHistory.action === 'RESOURCE_REMOVED'
+ ? 'resource'
+ : 'scene'
+
+ const actionDetail = JSON.parse(projectHistory.actionDetail) as {
+ url: string
+ }
+
+ const { relativeURL, resourceURL } = getResourceURL(projectName, actionDetail.url, object)
+
+ return (
+ <>
+
+ {verb} the {object}
+
+
+ {relativeURL}
+
+ >
+ )
+ } else if (projectHistory.action === 'RESOURCE_RENAMED' || projectHistory.action === 'SCENE_RENAMED') {
+ const object = projectHistory.action === 'RESOURCE_RENAMED' ? 'resource' : 'scene'
+ const actionDetail = JSON.parse(projectHistory.actionDetail) as {
+ oldURL: string
+ newURL: string
+ }
+
+ const { relativeURL: oldRelativeURL } = getResourceURL(projectName, actionDetail.oldURL, object)
+ const { relativeURL: newRelativeURL, resourceURL: newResourceURL } = getResourceURL(
+ projectName,
+ actionDetail.newURL,
+ object
+ )
+
+ return (
+ <>
+ renamed a {object} from
+
+ {oldRelativeURL}
+ to
+
+ {getRelativeURLFromProject(projectName, newRelativeURL)}
+
+ >
+ )
+ } else if (projectHistory.action === 'RESOURCE_MODIFIED' || projectHistory.action === 'SCENE_MODIFIED') {
+ const object = projectHistory.action === 'RESOURCE_MODIFIED' ? 'resource' : 'scene'
+ const actionDetail = JSON.parse(projectHistory.actionDetail) as {
+ url: string
+ }
+
+ const { relativeURL, resourceURL } = getResourceURL(projectName, actionDetail.url, object)
+
+ return (
+ <>
+ modified the {object}
+
+ {relativeURL}
+
+ >
+ )
+ }
+
+ return null
+ }
+
+ return (
+
+
+
+
+
{projectHistory.userName}
+
+
+
+
+
{toDisplayDateTime(projectHistory.createdAt)}
+
+ )
+}
+
+export const ProjectHistory = ({ projectId, projectName }: { projectId: string; projectName: string }) => {
+ const { t } = useTranslation()
+ const projectHistoryQuery = useFind(projectHistoryPath, {
+ query: {
+ projectId: projectId,
+ $sort: {
+ createdAt: -1
+ },
+ $limit: PROJECT_HISTORY_PAGE_LIMIT
+ }
+ })
+
+ const sortOrder = projectHistoryQuery.sort.createdAt
+
+ const toggleSortOrder = () => {
+ projectHistoryQuery.setSort({
+ createdAt: sortOrder === -1 ? 1 : -1
+ })
+ }
+
+ return (
+
+
:
}
+ >
+ {sortOrder === -1 ? t('admin:components.common.newestFirst') : t('admin:components.common.oldestFirst')}
+
+
+ {projectHistoryQuery.data &&
+ projectHistoryQuery.data.map((projectHistory, index) => (
+
+ ))}
+
+
projectHistoryQuery.setPage(newPage)}
+ />
+
+ )
+}
diff --git a/packages/client-core/src/admin/components/project/ProjectHistoryModal.tsx b/packages/client-core/src/admin/components/project/ProjectHistoryModal.tsx
new file mode 100644
index 0000000000..033242d548
--- /dev/null
+++ b/packages/client-core/src/admin/components/project/ProjectHistoryModal.tsx
@@ -0,0 +1,45 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+import Modal from '@etherealengine/ui/src/primitives/tailwind/Modal'
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import { PopoverState } from '../../../common/services/PopoverState'
+import { ProjectHistory } from './ProjectHistory'
+
+export const ProjectHistoryModal = ({ projectId, projectName }: { projectId: string; projectName: string }) => {
+ const { t } = useTranslation()
+ return (
+ {
+ PopoverState.hidePopupover()
+ }}
+ >
+
+
+ )
+}
diff --git a/packages/client-core/src/admin/components/project/ProjectTable.tsx b/packages/client-core/src/admin/components/project/ProjectTable.tsx
index a664aeff37..f27d9e058b 100644
--- a/packages/client-core/src/admin/components/project/ProjectTable.tsx
+++ b/packages/client-core/src/admin/components/project/ProjectTable.tsx
@@ -28,6 +28,7 @@ import { useTranslation } from 'react-i18next'
import { GrGithub } from 'react-icons/gr'
import {
HiOutlineArrowPath,
+ HiOutlineClock,
HiOutlineCommandLine,
HiOutlineExclamationCircle,
HiOutlineFolder,
@@ -55,6 +56,7 @@ import { ProjectRowType, projectsColumns } from '../../common/constants/project'
import { ProjectUpdateState } from '../../services/ProjectUpdateService'
import AddEditProjectModal from './AddEditProjectModal'
import ManageUserPermissionModal from './ManageUserPermissionModal'
+import { ProjectHistoryModal } from './ProjectHistoryModal'
const logger = multiLogger.child({ component: 'client-core:ProjectTable' })
@@ -186,6 +188,16 @@ export default function ProjectTable(props: { search: string }) {
>
{t('admin:components.common.view')}
+ }
+ size="small"
+ className="mr-2 h-min whitespace-pre bg-theme-blue-secondary text-[#214AA6] disabled:opacity-50 dark:text-white"
+ onClick={() => {
+ PopoverState.showPopupover( )
+ }}
+ >
+ {t('admin:components.project.actions.history')}
+
}
size="small"
diff --git a/packages/client-core/src/social/services/LocationService.ts b/packages/client-core/src/social/services/LocationService.ts
index 46b1ca2662..689fd5c084 100755
--- a/packages/client-core/src/social/services/LocationService.ts
+++ b/packages/client-core/src/social/services/LocationService.ts
@@ -65,6 +65,7 @@ export const LocationSeed: LocationType = {
},
locationAuthorizedUsers: [],
locationBans: [],
+ updatedBy: '' as UserID,
createdAt: '',
updatedAt: ''
}
diff --git a/packages/common/src/schema.type.module.ts b/packages/common/src/schema.type.module.ts
index 6a5acad8ad..1766dbb720 100644
--- a/packages/common/src/schema.type.module.ts
+++ b/packages/common/src/schema.type.module.ts
@@ -119,6 +119,8 @@ export type * from './schemas/user/user-setting.schema'
export type * from './schemas/user/user.schema'
export type * from './schemas/world/spawn-point.schema'
+export type * from './schemas/projects/project-history.schema'
+
export const locationPath = 'location'
export const userRelationshipPath = 'user-relationship'
@@ -310,6 +312,8 @@ export const imageConvertPath = 'image-convert'
export const zendeskPath = 'zendesk'
+export const projectHistoryPath = 'project-history'
+
export const metabaseSettingPath = 'metabase-setting'
export const metabaseUrlPath = 'metabase-url'
diff --git a/packages/common/src/schemas/media/static-resource.schema.ts b/packages/common/src/schemas/media/static-resource.schema.ts
index cf0beef8ed..06cdd9d7f7 100755
--- a/packages/common/src/schemas/media/static-resource.schema.ts
+++ b/packages/common/src/schemas/media/static-resource.schema.ts
@@ -59,6 +59,9 @@ export const staticResourceSchema = Type.Object(
thumbnailKey: Type.Optional(Type.String()),
thumbnailURL: Type.Optional(Type.String()),
thumbnailMode: Type.Optional(Type.String()), // 'automatic' | 'manual'
+ updatedBy: TypedString({
+ format: 'uuid'
+ }),
createdAt: Type.String({ format: 'date-time' }),
updatedAt: Type.String({ format: 'date-time' })
},
diff --git a/packages/common/src/schemas/projects/project-history.schema.ts b/packages/common/src/schemas/projects/project-history.schema.ts
new file mode 100644
index 0000000000..971398724e
--- /dev/null
+++ b/packages/common/src/schemas/projects/project-history.schema.ts
@@ -0,0 +1,110 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+// For more information about this file see https://dove.feathersjs.com/guides/cli/service.schemas.html
+import { UserID } from '@etherealengine/common/src/schemas/user/user.schema'
+import { dataValidator, queryValidator } from '@etherealengine/common/src/schemas/validators'
+import { TypedString } from '@etherealengine/common/src/types/TypeboxUtils'
+import { Static, StringEnum, Type, getValidator, querySyntax } from '@feathersjs/typebox'
+
+export const projectHistoryPath = 'project-history'
+export const projectHistoryMethods = ['create', 'find', 'remove'] as const
+
+export const ActionTypes = [
+ 'SCENE_CREATED',
+ 'SCENE_RENAMED',
+ 'SCENE_MODIFIED',
+ 'SCENE_REMOVED',
+ 'RESOURCE_CREATED',
+ 'RESOURCE_RENAMED',
+ 'RESOURCE_MODIFIED',
+ 'RESOURCE_REMOVED',
+ 'PROJECT_CREATED',
+ 'PERMISSION_CREATED',
+ 'PERMISSION_MODIFIED',
+ 'PERMISSION_REMOVED',
+ 'LOCATION_PUBLISHED',
+ 'LOCATION_MODIFIED',
+ 'LOCATION_UNPUBLISHED'
+] as const
+
+export type ActionType = (typeof ActionTypes)[number]
+
+export const ActionIdentifierTypes = ['static-resource', 'project', 'location', 'project-permission'] as const
+
+// Schema for creating new entries
+export const projectHistorySchema = Type.Object(
+ {
+ id: Type.String({
+ format: 'uuid'
+ }),
+ projectId: Type.String({
+ format: 'uuid'
+ }),
+ userId: Type.Union([
+ TypedString({
+ format: 'uuid'
+ }),
+ Type.Null()
+ ]),
+
+ userName: Type.String(),
+ userAvatarURL: Type.String({ format: 'uri' }),
+
+ // @ts-ignore
+ action: StringEnum(ActionTypes),
+ actionIdentifier: Type.String(),
+
+ // @ts-ignore
+ actionIdentifierType: StringEnum(ActionIdentifierTypes),
+ actionDetail: Type.String(),
+
+ createdAt: Type.String({ format: 'date-time' })
+ },
+ { $id: 'ProjectHistory', additionalProperties: false }
+)
+export interface ProjectHistoryType extends Static {}
+
+// Schema for creating new entries
+export const projectHistoryDataSchema = Type.Pick(
+ projectHistorySchema,
+ ['projectId', 'userId', 'action', 'actionIdentifier', 'actionIdentifierType', 'actionDetail'],
+ {
+ $id: 'ProjectHistoryData'
+ }
+)
+export interface ProjectHistoryData extends Static {}
+
+// Schema for allowed query properties
+export const projectHistoryQueryProperties = Type.Pick(projectHistorySchema, ['projectId', 'createdAt'])
+
+export const projectHistoryQuerySchema = Type.Intersect([querySyntax(projectHistoryQueryProperties, {})], {
+ additionalProperties: false
+})
+export interface ProjectHistoryQuery extends Static {}
+
+export const projectHistoryValidator = /* @__PURE__ */ getValidator(projectHistorySchema, dataValidator)
+export const projectHistoryDataValidator = /* @__PURE__ */ getValidator(projectHistoryDataSchema, dataValidator)
+export const projectHistoryQueryValidator = /* @__PURE__ */ getValidator(projectHistoryQuerySchema, queryValidator)
diff --git a/packages/common/src/schemas/projects/project-permission.schema.ts b/packages/common/src/schemas/projects/project-permission.schema.ts
index 2ffb3a4798..f738a6cbdb 100644
--- a/packages/common/src/schemas/projects/project-permission.schema.ts
+++ b/packages/common/src/schemas/projects/project-permission.schema.ts
@@ -50,6 +50,9 @@ export const projectPermissionSchema = Type.Object(
}),
type: Type.String(),
user: Type.Ref(userSchema),
+ updatedBy: TypedString({
+ format: 'uuid'
+ }),
createdAt: Type.String({ format: 'date-time' }),
updatedAt: Type.String({ format: 'date-time' })
},
diff --git a/packages/common/src/schemas/projects/project.schema.ts b/packages/common/src/schemas/projects/project.schema.ts
index dd19c0b566..bda2785e7b 100644
--- a/packages/common/src/schemas/projects/project.schema.ts
+++ b/packages/common/src/schemas/projects/project.schema.ts
@@ -27,8 +27,9 @@ Ethereal Engine. All Rights Reserved.
import type { Static } from '@feathersjs/typebox'
import { getValidator, querySyntax, StringEnum, Type } from '@feathersjs/typebox'
+import { TypedString } from '../../types/TypeboxUtils'
import { projectSettingSchema } from '../setting/project-setting.schema'
-import { UserType } from '../user/user.schema'
+import { UserID, UserType } from '../user/user.schema'
import { dataValidator, queryValidator } from '../validators'
import { projectPermissionSchema } from './project-permission.schema'
@@ -65,6 +66,9 @@ export const projectSchema = Type.Object(
assetsOnly: Type.Boolean(),
visibility: StringEnum(['private', 'public']),
settings: Type.Optional(Type.Array(Type.Ref(projectSettingSchema))),
+ updatedBy: TypedString({
+ format: 'uuid'
+ }),
createdAt: Type.String({ format: 'date-time' }),
updatedAt: Type.String({ format: 'date-time' })
},
diff --git a/packages/common/src/schemas/social/location.schema.ts b/packages/common/src/schemas/social/location.schema.ts
index 6903ad2500..4a155ecd49 100644
--- a/packages/common/src/schemas/social/location.schema.ts
+++ b/packages/common/src/schemas/social/location.schema.ts
@@ -29,6 +29,7 @@ import { getValidator, querySyntax, Type } from '@feathersjs/typebox'
import { OpaqueType } from '@etherealengine/common/src/interfaces/OpaqueType'
+import { UserID } from '../../schema.type.module'
import { TypedString } from '../../types/TypeboxUtils'
import { staticResourceSchema } from '../media/static-resource.schema'
import { dataValidator, queryValidator } from '../validators'
@@ -67,6 +68,9 @@ export const locationSchema = Type.Object(
locationAdmin: Type.Optional(Type.Ref(locationAdminSchema)),
locationAuthorizedUsers: Type.Array(Type.Ref(locationAuthorizedUserSchema)),
locationBans: Type.Array(Type.Ref(locationBanSchema)),
+ updatedBy: TypedString({
+ format: 'uuid'
+ }),
createdAt: Type.String({ format: 'date-time' }),
updatedAt: Type.String({ format: 'date-time' })
},
diff --git a/packages/server-core/knexfile.ts b/packages/server-core/knexfile.ts
index 693a88bbbe..4622cd3fe7 100644
--- a/packages/server-core/knexfile.ts
+++ b/packages/server-core/knexfile.ts
@@ -68,7 +68,15 @@ if (projectsExists) {
const config: Knex.Config = {
client: 'mysql',
- connection: appConfig.db.url,
+ connection: {
+ user: appConfig.db.username,
+ password: appConfig.db.password,
+ host: appConfig.db.host,
+ port: parseInt(appConfig.db.port),
+ database: appConfig.db.database,
+ charset: 'utf8mb4',
+ multipleStatements: true
+ },
migrations: {
directory: migrationsDirectories,
tableName: 'knex_migrations',
diff --git a/packages/server-core/src/media/static-resource/migrations/20240731173405_updatedBy.ts b/packages/server-core/src/media/static-resource/migrations/20240731173405_updatedBy.ts
new file mode 100644
index 0000000000..52e2f197b7
--- /dev/null
+++ b/packages/server-core/src/media/static-resource/migrations/20240731173405_updatedBy.ts
@@ -0,0 +1,59 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+import type { Knex } from 'knex'
+
+import { staticResourcePath } from '@etherealengine/common/src/schemas/media/static-resource.schema'
+
+export async function up(knex: Knex): Promise {
+ await knex.raw('SET FOREIGN_KEY_CHECKS=0')
+
+ const updatedByColumnExists = await knex.schema.hasColumn(staticResourcePath, 'updatedBy')
+ if (!updatedByColumnExists) {
+ await knex.schema.alterTable(staticResourcePath, async (table) => {
+ //@ts-ignore
+ table.uuid('updatedBy', 36).collate('utf8mb4_bin')
+
+ // Foreign keys
+ table.foreign('updatedBy').references('id').inTable('user').onDelete('SET NULL').onUpdate('CASCADE')
+ })
+ }
+
+ await knex.raw('SET FOREIGN_KEY_CHECKS=1')
+}
+
+export async function down(knex: Knex): Promise {
+ await knex.raw('SET FOREIGN_KEY_CHECKS=0')
+
+ const updatedByColumnExists = await knex.schema.hasColumn(staticResourcePath, 'updatedBy')
+ if (updatedByColumnExists) {
+ await knex.schema.alterTable(staticResourcePath, async (table) => {
+ table.dropForeign('updatedBy')
+ table.dropColumn('updatedBy')
+ })
+ }
+
+ await knex.raw('SET FOREIGN_KEY_CHECKS=1')
+}
diff --git a/packages/server-core/src/media/static-resource/static-resource.hooks.ts b/packages/server-core/src/media/static-resource/static-resource.hooks.ts
index d9b389b3ff..6fb94e5271 100755
--- a/packages/server-core/src/media/static-resource/static-resource.hooks.ts
+++ b/packages/server-core/src/media/static-resource/static-resource.hooks.ts
@@ -28,6 +28,7 @@ import { discardQuery, iff, iffElse, isProvider } from 'feathers-hooks-common'
import { StaticResourceType, staticResourcePath } from '@etherealengine/common/src/schemas/media/static-resource.schema'
+import { projectHistoryPath, projectPath } from '@etherealengine/common/src/schema.type.module'
import { HookContext } from '../../../declarations'
import allowNullQuery from '../../hooks/allow-null-query'
import checkScope from '../../hooks/check-scope'
@@ -221,6 +222,36 @@ const resolveThumbnailURL = async (context: HookContext)
return context
}
+const addDeleteLog = async (context: HookContext) => {
+ try {
+ const resource = context.result as StaticResourceType
+
+ const project = await context.app.service(projectPath).find({
+ query: {
+ name: resource.project,
+ $limit: 1
+ }
+ })
+
+ const projectId = project.data[0].id
+
+ const action = resource.type === 'scene' ? 'SCENE_REMOVED' : 'RESOURCE_REMOVED'
+
+ await context.app.service(projectHistoryPath).create({
+ projectId: projectId,
+ userId: context.params.user?.id || null,
+ action: action,
+ actionIdentifier: resource.id,
+ actionIdentifierType: 'static-resource',
+ actionDetail: JSON.stringify({
+ url: resource.key
+ })
+ })
+ } catch (error) {
+ console.error('Error in adding delete log: ', error)
+ }
+}
+
export default {
around: {
all: [schemaHooks.resolveResult(staticResourceResolver)]
@@ -331,7 +362,7 @@ export default {
create: [updateResourcesJson],
update: [updateResourcesJson],
patch: [updateResourcesJson],
- remove: [removeResourcesJson]
+ remove: [removeResourcesJson, addDeleteLog]
},
error: {
diff --git a/packages/server-core/src/media/static-resource/static-resource.resolvers.ts b/packages/server-core/src/media/static-resource/static-resource.resolvers.ts
index b5eb9a61a0..813dbc5915 100644
--- a/packages/server-core/src/media/static-resource/static-resource.resolvers.ts
+++ b/packages/server-core/src/media/static-resource/static-resource.resolvers.ts
@@ -107,7 +107,10 @@ export const staticResourceDataResolver = resolve {
+ return context.params?.user?.id || null
+ }
},
{
// Convert the raw data into a new structure before running property resolvers
@@ -124,7 +127,10 @@ export const staticResourceDataResolver = resolve(
{
- updatedAt: getDateTimeSql
+ updatedAt: getDateTimeSql,
+ updatedBy: async (_, __, context) => {
+ return context.params?.user?.id || null
+ }
},
{
// Convert the raw data into a new structure before running property resolvers
diff --git a/packages/server-core/src/mysql.ts b/packages/server-core/src/mysql.ts
index 488c225522..40da035285 100755
--- a/packages/server-core/src/mysql.ts
+++ b/packages/server-core/src/mysql.ts
@@ -83,7 +83,8 @@ export default (app: Application): void => {
host: appConfig.db.host,
port: parseInt(appConfig.db.port),
database: appConfig.db.database,
- charset: 'utf8mb4'
+ charset: 'utf8mb4',
+ multipleStatements: true
},
pool: {
min: 0,
diff --git a/packages/server-core/src/projects/project-history/migrations/20240730102300_project-history.ts b/packages/server-core/src/projects/project-history/migrations/20240730102300_project-history.ts
new file mode 100644
index 0000000000..4682491ca4
--- /dev/null
+++ b/packages/server-core/src/projects/project-history/migrations/20240730102300_project-history.ts
@@ -0,0 +1,86 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+import {
+ ActionIdentifierTypes,
+ ActionTypes,
+ projectHistoryPath
+} from '@etherealengine/common/src/schemas/projects/project-history.schema'
+import type { Knex } from 'knex'
+
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+export async function up(knex: Knex): Promise {
+ await knex.raw('SET FOREIGN_KEY_CHECKS=0')
+
+ const tableExists = await knex.schema.hasTable(projectHistoryPath)
+
+ if (tableExists === false) {
+ await knex.schema.createTable(projectHistoryPath, (table) => {
+ //@ts-ignore
+ table.uuid('id').collate('utf8mb4_bin').primary()
+
+ //@ts-ignore
+ table.uuid('projectId', 36).collate('utf8mb4_bin').index()
+
+ //@ts-ignore
+ table.uuid('userId', 36).collate('utf8mb4_bin')
+
+ table.enum('action', ActionTypes).notNullable()
+
+ table.string('actionIdentifier').notNullable()
+
+ table.enum('actionIdentifierType', ActionIdentifierTypes).notNullable()
+
+ table.json('actionDetail').nullable()
+
+ table.dateTime('createdAt').notNullable()
+
+ table.foreign('userId').references('id').inTable('user').onDelete('SET NULL').onUpdate('CASCADE')
+
+ table.foreign('projectId').references('id').inTable('project').onDelete('SET NULL').onUpdate('CASCADE')
+ })
+ }
+
+ await knex.raw('SET FOREIGN_KEY_CHECKS=1')
+}
+
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+export async function down(knex: Knex): Promise {
+ await knex.raw('SET FOREIGN_KEY_CHECKS=0')
+
+ const tableExists = await knex.schema.hasTable(projectHistoryPath)
+
+ if (tableExists === true) {
+ await knex.schema.dropTable(projectHistoryPath)
+ }
+
+ await knex.raw('SET FOREIGN_KEY_CHECKS=1')
+}
diff --git a/packages/server-core/src/projects/project-history/migrations/20240731180000_static_resource_triggers.ts b/packages/server-core/src/projects/project-history/migrations/20240731180000_static_resource_triggers.ts
new file mode 100644
index 0000000000..ded56eb32b
--- /dev/null
+++ b/packages/server-core/src/projects/project-history/migrations/20240731180000_static_resource_triggers.ts
@@ -0,0 +1,51 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+import * as fs from 'fs'
+import type { Knex } from 'knex'
+import * as path from 'path'
+
+const sqlFilePath = path.join(__dirname, './static_resource_triggers.sql')
+
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+export async function up(knex: Knex): Promise {
+ const sql = fs.readFileSync(sqlFilePath, 'utf8')
+ await knex.raw(sql)
+}
+
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+export async function down(knex: Knex): Promise {
+ await knex.raw('DROP PROCEDURE IF EXISTS update_static_resource_history;')
+ await knex.raw('DROP TRIGGER IF EXISTS after_static_resource_update;')
+
+ await knex.raw('DROP PROCEDURE IF EXISTS insert_static_resource_history;')
+ await knex.raw('DROP TRIGGER IF EXISTS after_static_resource_insert;')
+}
diff --git a/packages/server-core/src/projects/project-history/migrations/20240806175210_location_triggers.ts b/packages/server-core/src/projects/project-history/migrations/20240806175210_location_triggers.ts
new file mode 100644
index 0000000000..7811c9359d
--- /dev/null
+++ b/packages/server-core/src/projects/project-history/migrations/20240806175210_location_triggers.ts
@@ -0,0 +1,51 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+import * as fs from 'fs'
+import type { Knex } from 'knex'
+import * as path from 'path'
+
+const sqlFilePath = path.join(__dirname, './location_triggers.sql')
+
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+export async function up(knex: Knex): Promise {
+ const sql = fs.readFileSync(sqlFilePath, 'utf8')
+ await knex.raw(sql)
+}
+
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+export async function down(knex: Knex): Promise {
+ await knex.raw('DROP PROCEDURE IF EXISTS update_location_history;')
+ await knex.raw('DROP TRIGGER IF EXISTS after_location_update;')
+
+ await knex.raw('DROP PROCEDURE IF EXISTS insert_location_history;')
+ await knex.raw('DROP TRIGGER IF EXISTS after_location_insert;')
+}
diff --git a/packages/server-core/src/projects/project-history/migrations/20240806191009_project_triggers.ts b/packages/server-core/src/projects/project-history/migrations/20240806191009_project_triggers.ts
new file mode 100644
index 0000000000..fee3a5ffad
--- /dev/null
+++ b/packages/server-core/src/projects/project-history/migrations/20240806191009_project_triggers.ts
@@ -0,0 +1,48 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+import * as fs from 'fs'
+import type { Knex } from 'knex'
+import * as path from 'path'
+
+const sqlFilePath = path.join(__dirname, './project-triggers.sql')
+
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+export async function up(knex: Knex): Promise {
+ const sql = fs.readFileSync(sqlFilePath, 'utf8')
+ await knex.raw(sql)
+}
+
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+export async function down(knex: Knex): Promise {
+ await knex.raw('DROP PROCEDURE IF EXISTS insert_project_history;')
+ await knex.raw('DROP TRIGGER IF EXISTS after_project_insert;')
+}
diff --git a/packages/server-core/src/projects/project-history/migrations/20240806192128_project-permission_triggers.ts b/packages/server-core/src/projects/project-history/migrations/20240806192128_project-permission_triggers.ts
new file mode 100644
index 0000000000..572fe15f2b
--- /dev/null
+++ b/packages/server-core/src/projects/project-history/migrations/20240806192128_project-permission_triggers.ts
@@ -0,0 +1,51 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+import * as fs from 'fs'
+import type { Knex } from 'knex'
+import * as path from 'path'
+
+const sqlFilePath = path.join(__dirname, './project-permission_triggers.sql')
+
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+export async function up(knex: Knex): Promise {
+ const sql = fs.readFileSync(sqlFilePath, 'utf8')
+ await knex.raw(sql)
+}
+
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+export async function down(knex: Knex): Promise {
+ await knex.raw('DROP PROCEDURE IF EXISTS insert_project_permission_history;')
+ await knex.raw('DROP TRIGGER IF EXISTS after_project_permission_insert;')
+
+ await knex.raw('DROP PROCEDURE IF EXISTS update_project_permission_history;')
+ await knex.raw('DROP TRIGGER IF EXISTS after_project_permission_update;')
+}
diff --git a/packages/server-core/src/projects/project-history/migrations/location_triggers.sql b/packages/server-core/src/projects/project-history/migrations/location_triggers.sql
new file mode 100644
index 0000000000..48593430e8
--- /dev/null
+++ b/packages/server-core/src/projects/project-history/migrations/location_triggers.sql
@@ -0,0 +1,108 @@
+DROP PROCEDURE IF EXISTS `update_location_history`;
+CREATE PROCEDURE update_location_history (
+ IN projectId CHAR(36),
+ IN locationId CHAR(36),
+ IN locationSlugifiedName VARCHAR(255),
+ IN updatedBy CHAR(36)
+)
+sp: BEGIN
+ DECLARE actionDetail VARCHAR(600);
+
+ -- JSON object with location name
+ SET actionDetail = JSON_OBJECT("locationName", locationSlugifiedName);
+
+ -- Insert the action into the project-history table
+ INSERT INTO `project-history` (
+ `id`,
+ `projectId`,
+ `userId`,
+ `action`,
+ `actionIdentifier`,
+ `actionIdentifierType`,
+ `actionDetail`,
+ `createdAt`
+ ) VALUES (
+ UUID(),
+ projectId,
+ updatedBy,
+ 'LOCATION_MODIFIED',
+ locationId,
+ 'location',
+ actionDetail,
+ NOW()
+ );
+END;
+
+DROP TRIGGER IF EXISTS `after_location_update`;
+CREATE TRIGGER after_location_update
+AFTER UPDATE ON `location`
+FOR EACH ROW
+BEGIN
+ -- Call the stored procedure with the necessary parameters
+ CALL update_location_history(
+ NEW.projectId, -- projectName
+ NEW.id, -- locationId
+ NEW.slugifiedName, -- locationSlugifiedName
+ NEW.updatedBy -- updatedBy
+ );
+END;
+
+
+DROP PROCEDURE IF EXISTS `insert_location_history`;
+CREATE PROCEDURE insert_location_history (
+ IN projectId CHAR(36),
+ IN locationId CHAR(36),
+ IN locationSlugifiedName VARCHAR(255),
+ IN sceneId CHAR(36),
+ IN updatedBy CHAR(36)
+)
+sp: BEGIN
+ DECLARE sceneURL VARCHAR(255);
+ DECLARE actionDetail VARCHAR(600);
+
+ -- Find the scene name based on the scene ID
+ SELECT `key` INTO sceneURL FROM `static-resource` WHERE `id` = sceneId;
+
+ -- JSON object with location name, scene URL, and scene ID
+ SET actionDetail = JSON_OBJECT(
+ "locationName", locationSlugifiedName,
+ "sceneURL", sceneURL,
+ "sceneId", sceneId
+ );
+
+ -- Insert the action into the project-history table
+ INSERT INTO `project-history` (
+ `id`,
+ `projectId`,
+ `userId`,
+ `action`,
+ `actionIdentifier`,
+ `actionIdentifierType`,
+ `actionDetail`,
+ `createdAt`
+ ) VALUES (
+ UUID(),
+ projectId,
+ updatedBy,
+ 'LOCATION_PUBLISHED',
+ locationId,
+ 'location',
+ actionDetail,
+ NOW()
+ );
+END;
+
+DROP TRIGGER IF EXISTS `after_location_insert`;
+CREATE TRIGGER after_location_insert
+AFTER INSERT ON `location`
+FOR EACH ROW
+BEGIN
+ -- Call the stored procedure with the necessary parameters
+ CALL insert_location_history(
+ NEW.projectId, -- projectName
+ NEW.id, -- locationId
+ NEW.slugifiedName, -- locationSlugifiedName
+ NEW.sceneId, -- sceneId
+ NEW.updatedBy -- updatedBy
+ );
+END;
diff --git a/packages/server-core/src/projects/project-history/migrations/project-permission_triggers.sql b/packages/server-core/src/projects/project-history/migrations/project-permission_triggers.sql
new file mode 100644
index 0000000000..c49ad4feaf
--- /dev/null
+++ b/packages/server-core/src/projects/project-history/migrations/project-permission_triggers.sql
@@ -0,0 +1,120 @@
+DROP PROCEDURE IF EXISTS `insert_project_permission_history`;
+CREATE PROCEDURE insert_project_permission_history (
+ IN projectId CHAR(36),
+ IN projectPermissionId CHAR(36),
+ IN permissionType VARCHAR(255),
+ IN givenTo CHAR(36),
+ IN updatedBy CHAR(36)
+)
+sp: BEGIN
+ DECLARE actionDetail VARCHAR(600);
+ DECLARE userName VARCHAR(255);
+
+ -- Find the user name based on the user ID
+ SELECT `name` INTO userName FROM `user` WHERE `id` = givenTo;
+
+ -- JSON object with userName and permissionType
+ SET actionDetail = JSON_OBJECT(
+ "userName", userName,
+ "userId", givenTo,
+ "permissionType", permissionType
+ );
+
+ -- Insert the action into the project-history table
+ INSERT INTO `project-history` (
+ `id`,
+ `projectId`,
+ `userId`,
+ `action`,
+ `actionIdentifier`,
+ `actionIdentifierType`,
+ `actionDetail`,
+ `createdAt`
+ ) VALUES (
+ UUID(),
+ projectId,
+ updatedBy,
+ 'PERMISSION_CREATED',
+ projectPermissionId,
+ 'project-permission',
+ actionDetail,
+ NOW()
+ );
+END;
+
+DROP TRIGGER IF EXISTS `after_project_permission_insert`;
+CREATE TRIGGER after_project_permission_insert
+AFTER INSERT ON `project-permission`
+FOR EACH ROW
+BEGIN
+ -- Call the stored procedure with the necessary parameters
+ CALL insert_project_permission_history(
+ NEW.projectId, -- projectId
+ NEW.id, -- projectPermissionId
+ NEW.type, -- permissionType
+ NEW.userId, -- givenTo
+ NEW.updatedBy -- updatedBy
+ );
+END;
+
+DROP PROCEDURE IF EXISTS `update_project_permission_history`;
+CREATE PROCEDURE update_project_permission_history (
+ IN projectId CHAR(36),
+ IN projectPermissionId CHAR(36),
+ IN oldPermissionType VARCHAR(255),
+ IN newPermissionType VARCHAR(255),
+ IN givenTo CHAR(36),
+ IN updatedBy CHAR(36)
+)
+sp: BEGIN
+ DECLARE actionDetail VARCHAR(600);
+ DECLARE userName VARCHAR(255);
+
+ -- Find the user name based on the user ID
+ SELECT `name` INTO userName FROM `user` WHERE `id` = givenTo;
+
+ -- JSON object with userName, oldPermissionType and newPermissionType
+ SET actionDetail = JSON_OBJECT(
+ "userName", userName,
+ "userId", givenTo,
+ "oldPermissionType", oldPermissionType,
+ "newPermissionType", newPermissionType
+ );
+
+ -- Insert the action into the project-history table
+ INSERT INTO `project-history` (
+ `id`,
+ `projectId`,
+ `userId`,
+ `action`,
+ `actionIdentifier`,
+ `actionIdentifierType`,
+ `actionDetail`,
+ `createdAt`
+ ) VALUES (
+ UUID(),
+ projectId,
+ updatedBy,
+ 'PERMISSION_MODIFIED',
+ projectPermissionId,
+ 'project-permission',
+ actionDetail,
+ NOW()
+ );
+END;
+
+DROP TRIGGER IF EXISTS `after_project_permission_update`;
+CREATE TRIGGER after_project_permission_update
+AFTER UPDATE ON `project-permission`
+FOR EACH ROW
+BEGIN
+ -- Call the stored procedure with the necessary parameters
+ CALL update_project_permission_history(
+ NEW.projectId, -- projectId
+ NEW.id, -- projectPermissionId
+ OLD.type, -- oldPermissionType
+ NEW.type, -- newPermissionType
+ NEW.userId, -- givenTo
+ NEW.updatedBy -- updatedBy
+ );
+END;
diff --git a/packages/server-core/src/projects/project-history/migrations/project-triggers.sql b/packages/server-core/src/projects/project-history/migrations/project-triggers.sql
new file mode 100644
index 0000000000..c2dc9df182
--- /dev/null
+++ b/packages/server-core/src/projects/project-history/migrations/project-triggers.sql
@@ -0,0 +1,46 @@
+DROP PROCEDURE IF EXISTS `insert_project_history`;
+CREATE PROCEDURE insert_project_history (
+ IN projectId CHAR(36),
+ IN projectName VARCHAR(255),
+ IN updatedBy CHAR(36)
+)
+sp: BEGIN
+ DECLARE actionDetail VARCHAR(600);
+
+ -- JSON object with project name, scene URL, and scene ID
+ SET actionDetail = JSON_OBJECT("projectName", projectName);
+
+ -- Insert the action into the project-history table
+ INSERT INTO `project-history` (
+ `id`,
+ `projectId`,
+ `userId`,
+ `action`,
+ `actionIdentifier`,
+ `actionIdentifierType`,
+ `actionDetail`,
+ `createdAt`
+ ) VALUES (
+ UUID(),
+ projectId,
+ updatedBy,
+ 'PROJECT_CREATED',
+ projectId,
+ 'project',
+ actionDetail,
+ NOW()
+ );
+END;
+
+DROP TRIGGER IF EXISTS `after_project_insert`;
+CREATE TRIGGER after_project_insert
+AFTER INSERT ON `project`
+FOR EACH ROW
+BEGIN
+ -- Call the stored procedure with the necessary parameters
+ CALL insert_project_history(
+ NEW.id, -- projectId
+ NEW.name, -- projectName
+ NEW.updatedBy -- updatedBy
+ );
+END;
diff --git a/packages/server-core/src/projects/project-history/migrations/static_resource_triggers.sql b/packages/server-core/src/projects/project-history/migrations/static_resource_triggers.sql
new file mode 100644
index 0000000000..ddcb4b6578
--- /dev/null
+++ b/packages/server-core/src/projects/project-history/migrations/static_resource_triggers.sql
@@ -0,0 +1,142 @@
+DROP PROCEDURE IF EXISTS `update_static_resource_history`;
+CREATE PROCEDURE update_static_resource_history (
+ IN projectName VARCHAR(255),
+ IN staticResourceId CHAR(36),
+ IN staticResourceType VARCHAR(255),
+ IN oldURL VARCHAR(255),
+ IN newURL VARCHAR(255),
+ IN updatedBy CHAR(36)
+)
+sp: BEGIN
+ DECLARE projectId CHAR(36);
+ DECLARE actionType VARCHAR(255);
+ DECLARE actionDetail VARCHAR(600);
+
+ -- Find the project ID based on the project name
+ SELECT `id` INTO projectId FROM `project` WHERE `name` = projectName;
+
+ -- Determine the action type based on static-resource type, oldURL and newURL
+ IF oldURL <> newURL THEN
+ IF staticResourceType = 'scene' THEN
+ SET actionType = 'SCENE_RENAMED';
+ ELSE
+ SET actionType = 'RESOURCE_RENAMED';
+ END IF;
+
+ -- JSON object with old and new URL
+ SET actionDetail = JSON_OBJECT(
+ "oldURL", oldURL,
+ "newURL", newURL
+ );
+
+ ELSE
+ IF staticResourceType = 'scene' THEN
+ SET actionType = 'SCENE_MODIFIED';
+ ELSE
+ SET actionType = 'RESOURCE_MODIFIED';
+ END IF;
+
+ SET actionDetail = JSON_OBJECT("url", newURL);
+ END IF;
+
+ -- Insert the action into the project-history table
+ INSERT INTO `project-history` (
+ `id`,
+ `projectId`,
+ `userId`,
+ `action`,
+ `actionIdentifier`,
+ `actionIdentifierType`,
+ `actionDetail`,
+ `createdAt`
+ ) VALUES (
+ UUID(),
+ projectId,
+ updatedBy,
+ actionType,
+ staticResourceId,
+ 'static-resource',
+ actionDetail,
+ NOW()
+ );
+END;
+
+DROP TRIGGER IF EXISTS `after_static_resource_update`;
+CREATE TRIGGER after_static_resource_update
+AFTER UPDATE ON `static-resource`
+FOR EACH ROW
+BEGIN
+ -- Call the stored procedure with the necessary parameters
+ CALL update_static_resource_history(
+ OLD.project, -- projectName
+ OLD.id, -- staticResourceId
+ OLD.type, -- staticResourceType
+ OLD.key, -- oldKey
+ NEW.key, -- newKey
+ NEW.updatedBy -- updatedBy
+ );
+END;
+
+
+DROP PROCEDURE IF EXISTS `insert_static_resource_history`;
+CREATE PROCEDURE insert_static_resource_history (
+ IN projectName VARCHAR(255),
+ IN staticResourceId CHAR(36),
+ IN staticResourceType VARCHAR(255),
+ IN url VARCHAR(255),
+ IN updatedBy CHAR(36)
+)
+sp: BEGIN
+ DECLARE projectId CHAR(36);
+ DECLARE actionType VARCHAR(255);
+ DECLARE actionDetail VARCHAR(600);
+
+ -- Find the project ID based on the project name
+ SELECT `id` INTO projectId FROM `project` WHERE `name` = projectName;
+
+ -- Determine the action type based on static-resource type
+ IF staticResourceType = 'scene' THEN
+ SET actionType = 'SCENE_CREATED';
+ ELSE
+ SET actionType = 'RESOURCE_CREATED';
+ END IF;
+
+ -- JSON object with URL
+ SET actionDetail = JSON_OBJECT("url", url);
+
+ -- Insert the action into the project-history table
+ INSERT INTO `project-history` (
+ `id`,
+ `projectId`,
+ `userId`,
+ `action`,
+ `actionIdentifier`,
+ `actionIdentifierType`,
+ `actionDetail`,
+ `createdAt`
+ ) VALUES (
+ UUID(),
+ projectId,
+ updatedBy,
+ actionType,
+ staticResourceId,
+ 'static-resource',
+ actionDetail,
+ NOW()
+ );
+END;
+
+DROP TRIGGER IF EXISTS `after_static_resource_insert`;
+CREATE TRIGGER after_static_resource_insert
+AFTER INSERT ON `static-resource`
+FOR EACH ROW
+BEGIN
+ -- Call the stored procedure with the necessary parameters
+ CALL insert_static_resource_history(
+ NEW.project, -- projectName
+ NEW.id, -- staticResourceId
+ NEW.type, -- staticResourceType
+ NEW.key, -- url
+ NEW.updatedBy -- updatedBy
+ );
+END;
diff --git a/packages/server-core/src/projects/project-history/project-history.class.ts b/packages/server-core/src/projects/project-history/project-history.class.ts
new file mode 100644
index 0000000000..c8e54e20a6
--- /dev/null
+++ b/packages/server-core/src/projects/project-history/project-history.class.ts
@@ -0,0 +1,43 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+import type { Params } from '@feathersjs/feathers'
+import { KnexAdapterParams, KnexService } from '@feathersjs/knex'
+
+import {
+ ProjectHistoryData,
+ ProjectHistoryQuery,
+ ProjectHistoryType
+} from '@etherealengine/common/src/schemas/projects/project-history.schema'
+import { Application } from '@etherealengine/server-core/declarations'
+
+export interface ProjectHistoryParams extends KnexAdapterParams {}
+
+export class ProjectHistoryService<
+ T = ProjectHistoryType,
+ ServiceParams extends Params = ProjectHistoryParams
+> extends KnexService {
+ app: Application
+}
diff --git a/packages/server-core/src/projects/project-history/project-history.docs.ts b/packages/server-core/src/projects/project-history/project-history.docs.ts
new file mode 100644
index 0000000000..a4066a59a5
--- /dev/null
+++ b/packages/server-core/src/projects/project-history/project-history.docs.ts
@@ -0,0 +1,44 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+import { createSwaggerServiceOptions } from 'feathers-swagger'
+
+import {
+ projectHistoryDataSchema,
+ projectHistoryQuerySchema,
+ projectHistorySchema
+} from '@etherealengine/common/src/schemas/projects/project-history.schema'
+
+export default createSwaggerServiceOptions({
+ schemas: {
+ projectHistoryDataSchema,
+ projectHistoryQuerySchema,
+ projectHistorySchema
+ },
+ docs: {
+ description: 'Project History service description',
+ securities: ['all']
+ }
+})
diff --git a/packages/server-core/src/projects/project-history/project-history.hooks.ts b/packages/server-core/src/projects/project-history/project-history.hooks.ts
new file mode 100644
index 0000000000..f2e55f5542
--- /dev/null
+++ b/packages/server-core/src/projects/project-history/project-history.hooks.ts
@@ -0,0 +1,163 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+import { hooks as schemaHooks } from '@feathersjs/schema'
+import { disallow, iff, iffElse, isProvider } from 'feathers-hooks-common'
+
+import {
+ projectHistoryDataValidator,
+ projectHistoryQueryValidator
+} from '@etherealengine/common/src/schemas/projects/project-history.schema'
+
+import {
+ projectHistoryDataResolver,
+ projectHistoryExternalResolver,
+ projectHistoryQueryResolver,
+ projectHistoryResolver
+} from './project-history.resolvers'
+
+import {
+ AvatarID,
+ avatarPath,
+ AvatarType,
+ userAvatarPath,
+ UserID,
+ userPath
+} from '@etherealengine/common/src/schema.type.module'
+import { HookContext } from '../../../declarations'
+import checkScope from '../../hooks/check-scope'
+import verifyProjectPermission from '../../hooks/verify-project-permission'
+import { ProjectHistoryService } from './project-history.class'
+
+const populateUsernameAndAvatar = async (context: HookContext) => {
+ if (!context.result) return
+ const data = context.result
+ const dataArr = data ? (Array.isArray(data) ? data : 'data' in data ? data.data : [data]) : []
+
+ const userIds: UserID[] = []
+
+ for (const data of dataArr) {
+ const { userId } = data
+ if (userId) userIds.push(userId)
+ }
+ const uniqueUsers = [...new Set(userIds)]
+ const nonNullUsers = uniqueUsers.filter((userId) => !!userId)
+
+ const users = await context.app.service(userPath).find({
+ query: {
+ id: {
+ $in: nonNullUsers
+ }
+ },
+ paginate: false
+ })
+
+ const userAvatars = await context.app.service(userAvatarPath).find({
+ query: {
+ userId: {
+ $in: nonNullUsers
+ }
+ },
+ paginate: false
+ })
+
+ const uniqueUserAvatarIds = [...new Set(userAvatars.map((avatar) => avatar.avatarId))]
+ const avatars = await context.app.service(avatarPath).find({
+ query: {
+ id: {
+ $in: uniqueUserAvatarIds
+ }
+ },
+ paginate: false
+ })
+
+ const avatarIdAvatarMap = {} as Record
+ for (const avatar of avatars) {
+ avatarIdAvatarMap[avatar.id] = avatar
+ }
+
+ const userIdAvatarIdMap = {} as Record
+ for (const userAvatar of userAvatars) {
+ userIdAvatarIdMap[userAvatar.userId] = avatarIdAvatarMap[userAvatar.avatarId]
+ }
+
+ const usersInfo = {} as Record
+ for (const user of users) {
+ usersInfo[user.id] = {
+ userName: user.name,
+ userAvatarURL: userIdAvatarIdMap[user.id].thumbnailResource?.url || ''
+ }
+ }
+
+ context.userInfo = usersInfo
+}
+
+export default {
+ around: {
+ all: [
+ schemaHooks.resolveResult(projectHistoryResolver),
+ schemaHooks.resolveExternal(projectHistoryExternalResolver)
+ ]
+ },
+
+ before: {
+ all: [
+ schemaHooks.validateQuery(projectHistoryQueryValidator),
+ schemaHooks.resolveQuery(projectHistoryQueryResolver)
+ ],
+ find: [
+ iff(isProvider('external'), iffElse(checkScope('projects', 'read'), [], verifyProjectPermission(['owner'])))
+ ],
+ get: [disallow('external')],
+ create: [
+ disallow('external'),
+ schemaHooks.validateData(projectHistoryDataValidator),
+ schemaHooks.resolveData(projectHistoryDataResolver)
+ ],
+ patch: [disallow('external')],
+ update: [disallow('external')],
+ remove: [disallow('external')]
+ },
+
+ after: {
+ all: [],
+ find: [populateUsernameAndAvatar],
+ get: [],
+ create: [],
+ update: [],
+ patch: [],
+ remove: []
+ },
+
+ error: {
+ all: [],
+ find: [],
+ get: [],
+ create: [],
+ update: [],
+ patch: [],
+ remove: []
+ }
+} as any
diff --git a/packages/server-core/src/projects/project-history/project-history.resolvers.ts b/packages/server-core/src/projects/project-history/project-history.resolvers.ts
new file mode 100644
index 0000000000..76347e486d
--- /dev/null
+++ b/packages/server-core/src/projects/project-history/project-history.resolvers.ts
@@ -0,0 +1,81 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+// For more information about this file see https://dove.feathersjs.com/guides/cli/service.schemas.html
+import { resolve, virtual } from '@feathersjs/schema'
+import { v4 } from 'uuid'
+
+import {
+ ProjectHistoryQuery,
+ ProjectHistoryType
+} from '@etherealengine/common/src/schemas/projects/project-history.schema'
+import { fromDateTimeSql, getDateTimeSql } from '@etherealengine/common/src/utils/datetime-sql'
+import type { HookContext } from '@etherealengine/server-core/declarations'
+
+export const projectHistoryResolver = resolve({
+ createdAt: virtual(async (projectHistory) => fromDateTimeSql(projectHistory.createdAt))
+})
+
+export const projectHistoryDataResolver = resolve({
+ id: async () => {
+ return v4()
+ },
+ createdAt: getDateTimeSql
+})
+
+const getUserNameAndAvatarURL = (projectHistory: ProjectHistoryType, context: HookContext) => {
+ if (context.method !== 'find') {
+ return {
+ userName: '',
+ userAvatarURL: ''
+ }
+ }
+
+ if (!projectHistory.userId) {
+ return {
+ userName: 'Admin',
+ userAvatarURL: ''
+ }
+ }
+
+ const userInfo = context.userInfo[projectHistory.userId] as {
+ userName: string
+ userAvatarURL: string
+ }
+
+ return userInfo
+}
+
+export const projectHistoryExternalResolver = resolve({
+ userName: virtual(async (projectHistory, context) => {
+ return getUserNameAndAvatarURL(projectHistory, context).userName
+ }),
+
+ userAvatarURL: virtual(async (projectHistory, context) => {
+ return getUserNameAndAvatarURL(projectHistory, context).userAvatarURL
+ })
+})
+
+export const projectHistoryQueryResolver = resolve({})
diff --git a/packages/server-core/src/projects/project-history/project-history.ts b/packages/server-core/src/projects/project-history/project-history.ts
new file mode 100644
index 0000000000..1f6122963a
--- /dev/null
+++ b/packages/server-core/src/projects/project-history/project-history.ts
@@ -0,0 +1,59 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+import {
+ projectHistoryMethods,
+ projectHistoryPath
+} from '@etherealengine/common/src/schemas/projects/project-history.schema'
+import { Application } from '@etherealengine/server-core/declarations'
+import { ProjectHistoryService } from './project-history.class'
+import projectHistoryDocs from './project-history.docs'
+import hooks from './project-history.hooks'
+
+declare module '@etherealengine/common/declarations' {
+ interface ServiceTypes {
+ [projectHistoryPath]: ProjectHistoryService
+ }
+}
+
+export default (app: Application): void => {
+ const options = {
+ name: projectHistoryPath,
+ paginate: app.get('paginate'),
+ Model: app.get('knexClient'),
+ multi: true
+ }
+
+ app.use(projectHistoryPath, new ProjectHistoryService(options), {
+ // A list of all methods this service exposes externally
+ methods: projectHistoryMethods,
+ // You can add additional custom events to be sent to clients here
+ events: [],
+ docs: projectHistoryDocs
+ })
+
+ const service = app.service(projectHistoryPath)
+ service.hooks(hooks)
+}
diff --git a/packages/server-core/src/projects/project-permission/migrations/20240806192034_updatedBy.ts b/packages/server-core/src/projects/project-permission/migrations/20240806192034_updatedBy.ts
new file mode 100644
index 0000000000..c0baa281b7
--- /dev/null
+++ b/packages/server-core/src/projects/project-permission/migrations/20240806192034_updatedBy.ts
@@ -0,0 +1,58 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+import { projectPermissionPath } from '@etherealengine/common/src/schema.type.module'
+import type { Knex } from 'knex'
+
+export async function up(knex: Knex): Promise {
+ await knex.raw('SET FOREIGN_KEY_CHECKS=0')
+
+ const updatedByColumnExists = await knex.schema.hasColumn(projectPermissionPath, 'updatedBy')
+ if (!updatedByColumnExists) {
+ await knex.schema.alterTable(projectPermissionPath, async (table) => {
+ //@ts-ignore
+ table.uuid('updatedBy', 36).collate('utf8mb4_bin')
+
+ // Foreign keys
+ table.foreign('updatedBy').references('id').inTable('user').onDelete('SET NULL').onUpdate('CASCADE')
+ })
+ }
+
+ await knex.raw('SET FOREIGN_KEY_CHECKS=1')
+}
+
+export async function down(knex: Knex): Promise {
+ await knex.raw('SET FOREIGN_KEY_CHECKS=0')
+
+ const updatedByColumnExists = await knex.schema.hasColumn(projectPermissionPath, 'updatedBy')
+ if (updatedByColumnExists) {
+ await knex.schema.alterTable(projectPermissionPath, async (table) => {
+ table.dropForeign('updatedBy')
+ table.dropColumn('updatedBy')
+ })
+ }
+
+ await knex.raw('SET FOREIGN_KEY_CHECKS=1')
+}
diff --git a/packages/server-core/src/projects/project-permission/project-permission.hooks.ts b/packages/server-core/src/projects/project-permission/project-permission.hooks.ts
index 1f3ef07d72..5543512c33 100644
--- a/packages/server-core/src/projects/project-permission/project-permission.hooks.ts
+++ b/packages/server-core/src/projects/project-permission/project-permission.hooks.ts
@@ -42,6 +42,7 @@ import { Paginated } from '@feathersjs/feathers'
import { hooks as schemaHooks } from '@feathersjs/schema'
import { disallow, discardQuery, iff, iffElse, isProvider } from 'feathers-hooks-common'
+import { projectHistoryPath } from '@etherealengine/common/src/schema.type.module'
import { HookContext } from '../../../declarations'
import logger from '../../ServerLogger'
import checkScopeHook from '../../hooks/check-scope'
@@ -224,6 +225,30 @@ const resolvePermissionId = async (context: HookContext) => {
+ try {
+ const resource = context.result as ProjectPermissionType
+
+ const givenTo = resource.userId
+ const user = await context.app.service(userPath).get(givenTo)
+
+ await context.app.service(projectHistoryPath).create({
+ projectId: resource.projectId,
+ userId: context.params.user?.id || null,
+ action: 'PERMISSION_REMOVED',
+ actionIdentifier: resource.id,
+ actionIdentifierType: 'project-permission',
+ actionDetail: JSON.stringify({
+ userName: user.name,
+ userId: givenTo,
+ permissionType: resource.type
+ })
+ })
+ } catch (error) {
+ console.error('Error in adding delete log: ', error)
+ }
+}
+
export default {
around: {
all: [
@@ -280,7 +305,7 @@ export default {
create: [],
update: [],
patch: [makeRandomProjectOwner],
- remove: [makeRandomProjectOwner]
+ remove: [makeRandomProjectOwner, addDeleteLog]
},
error: {
diff --git a/packages/server-core/src/projects/project-permission/project-permission.resolvers.ts b/packages/server-core/src/projects/project-permission/project-permission.resolvers.ts
index 4ffb34a00f..53382196c1 100644
--- a/packages/server-core/src/projects/project-permission/project-permission.resolvers.ts
+++ b/packages/server-core/src/projects/project-permission/project-permission.resolvers.ts
@@ -50,10 +50,16 @@ export const projectPermissionDataResolver = resolve {
+ return context.params?.user?.id || null
+ },
updatedAt: getDateTimeSql
})
export const projectPermissionPatchResolver = resolve({
+ updatedBy: async (_, __, context) => {
+ return context.params?.user?.id || null
+ },
updatedAt: getDateTimeSql
})
diff --git a/packages/server-core/src/projects/project/migrations/20240806170758_updatedBy.ts b/packages/server-core/src/projects/project/migrations/20240806170758_updatedBy.ts
new file mode 100644
index 0000000000..c45c6ea9b9
--- /dev/null
+++ b/packages/server-core/src/projects/project/migrations/20240806170758_updatedBy.ts
@@ -0,0 +1,58 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+import { projectPath } from '@etherealengine/common/src/schema.type.module'
+import type { Knex } from 'knex'
+
+export async function up(knex: Knex): Promise {
+ await knex.raw('SET FOREIGN_KEY_CHECKS=0')
+
+ const updatedByColumnExists = await knex.schema.hasColumn(projectPath, 'updatedBy')
+ if (!updatedByColumnExists) {
+ await knex.schema.alterTable(projectPath, async (table) => {
+ //@ts-ignore
+ table.uuid('updatedBy', 36).collate('utf8mb4_bin')
+
+ // Foreign keys
+ table.foreign('updatedBy').references('id').inTable('user').onDelete('SET NULL').onUpdate('CASCADE')
+ })
+ }
+
+ await knex.raw('SET FOREIGN_KEY_CHECKS=1')
+}
+
+export async function down(knex: Knex): Promise {
+ await knex.raw('SET FOREIGN_KEY_CHECKS=0')
+
+ const updatedByColumnExists = await knex.schema.hasColumn(projectPath, 'updatedBy')
+ if (updatedByColumnExists) {
+ await knex.schema.alterTable(projectPath, async (table) => {
+ table.dropForeign('updatedBy')
+ table.dropColumn('updatedBy')
+ })
+ }
+
+ await knex.raw('SET FOREIGN_KEY_CHECKS=1')
+}
diff --git a/packages/server-core/src/projects/project/project.resolvers.ts b/packages/server-core/src/projects/project/project.resolvers.ts
index f81347c038..331e82904a 100644
--- a/packages/server-core/src/projects/project/project.resolvers.ts
+++ b/packages/server-core/src/projects/project/project.resolvers.ts
@@ -85,10 +85,16 @@ export const projectDataResolver = resolve({
return uuidv4()
},
createdAt: getDateTimeSql,
+ updatedBy: async (_, __, context) => {
+ return context.params?.user?.id || null
+ },
updatedAt: getDateTimeSql
})
export const projectPatchResolver = resolve({
+ updatedBy: async (_, __, context) => {
+ return context.params?.user?.id || null
+ },
updatedAt: getDateTimeSql
})
diff --git a/packages/server-core/src/projects/services.ts b/packages/server-core/src/projects/services.ts
index 6c7758bb18..77da365576 100755
--- a/packages/server-core/src/projects/services.ts
+++ b/packages/server-core/src/projects/services.ts
@@ -32,6 +32,7 @@ import ProjectCheckUnfetchedCommit from './project-check-unfetched-commit/projec
import ProjectCommits from './project-commits/project-commits'
import ProjectDestinationCheck from './project-destination-check/project-destination-check'
import ProjectGithubPush from './project-github-push/project-github-push'
+import ProjectHistory from './project-history/project-history'
import ProjectInvalidate from './project-invalidate/project-invalidate'
import ProjectPermission from './project-permission/project-permission'
import Project from './project/project'
@@ -50,5 +51,6 @@ export default [
ProjectCommits,
ProjectDestinationCheck,
ProjectCheckUnfetchedCommit,
- ProjectCheckSourceDestinationMatch
+ ProjectCheckSourceDestinationMatch,
+ ProjectHistory
]
diff --git a/packages/server-core/src/social/location/location.hooks.ts b/packages/server-core/src/social/location/location.hooks.ts
index f31bb09523..3c0ea4c293 100755
--- a/packages/server-core/src/social/location/location.hooks.ts
+++ b/packages/server-core/src/social/location/location.hooks.ts
@@ -42,6 +42,7 @@ import {
import { UserID } from '@etherealengine/common/src/schemas/user/user.schema'
import verifyScope from '@etherealengine/server-core/src/hooks/verify-scope'
+import { projectHistoryPath, staticResourcePath } from '@etherealengine/common/src/schema.type.module'
import { HookContext } from '../../../declarations'
import checkScope from '../../hooks/check-scope'
import disallowNonId from '../../hooks/disallow-non-id'
@@ -189,6 +190,27 @@ const removeLocationAdmin = async (context: HookContext) => {
}
}
+const addDeleteLog = async (context: HookContext) => {
+ try {
+ const resource = context.result as LocationType
+ const scene = await context.app.service(staticResourcePath).get(resource.sceneId)
+ await context.app.service(projectHistoryPath).create({
+ projectId: resource.projectId,
+ userId: context.params.user?.id || null,
+ action: 'LOCATION_UNPUBLISHED',
+ actionIdentifier: resource.id,
+ actionIdentifierType: 'location',
+ actionDetail: JSON.stringify({
+ locationName: resource.slugifiedName,
+ sceneURL: scene.key,
+ sceneId: resource.sceneId
+ })
+ })
+ } catch (error) {
+ console.error('Error in adding delete log: ', error)
+ }
+}
+
/* ERROR HOOKS */
const duplicateNameError = async (context: HookContext) => {
@@ -263,7 +285,7 @@ export default {
create: [makeLobbies, createLocationSetting, createAuthorizedLocation],
update: [],
patch: [makeLobbies, patchLocationSetting],
- remove: []
+ remove: [addDeleteLog]
},
error: {
diff --git a/packages/server-core/src/social/location/location.resolvers.ts b/packages/server-core/src/social/location/location.resolvers.ts
index 7d4670aead..1f337abff1 100644
--- a/packages/server-core/src/social/location/location.resolvers.ts
+++ b/packages/server-core/src/social/location/location.resolvers.ts
@@ -123,6 +123,9 @@ export const locationDataResolver = resolve({
updatedAt: await getDateTimeSql()
}
},
+ updatedBy: async (_, __, context) => {
+ return context.params?.user?.id || null
+ },
createdAt: getDateTimeSql,
updatedAt: getDateTimeSql
})
@@ -131,6 +134,9 @@ export const locationPatchResolver = resolve({
slugifiedName: async (value, location) => {
if (location.name) return slugify(location.name, { lower: true })
},
+ updatedBy: async (_, __, context) => {
+ return context.params?.user?.id || null
+ },
updatedAt: getDateTimeSql
})
diff --git a/packages/server-core/src/social/location/migrations/20240806175038_updatedBy.ts b/packages/server-core/src/social/location/migrations/20240806175038_updatedBy.ts
new file mode 100644
index 0000000000..31ee77f6d5
--- /dev/null
+++ b/packages/server-core/src/social/location/migrations/20240806175038_updatedBy.ts
@@ -0,0 +1,58 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+import { locationPath } from '@etherealengine/common/src/schema.type.module'
+import type { Knex } from 'knex'
+
+export async function up(knex: Knex): Promise {
+ await knex.raw('SET FOREIGN_KEY_CHECKS=0')
+
+ const updatedByColumnExists = await knex.schema.hasColumn(locationPath, 'updatedBy')
+ if (!updatedByColumnExists) {
+ await knex.schema.alterTable(locationPath, async (table) => {
+ //@ts-ignore
+ table.uuid('updatedBy', 36).collate('utf8mb4_bin')
+
+ // Foreign keys
+ table.foreign('updatedBy').references('id').inTable('user').onDelete('SET NULL').onUpdate('CASCADE')
+ })
+ }
+
+ await knex.raw('SET FOREIGN_KEY_CHECKS=1')
+}
+
+export async function down(knex: Knex): Promise {
+ await knex.raw('SET FOREIGN_KEY_CHECKS=0')
+
+ const updatedByColumnExists = await knex.schema.hasColumn(locationPath, 'updatedBy')
+ if (updatedByColumnExists) {
+ await knex.schema.alterTable(locationPath, async (table) => {
+ table.dropForeign('updatedBy')
+ table.dropColumn('updatedBy')
+ })
+ }
+
+ await knex.raw('SET FOREIGN_KEY_CHECKS=1')
+}
diff --git a/packages/ui/src/components/editor/panels/Files/container/index.tsx b/packages/ui/src/components/editor/panels/Files/container/index.tsx
index 3d34986aca..b851062aad 100644
--- a/packages/ui/src/components/editor/panels/Files/container/index.tsx
+++ b/packages/ui/src/components/editor/panels/Files/container/index.tsx
@@ -140,6 +140,7 @@ export const createStaticResourceDigest = (staticResources: ImmutableArray
name?: string
}
-const AvatarPlaceholder = ({ className, name }: { className: string; name: string }) => (
-
- {name[0] ? name[0] : 'U'}
+const AvatarPlaceholder = ({ className, label }: { className: string; label: string }) => (
+
+ {label}
)
const AvatarImage = ({ src, size = 'medium', className, name }: AvatarImageProps) => {
const imageLoaded = useHookstate(true)
const twClassName = twMerge(`${sizes[size]}`, className)
+ const label = name
+ ? name
+ .split(' ')
+ .map((s) => s[0])
+ .join('')
+ .slice(0, 2)
+ .toUpperCase()
+ : 'U'
return imageLoaded.value ? (
imageLoaded.set(false)}
/>
) : (
-
+
)
}
diff --git a/packages/ui/src/primitives/tailwind/Text/index.tsx b/packages/ui/src/primitives/tailwind/Text/index.tsx
index e38004c61a..e7dd423438 100644
--- a/packages/ui/src/primitives/tailwind/Text/index.tsx
+++ b/packages/ui/src/primitives/tailwind/Text/index.tsx
@@ -34,15 +34,17 @@ const componentTypes = {
h5: (props: React.HTMLAttributes
) => ,
h6: (props: React.HTMLAttributes) => ,
p: (props: React.HTMLAttributes) =>
,
- span: (props: React.HTMLAttributes) =>
+ span: (props: React.HTMLAttributes) => ,
+ a: (props: React.HTMLAttributes) =>
}
export interface TextProps extends React.HTMLAttributes {
fontSize?: 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl'
fontWeight?: 'light' | 'normal' | 'semibold' | 'medium' | 'bold'
- component?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span'
+ component?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span' | 'a'
className?: string
theme?: 'primary' | 'secondary'
+ href?: string
}
const Text = ({
From d89646f8324c43d8a7465ead03152d5199008e90 Mon Sep 17 00:00:00 2001
From: Moiz Adnan <67912355+MoizAdnan@users.noreply.github.com>
Date: Tue, 13 Aug 2024 13:03:17 +0500
Subject: [PATCH 06/29] IR-3700: Added search by sso/email in user table
(#10929)
* chore: Update search functionality in UserTable component
* Added persistQuery in hook
* Optimized query
---------
Co-authored-by: Hanzla Mateen
---
.../src/admin/components/user/UserTable.tsx | 13 +------------
.../src/schemas/user/identity-provider.schema.ts | 3 +++
packages/server-core/src/user/user/user.hooks.ts | 16 +++++++++++++---
3 files changed, 17 insertions(+), 15 deletions(-)
diff --git a/packages/client-core/src/admin/components/user/UserTable.tsx b/packages/client-core/src/admin/components/user/UserTable.tsx
index f3587a219e..d73dd665d0 100644
--- a/packages/client-core/src/admin/components/user/UserTable.tsx
+++ b/packages/client-core/src/admin/components/user/UserTable.tsx
@@ -87,18 +87,7 @@ export default function UserTable({
useSearch(
adminUserQuery,
{
- $or: [
- {
- id: {
- $like: `%${search}%`
- }
- },
- {
- name: {
- $like: `%${search}%`
- }
- }
- ]
+ search
},
search
)
diff --git a/packages/common/src/schemas/user/identity-provider.schema.ts b/packages/common/src/schemas/user/identity-provider.schema.ts
index 5405906124..80943c49be 100644
--- a/packages/common/src/schemas/user/identity-provider.schema.ts
+++ b/packages/common/src/schemas/user/identity-provider.schema.ts
@@ -106,6 +106,9 @@ export const identityProviderQuerySchema = Type.Intersect(
querySyntax(identityProviderQueryProperties, {
accountIdentifier: {
$like: Type.String()
+ },
+ email: {
+ $like: Type.String()
}
}),
// Add additional query properties here
diff --git a/packages/server-core/src/user/user/user.hooks.ts b/packages/server-core/src/user/user/user.hooks.ts
index 93529db683..1f17feabd3 100755
--- a/packages/server-core/src/user/user/user.hooks.ts
+++ b/packages/server-core/src/user/user/user.hooks.ts
@@ -284,9 +284,19 @@ const handleUserSearch = async (context: HookContext) => {
const searchedIdentityProviders = (await context.app.service(identityProviderPath).find({
query: {
- accountIdentifier: {
- $like: `%${search}%`
- }
+ $select: ['id', 'userId'],
+ $or: [
+ {
+ accountIdentifier: {
+ $like: `%${search}%`
+ }
+ },
+ {
+ email: {
+ $like: `%${search}%`
+ }
+ }
+ ]
},
paginate: false
})) as IdentityProviderType[]
From 8161d72f8242816091fb3d108cc48a6e17f8e762 Mon Sep 17 00:00:00 2001
From: Hurairah Mateen
Date: Tue, 13 Aug 2024 14:34:39 +0400
Subject: [PATCH 07/29] IR-2873 Fix disabled state for select dropdown arrow
(#10950)
* Fix disabled state for select dropdown arrow
* Updated disabled icon color
---------
Co-authored-by: Hanzla Mateen
---
packages/ui/src/primitives/tailwind/Select/index.tsx | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/packages/ui/src/primitives/tailwind/Select/index.tsx b/packages/ui/src/primitives/tailwind/Select/index.tsx
index 2dc53d8288..63fa92fe68 100644
--- a/packages/ui/src/primitives/tailwind/Select/index.tsx
+++ b/packages/ui/src/primitives/tailwind/Select/index.tsx
@@ -157,8 +157,14 @@ const Select = ({
endComponent={
{
+ if (!disabled) {
+ toggleDropdown()
+ }
+ }}
/>
}
containerClassname={inputContainerClassName}
From 5e3ed4f177330952324e274ced2e70e736b0ac04 Mon Sep 17 00:00:00 2001
From: Kyle Baran
Date: Tue, 13 Aug 2024 03:44:03 -0700
Subject: [PATCH 08/29] Fixed a bug with project PUTs without commit SHAs
(#10942)
If a project PUT had a sourceBranch but no commit SHA, it was
not checking out anything and staying with the default branch's
tip. Now a PUT with a sourceBranch and no SHA checks out that
branch. Specifying a commit will always checkout that commit.
Resolves IR-3820
---
packages/server-core/src/projects/project/project-helper.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/server-core/src/projects/project/project-helper.ts b/packages/server-core/src/projects/project/project-helper.ts
index c8a7e42c0d..887ddbdd04 100644
--- a/packages/server-core/src/projects/project/project-helper.ts
+++ b/packages/server-core/src/projects/project/project-helper.ts
@@ -1452,6 +1452,7 @@ export const updateProject = async (
try {
const branchExists = await git.raw(['ls-remote', '--heads', repoPath, `${branchName}`])
if (data.commitSHA) await git.checkout(data.commitSHA)
+ else if (data.sourceBranch) await git.checkout(data.sourceBranch)
if (branchExists.length === 0 || data.reset) {
try {
await git.deleteLocalBranch(branchName)
From 8cf614bca86c7065d56698a75497a46a6a0213d6 Mon Sep 17 00:00:00 2001
From: Andy Chen <44120813+achen5671@users.noreply.github.com>
Date: Tue, 13 Aug 2024 06:51:03 -0400
Subject: [PATCH 09/29] studio: open context menu on file menu right click
(#10908)
* open context menu on file menu right click
* remove console log
* set clickable height to full
---------
Co-authored-by: Rahul Ghosh
Co-authored-by: aditya-mitra <55396651+aditya-mitra@users.noreply.github.com>
---
.../editor/panels/Files/container/index.tsx | 231 +++++++++++-------
1 file changed, 138 insertions(+), 93 deletions(-)
diff --git a/packages/ui/src/components/editor/panels/Files/container/index.tsx b/packages/ui/src/components/editor/panels/Files/container/index.tsx
index b851062aad..feaca8373a 100644
--- a/packages/ui/src/components/editor/panels/Files/container/index.tsx
+++ b/packages/ui/src/components/editor/panels/Files/container/index.tsx
@@ -79,6 +79,7 @@ import Input from '../../../../../primitives/tailwind/Input'
import LoadingView from '../../../../../primitives/tailwind/LoadingView'
import Slider from '../../../../../primitives/tailwind/Slider'
import Tooltip from '../../../../../primitives/tailwind/Tooltip'
+import { ContextMenu } from '../../../../tailwind/ContextMenu'
import { Popup } from '../../../../tailwind/Popup'
import BooleanInput from '../../../input/Boolean'
import InputGroup from '../../../input/Group'
@@ -543,111 +544,155 @@ const FileBrowserContentPanel: React.FC = (props)
ClickPlacementState.resetSelectedAsset()
}
+ const [anchorEvent, setAnchorEvent] = React.useState>(undefined)
+ const handleClose = () => {
+ setAnchorEvent(undefined)
+ }
+
+ const pasteContent = async () => {
+ handleClose()
+ if (isLoading) return
+
+ fileService.update(null, {
+ oldProject: projectName,
+ newProject: projectName,
+ oldName: currentContentRef.current.item.fullName,
+ newName: currentContentRef.current.item.fullName,
+ oldPath: currentContentRef.current.item.path,
+ newPath: currentContentRef.current.item.path,
+ isCopy: currentContentRef.current.isCopy
+ })
+ }
+
return (
{
+ className="h-full"
+ onContextMenu={(event) => {
+ event.preventDefault()
event.stopPropagation()
- resetSelection()
+ setAnchorEvent(event)
}}
>
-
-
- <>
- {unique(files, (file) => file.key).map((file) => (
- {
- handleFileBrowserItemClick(event, file)
- onSelect(event, file)
- }}
- onContextMenu={(event, currentFile) => {
- if (!fileProperties.value.length) {
- fileProperties.set([file])
- }
- }}
- currentContent={currentContentRef}
- handleDropItemsOnPanel={(data, dropOn) =>
- dropItemsOnPanel(
- data,
- dropOn,
- fileProperties.value.map((file) => file.key)
- )
- }
- openFileProperties={(item) => {
- /** If the file is not in the list of files, add it */
- if (!(fileProperties.get(NO_PROXY) as FileDataType[]).includes(item)) {
- if (fileProperties.value.length > 1) {
- fileProperties.merge([item])
- } else {
- fileProperties.set([item])
+ {
+ event.stopPropagation()
+ resetSelection()
+ }}
+ >
+
+
+ <>
+ {unique(files, (file) => file.key).map((file) => (
+ {
+ handleFileBrowserItemClick(event, file)
+ onSelect(event, file)
+ }}
+ onContextMenu={(event, currentFile) => {
+ if (!fileProperties.value.length) {
+ fileProperties.set([file])
}
+ }}
+ currentContent={currentContentRef}
+ handleDropItemsOnPanel={(data, dropOn) =>
+ dropItemsOnPanel(
+ data,
+ dropOn,
+ fileProperties.value.map((file) => file.key)
+ )
}
- PopoverState.showPopupover(
-
- )
- }}
- openDeleteFileModal={() => {
- PopoverState.showPopupover(
- {
- resetSelection()
- }}
- />
- )
- }}
- openImageCompress={() => {
- if (filesConsistOfContentType(fileProperties.value, 'image')) {
+ openFileProperties={(item) => {
+ /** If the file is not in the list of files, add it */
+ if (!(fileProperties.get(NO_PROXY) as FileDataType[]).includes(item)) {
+ if (fileProperties.value.length > 1) {
+ fileProperties.merge([item])
+ } else {
+ fileProperties.set([item])
+ }
+ }
PopoverState.showPopupover(
-
+
)
- }
- }}
- openModelCompress={() => {
- if (filesConsistOfContentType(fileProperties.value, 'model')) {
+ }}
+ openDeleteFileModal={() => {
PopoverState.showPopupover(
- {
+ resetSelection()
+ }}
/>
)
- }
- }}
- isFilesLoading={isLoading}
- addFolder={createNewFolder}
- isListView={isListView}
- staticResourceModifiedDates={staticResourceModifiedDates.value}
- isSelected={fileProperties.value.some(({ key }) => key === file.key)}
- refreshDirectory={refreshDirectory}
- selectedFileKeys={fileProperties.value.map((file) => file.key)}
- />
- ))}
- >
-
- {/*
- {total > 0 && validFiles.value.length < total && (
-
- )}*/}
+ }}
+ openImageCompress={() => {
+ if (filesConsistOfContentType(fileProperties.value, 'image')) {
+ PopoverState.showPopupover(
+
+ )
+ }
+ }}
+ openModelCompress={() => {
+ if (filesConsistOfContentType(fileProperties.value, 'model')) {
+ PopoverState.showPopupover(
+
+ )
+ }
+ }}
+ isFilesLoading={isLoading}
+ addFolder={createNewFolder}
+ isListView={isListView}
+ staticResourceModifiedDates={staticResourceModifiedDates.value}
+ isSelected={fileProperties.value.some(({ key }) => key === file.key)}
+ refreshDirectory={refreshDirectory}
+ selectedFileKeys={fileProperties.value.map((file) => file.key)}
+ />
+ ))}
+ >
+
+ {/*
+ {total > 0 && validFiles.value.length < total && (
+
+ )}*/}
+
+
+
+ createNewFolder()}>
+ {t('editor:layout.filebrowser.addNewFolder')}
+
+
+ {t('editor:layout.filebrowser.pasteAsset')}
+
+
)
From a3ed347b97089b58f81692e23a1d213a2d4a03a6 Mon Sep 17 00:00:00 2001
From: Jeremy Sachs
Date: Tue, 13 Aug 2024 03:53:31 -0700
Subject: [PATCH 10/29] Left aligning the text in the "Add Component" and "Add
Entity" menus (#10946)
---
.../editor/panels/Properties/elementList/index.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/packages/ui/src/components/editor/panels/Properties/elementList/index.tsx b/packages/ui/src/components/editor/panels/Properties/elementList/index.tsx
index 03ae756def..07df1cbde3 100644
--- a/packages/ui/src/components/editor/panels/Properties/elementList/index.tsx
+++ b/packages/ui/src/components/editor/panels/Properties/elementList/index.tsx
@@ -78,10 +78,10 @@ const ComponentListItem = ({ item, onSelect }: { item: Component; onSelect: () =
startIcon={ }
>
-
+
{startCase(jsonName.replace('-', ' ').toLowerCase())}
-
+
{t(`editor:layout.assetGrid.component-detail.${jsonName}`, '')}
@@ -107,8 +107,8 @@ const PrefabListItem = ({ item, onSelect }: { item: PrefabShelfItem; onSelect: (
startIcon={ }
>
- {item.name}
-
+ {item.name}
+
{item.detail}
From 81d431741f591686276010e702d1a6d8823bf026 Mon Sep 17 00:00:00 2001
From: Jeremy Sachs
Date: Tue, 13 Aug 2024 03:54:32 -0700
Subject: [PATCH 11/29] Updating the description of hemisphere lights to better
explain what they do and why. (#10945)
---
packages/client-core/i18n/en/editor.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/client-core/i18n/en/editor.json b/packages/client-core/i18n/en/editor.json
index 9544c2e0c7..3fbb540085 100755
--- a/packages/client-core/i18n/en/editor.json
+++ b/packages/client-core/i18n/en/editor.json
@@ -568,7 +568,7 @@
},
"hemisphere": {
"name": "Hemisphere Light",
- "description": "A light which illuminates the scene from directly overhead.",
+ "description": "A light which illuminates the scene with a sky color from above and a ground color from below.",
"lbl-skyColor": "Sky Color",
"lbl-groundColor": "Ground Color",
"lbl-intensity": "Intensity"
@@ -1177,7 +1177,7 @@
"point-light": "A light which emits in all directions from a single point.",
"spot-light": "Creates a light that shines in a specific direction.",
"directional-light": "Creates a light that emits evenly in a single direction.",
- "hemisphere-light": "A light which illuminates the scene from directly overhead.",
+ "hemisphere-light": "A light which illuminates the scene with a sky color from above and a ground color from below.",
"particle-system": "Creates a particle emitter.",
"system": "Inserts code into the scene by creating a new Entity Component System based on the provided .ts file",
"visual-script": "Customizes state and behavior of entities through a visual node connections.",
From 47ca0b896b5659915584d30472968e0798522062 Mon Sep 17 00:00:00 2001
From: MbfloydIR <144718558+MbfloydIR@users.noreply.github.com>
Date: Tue, 13 Aug 2024 03:57:30 -0700
Subject: [PATCH 12/29] added tailwind class to break the line in the middle of
words to prevent overflowing the alocated space (#10944)
---
.../src/components/editor/panels/Properties/material/index.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/ui/src/components/editor/panels/Properties/material/index.tsx b/packages/ui/src/components/editor/panels/Properties/material/index.tsx
index d9900fab51..1df3f0a34b 100644
--- a/packages/ui/src/components/editor/panels/Properties/material/index.tsx
+++ b/packages/ui/src/components/editor/panels/Properties/material/index.tsx
@@ -220,7 +220,7 @@ export function MaterialEditor(props: { materialUUID: EntityUUID }) {
{t('editor:properties.mesh.material.path')}
- {getOptionalComponent(entity, SourceComponent) ?? 'None'}
+ {getOptionalComponent(entity, SourceComponent) ?? 'None'}
From 3e648cd8371286512c0ae84f20cbb78258be0564 Mon Sep 17 00:00:00 2001
From: dtlehrer <19999194+dtlehrer@users.noreply.github.com>
Date: Tue, 13 Aug 2024 06:43:16 -0500
Subject: [PATCH 13/29] [IR-3547] studio: show unsaved changes dialog when
switching between scenes (#10921)
* show unsaved changes dialog when switching between scenes
* replace beforeunload event
* rename function to `confirmSceneSaveIfModified`
* use web standard alert for saving changes
---------
Co-authored-by: aditya-mitra <55396651+aditya-mitra@users.noreply.github.com>
---
packages/client-core/i18n/en/editor.json | 5 ++-
.../editor/src/components/EditorContainer.tsx | 10 +++++
.../components/dialogs/SaveSceneDialog.tsx | 1 +
.../editor/src/components/toolbar/Toolbar.tsx | 39 +++++--------------
.../editor/panels/Scenes/container/index.tsx | 5 ++-
.../panels/Viewport/container/index.tsx | 25 ++----------
6 files changed, 31 insertions(+), 54 deletions(-)
diff --git a/packages/client-core/i18n/en/editor.json b/packages/client-core/i18n/en/editor.json
index 3fbb540085..a48ec56ec9 100755
--- a/packages/client-core/i18n/en/editor.json
+++ b/packages/client-core/i18n/en/editor.json
@@ -1360,7 +1360,10 @@
"lbl-thumbnail": "Generate thumbnail & envmap",
"lbl-confirm": "Save Scene",
"info-confirm": "Are you sure you want to save the scene?",
- "info-question": "Do you want to save the current scene?"
+ "info-question": "Do you want to save the current scene?",
+ "unsavedChanges": {
+ "title": "Unsaved Changes"
+ }
},
"saveNewScene": {
"title": "Save As",
diff --git a/packages/editor/src/components/EditorContainer.tsx b/packages/editor/src/components/EditorContainer.tsx
index 6f374f3b35..e62b06f908 100644
--- a/packages/editor/src/components/EditorContainer.tsx
+++ b/packages/editor/src/components/EditorContainer.tsx
@@ -200,6 +200,16 @@ const EditorContainer = () => {
}
}, [errorState])
+ useEffect(() => {
+ const handleBeforeUnload = async (event: BeforeUnloadEvent) => {
+ if (EditorState.isModified()) {
+ event.preventDefault()
+ }
+ }
+ window.addEventListener('beforeunload', handleBeforeUnload)
+ return () => window.removeEventListener('beforeunload', handleBeforeUnload)
+ }, [])
+
return (
return (
{
PopoverState.hidePopupover()
diff --git a/packages/editor/src/components/toolbar/Toolbar.tsx b/packages/editor/src/components/toolbar/Toolbar.tsx
index 316da9c6c9..4ecf87012e 100644
--- a/packages/editor/src/components/toolbar/Toolbar.tsx
+++ b/packages/editor/src/components/toolbar/Toolbar.tsx
@@ -60,25 +60,21 @@ const onImportAsset = async () => {
}
}
-const onClickNewScene = async () => {
+export const confirmSceneSaveIfModified = async () => {
const isModified = EditorState.isModified()
if (isModified) {
- const confirm = await new Promise((resolve) => {
+ return new Promise((resolve) => {
PopoverState.showPopupover(
- {
- resolve(true)
- }}
- onCancel={() => {
- resolve(false)
- }}
- />
+ resolve(true)} onCancel={() => resolve(false)} />
)
})
- if (!confirm) return
}
+ return true
+}
+
+const onClickNewScene = async () => {
+ if (!(await confirmSceneSaveIfModified())) return
const newSceneUIAddons = getState(EditorState).uiAddons.newScene
if (Object.keys(newSceneUIAddons).length > 0) {
@@ -89,24 +85,7 @@ const onClickNewScene = async () => {
}
const onCloseProject = async () => {
- const isModified = EditorState.isModified()
-
- if (isModified) {
- const confirm = await new Promise((resolve) => {
- PopoverState.showPopupover(
- {
- resolve(true)
- }}
- onCancel={() => {
- resolve(false)
- }}
- />
- )
- })
- if (!confirm) return
- }
+ if (!(await confirmSceneSaveIfModified())) return
const editorState = getMutableState(EditorState)
getMutableState(GLTFModifiedState).set({})
diff --git a/packages/ui/src/components/editor/panels/Scenes/container/index.tsx b/packages/ui/src/components/editor/panels/Scenes/container/index.tsx
index cb0883dfb8..e11d76ee1b 100644
--- a/packages/ui/src/components/editor/panels/Scenes/container/index.tsx
+++ b/packages/ui/src/components/editor/panels/Scenes/container/index.tsx
@@ -27,6 +27,7 @@ import { SceneItem } from '@etherealengine/client-core/src/admin/components/scen
import { PopoverState } from '@etherealengine/client-core/src/common/services/PopoverState'
import { StaticResourceType, fileBrowserPath, staticResourcePath } from '@etherealengine/common/src/schema.type.module'
import CreateSceneDialog from '@etherealengine/editor/src/components/dialogs/CreateScenePanelDialog'
+import { confirmSceneSaveIfModified } from '@etherealengine/editor/src/components/toolbar/Toolbar'
import { onNewScene } from '@etherealengine/editor/src/functions/sceneFunctions'
import { EditorState } from '@etherealengine/editor/src/services/EditorServices'
import { getMutableState, useHookstate, useMutableState } from '@etherealengine/hyperflux'
@@ -47,7 +48,9 @@ export default function ScenesPanel() {
const scenesLoading = scenesQuery.status === 'pending'
- const onClickScene = (scene: StaticResourceType) => {
+ const onClickScene = async (scene: StaticResourceType) => {
+ if (!(await confirmSceneSaveIfModified())) return
+
getMutableState(EditorState).merge({
scenePath: scene.key
})
diff --git a/packages/ui/src/components/editor/panels/Viewport/container/index.tsx b/packages/ui/src/components/editor/panels/Viewport/container/index.tsx
index 580bd920c3..11531b6605 100644
--- a/packages/ui/src/components/editor/panels/Viewport/container/index.tsx
+++ b/packages/ui/src/components/editor/panels/Viewport/container/index.tsx
@@ -29,21 +29,19 @@ import { uploadToFeathersService } from '@etherealengine/client-core/src/util/up
import { FeatureFlags } from '@etherealengine/common/src/constants/FeatureFlags'
import { clientSettingPath, fileBrowserUploadPath } from '@etherealengine/common/src/schema.type.module'
import { processFileName } from '@etherealengine/common/src/utils/processFileName'
-import { getComponent, useComponent, useQuery } from '@etherealengine/ecs'
+import { useComponent, useQuery } from '@etherealengine/ecs'
import { ItemTypes, SupportedFileTypes } from '@etherealengine/editor/src/constants/AssetTypes'
import { EditorControlFunctions } from '@etherealengine/editor/src/functions/EditorControlFunctions'
import { addMediaNode } from '@etherealengine/editor/src/functions/addMediaNode'
import { getCursorSpawnPosition } from '@etherealengine/editor/src/functions/screenSpaceFunctions'
import { EditorState } from '@etherealengine/editor/src/services/EditorServices'
import { GLTFComponent } from '@etherealengine/engine/src/gltf/GLTFComponent'
-import { GLTFModifiedState } from '@etherealengine/engine/src/gltf/GLTFDocumentState'
import { ResourcePendingComponent } from '@etherealengine/engine/src/gltf/ResourcePendingComponent'
-import { SourceComponent } from '@etherealengine/engine/src/scene/components/SourceComponent'
import useFeatureFlags from '@etherealengine/engine/src/useFeatureFlags'
-import { getMutableState, useHookstate, useMutableState } from '@etherealengine/hyperflux'
+import { useMutableState } from '@etherealengine/hyperflux'
import { TransformComponent } from '@etherealengine/spatial'
import { useFind } from '@etherealengine/spatial/src/common/functions/FeathersHooks'
-import React, { useEffect } from 'react'
+import React from 'react'
import { useDrop } from 'react-dnd'
import { useTranslation } from 'react-i18next'
import { twMerge } from 'tailwind-merge'
@@ -125,23 +123,6 @@ const SceneLoadingProgress = ({ rootEntity }) => {
const progress = useComponent(rootEntity, GLTFComponent).progress.value
const loaded = GLTFComponent.useSceneLoaded(rootEntity)
const resourcePendingQuery = useQuery([ResourcePendingComponent])
- const root = getComponent(rootEntity, SourceComponent)
- const sceneModified = useHookstate(getMutableState(GLTFModifiedState)[root]).value
-
- useEffect(() => {
- if (!sceneModified) return
- const onBeforeUnload = (e: BeforeUnloadEvent) => {
- alert('You have unsaved changes. Please save before leaving.')
- e.preventDefault()
- e.returnValue = ''
- }
-
- window.addEventListener('beforeunload', onBeforeUnload)
-
- return () => {
- window.removeEventListener('beforeunload', onBeforeUnload)
- }
- }, [sceneModified])
if (loaded) return null
From 38535e7707b614806e454837710c83366c5a769a Mon Sep 17 00:00:00 2001
From: Rahul Ghosh
Date: Tue, 13 Aug 2024 18:28:43 +0530
Subject: [PATCH 14/29] Add download progress bar and optimize util files
(#10914)
* download progress bar
* optimize util files
* move handleProjectDownload to projectDownload.tsx
---------
Co-authored-by: aditya-mitra <55396651+aditya-mitra@users.noreply.github.com>
---
packages/client-core/i18n/en/editor.json | 1 +
packages/common/src/utils/btyesToSize.ts | 43 +++++
.../src/utils/{getOS.ts => getDeviceStats.ts} | 16 ++
packages/common/src/utils/mapToObject.ts | 4 +
packages/common/src/utils/miscUtils.ts | 50 ++++++
packages/editor/src/functions/utils.ts | 82 +--------
.../components/editor/input/Numeric/index.tsx | 3 +-
.../src/components/editor/layout/Scrubber.tsx | 3 +-
.../editor/panels/Files/container/index.tsx | 164 ++++++++----------
.../panels/Files/download/projectDownload.tsx | 111 ++++++++++++
10 files changed, 303 insertions(+), 174 deletions(-)
create mode 100644 packages/common/src/utils/btyesToSize.ts
rename packages/common/src/utils/{getOS.ts => getDeviceStats.ts} (75%)
create mode 100644 packages/ui/src/components/editor/panels/Files/download/projectDownload.tsx
diff --git a/packages/client-core/i18n/en/editor.json b/packages/client-core/i18n/en/editor.json
index a48ec56ec9..cbd6e57567 100755
--- a/packages/client-core/i18n/en/editor.json
+++ b/packages/client-core/i18n/en/editor.json
@@ -1217,6 +1217,7 @@
"uploadFiles": "Upload Files",
"uploadFolder": "Upload Folder",
"uploadingFiles": "Uploading Files ({{completed}}/{{total}})",
+ "downloadingProject": "Downloading Project ({{completed}}/{{total}})",
"search-placeholder": "Search",
"generatingThumbnails": "Generating Thumbnails ({{count}} remaining)",
"file": "File",
diff --git a/packages/common/src/utils/btyesToSize.ts b/packages/common/src/utils/btyesToSize.ts
new file mode 100644
index 0000000000..edffca23f8
--- /dev/null
+++ b/packages/common/src/utils/btyesToSize.ts
@@ -0,0 +1,43 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+/**
+ * Converts bytes to a human-readable size
+ * @param bytes The number of bytes
+ * @param decimals The number of decimal places to include
+ * @returns The human-readable size
+ */
+
+export function bytesToSize(bytes: number, decimals = 2) {
+ if (bytes === 0) return '0 Bytes'
+
+ const k = 1024
+ const dm = decimals < 0 ? 0 : decimals
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
+}
diff --git a/packages/common/src/utils/getOS.ts b/packages/common/src/utils/getDeviceStats.ts
similarity index 75%
rename from packages/common/src/utils/getOS.ts
rename to packages/common/src/utils/getDeviceStats.ts
index 40e44c697e..5fa025b4d2 100644
--- a/packages/common/src/utils/getOS.ts
+++ b/packages/common/src/utils/getDeviceStats.ts
@@ -35,3 +35,19 @@ export function getOS() {
}
return 'other'
}
+
+export const isApple = () => {
+ if ('navigator' in globalThis === false) return false
+
+ const iOS_1to12 = /iPad|iPhone|iPod/.test(navigator.platform)
+
+ const iOS13_iPad = navigator.platform === 'MacIntel'
+
+ const iOS1to12quirk = () => {
+ const audio = new Audio() // temporary Audio object
+ audio.volume = 0.5 // has no effect on iOS <= 12
+ return audio.volume === 1
+ }
+
+ return iOS_1to12 || iOS13_iPad || iOS1to12quirk()
+}
diff --git a/packages/common/src/utils/mapToObject.ts b/packages/common/src/utils/mapToObject.ts
index 9db2ca7dfe..6370aa323f 100644
--- a/packages/common/src/utils/mapToObject.ts
+++ b/packages/common/src/utils/mapToObject.ts
@@ -52,3 +52,7 @@ export const iterativeMapToObject = (root: Record) => {
}
return cloneDeep(iterate(root))
}
+
+export function objectToMap(object: object) {
+ return new Map(Object.entries(object))
+}
diff --git a/packages/common/src/utils/miscUtils.ts b/packages/common/src/utils/miscUtils.ts
index 24b6f9ff93..3cdbb44c5c 100644
--- a/packages/common/src/utils/miscUtils.ts
+++ b/packages/common/src/utils/miscUtils.ts
@@ -38,6 +38,11 @@ export function isNumber(value: string | number): boolean {
return value != null && value !== '' && !isNaN(Number(value.toString()))
}
+export function toPrecision(value, precision) {
+ const p = 1 / precision
+ return Math.round(value * p) / p
+}
+
export function combine(first, second, third) {
const res: any[] = []
@@ -47,6 +52,23 @@ export function combine(first, second, third) {
return res
}
+
+export const unique = (arr: T[], keyFinder: (item: T) => S): T[] => {
+ const set = new Set()
+ const newArr = [] as T[]
+ if (!keyFinder) keyFinder = (item: T) => item as any as S
+
+ for (const item of arr) {
+ const key = keyFinder(item)
+ if (set.has(key)) continue
+
+ newArr.push(item)
+ set.add(key)
+ }
+
+ return newArr
+}
+
export function combineArrays(arrays: [[]]) {
const res = []
@@ -59,6 +81,23 @@ export function combineArrays(arrays: [[]]) {
return res
}
+export function insertArraySeparator(children, separatorFn) {
+ if (!Array.isArray(children)) {
+ return children
+ }
+ const length = children.length
+ if (length === 1) {
+ return children[0]
+ }
+ return children.reduce((acc, item, index) => {
+ acc.push(item)
+ if (index !== length - 1) {
+ acc.push(separatorFn(index))
+ }
+ return acc
+ }, [])
+}
+
export function arraysAreEqual(arr1: any[], arr2: any[]): boolean {
if (arr1.length !== arr2.length) return false
@@ -154,3 +193,14 @@ export const toCapitalCase = (source: string) => {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()
})
}
+
+export function toCamelPad(source: string) {
+ return source
+ .replace(/([A-Z]+)([A-Z][a-z])/g, ' $1 $2')
+ .replace(/([a-z\d])([A-Z])/g, '$1 $2')
+ .replace(/([a-zA-Z])(\d)/g, '$1 $2')
+ .replace(/^./, (str) => {
+ return str.toUpperCase()
+ })
+ .trim()
+}
diff --git a/packages/editor/src/functions/utils.ts b/packages/editor/src/functions/utils.ts
index 38e654987e..f1d2b197f5 100755
--- a/packages/editor/src/functions/utils.ts
+++ b/packages/editor/src/functions/utils.ts
@@ -22,60 +22,7 @@ Original Code is the Ethereal Engine team.
All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
Ethereal Engine. All Rights Reserved.
*/
-
-export function insertSeparator(children, separatorFn) {
- if (!Array.isArray(children)) {
- return children
- }
- const length = children.length
- if (length === 1) {
- return children[0]
- }
- return children.reduce((acc, item, index) => {
- acc.push(item)
- if (index !== length - 1) {
- acc.push(separatorFn(index))
- }
- return acc
- }, [])
-}
-export function objectToMap(object: object) {
- return new Map(Object.entries(object))
-}
-
-export const unique = (arr: T[], keyFinder: (item: T) => S): T[] => {
- const set = new Set()
- const newArr = [] as T[]
- if (!keyFinder) keyFinder = (item: T) => item as any as S
-
- for (const item of arr) {
- const key = keyFinder(item)
- if (set.has(key)) continue
-
- newArr.push(item)
- set.add(key)
- }
-
- return newArr
-}
-
-export const isApple = () => {
- if ('navigator' in globalThis === false) return false
-
- const iOS_1to12 = /iPad|iPhone|iPod/.test(navigator.platform)
-
- const iOS13_iPad = navigator.platform === 'MacIntel'
-
- const iOS1to12quirk = () => {
- const audio = new Audio() // temporary Audio object
- audio.volume = 0.5 // has no effect on iOS <= 12
- return audio.volume === 1
- }
-
- return iOS_1to12 || iOS13_iPad || iOS1to12quirk()
-}
-
-export const cmdOrCtrlString = isApple() ? 'meta' : 'ctrl'
+import { isApple } from '@etherealengine/common/src/utils/getDeviceStats'
export function getStepSize(event, smallStep, mediumStep, largeStep) {
if (event.altKey) {
@@ -86,29 +33,4 @@ export function getStepSize(event, smallStep, mediumStep, largeStep) {
return mediumStep
}
-export function toPrecision(value, precision) {
- const p = 1 / precision
- return Math.round(value * p) / p
-}
-// https://stackoverflow.com/a/26188910
-export function camelPad(str) {
- return str
- .replace(/([A-Z]+)([A-Z][a-z])/g, ' $1 $2')
- .replace(/([a-z\d])([A-Z])/g, '$1 $2')
- .replace(/([a-zA-Z])(\d)/g, '$1 $2')
- .replace(/^./, (str) => {
- return str.toUpperCase()
- })
- .trim()
-}
-export function bytesToSize(bytes: number, decimals = 2) {
- if (bytes === 0) return '0 Bytes'
-
- const k = 1024
- const dm = decimals < 0 ? 0 : decimals
- const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
-
- const i = Math.floor(Math.log(bytes) / Math.log(k))
-
- return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
-}
+export const cmdOrCtrlString = isApple() ? 'meta' : 'ctrl'
diff --git a/packages/ui/src/components/editor/input/Numeric/index.tsx b/packages/ui/src/components/editor/input/Numeric/index.tsx
index ba948c519a..d6feb6c37e 100644
--- a/packages/ui/src/components/editor/input/Numeric/index.tsx
+++ b/packages/ui/src/components/editor/input/Numeric/index.tsx
@@ -27,7 +27,8 @@ import React from 'react'
import { clamp } from '@etherealengine/spatial/src/common/functions/MathLerpFunctions'
-import { getStepSize, toPrecision } from '@etherealengine/editor/src/functions/utils'
+import { toPrecision } from '@etherealengine/common/src/utils/miscUtils'
+import { getStepSize } from '@etherealengine/editor/src/functions/utils'
import { useHookstate } from '@etherealengine/hyperflux'
import { twMerge } from 'tailwind-merge'
import Text from '../../../../primitives/tailwind/Text'
diff --git a/packages/ui/src/components/editor/layout/Scrubber.tsx b/packages/ui/src/components/editor/layout/Scrubber.tsx
index 2eccf3f469..08198f3f4b 100644
--- a/packages/ui/src/components/editor/layout/Scrubber.tsx
+++ b/packages/ui/src/components/editor/layout/Scrubber.tsx
@@ -23,7 +23,8 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20
Ethereal Engine. All Rights Reserved.
*/
-import { getStepSize, toPrecision } from '@etherealengine/editor/src/functions/utils'
+import { toPrecision } from '@etherealengine/common/src/utils/miscUtils'
+import { getStepSize } from '@etherealengine/editor/src/functions/utils'
import { useHookstate } from '@etherealengine/hyperflux'
import React, { useRef } from 'react'
import { twMerge } from 'tailwind-merge'
diff --git a/packages/ui/src/components/editor/panels/Files/container/index.tsx b/packages/ui/src/components/editor/panels/Files/container/index.tsx
index feaca8373a..5793b4bc76 100644
--- a/packages/ui/src/components/editor/panels/Files/container/index.tsx
+++ b/packages/ui/src/components/editor/panels/Files/container/index.tsx
@@ -25,18 +25,17 @@ Ethereal Engine. All Rights Reserved.
import { FileThumbnailJobState } from '@etherealengine/client-core/src/common/services/FileThumbnailJobState'
import { NotificationService } from '@etherealengine/client-core/src/common/services/NotificationService'
import { PopoverState } from '@etherealengine/client-core/src/common/services/PopoverState'
-import config from '@etherealengine/common/src/config'
import {
FileBrowserContentType,
StaticResourceType,
UserID,
- archiverPath,
fileBrowserPath,
projectPath,
staticResourcePath
} from '@etherealengine/common/src/schema.type.module'
import { CommonKnownContentTypes } from '@etherealengine/common/src/utils/CommonKnownContentTypes'
-import { Engine } from '@etherealengine/ecs'
+import { bytesToSize } from '@etherealengine/common/src/utils/btyesToSize'
+import { unique } from '@etherealengine/common/src/utils/miscUtils'
import { AssetSelectionChangePropsType } from '@etherealengine/editor/src/components/assets/AssetsPreviewPanel'
import {
FilesViewModeSettings,
@@ -48,12 +47,7 @@ import ImageCompressionPanel from '@etherealengine/editor/src/components/assets/
import ModelCompressionPanel from '@etherealengine/editor/src/components/assets/ModelCompressionPanel'
import { DndWrapper } from '@etherealengine/editor/src/components/dnd/DndWrapper'
import { SupportedFileTypes } from '@etherealengine/editor/src/constants/AssetTypes'
-import {
- downloadBlobAsZip,
- handleUploadFiles,
- inputFileWithAddToScene
-} from '@etherealengine/editor/src/functions/assetFunctions'
-import { bytesToSize, unique } from '@etherealengine/editor/src/functions/utils'
+import { handleUploadFiles, inputFileWithAddToScene } from '@etherealengine/editor/src/functions/assetFunctions'
import { EditorState } from '@etherealengine/editor/src/services/EditorServices'
import { ClickPlacementState } from '@etherealengine/editor/src/systems/ClickPlacementSystem'
import { AssetLoader } from '@etherealengine/engine/src/assets/classes/AssetLoader'
@@ -86,6 +80,7 @@ import InputGroup from '../../../input/Group'
import { FileBrowserItem, FileTableWrapper, canDropItemOverFolder } from '../browserGrid'
import DeleteFileModal from '../browserGrid/DeleteFileModal'
import FilePropertiesModal from '../browserGrid/FilePropertiesModal'
+import { ProjectDownloadProgress, handleDownloadProject } from '../download/projectDownload'
import { FileUploadProgress } from '../upload/FileUploadProgress'
type FileBrowserContentPanelProps = {
@@ -230,6 +225,66 @@ function GeneratingThumbnailsProgress() {
)
}
+export const ViewModeSettings = () => {
+ const { t } = useTranslation()
+
+ const filesViewMode = useMutableState(FilesViewModeState).viewMode
+
+ const viewModeSettings = useHookstate(getMutableState(FilesViewModeSettings))
+ return (
+
+ } className="h-7 w-7 rounded-lg bg-[#2F3137] p-0" />
+
+ }
+ >
+ {filesViewMode.value === 'icons' ? (
+
+
+
+ ) : (
+ <>
+
+
+
+
+
+
+ {t('editor:layout.filebrowser.view-mode.settings.select-listColumns')}
+
+
+ {availableTableColumns.map((column) => (
+
+ viewModeSettings.list.selectedTableColumns[column].set(value)}
+ />
+
+ ))}
+
+
+ >
+ )}
+
+ )
+}
/**
* FileBrowserPanel used to render view for AssetsPanel.
*/
@@ -238,6 +293,12 @@ const FileBrowserContentPanel: React.FC = (props)
const selectedDirectory = useHookstate(props.originalPath)
+ const downloadState = useHookstate({
+ total: 0,
+ progress: 0,
+ isDownloading: false
+ })
+
const projectName = useValidProjectForFileBrowser(selectedDirectory.value)
const orgName = projectName.includes('/') ? projectName.split('/')[0] : ''
@@ -414,27 +475,6 @@ const FileBrowserContentPanel: React.FC = (props)
selectedDirectory.value.startsWith('/projects/' + projectName + '/assets/')
const showBackButton = selectedDirectory.value.split('/').length > props.originalPath.split('/').length
- const handleDownloadProject = async () => {
- const data = await Engine.instance.api
- .service(archiverPath)
- .get(null, { query: { project: projectName } })
- .catch((err: Error) => {
- NotificationService.dispatchNotify(err.message, { variant: 'warning' })
- return null
- })
- if (!data) return
- const blob = await (await fetch(`${config.client.fileServer}/${data}`)).blob()
-
- let fileName: string
- if (selectedDirectory.value.at(-1) === '/') {
- fileName = selectedDirectory.value.split('/').at(-2) as string
- } else {
- fileName = selectedDirectory.value.split('/').at(-1) as string
- }
-
- downloadBlobAsZip(blob, fileName)
- }
-
const BreadcrumbItems = () => {
const handleBreadcrumbDirectoryClick = (targetFolder: string) => {
if (orgName && targetFolder === 'projects') return
@@ -808,7 +848,7 @@ const FileBrowserContentPanel: React.FC = (props)
variant="transparent"
startIcon={ }
className="p-0"
- onClick={handleDownloadProject}
+ onClick={() => handleDownloadProject(projectName, selectedDirectory.value)}
disabled={!showDownloadButtons}
/>
@@ -860,6 +900,7 @@ const FileBrowserContentPanel: React.FC = (props)
+
{isLoading && (
)}
@@ -888,64 +929,3 @@ export default function FilesPanelContainer() {
/>
)
}
-
-export const ViewModeSettings = () => {
- const { t } = useTranslation()
-
- const filesViewMode = useMutableState(FilesViewModeState).viewMode
-
- const viewModeSettings = useHookstate(getMutableState(FilesViewModeSettings))
- return (
-
- } className="h-7 w-7 rounded-lg bg-[#2F3137] p-0" />
-
- }
- >
- {filesViewMode.value === 'icons' ? (
-
-
-
- ) : (
- <>
-
-
-
-
-
-
- {t('editor:layout.filebrowser.view-mode.settings.select-listColumns')}
-
-
- {availableTableColumns.map((column) => (
-
- viewModeSettings.list.selectedTableColumns[column].set(value)}
- />
-
- ))}
-
-
- >
- )}
-
- )
-}
diff --git a/packages/ui/src/components/editor/panels/Files/download/projectDownload.tsx b/packages/ui/src/components/editor/panels/Files/download/projectDownload.tsx
new file mode 100644
index 0000000000..bb6b4406dc
--- /dev/null
+++ b/packages/ui/src/components/editor/panels/Files/download/projectDownload.tsx
@@ -0,0 +1,111 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+import { NotificationService } from '@etherealengine/client-core/src/common/services/NotificationService'
+import config from '@etherealengine/common/src/config'
+import { archiverPath } from '@etherealengine/common/src/schema.type.module'
+import { bytesToSize } from '@etherealengine/common/src/utils/btyesToSize'
+import { Engine } from '@etherealengine/ecs'
+import { downloadBlobAsZip } from '@etherealengine/editor/src/functions/assetFunctions'
+import { defineState, getMutableState, useMutableState } from '@etherealengine/hyperflux'
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import Progress from '../../../../../primitives/tailwind/Progress'
+
+const DownloadProjectState = defineState({
+ name: 'DownloadProjectState',
+ initial: () => ({
+ total: 0,
+ progress: 0,
+ isDownloading: false
+ })
+})
+
+export const handleDownloadProject = async (projectName: string, selectedDirectory: string) => {
+ const data = await Engine.instance.api
+ .service(archiverPath)
+ .get(null, { query: { project: projectName } })
+ .catch((err: Error) => {
+ NotificationService.dispatchNotify(err.message, { variant: 'warning' })
+ return null
+ })
+ if (!data) return
+
+ const downloadState = getMutableState(DownloadProjectState)
+
+ downloadState.isDownloading.set(true) // Start Download
+
+ const response = await fetch(`${config.client.fileServer}/${data}`)
+ const totalBytes = parseInt(response.headers.get('Content-Length') || '0', 10)
+ downloadState.total.set(totalBytes) // Set the total bytes
+
+ const reader = response.body?.getReader()
+ const chunks: Uint8Array[] = []
+ let bytesReceived = 0
+
+ while (true) {
+ const { done, value } = await reader!.read()
+ if (done) break
+ chunks.push(value)
+ bytesReceived += value.length
+ downloadState.progress.set(bytesReceived)
+ }
+
+ const blob = new Blob(chunks)
+ downloadState.isDownloading.set(false) // Mark as completed
+ downloadState.progress.set(0)
+ downloadState.total.set(0)
+
+ let fileName: string
+ if (selectedDirectory.at(-1) === '/') {
+ fileName = selectedDirectory.split('/').at(-2) as string
+ } else {
+ fileName = selectedDirectory.split('/').at(-1) as string
+ }
+
+ downloadBlobAsZip(blob, fileName)
+}
+
+export const ProjectDownloadProgress = () => {
+ const { t } = useTranslation()
+ const downloadState = useMutableState(DownloadProjectState)
+ const isDownloading = downloadState.isDownloading.value
+ const completed = bytesToSize(downloadState.progress.value)
+ const total = bytesToSize(downloadState.total.value)
+ const progress = (downloadState.progress.value / downloadState.total.value) * 100
+
+ return isDownloading ? (
+
+
+
+ {t('editor:layout.filebrowser.downloadingProject', { completed, total })}
+
+
+
+
+ ) : null
+}
From 81404effc3370dd329a84baf7a22c650db6ad4e3 Mon Sep 17 00:00:00 2001
From: lonedevr <102248647+AidanCaruso@users.noreply.github.com>
Date: Tue, 13 Aug 2024 12:08:08 -0400
Subject: [PATCH 15/29] hotfix gizmo zeroed position on rigidbody entities
(#10943)
---
packages/editor/src/functions/gizmoHelper.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/packages/editor/src/functions/gizmoHelper.ts b/packages/editor/src/functions/gizmoHelper.ts
index e9e80e8f28..77b1ece16e 100644
--- a/packages/editor/src/functions/gizmoHelper.ts
+++ b/packages/editor/src/functions/gizmoHelper.ts
@@ -533,7 +533,9 @@ function pointerDown(gizmoEntity) {
const planeIntersect = intersectObjectWithRay(plane, _raycaster, true)
if (planeIntersect) {
const currenttransform = getComponent(targetEntity, TransformComponent)
- currenttransform.matrix.decompose(_positionStart, _quaternionStart, _scaleStart)
+ _positionStart.copy(currenttransform.position)
+ _quaternionStart.copy(currenttransform.rotation)
+ _scaleStart.copy(currenttransform.scale)
gizmoControlComponent.worldPositionStart.set(_positionStart)
gizmoControlComponent.worldQuaternionStart.set(_quaternionStart)
From a3efe4d8ee7736f0d5415f89d97f17efdde940ac Mon Sep 17 00:00:00 2001
From: Michael Estes
Date: Tue, 13 Aug 2024 12:46:54 -0700
Subject: [PATCH 16/29]
IR-3602-Changing-CSM-cascade-count-or-tonemapping-spams-errors (#10926)
* Only update uniforms once
* Check component exists
* revert
---
packages/spatial/src/renderer/csm/CSM.ts | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/packages/spatial/src/renderer/csm/CSM.ts b/packages/spatial/src/renderer/csm/CSM.ts
index 32e7a338db..40f1a97617 100644
--- a/packages/spatial/src/renderer/csm/CSM.ts
+++ b/packages/spatial/src/renderer/csm/CSM.ts
@@ -43,6 +43,7 @@ import { Engine } from '@etherealengine/ecs/src/Engine'
import { Entity } from '@etherealengine/ecs/src/Entity'
import { createEntity, removeEntity } from '@etherealengine/ecs/src/EntityFunctions'
+import { getState } from '@etherealengine/hyperflux'
import { CameraComponent } from '../../camera/components/CameraComponent'
import { NameComponent } from '../../common/NameComponent'
import { Vector3_Zero } from '../../common/constants/MathConstants'
@@ -51,6 +52,7 @@ import { addObjectToGroup } from '../../renderer/components/GroupComponent'
import { VisibleComponent } from '../../renderer/components/VisibleComponent'
import { EntityTreeComponent } from '../../transform/components/EntityTree'
import { TransformComponent } from '../../transform/components/TransformComponent'
+import { RendererState } from '../RendererState'
import Frustum from './Frustum'
import Shader from './Shader'
@@ -319,7 +321,8 @@ export class CSM {
if (this.sourceLight) this.lightDirection.subVectors(this.sourceLight.target.position, this.sourceLight.position)
if (this.needsUpdate) {
this.injectInclude()
- this.updateFrustums()
+ // Only update uniforms if WebGLRendererSystem isn't already updating them every frame
+ this.updateFrustums(!getState(RendererState).updateCSMFrustums)
for (const light of this.lights) {
light.shadow.map?.dispose()
light.shadow.map = null as any
@@ -439,7 +442,8 @@ export class CSM {
updateUniforms(): void {
const camera = getComponent(Engine.instance.cameraEntity, CameraComponent)
const far = Math.min(camera.far, this.maxFar)
- this.shaders.forEach(function (shader: ShaderType, material: Material) {
+
+ for (const [material, shader] of this.shaders.entries()) {
const camera = getComponent(Engine.instance.cameraEntity, CameraComponent)
if (shader !== null) {
@@ -456,7 +460,7 @@ export class CSM {
material.defines!.CSM_FADE = ''
material.needsUpdate = true
}
- }, this)
+ }
}
getExtendedBreaks(target: Vector2[]): void {
@@ -474,11 +478,11 @@ export class CSM {
}
}
- updateFrustums(): void {
+ updateFrustums(updateUniforms = true): void {
this.getBreaks()
this.initCascades()
this.updateShadowBounds()
- this.updateUniforms()
+ if (updateUniforms) this.updateUniforms()
}
remove(): void {
From e419b5ddf28454829930b87f546a3be01957d35e Mon Sep 17 00:00:00 2001
From: Michael Estes
Date: Tue, 13 Aug 2024 14:24:56 -0700
Subject: [PATCH 17/29] Frustum cull meshes (#10947)
---
.../src/scene/systems/SceneObjectSystem.tsx | 11 +++++++++--
.../src/renderer/components/MeshComponent.ts | 15 +++++++++++++--
2 files changed, 22 insertions(+), 4 deletions(-)
diff --git a/packages/engine/src/scene/systems/SceneObjectSystem.tsx b/packages/engine/src/scene/systems/SceneObjectSystem.tsx
index 25d82268b0..41478ea2b7 100644
--- a/packages/engine/src/scene/systems/SceneObjectSystem.tsx
+++ b/packages/engine/src/scene/systems/SceneObjectSystem.tsx
@@ -37,7 +37,7 @@ import {
Texture
} from 'three'
-import { useEntityContext, UUIDComponent } from '@etherealengine/ecs'
+import { entityExists, useEntityContext, UUIDComponent } from '@etherealengine/ecs'
import {
getComponent,
getOptionalComponent,
@@ -52,7 +52,7 @@ import { Entity, EntityUUID } from '@etherealengine/ecs/src/Entity'
import { defineQuery, QueryReactor } from '@etherealengine/ecs/src/QueryFunctions'
import { defineSystem } from '@etherealengine/ecs/src/SystemFunctions'
import { AnimationSystemGroup } from '@etherealengine/ecs/src/SystemGroups'
-import { getMutableState, getState, useHookstate } from '@etherealengine/hyperflux'
+import { getMutableState, getState, useHookstate, useImmediateEffect } from '@etherealengine/hyperflux'
import { CallbackComponent } from '@etherealengine/spatial/src/common/CallbackComponent'
import { ColliderComponent } from '@etherealengine/spatial/src/physics/components/ColliderComponent'
import { RigidBodyComponent } from '@etherealengine/spatial/src/physics/components/RigidBodyComponent'
@@ -167,6 +167,13 @@ function SceneObjectReactor(props: { entity: Entity; obj: Object3D }) {
const renderState = getMutableState(RendererState)
const forceBasicMaterials = useHookstate(renderState.forceBasicMaterials)
+ useImmediateEffect(() => {
+ setComponent(entity, DistanceFromCameraComponent)
+ return () => {
+ if (entityExists(entity)) removeComponent(entity, DistanceFromCameraComponent)
+ }
+ }, [])
+
useEffect(() => {
const source = hasComponent(entity, ModelComponent)
? getModelSceneID(entity)
diff --git a/packages/spatial/src/renderer/components/MeshComponent.ts b/packages/spatial/src/renderer/components/MeshComponent.ts
index 9f1a308fac..af917d2cd4 100644
--- a/packages/spatial/src/renderer/components/MeshComponent.ts
+++ b/packages/spatial/src/renderer/components/MeshComponent.ts
@@ -24,7 +24,7 @@ Ethereal Engine. All Rights Reserved.
*/
import { useEffect } from 'react'
-import { BufferGeometry, Material, Mesh } from 'three'
+import { Box3, BufferGeometry, Material, Mesh } from 'three'
import { Entity, useEntityContext } from '@etherealengine/ecs'
import {
@@ -34,9 +34,10 @@ import {
setComponent,
useComponent
} from '@etherealengine/ecs/src/ComponentFunctions'
-import { State, useImmediateEffect } from '@etherealengine/hyperflux'
+import { NO_PROXY, State, useImmediateEffect } from '@etherealengine/hyperflux'
import { useResource } from '../../resources/resourceHooks'
+import { BoundingBoxComponent } from '../../transform/components/BoundingBoxComponents'
import { addObjectToGroup, removeObjectFromGroup } from './GroupComponent'
export const MeshComponent = defineComponent({
@@ -61,6 +62,16 @@ export const MeshComponent = defineComponent({
!Array.isArray(meshComponent.material.value) ? (meshComponent.material.value as Material).uuid : undefined
)
+ useEffect(() => {
+ const box = geometryResource.boundingBox.get(NO_PROXY) as Box3 | null
+ if (!box) return
+
+ setComponent(entity, BoundingBoxComponent, { box: box })
+ return () => {
+ removeComponent(entity, BoundingBoxComponent)
+ }
+ }, [geometryResource.boundingBox])
+
useEffect(() => {
if (meshComponent.value !== meshResource.value) meshResource.set(meshComponent.value)
}, [meshComponent])
From a9135cd3593dee77c6b74b789869bc264907bb79 Mon Sep 17 00:00:00 2001
From: lonedevr <102248647+AidanCaruso@users.noreply.github.com>
Date: Tue, 13 Aug 2024 17:26:14 -0400
Subject: [PATCH 18/29] prevent animations being overwritten (#10956)
---
packages/engine/src/scene/components/ModelComponent.tsx | 1 -
1 file changed, 1 deletion(-)
diff --git a/packages/engine/src/scene/components/ModelComponent.tsx b/packages/engine/src/scene/components/ModelComponent.tsx
index fd8297b382..3ac28e3815 100644
--- a/packages/engine/src/scene/components/ModelComponent.tsx
+++ b/packages/engine/src/scene/components/ModelComponent.tsx
@@ -148,7 +148,6 @@ function ModelReactor() {
/**if we've loaded or converted to vrm, create animation component whose mixer's root is the normalized rig */
if (boneMatchedAsset instanceof VRM)
setComponent(entity, AnimationComponent, {
- animations: gltf.animations,
mixer: new AnimationMixer(boneMatchedAsset.humanoid.normalizedHumanBonesRoot)
})
From 9d2209865f6fb71d06e3fc195f069598090fdced Mon Sep 17 00:00:00 2001
From: MbfloydIR <144718558+MbfloydIR@users.noreply.github.com>
Date: Tue, 13 Aug 2024 14:47:16 -0700
Subject: [PATCH 19/29] moved the rename / delete menu up to prevent the delete
from being hidden (#10934)
* moved the rename / delete menu up to prevent the delete from being hidden
* added boolean to the sceneItem props to determine if the menu should be moved up or not
---------
Co-authored-by: Hanzla Mateen
---
.../src/admin/components/scene/SceneItem.tsx | 12 ++++++++++--
.../editor/panels/Scenes/container/index.tsx | 1 +
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/packages/client-core/src/admin/components/scene/SceneItem.tsx b/packages/client-core/src/admin/components/scene/SceneItem.tsx
index 131568e135..d100b0970a 100644
--- a/packages/client-core/src/admin/components/scene/SceneItem.tsx
+++ b/packages/client-core/src/admin/components/scene/SceneItem.tsx
@@ -43,11 +43,18 @@ import { twMerge } from 'tailwind-merge'
type SceneItemProps = {
scene: StaticResourceType
updateEditorState?: boolean
+ moveMenuUp?: boolean
handleOpenScene: () => void
refetchProjectsData: () => void
}
-export const SceneItem = ({ scene, updateEditorState, handleOpenScene, refetchProjectsData }: SceneItemProps) => {
+export const SceneItem = ({
+ scene,
+ updateEditorState,
+ moveMenuUp,
+ handleOpenScene,
+ refetchProjectsData
+}: SceneItemProps) => {
const { t } = useTranslation()
const editorState = useMutableState(EditorState)
@@ -118,7 +125,8 @@ export const SceneItem = ({ scene, updateEditorState, handleOpenScene, refetchPr
onClickScene(scene)}
refetchProjectsData={scenesQuery.refetch}
/>
From 90521f35688dceefb9fb438b58c22e6646cc0376 Mon Sep 17 00:00:00 2001
From: Jiatong Yao <142644522+JT00y@users.noreply.github.com>
Date: Tue, 13 Aug 2024 17:35:25 -0500
Subject: [PATCH 20/29] cleanup lookdev (#10955)
---
packages/editor/src/functions/EditorControlFunctions.ts | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/packages/editor/src/functions/EditorControlFunctions.ts b/packages/editor/src/functions/EditorControlFunctions.ts
index e5ff0b648d..8660d40155 100644
--- a/packages/editor/src/functions/EditorControlFunctions.ts
+++ b/packages/editor/src/functions/EditorControlFunctions.ts
@@ -40,7 +40,6 @@ import {
import { Entity } from '@etherealengine/ecs/src/Entity'
import { GLTFDocumentState, GLTFSnapshotAction } from '@etherealengine/engine/src/gltf/GLTFDocumentState'
import { GLTFSnapshotState, GLTFSourceState } from '@etherealengine/engine/src/gltf/GLTFState'
-import { PrimitiveGeometryComponent } from '@etherealengine/engine/src/scene/components/PrimitiveGeometryComponent'
import { SkyboxComponent } from '@etherealengine/engine/src/scene/components/SkyboxComponent'
import { SourceComponent } from '@etherealengine/engine/src/scene/components/SourceComponent'
import { TransformSpace } from '@etherealengine/engine/src/scene/constants/transformConstants'
@@ -224,8 +223,7 @@ const overwriteLookdevObject = (
SkyboxComponent,
HemisphereLightComponent,
DirectionalLightComponent,
- PostProcessingComponent,
- PrimitiveGeometryComponent //this component is for test will remove later
+ PostProcessingComponent
]
let overwrited = false
for (const comp of lookDevComponent) {
From bce156b58f2875f6854fc485960722625b7c3876 Mon Sep 17 00:00:00 2001
From: MbfloydIR <144718558+MbfloydIR@users.noreply.github.com>
Date: Tue, 13 Aug 2024 16:01:54 -0700
Subject: [PATCH 21/29] removed the mesh colliders from the prefab list
(#10958)
---
packages/editor/src/components/prefabs/PrefabEditors.tsx | 6 ------
1 file changed, 6 deletions(-)
diff --git a/packages/editor/src/components/prefabs/PrefabEditors.tsx b/packages/editor/src/components/prefabs/PrefabEditors.tsx
index 2773ed9770..3d56b4ef8d 100644
--- a/packages/editor/src/components/prefabs/PrefabEditors.tsx
+++ b/packages/editor/src/components/prefabs/PrefabEditors.tsx
@@ -98,12 +98,6 @@ export const PrefabShelfState = defineState({
category: 'Collider',
detail: 'Simple cylinder collider'
},
- {
- name: 'Mesh Collider',
- url: `${config.client.fileServer}/projects/default-project/assets/prefabs/mesh-collider.prefab.gltf`,
- category: 'Collider',
- detail: 'Simple mesh collider, drag and drop your mesh'
- },
{
name: 'Text',
url: `${config.client.fileServer}/projects/default-project/assets/prefabs/text.prefab.gltf`,
From 815bda718217d7f98647cc1630d316b85dd05d26 Mon Sep 17 00:00:00 2001
From: Andres David Jimenez
Date: Wed, 14 Aug 2024 03:57:44 -0600
Subject: [PATCH 22/29] fix(IR-3746): fix secondary button (#10948)
---
packages/ui/src/primitives/tailwind/Button/index.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/ui/src/primitives/tailwind/Button/index.tsx b/packages/ui/src/primitives/tailwind/Button/index.tsx
index 8e5721c730..31641e2262 100644
--- a/packages/ui/src/primitives/tailwind/Button/index.tsx
+++ b/packages/ui/src/primitives/tailwind/Button/index.tsx
@@ -55,7 +55,7 @@ const sizes = {
const variants = {
primary: 'bg-blue-primary',
- secondary: 'bg-blue-secondary',
+ secondary: 'bg-theme-blue-secondary',
outline: 'border border-solid border-theme-primary bg-theme-surface-main dark:bg-theme-highlight text-theme-primary',
danger: 'bg-red-500',
success: 'bg-teal-700',
@@ -90,8 +90,8 @@ const Button = React.forwardRef(
sizes[size],
fullWidth ? 'w-full' : 'w-fit',
roundedTypes[rounded],
- disabled ? 'bg-[#F3F4F6] text-[#9CA3AF] dark:bg-[#5F7DBF] dark:text-[#FFFFFF]' : '',
variants[variant],
+ disabled ? 'bg-[#F3F4F6] text-[#9CA3AF] dark:bg-[#5F7DBF] dark:text-[#FFFFFF]' : '',
className
)
From 65bdc5ac9f3ee0ac7da93db3bc1924756d22ca16 Mon Sep 17 00:00:00 2001
From: Sam Mazer <162159423+SamMazerIR@users.noreply.github.com>
Date: Wed, 14 Aug 2024 10:21:22 -0700
Subject: [PATCH 23/29] IR-3588 adding a tag component -
ThumbnailLightTagComponent for filtering render settings light query (#10960)
* adding a tag component - ThumbnailLightTagComponent for filtering the query for light options in render settings primary light dropdown options
* Revert "adding a tag component - ThumbnailLightTagComponent for filtering the query for light options in render settings primary light dropdown options"
This reverts commit f792dfe0ab0c4cf3bb2491620939441409b5468d.
* reverting new tag component in favor of using SourceComponent as query filter
---
packages/ui/src/components/editor/properties/render/index.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/ui/src/components/editor/properties/render/index.tsx b/packages/ui/src/components/editor/properties/render/index.tsx
index ed90639229..b0aee17481 100644
--- a/packages/ui/src/components/editor/properties/render/index.tsx
+++ b/packages/ui/src/components/editor/properties/render/index.tsx
@@ -119,7 +119,7 @@ export const RenderSettingsEditor: EditorComponentType = (props) => {
value: '' as EntityUUID
}
].concat(
- useQuery([DirectionalLightComponent]).map((entity) => {
+ useQuery([DirectionalLightComponent, SourceComponent]).map((entity) => {
return {
label: getComponent(entity, NameComponent),
value: getComponent(entity, UUIDComponent)
From 0f5fb7e4689b7045c03f2b6d6070d73b220327ce Mon Sep 17 00:00:00 2001
From: Sam Mazer <162159423+SamMazerIR@users.noreply.github.com>
Date: Wed, 14 Aug 2024 10:32:47 -0700
Subject: [PATCH 24/29] fixing interactable XRUI (interact ui) and making it's
background reactive to handle all sizing. fixing font rasterization mismatch
issue with xrui by sticking to default font. Updating label still won't
properly update reactively until leaving/re-entering playmode however
(#10954)
---
.../components/InteractableComponent.ts | 33 ++++----
.../src/interaction/functions/createUI.ts | 36 ---------
.../interaction/ui/InteractiveModalView.tsx | 77 +++++++++++++++++--
.../editor/properties/interact/index.tsx | 8 +-
4 files changed, 91 insertions(+), 63 deletions(-)
diff --git a/packages/engine/src/interaction/components/InteractableComponent.ts b/packages/engine/src/interaction/components/InteractableComponent.ts
index 843b39b47d..4be83df0d0 100755
--- a/packages/engine/src/interaction/components/InteractableComponent.ts
+++ b/packages/engine/src/interaction/components/InteractableComponent.ts
@@ -29,7 +29,6 @@ import matches from 'ts-matches'
import { isClient } from '@etherealengine/common/src/utils/getEnvironment'
import {
ECSState,
- Engine,
Entity,
EntityUUID,
getComponent,
@@ -70,10 +69,12 @@ import {
DistanceFromCameraComponent,
DistanceFromLocalClientComponent
} from '@etherealengine/spatial/src/transform/components/DistanceComponents'
+import { useXRUIState } from '@etherealengine/spatial/src/xrui/functions/useXRUIState'
import { useEffect } from 'react'
import { AvatarComponent } from '../../avatar/components/AvatarComponent'
import { createUI } from '../functions/createUI'
import { inFrustum, InteractableState, InteractableTransitions } from '../functions/interactableFunctions'
+import { InteractiveModalState } from '../ui/InteractiveModalView'
/**
* Visibility override for XRUI, none is default behavior, on or off forces that state
@@ -131,7 +132,7 @@ export const updateInteractableUI = (entity: Entity) => {
xruiTransform.position.z = center.z
xruiTransform.position.y = MathUtils.lerp(xruiTransform.position.y, center.y + 0.7 * size.y, alpha)
- const cameraTransform = getComponent(Engine.instance.viewerEntity, TransformComponent)
+ const cameraTransform = getComponent(getState(EngineState).viewerEntity, TransformComponent)
xruiTransform.rotation.copy(cameraTransform.rotation)
}
@@ -145,7 +146,7 @@ export const updateInteractableUI = (entity: Entity) => {
const transition = InteractableTransitions.get(entity)!
let activateUI = false
- const inCameraFrustum = inFrustum(entity)
+ const inCameraFrustum = inFrustum(interactable.uiEntity)
let hovering = false
if (inCameraFrustum) {
@@ -204,9 +205,9 @@ const addInteractableUI = (entity: Entity) => {
const uiEntity = createUI(entity, interactable.label, interactable.uiInteractable).entity
getMutableComponent(entity, InteractableComponent).uiEntity.set(uiEntity)
- setComponent(uiEntity, EntityTreeComponent, { parentEntity: Engine.instance.originEntity })
+ setComponent(uiEntity, EntityTreeComponent, { parentEntity: getState(EngineState).originEntity })
setComponent(uiEntity, ComputedTransformComponent, {
- referenceEntities: [entity, Engine.instance.viewerEntity],
+ referenceEntities: [entity, getState(EngineState).viewerEntity],
computeFunction: () => updateInteractableUI(entity)
})
@@ -297,29 +298,19 @@ export const InteractableComponent = defineComponent({
const entity = useEntityContext()
const interactableComponent = useComponent(entity, InteractableComponent)
const isEditing = useMutableState(EngineState).isEditing
+ const modalState = useXRUIState()
useImmediateEffect(() => {
setComponent(entity, DistanceFromCameraComponent)
setComponent(entity, DistanceFromLocalClientComponent)
-
+ setComponent(entity, BoundingBoxComponent)
return () => {
removeComponent(entity, DistanceFromCameraComponent)
removeComponent(entity, DistanceFromLocalClientComponent)
+ removeComponent(entity, BoundingBoxComponent)
}
}, [])
- useImmediateEffect(() => {
- if (
- interactableComponent.uiActivationType.value === XRUIActivationType.hover ||
- interactableComponent.clickInteract.value
- ) {
- setComponent(entity, BoundingBoxComponent)
- return () => {
- removeComponent(entity, BoundingBoxComponent)
- }
- }
- }, [interactableComponent.uiActivationType, interactableComponent.clickInteract])
-
InputComponent.useExecuteWithInput(
() => {
const buttons = InputComponent.getMergedButtons(entity)
@@ -348,6 +339,12 @@ export const InteractableComponent = defineComponent({
}
}
}, [isEditing.value])
+
+ useEffect(() => {
+ //const xrUI = getMutableComponent(interactableComponent.uiEntity, XRUIComponent)
+ const msg = interactableComponent.label?.value ?? ''
+ modalState.interactMessage?.set(msg)
+ }, [interactableComponent.label]) //TODO just nuke the whole XRUI and recreate....
return null
}
})
diff --git a/packages/engine/src/interaction/functions/createUI.ts b/packages/engine/src/interaction/functions/createUI.ts
index 0148babac1..f5f8253652 100755
--- a/packages/engine/src/interaction/functions/createUI.ts
+++ b/packages/engine/src/interaction/functions/createUI.ts
@@ -30,12 +30,6 @@ import { TransformComponent } from '@etherealengine/spatial/src/transform/compon
import { XRUIComponent } from '@etherealengine/spatial/src/xrui/components/XRUIComponent'
import { WebLayer3D } from '@etherealengine/xrui'
-import { createEntity } from '@etherealengine/ecs'
-import { addObjectToGroup } from '@etherealengine/spatial/src/renderer/components/GroupComponent'
-import { MeshComponent } from '@etherealengine/spatial/src/renderer/components/MeshComponent'
-import { VisibleComponent } from '@etherealengine/spatial/src/renderer/components/VisibleComponent'
-import { EntityTreeComponent } from '@etherealengine/spatial/src/transform/components/EntityTree'
-import { Color, DoubleSide, Mesh, MeshPhysicalMaterial, Shape, ShapeGeometry, Vector3 } from 'three'
import { createModalView } from '../ui/InteractiveModalView'
/**
@@ -48,22 +42,6 @@ import { createModalView } from '../ui/InteractiveModalView'
export function createUI(entity: Entity, uiMessage: string, isInteractable = true) {
const ui = createModalView(entity, uiMessage, isInteractable)
- const blurMat = new MeshPhysicalMaterial({
- color: new Color('#B9B9B9'),
- transmission: 1,
- roughness: 0.5,
- opacity: 1,
- transparent: true,
- side: DoubleSide
- })
-
- const backgroundEid = createEntity()
- const mesh = new Mesh(roundedRect(-(100 / 1000) / 2, -(100 / 1000) / 2, 100 / 1000, 100 / 1000, 0.01), blurMat)
- setComponent(backgroundEid, EntityTreeComponent, { parentEntity: ui.entity })
- setComponent(backgroundEid, MeshComponent, mesh)
- setComponent(backgroundEid, VisibleComponent)
- const backgroundTransform = setComponent(backgroundEid, TransformComponent, { position: new Vector3(0, 0, -0.001) })
- addObjectToGroup(backgroundEid, mesh) // TODO: this should be managed by the MeshComponent
const nameComponent = getComponent(entity, NameComponent)
setComponent(ui.entity, NameComponent, 'interact-ui-' + uiMessage + '-' + nameComponent)
@@ -77,17 +55,3 @@ export function createUI(entity: Entity, uiMessage: string, isInteractable = tru
return ui
}
-
-function roundedRect(x: number, y: number, width: number, height: number, radius: number): ShapeGeometry {
- const shape = new Shape()
- shape.moveTo(x, y + radius)
- shape.lineTo(x, y + height - radius)
- shape.quadraticCurveTo(x, y + height, x + radius, y + height)
- shape.lineTo(x + width - radius, y + height)
- shape.quadraticCurveTo(x + width, y + height, x + width, y + height - radius)
- shape.lineTo(x + width, y + radius)
- shape.quadraticCurveTo(x + width, y, x + width - radius, y)
- shape.lineTo(x + radius, y)
- shape.quadraticCurveTo(x, y, x, y + radius)
- return new ShapeGeometry(shape)
-}
diff --git a/packages/engine/src/interaction/ui/InteractiveModalView.tsx b/packages/engine/src/interaction/ui/InteractiveModalView.tsx
index b34f3407be..f13ab90866 100755
--- a/packages/engine/src/interaction/ui/InteractiveModalView.tsx
+++ b/packages/engine/src/interaction/ui/InteractiveModalView.tsx
@@ -26,32 +26,91 @@ Ethereal Engine. All Rights Reserved.
import React from 'react'
import { isClient } from '@etherealengine/common/src/utils/getEnvironment'
+import { createEntity, setComponent } from '@etherealengine/ecs'
import { Entity } from '@etherealengine/ecs/src/Entity'
import { hookstate } from '@etherealengine/hyperflux'
+import { TransformComponent } from '@etherealengine/spatial'
+import { addObjectToGroup } from '@etherealengine/spatial/src/renderer/components/GroupComponent'
+import { MeshComponent } from '@etherealengine/spatial/src/renderer/components/MeshComponent'
+import { VisibleComponent } from '@etherealengine/spatial/src/renderer/components/VisibleComponent'
+import { EntityTreeComponent } from '@etherealengine/spatial/src/transform/components/EntityTree'
import { createXRUI } from '@etherealengine/spatial/src/xrui/functions/createXRUI'
import { useXRUIState } from '@etherealengine/spatial/src/xrui/functions/useXRUIState'
+import { Color, DoubleSide, Mesh, MeshPhysicalMaterial, Shape, ShapeGeometry, Vector3 } from 'three'
export interface InteractiveModalState {
interactMessage: string
}
export const createModalView = (entity: Entity, interactMessage: string, isInteractable = true) => {
+ const uiEntity = createEntity()
const ui = createXRUI(
- InteractiveModalView,
+ () => InteractiveModalView(uiEntity),
hookstate({
interactMessage
} as InteractiveModalState),
- { interactable: isInteractable }
+ { interactable: isInteractable },
+ uiEntity
)
return ui
}
-export const InteractiveModalView = () => {
+function createBackground(parentEntity: Entity, width: number, height: number): Entity {
+ const blurMat = new MeshPhysicalMaterial({
+ color: new Color('#B9B9B9'),
+ transmission: 1,
+ roughness: 0.5,
+ opacity: 1,
+ transparent: true,
+ side: DoubleSide
+ })
+
+ const backgroundEid = createEntity()
+ const calcWidth = width + 30 // 30 accounts for padding and border radius in the Element styling
+ const calcHeight = height + 30
+ const mesh = new Mesh(
+ roundedRect(-(calcWidth / 1000) / 2, -(calcHeight / 1000) / 2, calcWidth / 1000, calcHeight / 1000, 0.01),
+ blurMat
+ )
+ setComponent(backgroundEid, EntityTreeComponent, { parentEntity: parentEntity })
+ setComponent(backgroundEid, MeshComponent, mesh)
+ setComponent(backgroundEid, VisibleComponent)
+ const backgroundTransform = setComponent(backgroundEid, TransformComponent, { position: new Vector3(0, 0, -0.001) })
+ addObjectToGroup(backgroundEid, mesh) // TODO: this should be managed by the MeshComponent
+ return backgroundEid
+}
+
+function roundedRect(x: number, y: number, width: number, height: number, radius: number): ShapeGeometry {
+ const shape = new Shape()
+ shape.moveTo(x, y + radius)
+ shape.lineTo(x, y + height - radius)
+ shape.quadraticCurveTo(x, y + height, x + radius, y + height)
+ shape.lineTo(x + width - radius, y + height)
+ shape.quadraticCurveTo(x + width, y + height, x + width, y + height - radius)
+ shape.lineTo(x + width, y + radius)
+ shape.quadraticCurveTo(x + width, y, x + width - radius, y)
+ shape.lineTo(x + radius, y)
+ shape.quadraticCurveTo(x, y, x, y + radius)
+ return new ShapeGeometry(shape)
+}
+
+export const InteractiveModalView: React.FC = (entity: Entity) => {
const modalState = useXRUIState()
+ const rootElement = React.useRef(null)
+
if (!isClient) return <>>
+
+ React.useLayoutEffect(() => {
+ if (rootElement.current) {
+ createBackground(entity, rootElement.current.clientWidth, rootElement.current.clientHeight)
+ }
+ }, [rootElement.current]) //TODO this isn't firing, not calculating size to add BG
+
return (
-
- E
+
+ {modalState.interactMessage.value && modalState.interactMessage.value !== ''
+ ? modalState.interactMessage.value
+ : 'E'}