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 ( +
+ + + {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')} + + + ) @@ -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 ( - -