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/i18n/en/editor.json b/packages/client-core/i18n/en/editor.json
index 9544c2e0c7..cbd6e57567 100755
--- a/packages/client-core/i18n/en/editor.json
+++ b/packages/client-core/i18n/en/editor.json
@@ -568,7 +568,7 @@
},
"hemisphere": {
"name": "Hemisphere Light",
- "description": "A light which illuminates the scene from directly overhead.",
+ "description": "A light which illuminates the scene with a sky color from above and a ground color from below.",
"lbl-skyColor": "Sky Color",
"lbl-groundColor": "Ground Color",
"lbl-intensity": "Intensity"
@@ -1177,7 +1177,7 @@
"point-light": "A light which emits in all directions from a single point.",
"spot-light": "Creates a light that shines in a specific direction.",
"directional-light": "Creates a light that emits evenly in a single direction.",
- "hemisphere-light": "A light which illuminates the scene from directly overhead.",
+ "hemisphere-light": "A light which illuminates the scene with a sky color from above and a ground color from below.",
"particle-system": "Creates a particle emitter.",
"system": "Inserts code into the scene by creating a new Entity Component System based on the provided .ts file",
"visual-script": "Customizes state and behavior of entities through a visual node connections.",
@@ -1217,6 +1217,7 @@
"uploadFiles": "Upload Files",
"uploadFolder": "Upload Folder",
"uploadingFiles": "Uploading Files ({{completed}}/{{total}})",
+ "downloadingProject": "Downloading Project ({{completed}}/{{total}})",
"search-placeholder": "Search",
"generatingThumbnails": "Generating Thumbnails ({{count}} remaining)",
"file": "File",
@@ -1360,7 +1361,10 @@
"lbl-thumbnail": "Generate thumbnail & envmap",
"lbl-confirm": "Save Scene",
"info-confirm": "Are you sure you want to save the scene?",
- "info-question": "Do you want to save the current scene?"
+ "info-question": "Do you want to save the current scene?",
+ "unsavedChanges": {
+ "title": "Unsaved Changes"
+ }
},
"saveNewScene": {
"title": "Save As",
diff --git a/packages/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/common/src/utils/btyesToSize.ts b/packages/common/src/utils/btyesToSize.ts
new file mode 100644
index 0000000000..edffca23f8
--- /dev/null
+++ b/packages/common/src/utils/btyesToSize.ts
@@ -0,0 +1,43 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+/**
+ * Converts bytes to a human-readable size
+ * @param bytes The number of bytes
+ * @param decimals The number of decimal places to include
+ * @returns The human-readable size
+ */
+
+export function bytesToSize(bytes: number, decimals = 2) {
+ if (bytes === 0) return '0 Bytes'
+
+ const k = 1024
+ const dm = decimals < 0 ? 0 : decimals
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
+}
diff --git a/packages/common/src/utils/getOS.ts b/packages/common/src/utils/getDeviceStats.ts
similarity index 75%
rename from packages/common/src/utils/getOS.ts
rename to packages/common/src/utils/getDeviceStats.ts
index 40e44c697e..5fa025b4d2 100644
--- a/packages/common/src/utils/getOS.ts
+++ b/packages/common/src/utils/getDeviceStats.ts
@@ -35,3 +35,19 @@ export function getOS() {
}
return 'other'
}
+
+export const isApple = () => {
+ if ('navigator' in globalThis === false) return false
+
+ const iOS_1to12 = /iPad|iPhone|iPod/.test(navigator.platform)
+
+ const iOS13_iPad = navigator.platform === 'MacIntel'
+
+ const iOS1to12quirk = () => {
+ const audio = new Audio() // temporary Audio object
+ audio.volume = 0.5 // has no effect on iOS <= 12
+ return audio.volume === 1
+ }
+
+ return iOS_1to12 || iOS13_iPad || iOS1to12quirk()
+}
diff --git a/packages/common/src/utils/mapToObject.ts b/packages/common/src/utils/mapToObject.ts
index 9db2ca7dfe..6370aa323f 100644
--- a/packages/common/src/utils/mapToObject.ts
+++ b/packages/common/src/utils/mapToObject.ts
@@ -52,3 +52,7 @@ export const iterativeMapToObject = (root: Record) => {
}
return cloneDeep(iterate(root))
}
+
+export function objectToMap(object: object) {
+ return new Map(Object.entries(object))
+}
diff --git a/packages/common/src/utils/miscUtils.ts b/packages/common/src/utils/miscUtils.ts
index 24b6f9ff93..3cdbb44c5c 100644
--- a/packages/common/src/utils/miscUtils.ts
+++ b/packages/common/src/utils/miscUtils.ts
@@ -38,6 +38,11 @@ export function isNumber(value: string | number): boolean {
return value != null && value !== '' && !isNaN(Number(value.toString()))
}
+export function toPrecision(value, precision) {
+ const p = 1 / precision
+ return Math.round(value * p) / p
+}
+
export function combine(first, second, third) {
const res: any[] = []
@@ -47,6 +52,23 @@ export function combine(first, second, third) {
return res
}
+
+export const unique = (arr: T[], keyFinder: (item: T) => S): T[] => {
+ const set = new Set()
+ const newArr = [] as T[]
+ if (!keyFinder) keyFinder = (item: T) => item as any as S
+
+ for (const item of arr) {
+ const key = keyFinder(item)
+ if (set.has(key)) continue
+
+ newArr.push(item)
+ set.add(key)
+ }
+
+ return newArr
+}
+
export function combineArrays(arrays: [[]]) {
const res = []
@@ -59,6 +81,23 @@ export function combineArrays(arrays: [[]]) {
return res
}
+export function insertArraySeparator(children, separatorFn) {
+ if (!Array.isArray(children)) {
+ return children
+ }
+ const length = children.length
+ if (length === 1) {
+ return children[0]
+ }
+ return children.reduce((acc, item, index) => {
+ acc.push(item)
+ if (index !== length - 1) {
+ acc.push(separatorFn(index))
+ }
+ return acc
+ }, [])
+}
+
export function arraysAreEqual(arr1: any[], arr2: any[]): boolean {
if (arr1.length !== arr2.length) return false
@@ -154,3 +193,14 @@ export const toCapitalCase = (source: string) => {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()
})
}
+
+export function toCamelPad(source: string) {
+ return source
+ .replace(/([A-Z]+)([A-Z][a-z])/g, ' $1 $2')
+ .replace(/([a-z\d])([A-Z])/g, '$1 $2')
+ .replace(/([a-zA-Z])(\d)/g, '$1 $2')
+ .replace(/^./, (str) => {
+ return str.toUpperCase()
+ })
+ .trim()
+}
diff --git a/packages/editor/src/components/EditorContainer.tsx b/packages/editor/src/components/EditorContainer.tsx
index 6f374f3b35..e62b06f908 100644
--- a/packages/editor/src/components/EditorContainer.tsx
+++ b/packages/editor/src/components/EditorContainer.tsx
@@ -200,6 +200,16 @@ const EditorContainer = () => {
}
}, [errorState])
+ useEffect(() => {
+ const handleBeforeUnload = async (event: BeforeUnloadEvent) => {
+ if (EditorState.isModified()) {
+ event.preventDefault()
+ }
+ }
+ window.addEventListener('beforeunload', handleBeforeUnload)
+ return () => window.removeEventListener('beforeunload', handleBeforeUnload)
+ }, [])
+
return (
return (
{
PopoverState.hidePopupover()
diff --git a/packages/editor/src/components/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/components/toolbar/Toolbar.tsx b/packages/editor/src/components/toolbar/Toolbar.tsx
index 316da9c6c9..4ecf87012e 100644
--- a/packages/editor/src/components/toolbar/Toolbar.tsx
+++ b/packages/editor/src/components/toolbar/Toolbar.tsx
@@ -60,25 +60,21 @@ const onImportAsset = async () => {
}
}
-const onClickNewScene = async () => {
+export const confirmSceneSaveIfModified = async () => {
const isModified = EditorState.isModified()
if (isModified) {
- const confirm = await new Promise((resolve) => {
+ return new Promise((resolve) => {
PopoverState.showPopupover(
- {
- resolve(true)
- }}
- onCancel={() => {
- resolve(false)
- }}
- />
+ resolve(true)} onCancel={() => resolve(false)} />
)
})
- if (!confirm) return
}
+ return true
+}
+
+const onClickNewScene = async () => {
+ if (!(await confirmSceneSaveIfModified())) return
const newSceneUIAddons = getState(EditorState).uiAddons.newScene
if (Object.keys(newSceneUIAddons).length > 0) {
@@ -89,24 +85,7 @@ const onClickNewScene = async () => {
}
const onCloseProject = async () => {
- const isModified = EditorState.isModified()
-
- if (isModified) {
- const confirm = await new Promise((resolve) => {
- PopoverState.showPopupover(
- {
- resolve(true)
- }}
- onCancel={() => {
- resolve(false)
- }}
- />
- )
- })
- if (!confirm) return
- }
+ if (!(await confirmSceneSaveIfModified())) return
const editorState = getMutableState(EditorState)
getMutableState(GLTFModifiedState).set({})
diff --git a/packages/editor/src/functions/gizmoHelper.ts b/packages/editor/src/functions/gizmoHelper.ts
index e9e80e8f28..77b1ece16e 100644
--- a/packages/editor/src/functions/gizmoHelper.ts
+++ b/packages/editor/src/functions/gizmoHelper.ts
@@ -533,7 +533,9 @@ function pointerDown(gizmoEntity) {
const planeIntersect = intersectObjectWithRay(plane, _raycaster, true)
if (planeIntersect) {
const currenttransform = getComponent(targetEntity, TransformComponent)
- currenttransform.matrix.decompose(_positionStart, _quaternionStart, _scaleStart)
+ _positionStart.copy(currenttransform.position)
+ _quaternionStart.copy(currenttransform.rotation)
+ _scaleStart.copy(currenttransform.scale)
gizmoControlComponent.worldPositionStart.set(_positionStart)
gizmoControlComponent.worldQuaternionStart.set(_quaternionStart)
diff --git a/packages/editor/src/functions/utils.ts b/packages/editor/src/functions/utils.ts
index 38e654987e..f1d2b197f5 100755
--- a/packages/editor/src/functions/utils.ts
+++ b/packages/editor/src/functions/utils.ts
@@ -22,60 +22,7 @@ Original Code is the Ethereal Engine team.
All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
Ethereal Engine. All Rights Reserved.
*/
-
-export function insertSeparator(children, separatorFn) {
- if (!Array.isArray(children)) {
- return children
- }
- const length = children.length
- if (length === 1) {
- return children[0]
- }
- return children.reduce((acc, item, index) => {
- acc.push(item)
- if (index !== length - 1) {
- acc.push(separatorFn(index))
- }
- return acc
- }, [])
-}
-export function objectToMap(object: object) {
- return new Map(Object.entries(object))
-}
-
-export const unique = (arr: T[], keyFinder: (item: T) => S): T[] => {
- const set = new Set()
- const newArr = [] as T[]
- if (!keyFinder) keyFinder = (item: T) => item as any as S
-
- for (const item of arr) {
- const key = keyFinder(item)
- if (set.has(key)) continue
-
- newArr.push(item)
- set.add(key)
- }
-
- return newArr
-}
-
-export const isApple = () => {
- if ('navigator' in globalThis === false) return false
-
- const iOS_1to12 = /iPad|iPhone|iPod/.test(navigator.platform)
-
- const iOS13_iPad = navigator.platform === 'MacIntel'
-
- const iOS1to12quirk = () => {
- const audio = new Audio() // temporary Audio object
- audio.volume = 0.5 // has no effect on iOS <= 12
- return audio.volume === 1
- }
-
- return iOS_1to12 || iOS13_iPad || iOS1to12quirk()
-}
-
-export const cmdOrCtrlString = isApple() ? 'meta' : 'ctrl'
+import { isApple } from '@etherealengine/common/src/utils/getDeviceStats'
export function getStepSize(event, smallStep, mediumStep, largeStep) {
if (event.altKey) {
@@ -86,29 +33,4 @@ export function getStepSize(event, smallStep, mediumStep, largeStep) {
return mediumStep
}
-export function toPrecision(value, precision) {
- const p = 1 / precision
- return Math.round(value * p) / p
-}
-// https://stackoverflow.com/a/26188910
-export function camelPad(str) {
- return str
- .replace(/([A-Z]+)([A-Z][a-z])/g, ' $1 $2')
- .replace(/([a-z\d])([A-Z])/g, '$1 $2')
- .replace(/([a-zA-Z])(\d)/g, '$1 $2')
- .replace(/^./, (str) => {
- return str.toUpperCase()
- })
- .trim()
-}
-export function bytesToSize(bytes: number, decimals = 2) {
- if (bytes === 0) return '0 Bytes'
-
- const k = 1024
- const dm = decimals < 0 ? 0 : decimals
- const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
-
- const i = Math.floor(Math.log(bytes) / Math.log(k))
-
- return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
-}
+export const cmdOrCtrlString = isApple() ? 'meta' : 'ctrl'
diff --git a/packages/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/spatial/src/renderer/csm/CSM.ts b/packages/spatial/src/renderer/csm/CSM.ts
index 32e7a338db..40f1a97617 100644
--- a/packages/spatial/src/renderer/csm/CSM.ts
+++ b/packages/spatial/src/renderer/csm/CSM.ts
@@ -43,6 +43,7 @@ import { Engine } from '@etherealengine/ecs/src/Engine'
import { Entity } from '@etherealengine/ecs/src/Entity'
import { createEntity, removeEntity } from '@etherealengine/ecs/src/EntityFunctions'
+import { getState } from '@etherealengine/hyperflux'
import { CameraComponent } from '../../camera/components/CameraComponent'
import { NameComponent } from '../../common/NameComponent'
import { Vector3_Zero } from '../../common/constants/MathConstants'
@@ -51,6 +52,7 @@ import { addObjectToGroup } from '../../renderer/components/GroupComponent'
import { VisibleComponent } from '../../renderer/components/VisibleComponent'
import { EntityTreeComponent } from '../../transform/components/EntityTree'
import { TransformComponent } from '../../transform/components/TransformComponent'
+import { RendererState } from '../RendererState'
import Frustum from './Frustum'
import Shader from './Shader'
@@ -319,7 +321,8 @@ export class CSM {
if (this.sourceLight) this.lightDirection.subVectors(this.sourceLight.target.position, this.sourceLight.position)
if (this.needsUpdate) {
this.injectInclude()
- this.updateFrustums()
+ // Only update uniforms if WebGLRendererSystem isn't already updating them every frame
+ this.updateFrustums(!getState(RendererState).updateCSMFrustums)
for (const light of this.lights) {
light.shadow.map?.dispose()
light.shadow.map = null as any
@@ -439,7 +442,8 @@ export class CSM {
updateUniforms(): void {
const camera = getComponent(Engine.instance.cameraEntity, CameraComponent)
const far = Math.min(camera.far, this.maxFar)
- this.shaders.forEach(function (shader: ShaderType, material: Material) {
+
+ for (const [material, shader] of this.shaders.entries()) {
const camera = getComponent(Engine.instance.cameraEntity, CameraComponent)
if (shader !== null) {
@@ -456,7 +460,7 @@ export class CSM {
material.defines!.CSM_FADE = ''
material.needsUpdate = true
}
- }, this)
+ }
}
getExtendedBreaks(target: Vector2[]): void {
@@ -474,11 +478,11 @@ export class CSM {
}
}
- updateFrustums(): void {
+ updateFrustums(updateUniforms = true): void {
this.getBreaks()
this.initCascades()
this.updateShadowBounds()
- this.updateUniforms()
+ if (updateUniforms) this.updateUniforms()
}
remove(): void {
diff --git a/packages/ui/src/components/editor/input/Numeric/index.tsx b/packages/ui/src/components/editor/input/Numeric/index.tsx
index ba948c519a..d6feb6c37e 100644
--- a/packages/ui/src/components/editor/input/Numeric/index.tsx
+++ b/packages/ui/src/components/editor/input/Numeric/index.tsx
@@ -27,7 +27,8 @@ import React from 'react'
import { clamp } from '@etherealengine/spatial/src/common/functions/MathLerpFunctions'
-import { getStepSize, toPrecision } from '@etherealengine/editor/src/functions/utils'
+import { toPrecision } from '@etherealengine/common/src/utils/miscUtils'
+import { getStepSize } from '@etherealengine/editor/src/functions/utils'
import { useHookstate } from '@etherealengine/hyperflux'
import { twMerge } from 'tailwind-merge'
import Text from '../../../../primitives/tailwind/Text'
diff --git a/packages/ui/src/components/editor/layout/Scrubber.tsx b/packages/ui/src/components/editor/layout/Scrubber.tsx
index 2eccf3f469..08198f3f4b 100644
--- a/packages/ui/src/components/editor/layout/Scrubber.tsx
+++ b/packages/ui/src/components/editor/layout/Scrubber.tsx
@@ -23,7 +23,8 @@ All portions of the code written by the Ethereal Engine team are Copyright © 20
Ethereal Engine. All Rights Reserved.
*/
-import { getStepSize, toPrecision } from '@etherealengine/editor/src/functions/utils'
+import { toPrecision } from '@etherealengine/common/src/utils/miscUtils'
+import { getStepSize } from '@etherealengine/editor/src/functions/utils'
import { useHookstate } from '@etherealengine/hyperflux'
import React, { useRef } from 'react'
import { twMerge } from 'tailwind-merge'
diff --git a/packages/ui/src/components/editor/panels/Files/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..5793b4bc76 100644
--- a/packages/ui/src/components/editor/panels/Files/container/index.tsx
+++ b/packages/ui/src/components/editor/panels/Files/container/index.tsx
@@ -25,18 +25,17 @@ Ethereal Engine. All Rights Reserved.
import { FileThumbnailJobState } from '@etherealengine/client-core/src/common/services/FileThumbnailJobState'
import { NotificationService } from '@etherealengine/client-core/src/common/services/NotificationService'
import { PopoverState } from '@etherealengine/client-core/src/common/services/PopoverState'
-import config from '@etherealengine/common/src/config'
import {
FileBrowserContentType,
StaticResourceType,
UserID,
- archiverPath,
fileBrowserPath,
projectPath,
staticResourcePath
} from '@etherealengine/common/src/schema.type.module'
import { CommonKnownContentTypes } from '@etherealengine/common/src/utils/CommonKnownContentTypes'
-import { Engine } from '@etherealengine/ecs'
+import { bytesToSize } from '@etherealengine/common/src/utils/btyesToSize'
+import { unique } from '@etherealengine/common/src/utils/miscUtils'
import { AssetSelectionChangePropsType } from '@etherealengine/editor/src/components/assets/AssetsPreviewPanel'
import {
FilesViewModeSettings,
@@ -48,12 +47,7 @@ import ImageCompressionPanel from '@etherealengine/editor/src/components/assets/
import ModelCompressionPanel from '@etherealengine/editor/src/components/assets/ModelCompressionPanel'
import { DndWrapper } from '@etherealengine/editor/src/components/dnd/DndWrapper'
import { SupportedFileTypes } from '@etherealengine/editor/src/constants/AssetTypes'
-import {
- downloadBlobAsZip,
- handleUploadFiles,
- inputFileWithAddToScene
-} from '@etherealengine/editor/src/functions/assetFunctions'
-import { bytesToSize, unique } from '@etherealengine/editor/src/functions/utils'
+import { handleUploadFiles, inputFileWithAddToScene } from '@etherealengine/editor/src/functions/assetFunctions'
import { EditorState } from '@etherealengine/editor/src/services/EditorServices'
import { ClickPlacementState } from '@etherealengine/editor/src/systems/ClickPlacementSystem'
import { AssetLoader } from '@etherealengine/engine/src/assets/classes/AssetLoader'
@@ -79,12 +73,14 @@ import Input from '../../../../../primitives/tailwind/Input'
import LoadingView from '../../../../../primitives/tailwind/LoadingView'
import Slider from '../../../../../primitives/tailwind/Slider'
import Tooltip from '../../../../../primitives/tailwind/Tooltip'
+import { ContextMenu } from '../../../../tailwind/ContextMenu'
import { Popup } from '../../../../tailwind/Popup'
import BooleanInput from '../../../input/Boolean'
import InputGroup from '../../../input/Group'
import { FileBrowserItem, FileTableWrapper, canDropItemOverFolder } from '../browserGrid'
import DeleteFileModal from '../browserGrid/DeleteFileModal'
import FilePropertiesModal from '../browserGrid/FilePropertiesModal'
+import { ProjectDownloadProgress, handleDownloadProject } from '../download/projectDownload'
import { FileUploadProgress } from '../upload/FileUploadProgress'
type FileBrowserContentPanelProps = {
@@ -140,6 +136,7 @@ export const createStaticResourceDigest = (staticResources: ImmutableArray {
+ const { t } = useTranslation()
+
+ const filesViewMode = useMutableState(FilesViewModeState).viewMode
+
+ const viewModeSettings = useHookstate(getMutableState(FilesViewModeSettings))
+ return (
+
+ } className="h-7 w-7 rounded-lg bg-[#2F3137] p-0" />
+
+ }
+ >
+ {filesViewMode.value === 'icons' ? (
+
+
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+ {availableTableColumns.map((column) => (
+
+ viewModeSettings.list.selectedTableColumns[column].set(value)}
+ />
+
+ ))}
+
+
+ >
+ )}
+
+ )
+}
/**
* FileBrowserPanel used to render view for AssetsPanel.
*/
@@ -236,6 +293,12 @@ const FileBrowserContentPanel: React.FC = (props)
const selectedDirectory = useHookstate(props.originalPath)
+ const downloadState = useHookstate({
+ total: 0,
+ progress: 0,
+ isDownloading: false
+ })
+
const projectName = useValidProjectForFileBrowser(selectedDirectory.value)
const orgName = projectName.includes('/') ? projectName.split('/')[0] : ''
@@ -297,7 +360,10 @@ const FileBrowserContentPanel: React.FC = (props)
}
const onSelect = (event, params: FileDataType) => {
- if (params.type !== 'folder') {
+ if (params.isFolder && event.detail === 2) {
+ const newPath = `${selectedDirectory.value}${params.name}/`
+ changeDirectoryByPath(newPath)
+ } else {
props.onSelectionChanged({
resourceUrl: params.url,
name: params.name,
@@ -306,11 +372,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 +385,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 +392,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 {
@@ -419,27 +475,6 @@ const FileBrowserContentPanel: React.FC = (props)
selectedDirectory.value.startsWith('/projects/' + projectName + '/assets/')
const showBackButton = selectedDirectory.value.split('/').length > props.originalPath.split('/').length
- const handleDownloadProject = async () => {
- const data = await Engine.instance.api
- .service(archiverPath)
- .get(null, { query: { project: projectName } })
- .catch((err: Error) => {
- NotificationService.dispatchNotify(err.message, { variant: 'warning' })
- return null
- })
- if (!data) return
- const blob = await (await fetch(`${config.client.fileServer}/${data}`)).blob()
-
- let fileName: string
- if (selectedDirectory.value.at(-1) === '/') {
- fileName = selectedDirectory.value.split('/').at(-2) as string
- } else {
- fileName = selectedDirectory.value.split('/').at(-1) as string
- }
-
- downloadBlobAsZip(blob, fileName)
- }
-
const BreadcrumbItems = () => {
const handleBreadcrumbDirectoryClick = (targetFolder: string) => {
if (orgName && targetFolder === 'projects') return
@@ -549,110 +584,155 @@ const FileBrowserContentPanel: React.FC = (props)
ClickPlacementState.resetSelectedAsset()
}
+ const [anchorEvent, setAnchorEvent] = React.useState>(undefined)
+ const handleClose = () => {
+ setAnchorEvent(undefined)
+ }
+
+ const pasteContent = async () => {
+ handleClose()
+ if (isLoading) return
+
+ fileService.update(null, {
+ oldProject: projectName,
+ newProject: projectName,
+ oldName: currentContentRef.current.item.fullName,
+ newName: currentContentRef.current.item.fullName,
+ oldPath: currentContentRef.current.item.path,
+ newPath: currentContentRef.current.item.path,
+ isCopy: currentContentRef.current.isCopy
+ })
+ }
+
return (
{
+ className="h-full"
+ onContextMenu={(event) => {
+ event.preventDefault()
event.stopPropagation()
- resetSelection()
+ setAnchorEvent(event)
}}
>
-
-
- <>
- {unique(files, (file) => file.key).map((file) => (
- {
- handleFileBrowserItemClick(event, currentFile)
- onSelect(event, file)
- }}
- onContextMenu={(event, currentFile) => {
- if (!fileProperties.value.length) {
- fileProperties.set([file])
- }
- }}
- currentContent={currentContentRef}
- handleDropItemsOnPanel={(data, dropOn) =>
- dropItemsOnPanel(
- data,
- dropOn,
- fileProperties.value.map((file) => file.key)
- )
- }
- openFileProperties={(item) => {
- /** If the file is not in the list of files, add it */
- if (!(fileProperties.get(NO_PROXY) as FileDataType[]).includes(item)) {
- if (fileProperties.value.length > 1) {
- fileProperties.merge([item])
- } else {
- fileProperties.set([item])
+ {
+ event.stopPropagation()
+ resetSelection()
+ }}
+ >
+
+
+ <>
+ {unique(files, (file) => file.key).map((file) => (
+ {
+ handleFileBrowserItemClick(event, file)
+ onSelect(event, file)
+ }}
+ onContextMenu={(event, currentFile) => {
+ if (!fileProperties.value.length) {
+ fileProperties.set([file])
}
+ }}
+ currentContent={currentContentRef}
+ handleDropItemsOnPanel={(data, dropOn) =>
+ dropItemsOnPanel(
+ data,
+ dropOn,
+ fileProperties.value.map((file) => file.key)
+ )
}
- PopoverState.showPopupover(
-
- )
- }}
- openDeleteFileModal={() => {
- PopoverState.showPopupover(
- {
- resetSelection()
- }}
- />
- )
- }}
- openImageCompress={() => {
- if (filesConsistOfContentType(fileProperties.value, 'image')) {
+ openFileProperties={(item) => {
+ /** If the file is not in the list of files, add it */
+ if (!(fileProperties.get(NO_PROXY) as FileDataType[]).includes(item)) {
+ if (fileProperties.value.length > 1) {
+ fileProperties.merge([item])
+ } else {
+ fileProperties.set([item])
+ }
+ }
PopoverState.showPopupover(
-
+
)
- }
- }}
- openModelCompress={() => {
- if (filesConsistOfContentType(fileProperties.value, 'model')) {
+ }}
+ openDeleteFileModal={() => {
PopoverState.showPopupover(
- {
+ resetSelection()
+ }}
/>
)
- }
- }}
- isFilesLoading={isLoading}
- addFolder={createNewFolder}
- isListView={isListView}
- staticResourceModifiedDates={staticResourceModifiedDates.value}
- isSelected={fileProperties.value.some(({ key }) => key === file.key)}
- refreshDirectory={refreshDirectory}
- />
- ))}
- >
-
- {/*
- {total > 0 && validFiles.value.length < total && (
-
- )}*/}
+ }}
+ openImageCompress={() => {
+ if (filesConsistOfContentType(fileProperties.value, 'image')) {
+ PopoverState.showPopupover(
+
+ )
+ }
+ }}
+ openModelCompress={() => {
+ if (filesConsistOfContentType(fileProperties.value, 'model')) {
+ PopoverState.showPopupover(
+
+ )
+ }
+ }}
+ isFilesLoading={isLoading}
+ addFolder={createNewFolder}
+ isListView={isListView}
+ staticResourceModifiedDates={staticResourceModifiedDates.value}
+ isSelected={fileProperties.value.some(({ key }) => key === file.key)}
+ refreshDirectory={refreshDirectory}
+ selectedFileKeys={fileProperties.value.map((file) => file.key)}
+ />
+ ))}
+ >
+
+ {/*
+ {total > 0 && validFiles.value.length < total && (
+
+ )}*/}
+
+
+
+
+
+
)
@@ -768,7 +848,7 @@ const FileBrowserContentPanel: React.FC
= (props)
variant="transparent"
startIcon={}
className="p-0"
- onClick={handleDownloadProject}
+ onClick={() => handleDownloadProject(projectName, selectedDirectory.value)}
disabled={!showDownloadButtons}
/>
@@ -820,6 +900,7 @@ const FileBrowserContentPanel: React.FC = (props)
+
{isLoading && (
)}
@@ -848,64 +929,3 @@ export default function FilesPanelContainer() {
/>
)
}
-
-export const ViewModeSettings = () => {
- const { t } = useTranslation()
-
- const filesViewMode = useMutableState(FilesViewModeState).viewMode
-
- const viewModeSettings = useHookstate(getMutableState(FilesViewModeSettings))
- return (
-
- } className="h-7 w-7 rounded-lg bg-[#2F3137] p-0" />
-
- }
- >
- {filesViewMode.value === 'icons' ? (
-
-
-
- ) : (
- <>
-
-
-
-
-
-
-
-
-
- {availableTableColumns.map((column) => (
-
- viewModeSettings.list.selectedTableColumns[column].set(value)}
- />
-
- ))}
-
-
- >
- )}
-
- )
-}
diff --git a/packages/ui/src/components/editor/panels/Files/download/projectDownload.tsx b/packages/ui/src/components/editor/panels/Files/download/projectDownload.tsx
new file mode 100644
index 0000000000..bb6b4406dc
--- /dev/null
+++ b/packages/ui/src/components/editor/panels/Files/download/projectDownload.tsx
@@ -0,0 +1,111 @@
+/*
+CPAL-1.0 License
+
+The contents of this file are subject to the Common Public Attribution License
+Version 1.0. (the "License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE.
+The License is based on the Mozilla Public License Version 1.1, but Sections 14
+and 15 have been added to cover use of software over a computer network and
+provide for limited attribution for the Original Developer. In addition,
+Exhibit A has been modified to be consistent with Exhibit B.
+
+Software distributed under the License is distributed on an "AS IS" basis,
+WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the
+specific language governing rights and limitations under the License.
+
+The Original Code is Ethereal Engine.
+
+The Original Developer is the Initial Developer. The Initial Developer of the
+Original Code is the Ethereal Engine team.
+
+All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023
+Ethereal Engine. All Rights Reserved.
+*/
+
+import { NotificationService } from '@etherealengine/client-core/src/common/services/NotificationService'
+import config from '@etherealengine/common/src/config'
+import { archiverPath } from '@etherealengine/common/src/schema.type.module'
+import { bytesToSize } from '@etherealengine/common/src/utils/btyesToSize'
+import { Engine } from '@etherealengine/ecs'
+import { downloadBlobAsZip } from '@etherealengine/editor/src/functions/assetFunctions'
+import { defineState, getMutableState, useMutableState } from '@etherealengine/hyperflux'
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import Progress from '../../../../../primitives/tailwind/Progress'
+
+const DownloadProjectState = defineState({
+ name: 'DownloadProjectState',
+ initial: () => ({
+ total: 0,
+ progress: 0,
+ isDownloading: false
+ })
+})
+
+export const handleDownloadProject = async (projectName: string, selectedDirectory: string) => {
+ const data = await Engine.instance.api
+ .service(archiverPath)
+ .get(null, { query: { project: projectName } })
+ .catch((err: Error) => {
+ NotificationService.dispatchNotify(err.message, { variant: 'warning' })
+ return null
+ })
+ if (!data) return
+
+ const downloadState = getMutableState(DownloadProjectState)
+
+ downloadState.isDownloading.set(true) // Start Download
+
+ const response = await fetch(`${config.client.fileServer}/${data}`)
+ const totalBytes = parseInt(response.headers.get('Content-Length') || '0', 10)
+ downloadState.total.set(totalBytes) // Set the total bytes
+
+ const reader = response.body?.getReader()
+ const chunks: Uint8Array[] = []
+ let bytesReceived = 0
+
+ while (true) {
+ const { done, value } = await reader!.read()
+ if (done) break
+ chunks.push(value)
+ bytesReceived += value.length
+ downloadState.progress.set(bytesReceived)
+ }
+
+ const blob = new Blob(chunks)
+ downloadState.isDownloading.set(false) // Mark as completed
+ downloadState.progress.set(0)
+ downloadState.total.set(0)
+
+ let fileName: string
+ if (selectedDirectory.at(-1) === '/') {
+ fileName = selectedDirectory.split('/').at(-2) as string
+ } else {
+ fileName = selectedDirectory.split('/').at(-1) as string
+ }
+
+ downloadBlobAsZip(blob, fileName)
+}
+
+export const ProjectDownloadProgress = () => {
+ const { t } = useTranslation()
+ const downloadState = useMutableState(DownloadProjectState)
+ const isDownloading = downloadState.isDownloading.value
+ const completed = bytesToSize(downloadState.progress.value)
+ const total = bytesToSize(downloadState.total.value)
+ const progress = (downloadState.progress.value / downloadState.total.value) * 100
+
+ return isDownloading ? (
+
+
+
+ {t('editor:layout.filebrowser.downloadingProject', { completed, total })}
+
+
+
+
+ ) : null
+}
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/Properties/elementList/index.tsx b/packages/ui/src/components/editor/panels/Properties/elementList/index.tsx
index 03ae756def..07df1cbde3 100644
--- a/packages/ui/src/components/editor/panels/Properties/elementList/index.tsx
+++ b/packages/ui/src/components/editor/panels/Properties/elementList/index.tsx
@@ -78,10 +78,10 @@ const ComponentListItem = ({ item, onSelect }: { item: Component; onSelect: () =
startIcon={}
>
-
+
{startCase(jsonName.replace('-', ' ').toLowerCase())}
-
+
{t(`editor:layout.assetGrid.component-detail.${jsonName}`, '')}
@@ -107,8 +107,8 @@ const PrefabListItem = ({ item, onSelect }: { item: PrefabShelfItem; onSelect: (
startIcon={}
>
- {item.name}
-
+ {item.name}
+
{item.detail}
diff --git a/packages/ui/src/components/editor/panels/Properties/material/index.tsx b/packages/ui/src/components/editor/panels/Properties/material/index.tsx
index d9900fab51..1df3f0a34b 100644
--- a/packages/ui/src/components/editor/panels/Properties/material/index.tsx
+++ b/packages/ui/src/components/editor/panels/Properties/material/index.tsx
@@ -220,7 +220,7 @@ export function MaterialEditor(props: { materialUUID: EntityUUID }) {
- {getOptionalComponent(entity, SourceComponent) ?? 'None'}
+ {getOptionalComponent(entity, SourceComponent) ?? 'None'}
diff --git a/packages/ui/src/components/editor/panels/Scenes/container/index.tsx b/packages/ui/src/components/editor/panels/Scenes/container/index.tsx
index cb0883dfb8..e11d76ee1b 100644
--- a/packages/ui/src/components/editor/panels/Scenes/container/index.tsx
+++ b/packages/ui/src/components/editor/panels/Scenes/container/index.tsx
@@ -27,6 +27,7 @@ import { SceneItem } from '@etherealengine/client-core/src/admin/components/scen
import { PopoverState } from '@etherealengine/client-core/src/common/services/PopoverState'
import { StaticResourceType, fileBrowserPath, staticResourcePath } from '@etherealengine/common/src/schema.type.module'
import CreateSceneDialog from '@etherealengine/editor/src/components/dialogs/CreateScenePanelDialog'
+import { confirmSceneSaveIfModified } from '@etherealengine/editor/src/components/toolbar/Toolbar'
import { onNewScene } from '@etherealengine/editor/src/functions/sceneFunctions'
import { EditorState } from '@etherealengine/editor/src/services/EditorServices'
import { getMutableState, useHookstate, useMutableState } from '@etherealengine/hyperflux'
@@ -47,7 +48,9 @@ export default function ScenesPanel() {
const scenesLoading = scenesQuery.status === 'pending'
- const onClickScene = (scene: StaticResourceType) => {
+ const onClickScene = async (scene: StaticResourceType) => {
+ if (!(await confirmSceneSaveIfModified())) return
+
getMutableState(EditorState).merge({
scenePath: scene.key
})
diff --git a/packages/ui/src/components/editor/panels/Viewport/container/index.tsx b/packages/ui/src/components/editor/panels/Viewport/container/index.tsx
index e744cfb1b6..11531b6605 100644
--- a/packages/ui/src/components/editor/panels/Viewport/container/index.tsx
+++ b/packages/ui/src/components/editor/panels/Viewport/container/index.tsx
@@ -29,21 +29,19 @@ import { uploadToFeathersService } from '@etherealengine/client-core/src/util/up
import { FeatureFlags } from '@etherealengine/common/src/constants/FeatureFlags'
import { clientSettingPath, fileBrowserUploadPath } from '@etherealengine/common/src/schema.type.module'
import { processFileName } from '@etherealengine/common/src/utils/processFileName'
-import { getComponent, useComponent, useQuery } from '@etherealengine/ecs'
+import { useComponent, useQuery } from '@etherealengine/ecs'
import { ItemTypes, SupportedFileTypes } from '@etherealengine/editor/src/constants/AssetTypes'
import { EditorControlFunctions } from '@etherealengine/editor/src/functions/EditorControlFunctions'
import { addMediaNode } from '@etherealengine/editor/src/functions/addMediaNode'
import { getCursorSpawnPosition } from '@etherealengine/editor/src/functions/screenSpaceFunctions'
import { EditorState } from '@etherealengine/editor/src/services/EditorServices'
import { GLTFComponent } from '@etherealengine/engine/src/gltf/GLTFComponent'
-import { GLTFModifiedState } from '@etherealengine/engine/src/gltf/GLTFDocumentState'
import { ResourcePendingComponent } from '@etherealengine/engine/src/gltf/ResourcePendingComponent'
-import { SourceComponent } from '@etherealengine/engine/src/scene/components/SourceComponent'
import useFeatureFlags from '@etherealengine/engine/src/useFeatureFlags'
-import { getMutableState, useHookstate, useMutableState } from '@etherealengine/hyperflux'
+import { useMutableState } from '@etherealengine/hyperflux'
import { TransformComponent } from '@etherealengine/spatial'
import { useFind } from '@etherealengine/spatial/src/common/functions/FeathersHooks'
-import React, { useEffect } from 'react'
+import React from 'react'
import { useDrop } from 'react-dnd'
import { useTranslation } from 'react-i18next'
import { twMerge } from 'tailwind-merge'
@@ -123,31 +121,16 @@ 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
-
- useEffect(() => {
- if (!sceneModified) return
- const onBeforeUnload = (e: BeforeUnloadEvent) => {
- alert('You have unsaved changes. Please save before leaving.')
- e.preventDefault()
- e.returnValue = ''
- }
-
- window.addEventListener('beforeunload', onBeforeUnload)
-
- return () => {
- window.removeEventListener('beforeunload', onBeforeUnload)
- }
- }, [sceneModified])
- if (progress === 100) return null
+ if (loaded) return null
return (
)
@@ -183,8 +166,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 = ({
diff --git a/packages/ui/src/primitives/tailwind/Tooltip/index.tsx b/packages/ui/src/primitives/tailwind/Tooltip/index.tsx
index 83266c9dca..fae7fa566d 100644
--- a/packages/ui/src/primitives/tailwind/Tooltip/index.tsx
+++ b/packages/ui/src/primitives/tailwind/Tooltip/index.tsx
@@ -44,6 +44,10 @@ const Tooltip = ({ title, titleClassName, content, children, className, ...rest
keepTooltipInside
repositionOnResize
arrow={false}
+ contentStyle={{
+ animation: 'expandFromCenter 0.3s cubic-bezier(0.38, 0.1, 0.36, 0.9) forwards',
+ transformOrigin: 'center'
+ }}
{...rest}
>
diff --git a/packages/ui/src/primitives/tailwind/Tooltip/tooltip.css b/packages/ui/src/primitives/tailwind/Tooltip/tooltip.css
index 552f7d5e98..be3c3454e5 100644
--- a/packages/ui/src/primitives/tailwind/Tooltip/tooltip.css
+++ b/packages/ui/src/primitives/tailwind/Tooltip/tooltip.css
@@ -12,9 +12,3 @@
opacity: 1;
}
}
-
-.popup-content {
- animation: expandFromCenter 0.3s cubic-bezier(0.38, 0.1, 0.36, 0.9) forwards;
- -webkit-animation: expandFromCenter 0.3s cubic-bezier(0.38, 0.1, 0.36, 0.9) forwards;
- transform-origin: center;
-}