diff --git a/server/graph/resolvers/tree.js b/server/graph/resolvers/tree.js index 37fe081b..eb2bbba8 100644 --- a/server/graph/resolvers/tree.js +++ b/server/graph/resolvers/tree.js @@ -88,6 +88,18 @@ module.exports = { childrenCount: 0 } })) + }, + async folderById (obj, args, context) { + const folder = await WIKI.db.knex('tree') + .select(WIKI.db.knex.raw('tree.*, nlevel(tree."folderPath") AS depth')) + .where('id', args.id) + .first() + + return { + ...folder, + folderPath: folder.folderPath.replaceAll('.', '/').replaceAll('_', '-'), + childrenCount: 0 + } } }, Mutation: { @@ -96,6 +108,8 @@ module.exports = { */ async createFolder (obj, args, context) { try { + WIKI.logger.debug(`Creating new folder ${args.pathName}...`) + // Get parent path let parentPath = '' if (args.parentId) { @@ -128,6 +142,7 @@ module.exports = { } // Create folder + WIKI.logger.debug(`Creating new folder ${args.pathName} at path /${parentPath}...`) await WIKI.db.knex('tree').insert({ folderPath: parentPath, fileName: args.pathName, @@ -139,6 +154,74 @@ module.exports = { operation: graphHelper.generateSuccess('Folder created successfully') } } catch (err) { + WIKI.logger.debug(`Failed to create folder: ${err.message}`) + return graphHelper.generateError(err) + } + }, + /** + * RENAME FOLDER + */ + async renameFolder (obj, args, context) { + try { + // Get folder + const folder = await WIKI.db.knex('tree').where('id', args.folderId).first() + WIKI.logger.debug(`Renaming folder ${folder.id} path to ${args.pathName}...`) + + // Validate path name + if (!rePathName.test(args.pathName)) { + throw new Error('ERR_INVALID_PATH_NAME') + } + + // Validate title + if (!reTitle.test(args.title)) { + throw new Error('ERR_INVALID_TITLE') + } + + if (args.pathName !== folder.fileName) { + // Check for collision + const existingFolder = await WIKI.db.knex('tree') + .whereNot('id', folder.id) + .andWhere({ + siteId: folder.siteId, + folderPath: folder.folderPath, + fileName: args.pathName + }).first() + if (existingFolder) { + throw new Error('ERR_FOLDER_ALREADY_EXISTS') + } + + // Build new paths + const oldFolderPath = (folder.folderPath ? `${folder.folderPath}.${folder.fileName}` : folder.fileName).replaceAll('-', '_') + const newFolderPath = (folder.folderPath ? `${folder.folderPath}.${args.pathName}` : args.pathName).replaceAll('-', '_') + + // Update children nodes + WIKI.logger.debug(`Updating parent path of children nodes from ${oldFolderPath} to ${newFolderPath} ...`) + await WIKI.db.knex('tree').where('siteId', folder.siteId).andWhere('folderPath', oldFolderPath).update({ + folderPath: newFolderPath + }) + await WIKI.db.knex('tree').where('siteId', folder.siteId).andWhere('folderPath', '<@', oldFolderPath).update({ + folderPath: WIKI.db.knex.raw(`'${newFolderPath}' || subpath(tree."folderPath", nlevel('${newFolderPath}'))`) + }) + + // Rename the folder itself + await WIKI.db.knex('tree').where('id', folder.id).update({ + fileName: args.pathName, + title: args.title + }) + } else { + // Update the folder title only + await WIKI.db.knex('tree').where('id', folder.id).update({ + title: args.title + }) + } + + WIKI.logger.debug(`Renamed folder ${folder.id} successfully.`) + + return { + operation: graphHelper.generateSuccess('Folder renamed successfully') + } + } catch (err) { + WIKI.logger.debug(`Failed to rename folder ${args.folderId}: ${err.message}`) return graphHelper.generateError(err) } }, @@ -153,7 +236,7 @@ module.exports = { WIKI.logger.debug(`Deleting folder ${folder.id} at path ${folderPath}...`) // Delete all children - const deletedNodes = await WIKI.db.knex('tree').where('folderPath', '~', `${folderPath}.*`).del().returning(['id', 'type']) + const deletedNodes = await WIKI.db.knex('tree').where('folderPath', '<@', folderPath).del().returning(['id', 'type']) // Delete folders const deletedFolders = deletedNodes.filter(n => n.type === 'folder').map(n => n.id) @@ -179,12 +262,13 @@ module.exports = { // Delete the folder itself await WIKI.db.knex('tree').where('id', folder.id).del() - WIKI.logger.debug(`Deleting folder ${folder.id} successfully.`) + WIKI.logger.debug(`Deleted folder ${folder.id} successfully.`) return { operation: graphHelper.generateSuccess('Folder deleted successfully') } } catch (err) { + WIKI.logger.debug(`Failed to delete folder ${args.folderId}: ${err.message}`) return graphHelper.generateError(err) } } diff --git a/server/graph/schemas/tree.graphql b/server/graph/schemas/tree.graphql index 25f408fa..79e003a0 100644 --- a/server/graph/schemas/tree.graphql +++ b/server/graph/schemas/tree.graphql @@ -15,6 +15,9 @@ extend type Query { depth: Int includeAncestors: Boolean ): [TreeItem] + folderById( + id: UUID! + ): TreeItemFolder } extend type Mutation { @@ -39,8 +42,8 @@ extend type Mutation { ): DefaultResponse renameFolder( folderId: UUID! - pathName: String - title: String + pathName: String! + title: String! ): DefaultResponse } diff --git a/ux/public/_assets/icons/fluent-rename.svg b/ux/public/_assets/icons/fluent-rename.svg new file mode 100644 index 00000000..e8edc77d --- /dev/null +++ b/ux/public/_assets/icons/fluent-rename.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ux/src/components/FileManager.vue b/ux/src/components/FileManager.vue index 464f8b8b..c9602352 100644 --- a/ux/src/components/FileManager.vue +++ b/ux/src/components/FileManager.vue @@ -37,34 +37,44 @@ q-layout.fileman(view='hHh lpR lFr', container) ) q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.close`)}} q-drawer.fileman-left(:model-value='true', :width='350') - .q-px-md.q-pb-sm - tree( - ref='treeComp' - :nodes='state.treeNodes' - :roots='state.treeRoots' - v-model:selected='state.currentFolderId' - @lazy-load='treeLazyLoad' - :use-lazy-load='true' - @context-action='treeContextAction' - :display-mode='state.displayMode' + q-scroll-area( + :thumb-style='thumbStyle' + :bar-style='barStyle' + style='height: 100%;' ) - q-drawer.fileman-right(:model-value='$q.screen.gt.md', :width='350', side='right') - .q-pa-md - template(v-if='currentFileDetails') - q-img.rounded-borders.q-mb-md( - src='/_assets/illustrations/fileman-page.svg' - width='100%' - fit='cover' - :ratio='16/10' - no-spinner + .q-px-md.q-pb-sm + tree( + ref='treeComp' + :nodes='state.treeNodes' + :roots='state.treeRoots' + v-model:selected='state.currentFolderId' + @lazy-load='treeLazyLoad' + :use-lazy-load='true' + @context-action='treeContextAction' + :display-mode='state.displayMode' ) - .fileman-details-row( - v-for='item of currentFileDetails.items' + q-drawer.fileman-right(:model-value='$q.screen.gt.md', :width='350', side='right') + q-scroll-area( + :thumb-style='thumbStyle' + :bar-style='barStyle' + style='height: 100%;' + ) + .q-pa-md + template(v-if='currentFileDetails') + q-img.rounded-borders.q-mb-md( + src='/_assets/illustrations/fileman-page.svg' + width='100%' + fit='cover' + :ratio='16/10' + no-spinner ) - label {{item.label}} - span {{item.value}} + .fileman-details-row( + v-for='item of currentFileDetails.items' + ) + label {{item.label}} + span {{item.value}} q-page-container - q-page.fileman-center + q-page.fileman-center.column //- TOOLBAR ----------------------------------------------------- q-toolbar.fileman-toolbar template(v-if='state.isUploading') @@ -182,68 +192,79 @@ q-layout.fileman(view='hHh lpR lFr', container) icon='las la-cloud-upload-alt' @click='uploadFile' ) - .fileman-emptylist(v-if='files.length < 1') - template(v-if='state.fileListLoading') - q-spinner.q-mr-sm(color='primary', size='xs', :thickness='3') - span.text-primary Loading... - template(v-else) - q-icon.q-mr-sm(name='las la-exclamation-triangle', size='sm') - span This folder is empty. - q-list.fileman-filelist(v-else) - q-item( - v-for='item of files' - :key='item.id' - clickable - active-class='active' - :active='item.id === state.currentFileId' - @click.native='selectItem(item)' - @dblclick.native='openItem(item)' - ) - q-item-section.fileman-filelist-icon(avatar) - q-icon(:name='item.icon', :size='state.isCompact ? `md` : `xl`') - q-item-section.fileman-filelist-label - q-item-label {{item.title}} - q-item-label(caption, v-if='!state.isCompact') {{item.caption}} - q-item-section.fileman-filelist-side(side, v-if='item.side') - .text-caption {{item.side}} - //- RIGHT-CLICK MENU - q-menu( - touch-position - context-menu - auto-close - transition-show='jump-down' - transition-hide='jump-up' + + .row(style='flex: 1 1 100%;') + .col + q-scroll-area( + :thumb-style='thumbStyle' + :bar-style='barStyle' + style='height: 100%;' ) - q-card.q-pa-sm - q-list(dense, style='min-width: 150px;') - q-item(clickable, v-if='item.type === `page`') - q-item-section(side) - q-icon(name='las la-edit', color='orange') - q-item-section Edit - q-item(clickable, v-if='item.type !== `folder`', @click='openItem(item)') - q-item-section(side) - q-icon(name='las la-eye', color='primary') - q-item-section View - q-item(clickable, v-if='item.type !== `folder`', @click='copyItemURL(item)') - q-item-section(side) - q-icon(name='las la-clipboard', color='primary') - q-item-section Copy URL - q-item(clickable) - q-item-section(side) - q-icon(name='las la-copy', color='teal') - q-item-section Duplicate... - q-item(clickable) - q-item-section(side) - q-icon(name='las la-redo', color='teal') - q-item-section Rename... - q-item(clickable) - q-item-section(side) - q-icon(name='las la-arrow-right', color='teal') - q-item-section Move to... - q-item(clickable, @click='delItem(item)') - q-item-section(side) - q-icon(name='las la-trash-alt', color='negative') - q-item-section.text-negative Delete + .fileman-emptylist(v-if='files.length < 1') + template(v-if='state.fileListLoading') + q-spinner.q-mr-sm(color='primary', size='xs', :thickness='3') + span.text-primary Loading... + template(v-else) + q-icon.q-mr-sm(name='las la-folder-open', size='sm') + span This folder is empty. + q-list.fileman-filelist( + v-else + :class='state.isCompact && `is-compact`' + ) + q-item( + v-for='item of files' + :key='item.id' + clickable + active-class='active' + :active='item.id === state.currentFileId' + @click.native='selectItem(item)' + @dblclick.native='openItem(item)' + ) + q-item-section.fileman-filelist-icon(avatar) + q-icon(:name='item.icon', :size='state.isCompact ? `md` : `xl`') + q-item-section.fileman-filelist-label + q-item-label {{usePathTitle ? item.fileName : item.title}} + q-item-label(caption, v-if='!state.isCompact') {{item.caption}} + q-item-section.fileman-filelist-side(side, v-if='item.side') + .text-caption {{item.side}} + //- RIGHT-CLICK MENU + q-menu( + touch-position + context-menu + auto-close + transition-show='jump-down' + transition-hide='jump-up' + ) + q-card.q-pa-sm + q-list(dense, style='min-width: 150px;') + q-item(clickable, v-if='item.type === `page`') + q-item-section(side) + q-icon(name='las la-edit', color='orange') + q-item-section Edit + q-item(clickable, v-if='item.type !== `folder`', @click='openItem(item)') + q-item-section(side) + q-icon(name='las la-eye', color='primary') + q-item-section View + q-item(clickable, v-if='item.type !== `folder`', @click='copyItemURL(item)') + q-item-section(side) + q-icon(name='las la-clipboard', color='primary') + q-item-section Copy URL + q-item(clickable) + q-item-section(side) + q-icon(name='las la-copy', color='teal') + q-item-section Duplicate... + q-item(clickable, @click='renameItem(item)') + q-item-section(side) + q-icon(name='las la-redo', color='teal') + q-item-section Rename... + q-item(clickable) + q-item-section(side) + q-icon(name='las la-arrow-right', color='teal') + q-item-section Move to... + q-item(clickable, @click='delItem(item)') + q-item-section(side) + q-icon(name='las la-trash-alt', color='negative') + q-item-section.text-negative Delete q-footer q-bar.fileman-path small.text-caption.text-grey-7 {{folderPath}} @@ -259,7 +280,7 @@ q-layout.fileman(view='hHh lpR lFr', container) diff --git a/ux/src/components/PageSaveDialog.vue b/ux/src/components/PageSaveDialog.vue index 357cd8c5..9e921b91 100644 --- a/ux/src/components/PageSaveDialog.vue +++ b/ux/src/components/PageSaveDialog.vue @@ -282,7 +282,7 @@ async function loadTree (parentId, types) { title createdAt updatedAt - pageEditor + editor } } } diff --git a/ux/src/i18n/locales/en.json b/ux/src/i18n/locales/en.json index 94972dcc..a6b29b20 100644 --- a/ux/src/i18n/locales/en.json +++ b/ux/src/i18n/locales/en.json @@ -1611,5 +1611,8 @@ "admin.flags.advanced.label": "Custom Configuration", "admin.flags.advanced.hint": "Set custom configuration flags. Note that all values are public to all users! Do not insert senstive data.", "admin.flags.saveSuccess": "Flags have been updated successfully.", - "fileman.copyURLSuccess": "URL has been copied to the clipboard." + "fileman.copyURLSuccess": "URL has been copied to the clipboard.", + "fileman.folderRename": "Rename Folder", + "fileman.renameFolderInvalidData": "One or more fields are invalid.", + "fileman.renameFolderSuccess": "Folder renamed successfully." }