|
| 1 | +/* |
| 2 | +CPAL-1.0 License |
| 3 | +
|
| 4 | +The contents of this file are subject to the Common Public Attribution License |
| 5 | +Version 1.0. (the "License"); you may not use this file except in compliance |
| 6 | +with the License. You may obtain a copy of the License at |
| 7 | +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. |
| 8 | +The License is based on the Mozilla Public License Version 1.1, but Sections 14 |
| 9 | +and 15 have been added to cover use of software over a computer network and |
| 10 | +provide for limited attribution for the Original Developer. In addition, |
| 11 | +Exhibit A has been modified to be consistent with Exhibit B. |
| 12 | +
|
| 13 | +Software distributed under the License is distributed on an "AS IS" basis, |
| 14 | +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the |
| 15 | +specific language governing rights and limitations under the License. |
| 16 | +
|
| 17 | +The Original Code is Ethereal Engine. |
| 18 | +
|
| 19 | +The Original Developer is the Initial Developer. The Initial Developer of the |
| 20 | +Original Code is the Ethereal Engine team. |
| 21 | +
|
| 22 | +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 |
| 23 | +Ethereal Engine. All Rights Reserved. |
| 24 | +*/ |
| 25 | + |
| 26 | +import { projectHistoryPath } from '@etherealengine/common/src/schema.type.module' |
| 27 | +import { ProjectHistoryType } from '@etherealengine/common/src/schemas/projects/project-history.schema' |
| 28 | +import { useFind } from '@etherealengine/spatial/src/common/functions/FeathersHooks' |
| 29 | + |
| 30 | +import { toDisplayDateTime } from '@etherealengine/common/src/utils/datetime-sql' |
| 31 | +import AvatarImage from '@etherealengine/ui/src/primitives/tailwind/AvatarImage' |
| 32 | +import Button from '@etherealengine/ui/src/primitives/tailwind/Button' |
| 33 | +import { TablePagination } from '@etherealengine/ui/src/primitives/tailwind/Table' |
| 34 | +import Text from '@etherealengine/ui/src/primitives/tailwind/Text' |
| 35 | +import Tooltip from '@etherealengine/ui/src/primitives/tailwind/Tooltip' |
| 36 | +import React from 'react' |
| 37 | +import { useTranslation } from 'react-i18next' |
| 38 | +import { FaSortAmountDown, FaSortAmountUpAlt } from 'react-icons/fa' |
| 39 | + |
| 40 | +const PROJECT_HISTORY_PAGE_LIMIT = 10 |
| 41 | + |
| 42 | +const getRelativeURLFromProject = (projectName: string, url: string) => { |
| 43 | + const prefix = `projects/${projectName}/` |
| 44 | + if (url.startsWith(prefix)) { |
| 45 | + return url.replace(prefix, '') |
| 46 | + } |
| 47 | + return url |
| 48 | +} |
| 49 | + |
| 50 | +const getResourceURL = (projectName: string, url: string, resourceType: 'resource' | 'scene') => { |
| 51 | + const relativeURL = getRelativeURLFromProject(projectName, url) |
| 52 | + const resourceURL = |
| 53 | + resourceType === 'resource' |
| 54 | + ? `/projects/${projectName}/${relativeURL}` |
| 55 | + : `/studio?project=${projectName}&scenePath=${url}` |
| 56 | + return { |
| 57 | + relativeURL, |
| 58 | + resourceURL |
| 59 | + } |
| 60 | +} |
| 61 | + |
| 62 | +function HistoryLog({ projectHistory, projectName }: { projectHistory: ProjectHistoryType; projectName: string }) { |
| 63 | + const { t } = useTranslation() |
| 64 | + |
| 65 | + const RenderAction = () => { |
| 66 | + if (projectHistory.action === 'LOCATION_PUBLISHED' || projectHistory.action === 'LOCATION_UNPUBLISHED') { |
| 67 | + const actionDetail = JSON.parse(projectHistory.actionDetail) as { |
| 68 | + locationName: string |
| 69 | + sceneURL: string |
| 70 | + sceneId: string |
| 71 | + } |
| 72 | + |
| 73 | + const verb = projectHistory.action === 'LOCATION_PUBLISHED' ? 'published' : 'unpublished' |
| 74 | + |
| 75 | + const { relativeURL, resourceURL } = getResourceURL(projectName, actionDetail.sceneURL, 'scene') |
| 76 | + |
| 77 | + return ( |
| 78 | + <> |
| 79 | + <Text>{verb} the location</Text> |
| 80 | + |
| 81 | + {verb === 'published' ? ( |
| 82 | + <a href={`/location/${actionDetail.locationName}`}> |
| 83 | + <Text className="underline-offset-4 hover:underline" fontWeight="semibold"> |
| 84 | + {actionDetail.locationName} |
| 85 | + </Text> |
| 86 | + </a> |
| 87 | + ) : ( |
| 88 | + <Text className="underline-offset-4 hover:underline" fontWeight="semibold"> |
| 89 | + {actionDetail.locationName} |
| 90 | + </Text> |
| 91 | + )} |
| 92 | + |
| 93 | + <Text>from the scene</Text> |
| 94 | + |
| 95 | + <Text href={resourceURL} component="a" className="underline-offset-4 hover:underline" fontWeight="semibold"> |
| 96 | + {relativeURL}. |
| 97 | + </Text> |
| 98 | + </> |
| 99 | + ) |
| 100 | + } else if (projectHistory.action === 'LOCATION_MODIFIED') { |
| 101 | + const actionDetail = JSON.parse(projectHistory.actionDetail) as { |
| 102 | + locationName: string |
| 103 | + } |
| 104 | + |
| 105 | + return ( |
| 106 | + <> |
| 107 | + <Text>modified the location</Text> |
| 108 | + |
| 109 | + <a href={`/location/${actionDetail.locationName}`}> |
| 110 | + <Text className="underline-offset-4 hover:underline" fontWeight="semibold"> |
| 111 | + {actionDetail.locationName} |
| 112 | + </Text> |
| 113 | + </a> |
| 114 | + </> |
| 115 | + ) |
| 116 | + } else if (projectHistory.action === 'PERMISSION_CREATED' || projectHistory.action === 'PERMISSION_REMOVED') { |
| 117 | + const actionDetail = JSON.parse(projectHistory.actionDetail) as { |
| 118 | + userName: string |
| 119 | + userId: string |
| 120 | + permissionType: string |
| 121 | + } |
| 122 | + |
| 123 | + const verb = projectHistory.action === 'PERMISSION_CREATED' ? 'added' : 'removed' |
| 124 | + |
| 125 | + return ( |
| 126 | + <> |
| 127 | + <Text>{verb} the</Text> |
| 128 | + <Text fontWeight="semibold">{actionDetail.permissionType}</Text> |
| 129 | + |
| 130 | + <Text>access to</Text> |
| 131 | + |
| 132 | + <Tooltip content={`UserId: ${actionDetail.userId}`}> |
| 133 | + <Text>{actionDetail.userName}</Text> |
| 134 | + </Tooltip> |
| 135 | + </> |
| 136 | + ) |
| 137 | + } else if (projectHistory.action === 'PERMISSION_MODIFIED') { |
| 138 | + const actionDetail = JSON.parse(projectHistory.actionDetail) as { |
| 139 | + userName: string |
| 140 | + userId: string |
| 141 | + oldPermissionType: string |
| 142 | + newPermissionType: string |
| 143 | + } |
| 144 | + |
| 145 | + return ( |
| 146 | + <> |
| 147 | + <Text>updated the permission of the user</Text> |
| 148 | + <Tooltip content={`UserId: ${actionDetail.userId}`}> |
| 149 | + <Text>{actionDetail.userName}</Text> |
| 150 | + </Tooltip> |
| 151 | + <Text>from</Text> |
| 152 | + <Text fontWeight="semibold">{actionDetail.oldPermissionType}</Text> |
| 153 | + <Text>to</Text> |
| 154 | + <Text fontWeight="semibold">{actionDetail.newPermissionType}</Text> |
| 155 | + </> |
| 156 | + ) |
| 157 | + } else if (projectHistory.action === 'PROJECT_CREATED') { |
| 158 | + return <Text>created the project</Text> |
| 159 | + } else if ( |
| 160 | + projectHistory.action === 'RESOURCE_CREATED' || |
| 161 | + projectHistory.action === 'RESOURCE_REMOVED' || |
| 162 | + projectHistory.action === 'SCENE_CREATED' || |
| 163 | + projectHistory.action === 'SCENE_REMOVED' |
| 164 | + ) { |
| 165 | + const verb = |
| 166 | + projectHistory.action === 'RESOURCE_CREATED' || projectHistory.action === 'SCENE_CREATED' |
| 167 | + ? 'created' |
| 168 | + : 'removed' |
| 169 | + const object = |
| 170 | + projectHistory.action === 'RESOURCE_CREATED' || projectHistory.action === 'RESOURCE_REMOVED' |
| 171 | + ? 'resource' |
| 172 | + : 'scene' |
| 173 | + |
| 174 | + const actionDetail = JSON.parse(projectHistory.actionDetail) as { |
| 175 | + url: string |
| 176 | + } |
| 177 | + |
| 178 | + const { relativeURL, resourceURL } = getResourceURL(projectName, actionDetail.url, object) |
| 179 | + |
| 180 | + return ( |
| 181 | + <> |
| 182 | + <Text> |
| 183 | + {verb} the {object} |
| 184 | + </Text> |
| 185 | + <Text href={resourceURL} component="a" fontWeight="semibold" className="underline-offset-4 hover:underline"> |
| 186 | + {relativeURL} |
| 187 | + </Text> |
| 188 | + </> |
| 189 | + ) |
| 190 | + } else if (projectHistory.action === 'RESOURCE_RENAMED' || projectHistory.action === 'SCENE_RENAMED') { |
| 191 | + const object = projectHistory.action === 'RESOURCE_RENAMED' ? 'resource' : 'scene' |
| 192 | + const actionDetail = JSON.parse(projectHistory.actionDetail) as { |
| 193 | + oldURL: string |
| 194 | + newURL: string |
| 195 | + } |
| 196 | + |
| 197 | + const { relativeURL: oldRelativeURL } = getResourceURL(projectName, actionDetail.oldURL, object) |
| 198 | + const { relativeURL: newRelativeURL, resourceURL: newResourceURL } = getResourceURL( |
| 199 | + projectName, |
| 200 | + actionDetail.newURL, |
| 201 | + object |
| 202 | + ) |
| 203 | + |
| 204 | + return ( |
| 205 | + <> |
| 206 | + <Text>renamed a {object} from</Text> |
| 207 | + |
| 208 | + <Text fontWeight="semibold">{oldRelativeURL}</Text> |
| 209 | + <Text>to</Text> |
| 210 | + <Text |
| 211 | + href={newResourceURL} |
| 212 | + component="a" |
| 213 | + fontWeight="semibold" |
| 214 | + className="underline-offset-4 hover:underline" |
| 215 | + > |
| 216 | + {getRelativeURLFromProject(projectName, newRelativeURL)} |
| 217 | + </Text> |
| 218 | + </> |
| 219 | + ) |
| 220 | + } else if (projectHistory.action === 'RESOURCE_MODIFIED' || projectHistory.action === 'SCENE_MODIFIED') { |
| 221 | + const object = projectHistory.action === 'RESOURCE_MODIFIED' ? 'resource' : 'scene' |
| 222 | + const actionDetail = JSON.parse(projectHistory.actionDetail) as { |
| 223 | + url: string |
| 224 | + } |
| 225 | + |
| 226 | + const { relativeURL, resourceURL } = getResourceURL(projectName, actionDetail.url, object) |
| 227 | + |
| 228 | + return ( |
| 229 | + <> |
| 230 | + <Text>modified the {object}</Text> |
| 231 | + <Text href={resourceURL} component="a" fontWeight="semibold" className="underline-offset-4 hover:underline"> |
| 232 | + {relativeURL} |
| 233 | + </Text> |
| 234 | + </> |
| 235 | + ) |
| 236 | + } |
| 237 | + |
| 238 | + return null |
| 239 | + } |
| 240 | + |
| 241 | + return ( |
| 242 | + <div className="mb-3 flex w-full items-center justify-between rounded-lg bg-[#191B1F] px-5 py-2"> |
| 243 | + <div className="grid grid-flow-col place-items-center gap-x-2 [&>*]:text-nowrap"> |
| 244 | + <AvatarImage |
| 245 | + className="inline-grid min-h-10 min-w-10 rounded-full" |
| 246 | + src={projectHistory.userAvatarURL} |
| 247 | + name={projectHistory.userName} |
| 248 | + /> |
| 249 | + |
| 250 | + <Text className="text-nowrap">{projectHistory.userName}</Text> |
| 251 | + |
| 252 | + <RenderAction /> |
| 253 | + </div> |
| 254 | + |
| 255 | + <Text className="text-nowrap">{toDisplayDateTime(projectHistory.createdAt)}</Text> |
| 256 | + </div> |
| 257 | + ) |
| 258 | +} |
| 259 | + |
| 260 | +export const ProjectHistory = ({ projectId, projectName }: { projectId: string; projectName: string }) => { |
| 261 | + const { t } = useTranslation() |
| 262 | + const projectHistoryQuery = useFind(projectHistoryPath, { |
| 263 | + query: { |
| 264 | + projectId: projectId, |
| 265 | + $sort: { |
| 266 | + createdAt: -1 |
| 267 | + }, |
| 268 | + $limit: PROJECT_HISTORY_PAGE_LIMIT |
| 269 | + } |
| 270 | + }) |
| 271 | + |
| 272 | + const sortOrder = projectHistoryQuery.sort.createdAt |
| 273 | + |
| 274 | + const toggleSortOrder = () => { |
| 275 | + projectHistoryQuery.setSort({ |
| 276 | + createdAt: sortOrder === -1 ? 1 : -1 |
| 277 | + }) |
| 278 | + } |
| 279 | + |
| 280 | + return ( |
| 281 | + <div className="w-full flex-row justify-between gap-5 px-2"> |
| 282 | + <Button |
| 283 | + className="mb-4" |
| 284 | + onClick={toggleSortOrder} |
| 285 | + endIcon={sortOrder === -1 ? <FaSortAmountDown /> : <FaSortAmountUpAlt />} |
| 286 | + > |
| 287 | + {sortOrder === -1 ? t('admin:components.common.newestFirst') : t('admin:components.common.oldestFirst')} |
| 288 | + </Button> |
| 289 | + |
| 290 | + {projectHistoryQuery.data && |
| 291 | + projectHistoryQuery.data.map((projectHistory, index) => ( |
| 292 | + <HistoryLog key={index} projectHistory={projectHistory} projectName={projectName} /> |
| 293 | + ))} |
| 294 | + |
| 295 | + <TablePagination |
| 296 | + totalPages={Math.ceil(projectHistoryQuery.total / projectHistoryQuery.limit)} |
| 297 | + currentPage={projectHistoryQuery.page} |
| 298 | + onPageChange={(newPage) => projectHistoryQuery.setPage(newPage)} |
| 299 | + /> |
| 300 | + </div> |
| 301 | + ) |
| 302 | +} |
0 commit comments