diff options
Diffstat (limited to 'apps/files/src/views/Sidebar.vue')
-rw-r--r-- | apps/files/src/views/Sidebar.vue | 651 |
1 files changed, 651 insertions, 0 deletions
diff --git a/apps/files/src/views/Sidebar.vue b/apps/files/src/views/Sidebar.vue new file mode 100644 index 00000000000..40a16d42b42 --- /dev/null +++ b/apps/files/src/views/Sidebar.vue @@ -0,0 +1,651 @@ +<!-- + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcAppSidebar v-if="file" + ref="sidebar" + data-cy-sidebar + v-bind="appSidebar" + :force-menu="true" + @close="close" + @update:active="setActiveTab" + @[defaultActionListener].stop.prevent="onDefaultAction" + @opening="handleOpening" + @opened="handleOpened" + @closing="handleClosing" + @closed="handleClosed"> + <template v-if="fileInfo" #subname> + <div class="sidebar__subname"> + <NcIconSvgWrapper v-if="fileInfo.isFavourited" + :path="mdiStar" + :name="t('files', 'Favorite')" + inline /> + <span>{{ size }}</span> + <span class="sidebar__subname-separator">•</span> + <NcDateTime :timestamp="fileInfo.mtime" /> + <span class="sidebar__subname-separator">•</span> + <span>{{ t('files', 'Owner') }}</span> + <NcUserBubble :user="ownerId" + :display-name="nodeOwnerLabel" /> + </div> + </template> + + <!-- TODO: create a standard to allow multiple elements here? --> + <template v-if="fileInfo" #description> + <div class="sidebar__description"> + <SystemTags v-if="isSystemTagsEnabled && showTagsDefault" + v-show="showTags" + :disabled="!fileInfo?.canEdit()" + :file-id="fileInfo.id" /> + <LegacyView v-for="view in views" + :key="view.cid" + :component="view" + :file-info="fileInfo" /> + </div> + </template> + + <!-- Actions menu --> + <template v-if="fileInfo" #secondary-actions> + <NcActionButton :close-after-click="true" + @click="toggleStarred(!fileInfo.isFavourited)"> + <template #icon> + <NcIconSvgWrapper :path="fileInfo.isFavourited ? mdiStarOutline : mdiStar" /> + </template> + {{ fileInfo.isFavourited ? t('files', 'Remove from favorites') : t('files', 'Add to favorites') }} + </NcActionButton> + <!-- TODO: create proper api for apps to register actions + And inject themselves here. --> + <NcActionButton v-if="isSystemTagsEnabled" + :close-after-click="true" + icon="icon-tag" + @click="toggleTags"> + {{ t('files', 'Tags') }} + </NcActionButton> + </template> + + <!-- Error display --> + <NcEmptyContent v-if="error" icon="icon-error"> + {{ error }} + </NcEmptyContent> + + <!-- If fileInfo fetch is complete, render tabs --> + <template v-for="tab in tabs" v-else-if="fileInfo"> + <!-- Hide them if we're loading another file but keep them mounted --> + <SidebarTab v-if="tab.enabled(fileInfo)" + v-show="!loading" + :id="tab.id" + :key="tab.id" + :name="tab.name" + :icon="tab.icon" + :on-mount="tab.mount" + :on-update="tab.update" + :on-destroy="tab.destroy" + :on-scroll-bottom-reached="tab.scrollBottomReached" + :file-info="fileInfo"> + <template v-if="tab.iconSvg !== undefined" #icon> + <!-- eslint-disable-next-line vue/no-v-html --> + <span class="svg-icon" v-html="tab.iconSvg" /> + </template> + </SidebarTab> + </template> + </NcAppSidebar> +</template> +<script lang="ts"> +import { davRemoteURL, davRootPath, File, Folder, formatFileSize } from '@nextcloud/files' +import { defineComponent } from 'vue' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' +import { encodePath } from '@nextcloud/paths' +import { fetchNode } from '../services/WebdavClient.ts' +import { generateUrl } from '@nextcloud/router' +import { getCapabilities } from '@nextcloud/capabilities' +import { getCurrentUser } from '@nextcloud/auth' +import { mdiStar, mdiStarOutline } from '@mdi/js' +import { ShareType } from '@nextcloud/sharing' +import { showError } from '@nextcloud/dialogs' +import $ from 'jquery' +import axios from '@nextcloud/axios' + +import NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcDateTime from '@nextcloud/vue/components/NcDateTime' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcUserBubble from '@nextcloud/vue/components/NcUserBubble' + +import FileInfo from '../services/FileInfo.js' +import LegacyView from '../components/LegacyView.vue' +import SidebarTab from '../components/SidebarTab.vue' +import SystemTags from '../../../systemtags/src/components/SystemTags.vue' +import logger from '../logger.ts' + +export default defineComponent({ + name: 'Sidebar', + + components: { + LegacyView, + NcActionButton, + NcAppSidebar, + NcDateTime, + NcEmptyContent, + NcIconSvgWrapper, + SidebarTab, + SystemTags, + NcUserBubble, + }, + + setup() { + const currentUser = getCurrentUser() + + // Non reactive properties + return { + currentUser, + + mdiStar, + mdiStarOutline, + } + }, + + data() { + return { + // reactive state + Sidebar: OCA.Files.Sidebar.state, + showTags: false, + showTagsDefault: true, + error: null, + loading: true, + fileInfo: null, + node: null, + isFullScreen: false, + hasLowHeight: false, + } + }, + + computed: { + /** + * Current filename + * This is bound to the Sidebar service and + * is used to load a new file + * + * @return {string} + */ + file() { + return this.Sidebar.file + }, + + /** + * List of all the registered tabs + * + * @return {Array} + */ + tabs() { + return this.Sidebar.tabs + }, + + /** + * List of all the registered views + * + * @return {Array} + */ + views() { + return this.Sidebar.views + }, + + /** + * Current user dav root path + * + * @return {string} + */ + davPath() { + return `${davRemoteURL}${davRootPath}${encodePath(this.file)}` + }, + + /** + * Current active tab handler + * + * @return {string} the current active tab + */ + activeTab() { + return this.Sidebar.activeTab + }, + + /** + * File size formatted string + * + * @return {string} + */ + size() { + return formatFileSize(this.fileInfo?.size) + }, + + /** + * File background/figure to illustrate the sidebar header + * + * @return {string} + */ + background() { + return this.getPreviewIfAny(this.fileInfo) + }, + + /** + * App sidebar v-binding object + * + * @return {object} + */ + appSidebar() { + if (this.fileInfo) { + return { + 'data-mimetype': this.fileInfo.mimetype, + active: this.activeTab, + background: this.background, + class: { + 'app-sidebar--has-preview': this.fileInfo.hasPreview && !this.isFullScreen, + 'app-sidebar--full': this.isFullScreen, + }, + compact: this.hasLowHeight || !this.fileInfo.hasPreview || this.isFullScreen, + loading: this.loading, + name: this.node?.displayname ?? this.fileInfo.name, + title: this.node?.displayname ?? this.fileInfo.name, + } + } else if (this.error) { + return { + key: 'error', // force key to re-render + subname: '', + name: '', + class: { + 'app-sidebar--full': this.isFullScreen, + }, + } + } + // no fileInfo yet, showing empty data + return { + loading: this.loading, + subname: '', + name: '', + class: { + 'app-sidebar--full': this.isFullScreen, + }, + } + }, + + /** + * Default action object for the current file + * + * @return {object} + */ + defaultAction() { + return this.fileInfo + && OCA.Files && OCA.Files.App && OCA.Files.App.fileList + && OCA.Files.App.fileList.fileActions + && OCA.Files.App.fileList.fileActions.getDefaultFileAction + && OCA.Files.App.fileList + .fileActions.getDefaultFileAction(this.fileInfo.mimetype, this.fileInfo.type, OC.PERMISSION_READ) + + }, + + /** + * Dynamic header click listener to ensure + * nothing is listening for a click if there + * is no default action + * + * @return {string|null} + */ + defaultActionListener() { + return this.defaultAction ? 'figure-click' : null + }, + + isSystemTagsEnabled() { + return getCapabilities()?.systemtags?.enabled === true + }, + ownerId() { + return this.node?.attributes?.['owner-id'] ?? this.currentUser.uid + }, + currentUserIsOwner() { + return this.ownerId === this.currentUser.uid + }, + nodeOwnerLabel() { + let ownerDisplayName = this.node?.attributes?.['owner-display-name'] + if (this.currentUserIsOwner) { + ownerDisplayName = `${ownerDisplayName} (${t('files', 'You')})` + } + return ownerDisplayName + }, + sharedMultipleTimes() { + if (Array.isArray(node.attributes?.['share-types']) && node.attributes?.['share-types'].length > 1) { + return t('files', 'Shared multiple times with different people') + } + return null + }, + }, + created() { + subscribe('files:node:deleted', this.onNodeDeleted) + + window.addEventListener('resize', this.handleWindowResize) + this.handleWindowResize() + }, + beforeDestroy() { + unsubscribe('file:node:deleted', this.onNodeDeleted) + window.removeEventListener('resize', this.handleWindowResize) + }, + + methods: { + /** + * Can this tab be displayed ? + * + * @param {object} tab a registered tab + * @return {boolean} + */ + canDisplay(tab) { + return tab.enabled(this.fileInfo) + }, + resetData() { + this.error = null + this.fileInfo = null + this.$nextTick(() => { + if (this.$refs.tabs) { + this.$refs.tabs.updateTabs() + } + }) + }, + + getPreviewIfAny(fileInfo) { + if (fileInfo?.hasPreview && !this.isFullScreen) { + const etag = fileInfo?.etag || '' + return generateUrl(`/core/preview?fileId=${fileInfo.id}&x=${screen.width}&y=${screen.height}&a=true&v=${etag.slice(0, 6)}`) + } + return this.getIconUrl(fileInfo) + }, + + /** + * Copied from https://github.com/nextcloud/server/blob/16e0887ec63591113ee3f476e0c5129e20180cde/apps/files/js/filelist.js#L1377 + * TODO: We also need this as a standalone library + * + * @param {object} fileInfo the fileinfo + * @return {string} Url to the icon for mimeType + */ + getIconUrl(fileInfo) { + const mimeType = fileInfo?.mimetype || 'application/octet-stream' + if (mimeType === 'httpd/unix-directory') { + // use default folder icon + if (fileInfo.mountType === 'shared' || fileInfo.mountType === 'shared-root') { + return OC.MimeType.getIconUrl('dir-shared') + } else if (fileInfo.mountType === 'external-root') { + return OC.MimeType.getIconUrl('dir-external') + } else if (fileInfo.mountType !== undefined && fileInfo.mountType !== '') { + return OC.MimeType.getIconUrl('dir-' + fileInfo.mountType) + } else if (fileInfo.shareTypes && ( + fileInfo.shareTypes.indexOf(ShareType.Link) > -1 + || fileInfo.shareTypes.indexOf(ShareType.Email) > -1) + ) { + return OC.MimeType.getIconUrl('dir-public') + } else if (fileInfo.shareTypes && fileInfo.shareTypes.length > 0) { + return OC.MimeType.getIconUrl('dir-shared') + } + return OC.MimeType.getIconUrl('dir') + } + return OC.MimeType.getIconUrl(mimeType) + }, + + /** + * Set current active tab + * + * @param {string} id tab unique id + */ + setActiveTab(id) { + OCA.Files.Sidebar.setActiveTab(id) + this.tabs.forEach(tab => { + try { + tab.setIsActive(id === tab.id) + } catch (error) { + logger.error('Error while setting tab active state', { error, id: tab.id, tab }) + } + }) + }, + + /** + * Toggle favorite state + * TODO: better implementation + * + * @param {boolean} state is favorite or not + */ + async toggleStarred(state) { + try { + await axios({ + method: 'PROPPATCH', + url: this.davPath, + data: `<?xml version="1.0"?> + <d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"> + ${state ? '<d:set>' : '<d:remove>'} + <d:prop> + <oc:favorite>1</oc:favorite> + </d:prop> + ${state ? '</d:set>' : '</d:remove>'} + </d:propertyupdate>`, + }) + + /** + * TODO: adjust this when the Sidebar is finally using File/Folder classes + * @see https://github.com/nextcloud/server/blob/8a75cb6e72acd42712ab9fea22296aa1af863ef5/apps/files/src/views/favorites.ts#L83-L115 + */ + const isDir = this.fileInfo.type === 'dir' + const Node = isDir ? Folder : File + const node = new Node({ + fileid: this.fileInfo.id, + source: `${davRemoteURL}${davRootPath}${this.file}`, + root: davRootPath, + mime: isDir ? undefined : this.fileInfo.mimetype, + attributes: { + favorite: 1, + }, + }) + emit(state ? 'files:favorites:added' : 'files:favorites:removed', node) + + this.fileInfo.isFavourited = state + } catch (error) { + showError(t('files', 'Unable to change the favorite state of the file')) + logger.error('Unable to change favorite state', { error }) + } + }, + + onDefaultAction() { + if (this.defaultAction) { + // generate fake context + this.defaultAction.action(this.fileInfo.name, { + fileInfo: this.fileInfo, + dir: this.fileInfo.dir, + fileList: OCA.Files.App.fileList, + $file: $('body'), + }) + } + }, + + /** + * Toggle the tags selector + */ + toggleTags() { + // toggle + this.showTags = !this.showTags + // save the new state + this.setShowTagsDefault(this.showTags) + }, + + /** + * Open the sidebar for the given file + * + * @param {string} path the file path to load + * @return {Promise} + * @throws {Error} loading failure + */ + async open(path) { + if (!path || path.trim() === '') { + throw new Error(`Invalid path '${path}'`) + } + + // Only focus the tab when the selected file/tab is changed in already opened sidebar + // Focusing the sidebar on first file open is handled by NcAppSidebar + const focusTabAfterLoad = !!this.Sidebar.file + + // update current opened file + this.Sidebar.file = path + + // reset data, keep old fileInfo to not reload all tabs and just hide them + this.error = null + this.loading = true + + try { + this.node = await fetchNode(this.file) + this.fileInfo = FileInfo(this.node) + // adding this as fallback because other apps expect it + this.fileInfo.dir = this.file.split('/').slice(0, -1).join('/') + + // DEPRECATED legacy views + // TODO: remove + this.views.forEach(view => { + view.setFileInfo(this.fileInfo) + }) + + await this.$nextTick() + + this.setActiveTab(this.Sidebar.activeTab || this.tabs[0].id) + + this.loading = false + + await this.$nextTick() + + if (focusTabAfterLoad && this.$refs.sidebar) { + this.$refs.sidebar.focusActiveTabContent() + } + } catch (error) { + this.loading = false + this.error = t('files', 'Error while loading the file data') + console.error('Error while loading the file data', error) + + throw new Error(error) + } + }, + + /** + * Close the sidebar + */ + close() { + this.Sidebar.file = '' + this.showTags = false + this.resetData() + }, + + /** + * Handle if the current node was deleted + * @param {import('@nextcloud/files').Node} node The deleted node + */ + onNodeDeleted(node) { + if (this.fileInfo && node && this.fileInfo.id === node.fileid) { + this.close() + } + }, + + /** + * Allow to set the Sidebar as fullscreen from OCA.Files.Sidebar + * + * @param {boolean} isFullScreen - Whether or not to render the Sidebar in fullscreen. + */ + setFullScreenMode(isFullScreen) { + this.isFullScreen = isFullScreen + if (isFullScreen) { + document.querySelector('#content')?.classList.add('with-sidebar--full') + || document.querySelector('#content-vue')?.classList.add('with-sidebar--full') + } else { + document.querySelector('#content')?.classList.remove('with-sidebar--full') + || document.querySelector('#content-vue')?.classList.remove('with-sidebar--full') + } + }, + + /** + * Allow to set whether tags should be shown by default from OCA.Files.Sidebar + * + * @param {boolean} showTagsDefault - Whether or not to show the tags by default. + */ + setShowTagsDefault(showTagsDefault) { + this.showTagsDefault = showTagsDefault + }, + + /** + * Emit SideBar events. + */ + handleOpening() { + emit('files:sidebar:opening') + }, + handleOpened() { + emit('files:sidebar:opened') + }, + handleClosing() { + emit('files:sidebar:closing') + }, + handleClosed() { + emit('files:sidebar:closed') + }, + handleWindowResize() { + this.hasLowHeight = document.documentElement.clientHeight < 1024 + }, + }, +}) +</script> +<style lang="scss" scoped> +.app-sidebar { + &--has-preview:deep { + .app-sidebar-header__figure { + background-size: cover; + } + + &[data-mimetype="text/plain"], + &[data-mimetype="text/markdown"] { + .app-sidebar-header__figure { + background-size: contain; + } + } + } + + &--full { + position: fixed !important; + z-index: 2025 !important; + top: 0 !important; + height: 100% !important; + } + + :deep { + .app-sidebar-header__description { + margin: 0 16px 4px 16px !important; + } + } + + .svg-icon { + :deep(svg) { + width: 20px; + height: 20px; + fill: currentColor; + } + } +} + +.sidebar__subname { + display: flex; + align-items: center; + gap: 0 8px; + + &-separator { + display: inline-block; + font-weight: bold !important; + } + + .user-bubble__wrapper { + display: inline-flex; + } +} + +.sidebar__description { + display: flex; + flex-direction: column; + width: 100%; + gap: 8px 0; + } +</style> |