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/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/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/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/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/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/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/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/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/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/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/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/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-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)
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/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[]
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/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/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..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 = (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 +310,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 +323,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 +330,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 +565,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 +631,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)}
/>
))}
>
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]
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 && }
>
) : (
diff --git a/packages/ui/src/primitives/tailwind/AvatarImage/index.tsx b/packages/ui/src/primitives/tailwind/AvatarImage/index.tsx
index e6de5995a2..e6b1f48ec3 100644
--- a/packages/ui/src/primitives/tailwind/AvatarImage/index.tsx
+++ b/packages/ui/src/primitives/tailwind/AvatarImage/index.tsx
@@ -43,15 +43,25 @@ export interface AvatarImageProps extends React.HTMLAttributes
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/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}
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 = ({