From f4914ff435da1bc9f84d94ecec5981b910d78840 Mon Sep 17 00:00:00 2001 From: christianbeeznst Date: Wed, 24 Jul 2024 00:22:00 -0500 Subject: [PATCH] Internal: Fix subfolder navigation, file display, and thumbnail consistency in File Manager - refs BT#21647 --- assets/css/app.scss | 43 ++ assets/css/scss/_documents.scss | 56 ++ .../basecomponents/BaseContextMenu.vue | 36 ++ .../filemanager/CourseDocuments.vue | 223 +++++++ .../components/filemanager/PersonalFiles.vue | 224 +++++++ assets/vue/composables/useFileManager.js | 388 ++++++++++++ assets/vue/router/filemanager.js | 6 + .../vue/views/documents/DocumentsUpload.vue | 33 +- assets/vue/views/filemanager/List.vue | 579 ++---------------- assets/vue/views/filemanager/Upload.vue | 276 +++++---- assets/vue/views/personalfile/List.vue | 544 +++------------- .../Component/Editor/CkEditor/CkEditor.php | 19 +- 12 files changed, 1300 insertions(+), 1127 deletions(-) create mode 100644 assets/vue/components/basecomponents/BaseContextMenu.vue create mode 100644 assets/vue/components/filemanager/CourseDocuments.vue create mode 100644 assets/vue/components/filemanager/PersonalFiles.vue create mode 100644 assets/vue/composables/useFileManager.js diff --git a/assets/css/app.scss b/assets/css/app.scss index 9139441c3da..892ba4f1c5c 100644 --- a/assets/css/app.scss +++ b/assets/css/app.scss @@ -790,6 +790,49 @@ form .field { } } +.filemanager-container { + .mdi-icon { + font-size: 48px; + } + + .thumbnails { + display: flex; + flex-wrap: wrap; + gap: 10px; + } + + .thumbnail-item { + width: 150px; + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; + text-align: center; + } + + .thumbnail-icon { + font-size: 2rem; + } + + .thumbnail-title { + margin-top: 10px; + font-size: 1rem; + cursor: pointer; + } + + .thumbnail-actions { + margin-top: 10px; + display: flex; + justify-content: center; + gap: 5px; + } + + .thumbnail-image { + width: 100px; + height: 100px; + object-fit: cover; + } +} + //@import 'primevue-md-light-indigo/theme.css'; //@import '~primevue/resources/primevue.min.css'; //@import '~primeflex/primeflex.css'; diff --git a/assets/css/scss/_documents.scss b/assets/css/scss/_documents.scss index 42487356073..98d1f567df8 100644 --- a/assets/css/scss/_documents.scss +++ b/assets/css/scss/_documents.scss @@ -32,3 +32,59 @@ } } } + +.filemanager-container .mdi-icon { + @apply text-6xl; +} + +.filemanager-container .thumbnails-container { + @apply flex justify-center; +} + +.filemanager-container .thumbnails { + @apply flex flex-wrap gap-2.5 justify-center mb-12; +} + +.filemanager-container .thumbnail-item { + @apply w-36 p-2 border border-gray-25 rounded-md text-center cursor-pointer; +} + +.filemanager-container .thumbnail-item:hover { + @apply bg-gray-15; +} + +.filemanager-container .thumbnail-icon { + @apply text-2xl w-24 h-24 object-cover flex items-center justify-center mx-auto; +} + +.filemanager-container .thumbnail-title { + @apply mt-2 text-base break-words; +} + +.filemanager-container .thumbnail-actions { + @apply mt-2 flex justify-center gap-1; +} + +.filemanager-container .thumbnail-image { + @apply w-24 h-24 object-cover; +} + +.context-menu { + @apply absolute bg-white shadow-lg z-50 rounded-md py-1 min-w-[150px] font-sans text-[14px]; +} + +.context-menu ul { + @apply list-none m-0 p-0; +} + +.context-menu li { + @apply flex items-center px-4 py-2 cursor-pointer text-center transition duration-200 ease-in-out; +} + +.context-menu li:hover { + @apply bg-gray-15 shadow-inner; +} + +.context-menu li .mdi { + @apply mr-2; +} diff --git a/assets/vue/components/basecomponents/BaseContextMenu.vue b/assets/vue/components/basecomponents/BaseContextMenu.vue new file mode 100644 index 00000000000..c35bdba576f --- /dev/null +++ b/assets/vue/components/basecomponents/BaseContextMenu.vue @@ -0,0 +1,36 @@ + + + diff --git a/assets/vue/components/filemanager/CourseDocuments.vue b/assets/vue/components/filemanager/CourseDocuments.vue new file mode 100644 index 00000000000..702302d2697 --- /dev/null +++ b/assets/vue/components/filemanager/CourseDocuments.vue @@ -0,0 +1,223 @@ + + + diff --git a/assets/vue/components/filemanager/PersonalFiles.vue b/assets/vue/components/filemanager/PersonalFiles.vue new file mode 100644 index 00000000000..1892b49eb28 --- /dev/null +++ b/assets/vue/components/filemanager/PersonalFiles.vue @@ -0,0 +1,224 @@ + + + diff --git a/assets/vue/composables/useFileManager.js b/assets/vue/composables/useFileManager.js new file mode 100644 index 00000000000..5cab13bda37 --- /dev/null +++ b/assets/vue/composables/useFileManager.js @@ -0,0 +1,388 @@ +import { ref, computed, onMounted } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; +import { useStore } from 'vuex'; +import { storeToRefs } from 'pinia'; +import { useI18n } from 'vue-i18n'; +import { useSecurityStore } from '../store/securityStore'; +import { useCidReq } from './cidReq'; +import { RESOURCE_LINK_PUBLISHED } from '../components/resource_links/visibility'; +import { useCidReqStore } from "../store/cidReq" +import axios from "axios" + +export function useFileManager(entity, apiEndpoint, uploadRoute, isCourseDocument = false) { + const route = useRoute(); + const router = useRouter(); + const store = useStore(); + const { t } = useI18n(); + const securityStore = useSecurityStore(); + const { isAuthenticated, user } = storeToRefs(securityStore); + const cidReqStore = isCourseDocument ? useCidReqStore() : null; + const { course } = cidReqStore ? storeToRefs(cidReqStore) : { course: null }; + + const files = ref([]); + const totalFiles = ref(0); + const isLoading = ref(false); + const selectedFiles = ref([]); + const dialog = ref(false); + const deleteDialog = ref(false); + const deleteMultipleDialog = ref(false); + const detailsDialogVisible = ref(false); + const selectedItem = ref({}); + const itemToDelete = ref(null); + const item = ref({}); + const submitted = ref(false); + const filters = ref({ shared: 0, loadNode: 1 }); + const viewMode = ref('thumbnails'); + const contextMenuVisible = ref(false); + const contextMenuPosition = ref({ x: 0, y: 0 }); + const contextMenuFile = ref(null); + const previousFolders = ref([]); + const currentFolderTitle = ref('Root'); + const { cid, sid, gid } = useCidReq(); + + const flattenFilters = (filters) => { + return Object.keys(filters).reduce((acc, key) => { + acc[key] = filters[key]; + return acc; + }, {}); + }; + + const onUpdateOptions = async () => { + let flattenedFilters = flattenFilters({ + ...filters.value, + cid: route.query.cid || '', + sid: route.query.sid || '', + gid: route.query.gid || '', + type: route.query.type || '', + }); + + const params = { + ...flattenedFilters, + page: 1, + itemsPerPage: 10, + sortBy: '', + sortDesc: false, + }; + + isLoading.value = true; + + try { + const response = await fetch(`${apiEndpoint}?page=${params.page}&rows=${params.itemsPerPage}&sortBy=${params.sortBy}&sortDesc=${params.sortDesc}&shared=${params.shared}&loadNode=${params.loadNode}&resourceNode.parent=${params['resourceNode.parent']}&cid=${params.cid}&sid=${params.sid}&gid=${params.gid}&type=${params.type}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + }); + + const data = await response.json(); + if (data['hydra:member']) { + files.value = data['hydra:member']; + totalFiles.value = data['hydra:totalItems']; + } else { + console.error('Error: Data format is not correct', data); + } + } catch (error) { + console.error('Error fetching files:', error); + } finally { + isLoading.value = false; + } + }; + + const handleClickFile = (data) => { + if (data.resourceNode.firstResourceFile) { + returnToEditor(data); + } else { + previousFolders.value.push({ + id: filters.value["resourceNode.parent"], + title: currentFolderTitle.value + }); + filters.value["resourceNode.parent"] = data.resourceNode.id; + currentFolderTitle.value = data.resourceNode.title; + onUpdateOptions(); + } + }; + + const goBack = () => { + if (previousFolders.value.length > 0) { + const previousFolder = previousFolders.value.pop(); + filters.value["resourceNode.parent"] = previousFolder.id; + currentFolderTitle.value = previousFolder.title; + onUpdateOptions(); + } else { + filters.value["resourceNode.parent"] = isCourseDocument ? course.value.resourceNode.id : user.value.resourceNode.id; + currentFolderTitle.value = 'Root'; + onUpdateOptions(); + } + }; + + const returnToEditor = (data) => { + const url = data.contentUrl; + window.parent.postMessage({ url: url }, '*'); + if (parent.tinymce) { + parent.tinymce.activeEditor.windowManager.close(); + } + function getUrlParam(paramName) { + const reParam = new RegExp('(?:[\\?&]|&)' + paramName + '=([^&]+)', 'i'); + const match = window.location.search.match(reParam); + return (match && match.length > 1) ? match[1] : ''; + } + const funcNum = getUrlParam('CKEditorFuncNum'); + if (window.opener.CKEDITOR) { + window.opener.CKEDITOR.tools.callFunction(funcNum, url); + window.close(); + } + }; + + const toggleViewMode = () => { + viewMode.value = viewMode.value === 'list' ? 'thumbnails' : 'list'; + onUpdateOptions(); + }; + + const viewModeIcon = computed(() => viewMode.value === 'list' ? 'pi pi-th-large' : 'pi pi-list'); + + const isImage = (file) => { + const fileExtensions = ['jpeg', 'jpg', 'png', 'gif']; + const extension = file.resourceNode.title.split('.').pop().toLowerCase(); + return fileExtensions.includes(extension); + }; + + const getFileUrl = (file) => { + return file.contentUrl; + }; + + const getIcon = (file) => { + if (!file.resourceNode.firstResourceFile) { + return 'mdi-folder'; + } + const fileTypeIcons = { + 'pdf': 'mdi-file-pdf-box', + 'doc': 'mdi-file-word-box', + 'docx': 'mdi-file-word-box', + 'xls': 'mdi-file-excel-box', + 'xlsx': 'mdi-file-excel-box', + 'zip': 'mdi-zip-box', + 'jpeg': 'mdi-file-image-box', + 'jpg': 'mdi-file-image-box', + 'png': 'mdi-file-image-box', + 'gif': 'mdi-file-image-box', + 'default': 'mdi-file', + }; + const extension = file.resourceNode.title.split('.').pop().toLowerCase(); + return fileTypeIcons[extension] || fileTypeIcons['default']; + }; + + const showContextMenu = (event, file) => { + event.preventDefault(); + contextMenuFile.value = file; + contextMenuPosition.value = { x: event.clientX, y: event.clientY }; + contextMenuVisible.value = true; + }; + + const openNewDialog = () => { + item.value = {}; + submitted.value = false; + dialog.value = true; + }; + + const hideDialog = () => { + dialog.value = false; + submitted.value = false; + }; + + const saveItem = async () => { + submitted.value = true; + if (item.value.title.trim()) { + if (!item.value.id) { + item.value.filetype = 'folder'; + item.value.parentResourceNodeId = filters.value["resourceNode.parent"]; + item.value.resourceLinkList = JSON.stringify([{ gid, sid, cid, visibility: RESOURCE_LINK_PUBLISHED }]); + + try { + await store.dispatch(`${entity}/createWithFormData`, item.value); + await onUpdateOptions(); + } catch (error) { + console.error('Error creating folder:', error); + } + } + dialog.value = false; + item.value = {}; + submitted.value = false; + } + }; + + const confirmDeleteItem = (item) => { + itemToDelete.value = { ...item }; + deleteDialog.value = true; + }; + + const confirmDeleteMultiple = () => { + deleteMultipleDialog.value = true; + }; + + const deleteMultipleItems = async () => { + const ids = selectedFiles.value.map(file => file.id); + try { + await store.dispatch(`${entity}/delMultiple`, ids); + deleteMultipleDialog.value = false; + selectedFiles.value = []; + onUpdateOptions(); + } catch (error) { + console.error('Error deleting multiple items:', error); + } + }; + + const deleteItemButton = async () => { + if (isCourseDocument) { + if (itemToDelete.value && itemToDelete.value.iid) { + try { + await axios.delete(`/api/documents/${itemToDelete.value.iid}`); + deleteDialog.value = false; + itemToDelete.value = { resourceNode: {} }; + await onUpdateOptions(); + } catch (error) { + console.error('Error deleting document:', error); + } + } else { + console.error('Document to delete is missing or invalid', itemToDelete.value); + } + } else { + if (itemToDelete.value && itemToDelete.value.id) { + try { + await store.dispatch(`${entity}/del`, itemToDelete.value); + deleteDialog.value = false; + itemToDelete.value = null; + onUpdateOptions(); + } catch (error) { + console.error('An error occurred while deleting the item', error); + } + } + } + }; + + const onFilesPage = (event) => { + filters.value.itemsPerPage = event.rows; + filters.value.page = event.page + 1; + filters.value.sortBy = event.sortField; + filters.value.sortDesc = event.sortOrder === -1; + onUpdateOptions(); + }; + + const sortingFilesChanged = (event) => { + filters.value.sortBy = event.sortField; + filters.value.sortDesc = event.sortOrder === -1; + onUpdateOptions(); + }; + + const closeDetailsDialog = () => { + detailsDialogVisible.value = false; + }; + + const uploadDocumentHandler = async () => { + localStorage.setItem('previousFolders', JSON.stringify(previousFolders.value)); + localStorage.setItem('currentFolderTitle', currentFolderTitle.value); + localStorage.setItem('isUploaded', 'true'); + localStorage.setItem('uploadParentNodeId', filters.value['resourceNode.parent']); + + await router.push({ + name: uploadRoute, + query: { + ...route.query, + parentResourceNodeId: filters.value['resourceNode.parent'], + parent: filters.value['resourceNode.parent'], + returnTo: route.name + }, + }); + }; + + const onMountedCallback = () => { + onMounted(() => { + const savedPreviousFolders = localStorage.getItem('previousFolders'); + const savedCurrentFolderTitle = localStorage.getItem('currentFolderTitle'); + const isUploaded = localStorage.getItem('isUploaded'); + const uploadParentNodeId = localStorage.getItem('uploadParentNodeId'); + + if (isUploaded === 'true' && uploadParentNodeId) { + filters.value["resourceNode.parent"] = Number(uploadParentNodeId); + localStorage.removeItem('isUploaded'); + localStorage.removeItem('uploadParentNodeId'); + } else if (!filters.value["resourceNode.parent"] || filters.value["resourceNode.parent"] === 0) { + filters.value["resourceNode.parent"] = isCourseDocument ? course.value.resourceNode.id : user.value.resourceNode.id; + } + + if (savedPreviousFolders) { + previousFolders.value = JSON.parse(savedPreviousFolders); + localStorage.removeItem('previousFolders'); + } + if (savedCurrentFolderTitle) { + currentFolderTitle.value = savedCurrentFolderTitle; + localStorage.removeItem('currentFolderTitle'); + } + + onUpdateOptions(); + }); + }; + + const selectFile = (file) => { + returnToEditor(file); + contextMenuVisible.value = false; + }; + + const showHandler = (item) => { + selectedItem.value = item; + detailsDialogVisible.value = true; + }; + + const editHandler = (item) => { + item.value = { ...item }; + dialog.value = true; + }; + + return { + files, + totalFiles, + isLoading, + selectedFiles, + dialog, + deleteDialog, + deleteMultipleDialog, + detailsDialogVisible, + selectedItem, + itemToDelete, + item, + submitted, + filters, + viewMode, + contextMenuVisible, + contextMenuPosition, + contextMenuFile, + previousFolders, + currentFolderTitle, + flattenFilters, + onUpdateOptions, + handleClickFile, + goBack, + returnToEditor, + toggleViewMode, + viewModeIcon, + isImage, + getFileUrl, + getIcon, + showContextMenu, + openNewDialog, + hideDialog, + saveItem, + confirmDeleteItem, + confirmDeleteMultiple, + deleteMultipleItems, + deleteItemButton, + onFilesPage, + sortingFilesChanged, + closeDetailsDialog, + uploadDocumentHandler, + onMountedCallback, + isAuthenticated, + selectFile, + showHandler, + editHandler + }; +} diff --git a/assets/vue/router/filemanager.js b/assets/vue/router/filemanager.js index c2049c265a4..0748fe3b92e 100644 --- a/assets/vue/router/filemanager.js +++ b/assets/vue/router/filemanager.js @@ -15,5 +15,11 @@ export default { component: () => import('../views/filemanager/Upload.vue'), meta: { emptyLayout: true }, }, + { + name: 'CourseDocumentsUploadFile', + path: '/course-upload', + meta: { emptyLayout: true }, + component: () => import('../views/documents/DocumentsUpload.vue') + }, ], }; diff --git a/assets/vue/views/documents/DocumentsUpload.vue b/assets/vue/views/documents/DocumentsUpload.vue index 83f1f2e0a7c..f116debf2cb 100644 --- a/assets/vue/views/documents/DocumentsUpload.vue +++ b/assets/vue/views/documents/DocumentsUpload.vue @@ -12,7 +12,6 @@ store.getters["resourcenode/getResourceNode"]) -const parentResourceNodeId = ref(Number(route.params.node)) +const parentResourceNodeId = ref(Number(route.query.parentResourceNodeId || route.params.node)) const resourceLinkList = ref( JSON.stringify([ { @@ -130,7 +129,21 @@ uppy.value = new Uppy() onCreated(response.body) }) .on('complete', () => { - router.back() + console.log('Upload complete, sending message...'); + const parentNodeId = parentResourceNodeId.value; + localStorage.setItem('isUploaded', 'true'); + localStorage.setItem('uploadParentNodeId', parentNodeId); + setTimeout(() => { + if (route.query.returnTo) { + router.push({ + name: route.query.returnTo, + params: { node: parentNodeId }, + query: { ...route.query, parentResourceNodeId: parentNodeId }, + }); + } else { + router.back(); + } + }, 2000); }) uppy.value.setMeta({ @@ -164,11 +177,15 @@ watch(fileExistsOption, () => { }) function back() { - if (!resourceNode.value) { - return + let queryParams = { cid, sid, gid, filetype, tab: route.query.tab } + if (route.query.tab) { + router.push({ + name: 'FileManagerList', + params: { node: parentResourceNodeId.value }, + query: queryParams + }) + } else { + router.back() } - - let queryParams = { cid, sid, gid, filetype } - router.push({ name: "DocumentsList", params: { node: resourceNode.value.id }, query: queryParams }) } diff --git a/assets/vue/views/filemanager/List.vue b/assets/vue/views/filemanager/List.vue index ea1c351513b..a514deb03f9 100644 --- a/assets/vue/views/filemanager/List.vue +++ b/assets/vue/views/filemanager/List.vue @@ -1,545 +1,68 @@ - diff --git a/assets/vue/views/filemanager/Upload.vue b/assets/vue/views/filemanager/Upload.vue index d21f82a010f..9aa466cec1e 100644 --- a/assets/vue/views/filemanager/Upload.vue +++ b/assets/vue/views/filemanager/Upload.vue @@ -1,131 +1,171 @@ - diff --git a/assets/vue/views/personalfile/List.vue b/assets/vue/views/personalfile/List.vue index fa0d410a38a..854e2c9a4ac 100644 --- a/assets/vue/views/personalfile/List.vue +++ b/assets/vue/views/personalfile/List.vue @@ -1,77 +1,42 @@ - diff --git a/src/CoreBundle/Component/Editor/CkEditor/CkEditor.php b/src/CoreBundle/Component/Editor/CkEditor/CkEditor.php index 402be5b21cd..eabe6749b6a 100644 --- a/src/CoreBundle/Component/Editor/CkEditor/CkEditor.php +++ b/src/CoreBundle/Component/Editor/CkEditor/CkEditor.php @@ -201,16 +201,21 @@ private function getFileManagerPicker($onlyPersonalfiles = true): string if ($onlyPersonalfiles) { if (null !== $user) { + $cidReqQuery = ''; + if (null !== $course) { + $parentResourceNodeId = $course->getResourceNode()->getId(); + $cidReqQuery = '&'.api_get_cidreq().'&parentResourceNodeId='.$parentResourceNodeId; + } $resourceNodeId = $user->getResourceNode()->getId(); - $url = api_get_path(WEB_PATH).'resources/filemanager/personal_list/'.$resourceNodeId; + $url = api_get_path(WEB_PATH).'resources/filemanager/personal_list/'.$resourceNodeId.'?loadNode=1'.$cidReqQuery; } } else { if (null !== $course) { $resourceNodeId = $course->getResourceNode()->getId(); - $url = api_get_path(WEB_PATH).'resources/document/'.$resourceNodeId.'/manager?'.api_get_cidreq().'&type=images'; + $url = api_get_path(WEB_PATH).'resources/document/'.$resourceNodeId.'/manager?'.api_get_cidreq(); } elseif (null !== $user) { $resourceNodeId = $user->getResourceNode()->getId(); - $url = api_get_path(WEB_PATH).'resources/filemanager/personal_list/'.$resourceNodeId; + $url = api_get_path(WEB_PATH).'resources/filemanager/personal_list/'.$resourceNodeId.'?loadNode=1'; } } @@ -225,16 +230,16 @@ function(cb, value, meta) { let fileManagerUrl = "'.$url.'"; if (fileType === "image") { - fileManagerUrl += "?type=images"; + fileManagerUrl += "&type=images"; } else if (fileType === "file") { - fileManagerUrl += "?type=files"; + fileManagerUrl += "&type=files"; } tinymce.activeEditor.windowManager.openUrl({ title: "File Manager", url: fileManagerUrl, - width: 950, - height: 450 + width: 980, + height: 600 }); } ';