Merge pull request #40917 from nextcloud/feat/gridview

This commit is contained in:
John Molakvoæ 2023-10-17 13:55:51 +02:00 committed by GitHub
commit 106bf6cf87
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1707 additions and 698 deletions

View file

@ -47,6 +47,12 @@ class UserConfig {
'default' => true,
'allowed' => [true, false],
],
[
// Whether to show the files list in grid view or not
'key' => 'grid_view',
'default' => false,
'allowed' => [true, false],
],
];
protected IConfig $config;

View file

@ -37,124 +37,41 @@
<span v-if="source.attributes.failed" class="files-list__row--failed" />
<!-- Checkbox -->
<td class="files-list__row-checkbox">
<NcLoadingIcon v-if="isLoading" />
<NcCheckboxRadioSwitch v-else-if="visible"
:aria-label="t('files', 'Select the row for {displayName}', { displayName })"
:checked="isSelected"
@update:checked="onSelectionChange" />
</td>
<FileEntryCheckbox v-if="visible"
:display-name="displayName"
:fileid="fileid"
:is-loading="isLoading"
:nodes="nodes" />
<!-- Link to file -->
<td class="files-list__row-name" data-cy-files-list-row-name>
<!-- Icon or preview -->
<span class="files-list__row-icon" @click="execDefaultAction">
<template v-if="source.type === 'folder'">
<FolderOpenIcon v-if="dragover" />
<template v-else>
<FolderIcon />
<OverlayIcon :is="folderOverlay"
v-if="folderOverlay"
class="files-list__row-icon-overlay" />
</template>
</template>
<FileEntryPreview ref="preview"
:source="source"
:dragover="dragover"
@click.native="execDefaultAction" />
<!-- Decorative image, should not be aria documented -->
<img v-else-if="previewUrl && backgroundFailed !== true"
ref="previewImg"
alt=""
class="files-list__row-icon-preview"
:class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}"
:src="previewUrl"
@error="backgroundFailed = true"
@load="backgroundFailed = false">
<FileIcon v-else />
<!-- Favorite icon -->
<span v-if="isFavorite"
class="files-list__row-icon-favorite"
:aria-label="t('files', 'Favorite')">
<FavoriteIcon />
</span>
</span>
<!-- Rename input -->
<form v-if="isRenaming"
v-on-click-outside="stopRenaming"
:aria-hidden="!isRenaming"
:aria-label="t('files', 'Rename file')"
class="files-list__row-rename"
@submit.prevent.stop="onRename">
<NcTextField ref="renameInput"
:label="renameLabel"
:autofocus="true"
:minlength="1"
:required="true"
:value.sync="newName"
enterkeyhint="done"
@keyup="checkInputValidity"
@keyup.esc="stopRenaming" />
</form>
<a v-else
ref="basename"
:aria-hidden="isRenaming"
class="files-list__row-name-link"
data-cy-files-list-row-name-link
v-bind="linkTo"
@click="execDefaultAction">
<!-- File name -->
<span class="files-list__row-name-text">
<!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues-->
<span class="files-list__row-name-" v-text="displayName" />
<span class="files-list__row-name-ext" v-text="extension" />
</span>
</a>
<FileEntryName ref="name"
:display-name="displayName"
:extension="extension"
:files-list-width="filesListWidth"
:nodes="nodes"
:source="source"
@click="execDefaultAction" />
</td>
<!-- Actions -->
<td v-show="!isRenamingSmallScreen"
<FileEntryActions v-show="!isRenamingSmallScreen"
ref="actions"
:class="`files-list__row-actions-${uniqueId}`"
class="files-list__row-actions"
data-cy-files-list-row-actions>
<!-- Render actions -->
<CustomElementRender v-for="action in enabledRenderActions"
:key="action.id"
:class="'files-list__row-action-' + action.id"
:current-view="currentView"
:render="action.renderInline"
:source="source"
class="files-list__row-action--inline" />
<!-- Menu actions -->
<NcActions v-if="visible"
ref="actionsMenu"
:boundaries-element="getBoundariesElement()"
:container="getBoundariesElement()"
:disabled="isLoading"
:force-name="true"
:force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
:inline="enabledInlineActions.length"
:open.sync="openedMenu">
<NcActionButton v-for="action in enabledMenuActions"
:key="action.id"
:class="'files-list__row-action-' + action.id"
:close-after-click="true"
:data-cy-files-list-row-action="action.id"
:title="action.title?.([source], currentView)"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />
<NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
</template>
{{ actionDisplayName(action) }}
</NcActionButton>
</NcActions>
</td>
:files-list-width="filesListWidth"
:loading.sync="loading"
:opened.sync="openedMenu"
:source="source"
:visible="visible" />
<!-- Size -->
<td v-if="isSizeAvailable"
<td v-if="!compact && isSizeAvailable"
:style="sizeOpacity"
class="files-list__row-size"
data-cy-files-list-row-size
@ -163,7 +80,7 @@
</td>
<!-- Mtime -->
<td v-if="isMtimeAvailable"
<td v-if="!compact && isMtimeAvailable"
:style="mtimeOpacity"
class="files-list__row-mtime"
data-cy-files-list-row-mtime
@ -189,80 +106,43 @@
<script lang="ts">
import type { PropType } from 'vue'
import { emit, subscribe } from '@nextcloud/event-bus'
import { extname, join } from 'path'
import { generateUrl } from '@nextcloud/router'
import { getFileActions, DefaultType, FileType, formatFileSize, Permission, Folder, File as NcFile, FileAction, NodeStatus, Node } from '@nextcloud/files'
import { FileType, formatFileSize, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files'
import { getUploader } from '@nextcloud/upload'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { showError } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import { Type as ShareType } from '@nextcloud/sharing'
import { vOnClickOutside } from '@vueuse/components'
import axios from '@nextcloud/axios'
import moment from '@nextcloud/moment'
import Vue from 'vue'
import AccountGroupIcon from 'vue-material-design-icons/AccountGroup.vue'
import FileIcon from 'vue-material-design-icons/File.vue'
import FolderIcon from 'vue-material-design-icons/Folder.vue'
import FolderOpenIcon from 'vue-material-design-icons/FolderOpen.vue'
import KeyIcon from 'vue-material-design-icons/Key.vue'
import TagIcon from 'vue-material-design-icons/Tag.vue'
import LinkIcon from 'vue-material-design-icons/Link.vue'
import NetworkIcon from 'vue-material-design-icons/Network.vue'
import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
import { getDragAndDropPreview } from '../utils/dragUtils.ts'
import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts'
import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
import { hashCode } from '../utils/hashUtils.ts'
import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useDragAndDropStore } from '../store/dragging.ts'
import { useFilesStore } from '../store/files.ts'
import { useKeyboardStore } from '../store/keyboard.ts'
import { useRenamingStore } from '../store/renaming.ts'
import { useSelectionStore } from '../store/selection.ts'
import { useUserConfigStore } from '../store/userconfig.ts'
import CustomElementRender from './CustomElementRender.vue'
import FavoriteIcon from './FavoriteIcon.vue'
import FileEntryActions from './FileEntry/FileEntryActions.vue'
import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
import FileEntryName from './FileEntry/FileEntryName.vue'
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
import logger from '../logger.js'
import { loadState } from '@nextcloud/initial-state'
// The registered actions list
const actions = getFileActions()
Vue.directive('onClickOutside', vOnClickOutside)
const forbiddenCharacters = loadState('files', 'forbiddenCharacters', '') as string
export default Vue.extend({
name: 'FileEntry',
components: {
AccountGroupIcon,
AccountPlusIcon,
CustomElementRender,
FavoriteIcon,
FileIcon,
FolderIcon,
FolderOpenIcon,
KeyIcon,
LinkIcon,
NcActionButton,
NcActions,
NcCheckboxRadioSwitch,
NcIconSvgWrapper,
NcLoadingIcon,
NcTextField,
NetworkIcon,
TagIcon,
FileEntryActions,
FileEntryCheckbox,
FileEntryName,
FileEntryPreview,
},
props: {
@ -282,10 +162,6 @@ export default Vue.extend({
type: [Folder, NcFile, Node] as PropType<Node>,
required: true,
},
index: {
type: Number,
required: true,
},
nodes: {
type: Array as PropType<Node[]>,
required: true,
@ -294,48 +170,41 @@ export default Vue.extend({
type: Number,
default: 0,
},
compact: {
type: Boolean,
default: false,
},
},
setup() {
const actionsMenuStore = useActionsMenuStore()
const draggingStore = useDragAndDropStore()
const filesStore = useFilesStore()
const keyboardStore = useKeyboardStore()
const renamingStore = useRenamingStore()
const selectionStore = useSelectionStore()
const userConfigStore = useUserConfigStore()
return {
actionsMenuStore,
draggingStore,
filesStore,
keyboardStore,
renamingStore,
selectionStore,
userConfigStore,
}
},
data() {
return {
backgroundFailed: undefined,
loading: '',
dragover: false,
NodeStatus,
}
},
computed: {
userConfig() {
return this.userConfigStore.userConfig
},
currentView() {
return this.$navigation.active
currentView(): View {
return this.$navigation.active as View
},
columns() {
// Hide columns if the list is too small
if (this.filesListWidth < 512) {
if (this.filesListWidth < 512 || this.compact) {
return []
}
return this.currentView?.columns || []
@ -351,6 +220,12 @@ export default Vue.extend({
fileid() {
return this.source?.fileid?.toString?.()
},
uniqueId() {
return hashCode(this.source.source)
},
isLoading() {
return this.source.status === NodeStatus.LOADING
},
extension() {
if (this.source.attributes?.displayName) {
@ -418,73 +293,6 @@ export default Vue.extend({
return ''
},
folderOverlay() {
if (this.source.type !== FileType.Folder) {
return null
}
// Encrypted folders
if (this.source?.attributes?.['is-encrypted'] === 1) {
return KeyIcon
}
// System tags
if (this.source?.attributes?.['is-tag']) {
return TagIcon
}
// Link and mail shared folders
const shareTypes = Object.values(this.source?.attributes?.['share-types'] || {}).flat() as number[]
if (shareTypes.some(type => type === ShareType.SHARE_TYPE_LINK || type === ShareType.SHARE_TYPE_EMAIL)) {
return LinkIcon
}
// Shared folders
if (shareTypes.length > 0) {
return AccountPlusIcon
}
switch (this.source?.attributes?.['mount-type']) {
case 'external':
case 'external-session':
return NetworkIcon
case 'group':
return AccountGroupIcon
}
return null
},
linkTo() {
if (this.source.attributes.failed) {
return {
title: t('files', 'This node is unavailable'),
is: 'span',
}
}
if (this.enabledDefaultActions.length > 0) {
const action = this.enabledDefaultActions[0]
const displayName = action.displayName([this.source], this.currentView)
return {
title: displayName,
role: 'button',
}
}
if (this.source?.permissions & Permission.READ) {
return {
download: this.source.basename,
href: this.source.source,
title: t('files', 'Download file {name}', { name: this.displayName }),
}
}
return {
is: 'span',
}
},
draggingFiles() {
return this.draggingStore.dragging
},
@ -495,124 +303,12 @@ export default Vue.extend({
return this.selectedFiles.includes(this.fileid)
},
cropPreviews() {
return this.userConfig.crop_image_previews
},
previewUrl() {
if (this.source.type === FileType.Folder) {
return null
}
if (this.backgroundFailed === true) {
return null
}
try {
const previewUrl = this.source.attributes.previewUrl
|| generateUrl('/core/preview?fileId={fileid}', {
fileid: this.fileid,
})
const url = new URL(window.location.origin + previewUrl)
// Request tiny previews
url.searchParams.set('x', '32')
url.searchParams.set('y', '32')
url.searchParams.set('mimeFallback', 'true')
// Handle cropping
url.searchParams.set('a', this.cropPreviews === true ? '0' : '1')
return url.href
} catch (e) {
return null
}
},
// Sorted actions that are enabled for this node
enabledActions() {
if (this.source.attributes.failed) {
return []
}
return actions
.filter(action => !action.enabled || action.enabled([this.source], this.currentView))
.sort((a, b) => (a.order || 0) - (b.order || 0))
},
// Enabled action that are displayed inline
enabledInlineActions() {
if (this.filesListWidth < 768) {
return []
}
return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
},
// Enabled action that are displayed inline with a custom render function
enabledRenderActions() {
if (!this.visible) {
return []
}
return this.enabledActions.filter(action => typeof action.renderInline === 'function')
},
// Default actions
enabledDefaultActions() {
return this.enabledActions.filter(action => !!action?.default)
},
// Actions shown in the menu
enabledMenuActions() {
return [
// Showing inline first for the NcActions inline prop
...this.enabledInlineActions,
// Then the rest
...this.enabledActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
].filter((value, index, self) => {
// Then we filter duplicates to prevent inline actions to be shown twice
return index === self.findIndex(action => action.id === value.id)
})
},
openedMenu: {
get() {
return this.actionsMenuStore.opened === this.uniqueId
},
set(opened) {
this.actionsMenuStore.opened = opened ? this.uniqueId : null
},
},
uniqueId() {
return hashCode(this.source.source)
},
isFavorite() {
return this.source.attributes.favorite === 1
},
isLoading() {
return this.source.status === NodeStatus.LOADING
},
renameLabel() {
const matchLabel: Record<FileType, string> = {
[FileType.File]: t('files', 'File name'),
[FileType.Folder]: t('files', 'Folder name'),
}
return matchLabel[this.source.type]
},
isRenaming() {
return this.renamingStore.renamingNode === this.source
},
isRenamingSmallScreen() {
return this.isRenaming && this.filesListWidth < 512
},
newName: {
get() {
return this.renamingStore.newName
},
set(newName) {
this.renamingStore.newName = newName
},
},
isActive() {
return this.fileid === this.currentFileId?.toString?.()
@ -643,6 +339,15 @@ export default Vue.extend({
return (this.source.permissions & Permission.CREATE) !== 0
},
openedMenu: {
get() {
return this.actionsMenuStore.opened === this.uniqueId
},
set(opened) {
this.actionsMenuStore.opened = opened ? this.uniqueId : null
},
},
},
watch: {
@ -653,17 +358,6 @@ export default Vue.extend({
source() {
this.resetState()
},
/**
* If renaming starts, select the file name
* in the input, without the extension.
* @param renaming
*/
isRenaming(renaming) {
if (renaming) {
this.startRenaming()
}
},
},
beforeDestroy() {
@ -675,96 +369,12 @@ export default Vue.extend({
// Reset loading state
this.loading = ''
// Reset background state
this.backgroundFailed = undefined
if (this.$refs.previewImg) {
this.$refs.previewImg.src = ''
}
this.$refs.preview.reset()
// Close menu
this.openedMenu = false
},
async onActionClick(action) {
const displayName = action.displayName([this.source], this.currentView)
try {
// Set the loading marker
this.loading = action.id
Vue.set(this.source, 'status', NodeStatus.LOADING)
const success = await action.exec(this.source, this.currentView, this.currentDir)
// If the action returns null, we stay silent
if (success === null) {
return
}
if (success) {
showSuccess(t('files', '"{displayName}" action executed successfully', { displayName }))
return
}
showError(t('files', '"{displayName}" action failed', { displayName }))
} catch (e) {
logger.error('Error while executing action', { action, e })
showError(t('files', '"{displayName}" action failed', { displayName }))
} finally {
// Reset the loading marker
this.loading = ''
Vue.set(this.source, 'status', undefined)
}
},
execDefaultAction(event) {
if (this.enabledDefaultActions.length > 0) {
event.preventDefault()
event.stopPropagation()
// Execute the first default action if any
this.enabledDefaultActions[0].exec(this.source, this.currentView, this.currentDir)
}
},
openDetailsIfAvailable(event) {
event.preventDefault()
event.stopPropagation()
if (sidebarAction?.enabled?.([this.source], this.currentView)) {
sidebarAction.exec(this.source, this.currentView, this.currentDir)
}
},
onSelectionChange(selected: boolean) {
const newSelectedIndex = this.index
const lastSelectedIndex = this.selectionStore.lastSelectedIndex
// Get the last selected and select all files in between
if (this.keyboardStore?.shiftKey && lastSelectedIndex !== null) {
const isAlreadySelected = this.selectedFiles.includes(this.fileid)
const start = Math.min(newSelectedIndex, lastSelectedIndex)
const end = Math.max(lastSelectedIndex, newSelectedIndex)
const lastSelection = this.selectionStore.lastSelection
const filesToSelect = this.nodes
.map(file => file.fileid?.toString?.())
.slice(start, end + 1)
// If already selected, update the new selection _without_ the current file
const selection = [...lastSelection, ...filesToSelect]
.filter(fileid => !isAlreadySelected || fileid !== this.fileid)
logger.debug('Shift key pressed, selecting all files in between', { start, end, filesToSelect, isAlreadySelected })
// Keep previous lastSelectedIndex to be use for further shift selections
this.selectionStore.set(selection)
return
}
const selection = selected
? [...this.selectedFiles, this.fileid]
: this.selectedFiles.filter(fileid => fileid !== this.fileid)
logger.debug('Updating selection', { selection })
this.selectionStore.set(selection)
this.selectionStore.setLastIndex(newSelectedIndex)
},
// Open the actions menu on right click
onRightClick(event) {
// If already opened, fallback to default browser
@ -781,166 +391,16 @@ export default Vue.extend({
event.stopPropagation()
},
/**
* Check if the file name is valid and update the
* input validity using browser's native validation.
* @param event the keyup event
*/
checkInputValidity(event?: KeyboardEvent) {
const input = event.target as HTMLInputElement
const newName = this.newName.trim?.() || ''
logger.debug('Checking input validity', { newName })
try {
this.isFileNameValid(newName)
input.setCustomValidity('')
input.title = ''
} catch (e) {
input.setCustomValidity(e.message)
input.title = e.message
} finally {
input.reportValidity()
}
},
isFileNameValid(name) {
const trimmedName = name.trim()
if (trimmedName === '.' || trimmedName === '..') {
throw new Error(t('files', '"{name}" is an invalid file name.', { name }))
} else if (trimmedName.length === 0) {
throw new Error(t('files', 'File name cannot be empty.'))
} else if (trimmedName.indexOf('/') !== -1) {
throw new Error(t('files', '"/" is not allowed inside a file name.'))
} else if (trimmedName.match(OC.config.blacklist_files_regex)) {
throw new Error(t('files', '"{name}" is not an allowed filetype.', { name }))
} else if (this.checkIfNodeExists(name)) {
throw new Error(t('files', '{newName} already exists.', { newName: name }))
}
const toCheck = trimmedName.split('')
toCheck.forEach(char => {
if (forbiddenCharacters.indexOf(char) !== -1) {
throw new Error(this.t('files', '"{char}" is not allowed inside a file name.', { char }))
}
})
return true
},
checkIfNodeExists(name) {
return this.nodes.find(node => node.basename === name && node !== this.source)
execDefaultAction(...args) {
this.$refs.actions.execDefaultAction(...args)
},
startRenaming() {
this.$nextTick(() => {
// Using split to get the true string length
const extLength = (this.source.extension || '').split('').length
const length = this.source.basename.split('').length - extLength
const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input
if (!input) {
logger.error('Could not find the rename input')
return
}
input.setSelectionRange(0, length)
input.focus()
// Trigger a keyup event to update the input validity
input.dispatchEvent(new Event('keyup'))
})
},
stopRenaming() {
if (!this.isRenaming) {
return
openDetailsIfAvailable(event) {
event.preventDefault()
event.stopPropagation()
if (sidebarAction?.enabled?.([this.source], this.currentView)) {
sidebarAction.exec(this.source, this.currentView, this.currentDir)
}
// Reset the renaming store
this.renamingStore.$reset()
},
// Rename and move the file
async onRename() {
const oldName = this.source.basename
const oldEncodedSource = this.source.encodedSource
const newName = this.newName.trim?.() || ''
if (newName === '') {
showError(t('files', 'Name cannot be empty'))
return
}
if (oldName === newName) {
this.stopRenaming()
return
}
// Checking if already exists
if (this.checkIfNodeExists(newName)) {
showError(t('files', 'Another entry with the same name already exists'))
return
}
// Set loading state
this.loading = 'renaming'
Vue.set(this.source, 'status', NodeStatus.LOADING)
// Update node
this.source.rename(newName)
logger.debug('Moving file to', { destination: this.source.encodedSource, oldEncodedSource })
try {
await axios({
method: 'MOVE',
url: oldEncodedSource,
headers: {
Destination: this.source.encodedSource,
},
})
// Success 🎉
emit('files:node:updated', this.source)
emit('files:node:renamed', this.source)
showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
// Reset the renaming store
this.stopRenaming()
this.$nextTick(() => {
this.$refs.basename.focus()
})
} catch (error) {
logger.error('Error while renaming file', { error })
this.source.rename(oldName)
this.$refs.renameInput.focus()
// TODO: 409 means current folder does not exist, redirect ?
if (error?.response?.status === 404) {
showError(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
return
} else if (error?.response?.status === 412) {
showError(t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.currentDir }))
return
}
// Unknown error
showError(t('files', 'Could not rename "{oldName}"', { oldName }))
} finally {
this.loading = false
Vue.set(this.source, 'status', undefined)
}
},
/**
* Making this a function in case the files-list
* reference changes in the future. That way we're
* sure there is one at the time we call it.
*/
getBoundariesElement() {
return document.querySelector('.app-content > .files-list')
},
actionDisplayName(action: FileAction) {
if (this.filesListWidth < 768 && action.inline && typeof action.title === 'function') {
// if an inline action is rendered in the menu for
// lack of space we use the title first if defined
const title = action.title([this.source], this.currentView)
if (title) return title
}
return action.displayName([this.source], this.currentView)
},
onDragOver(event: DragEvent) {
@ -1057,43 +517,3 @@ export default Vue.extend({
},
})
</script>
<style scoped lang='scss'>
/* Hover effect on tbody lines only */
tr {
&:hover,
&:focus {
background-color: var(--color-background-dark);
}
}
// Folder overlay
.files-list__row-icon-overlay {
position: absolute;
max-height: 18px;
max-width: 18px;
color: var(--color-main-background);
// better alignment with the folder icon
margin-top: 2px;
}
/* Preview not loaded animation effect */
.files-list__row-icon-preview:not(.files-list__row-icon-preview--loaded) {
background: var(--color-loading-dark);
// animation: preview-gradient-fade 1.2s ease-in-out infinite;
}
</style>
<style>
/* @keyframes preview-gradient-fade {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
} */
</style>

View file

@ -0,0 +1,247 @@
<!--
- @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<td v-show="visible"
class="files-list__row-actions"
data-cy-files-list-row-actions>
<!-- Render actions -->
<CustomElementRender v-for="action in enabledRenderActions"
:key="action.id"
:class="'files-list__row-action-' + action.id"
:current-view="currentView"
:render="action.renderInline"
:source="source"
class="files-list__row-action--inline" />
<!-- Menu actions -->
<NcActions v-if="visible"
ref="actionsMenu"
:boundaries-element="getBoundariesElement()"
:container="getBoundariesElement()"
:disabled="isLoading || loading !== ''"
:force-name="true"
:force-menu="enabledInlineActions.length === 0 /* forceMenu only if no inline actions */"
:inline="enabledInlineActions.length"
:open.sync="openedMenu">
<NcActionButton v-for="action in enabledMenuActions"
:key="action.id"
:class="'files-list__row-action-' + action.id"
:close-after-click="true"
:data-cy-files-list-row-action="action.id"
:title="action.title?.([source], currentView)"
@click="onActionClick(action)">
<template #icon>
<NcLoadingIcon v-if="loading === action.id" :size="18" />
<NcIconSvgWrapper v-else :svg="action.iconSvgInline([source], currentView)" />
</template>
{{ actionDisplayName(action) }}
</NcActionButton>
</NcActions>
</td>
</template>
<script lang="ts">
import { DefaultType, FileAction, Node, NodeStatus, View, getFileActions } from '@nextcloud/files'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n';
import Vue, { PropType } from 'vue'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import logger from '../../logger.js'
// The registered actions list
const actions = getFileActions()
export default Vue.extend({
name: 'FileEntryActions',
components: {
NcActionButton,
NcActions,
NcIconSvgWrapper,
NcLoadingIcon,
},
props: {
filesListWidth: {
type: Number,
required: true,
},
loading: {
type: String,
required: true,
},
opened: {
type: Boolean,
default: false,
},
source: {
type: Object as PropType<Node>,
required: true,
},
visible: {
type: Boolean,
default: false,
},
gridMode: {
type: Boolean,
default: false,
},
},
setup() {
return {
}
},
computed: {
currentDir() {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
},
currentView(): View {
return this.$navigation.active as View
},
isLoading() {
return this.source.status === NodeStatus.LOADING
},
// Sorted actions that are enabled for this node
enabledActions() {
if (this.source.attributes.failed) {
return []
}
return actions
.filter(action => !action.enabled || action.enabled([this.source], this.currentView))
.sort((a, b) => (a.order || 0) - (b.order || 0))
},
// Enabled action that are displayed inline
enabledInlineActions() {
if (this.filesListWidth < 768 || this.gridMode) {
return []
}
return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
},
// Enabled action that are displayed inline with a custom render function
enabledRenderActions() {
if (!this.visible || this.gridMode) {
return []
}
return this.enabledActions.filter(action => typeof action.renderInline === 'function')
},
// Default actions
enabledDefaultActions() {
return this.enabledActions.filter(action => !!action?.default)
},
// Actions shown in the menu
enabledMenuActions() {
return [
// Showing inline first for the NcActions inline prop
...this.enabledInlineActions,
// Then the rest
...this.enabledActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
].filter((value, index, self) => {
// Then we filter duplicates to prevent inline actions to be shown twice
return index === self.findIndex(action => action.id === value.id)
})
},
openedMenu: {
get() {
return this.opened
},
set(value) {
this.$emit('update:opened', value)
},
},
},
methods: {
/**
* Making this a function in case the files-list
* reference changes in the future. That way we're
* sure there is one at the time we call it.
*/
getBoundariesElement() {
return document.querySelector('.app-content > table.files-list')
},
actionDisplayName(action: FileAction) {
if (this.filesListWidth < 768 && action.inline && typeof action.title === 'function') {
// if an inline action is rendered in the menu for
// lack of space we use the title first if defined
const title = action.title([this.source], this.currentView)
if (title) return title
}
return action.displayName([this.source], this.currentView)
},
async onActionClick(action) {
const displayName = action.displayName([this.source], this.currentView)
try {
// Set the loading marker
this.$emit('update:loading', action.id)
Vue.set(this.source, 'status', NodeStatus.LOADING)
const success = await action.exec(this.source, this.currentView, this.currentDir)
// If the action returns null, we stay silent
if (success === null) {
return
}
if (success) {
showSuccess(t('files', '"{displayName}" action executed successfully', { displayName }))
return
}
showError(t('files', '"{displayName}" action failed', { displayName }))
} catch (e) {
logger.error('Error while executing action', { action, e })
showError(t('files', '"{displayName}" action failed', { displayName }))
} finally {
// Reset the loading marker
this.$emit('update:loading', '')
Vue.set(this.source, 'status', undefined)
}
},
execDefaultAction(event) {
if (this.enabledDefaultActions.length > 0) {
event.preventDefault()
event.stopPropagation()
// Execute the first default action if any
this.enabledDefaultActions[0].exec(this.source, this.currentView, this.currentDir)
}
},
t,
},
})
</script>

View file

@ -0,0 +1,131 @@
<!--
- @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<td class="files-list__row-checkbox">
<NcLoadingIcon v-if="isLoading" />
<NcCheckboxRadioSwitch v-else
:aria-label="t('files', 'Select the row for {displayName}', { displayName })"
:checked="isSelected"
@update:checked="onSelectionChange" />
</td>
</template>
<script lang="ts">
import { Node } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import Vue, { PropType } from 'vue'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import { useKeyboardStore } from '../../store/keyboard.ts'
import { useSelectionStore } from '../../store/selection.ts'
import logger from '../../logger.js'
export default Vue.extend({
name: 'FileEntryCheckbox',
components: {
NcCheckboxRadioSwitch,
NcLoadingIcon,
},
props: {
displayName: {
type: String,
required: true,
},
fileid: {
type: String,
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
nodes: {
type: Array as PropType<Node[]>,
required: true,
},
},
setup() {
const selectionStore = useSelectionStore()
const keyboardStore = useKeyboardStore()
return {
keyboardStore,
selectionStore,
}
},
computed: {
selectedFiles() {
return this.selectionStore.selected
},
isSelected() {
return this.selectedFiles.includes(this.fileid)
},
index() {
return this.nodes.findIndex((node: Node) => node.fileid === parseInt(this.fileid))
},
},
methods: {
onSelectionChange(selected: boolean) {
const newSelectedIndex = this.index
const lastSelectedIndex = this.selectionStore.lastSelectedIndex
// Get the last selected and select all files in between
if (this.keyboardStore?.shiftKey && lastSelectedIndex !== null) {
const isAlreadySelected = this.selectedFiles.includes(this.fileid)
const start = Math.min(newSelectedIndex, lastSelectedIndex)
const end = Math.max(lastSelectedIndex, newSelectedIndex)
const lastSelection = this.selectionStore.lastSelection
const filesToSelect = this.nodes
.map(file => file.fileid?.toString?.())
.slice(start, end + 1)
// If already selected, update the new selection _without_ the current file
const selection = [...lastSelection, ...filesToSelect]
.filter(fileid => !isAlreadySelected || fileid !== this.fileid)
logger.debug('Shift key pressed, selecting all files in between', { start, end, filesToSelect, isAlreadySelected })
// Keep previous lastSelectedIndex to be use for further shift selections
this.selectionStore.set(selection)
return
}
const selection = selected
? [...this.selectedFiles, this.fileid]
: this.selectedFiles.filter(fileid => fileid !== this.fileid)
logger.debug('Updating selection', { selection })
this.selectionStore.set(selection)
this.selectionStore.setLastIndex(newSelectedIndex)
},
t,
},
})
</script>

View file

@ -0,0 +1,330 @@
<!--
- @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<!-- Rename input -->
<form v-if="isRenaming"
v-on-click-outside="stopRenaming"
:aria-label="t('files', 'Rename file')"
class="files-list__row-rename"
@submit.prevent.stop="onRename">
<NcTextField ref="renameInput"
:label="renameLabel"
:autofocus="true"
:minlength="1"
:required="true"
:value.sync="newName"
enterkeyhint="done"
@keyup="checkInputValidity"
@keyup.esc="stopRenaming" />
</form>
<a v-else
ref="basename"
:aria-hidden="isRenaming"
class="files-list__row-name-link"
data-cy-files-list-row-name-link
v-bind="linkTo"
@click="$emit('click', $event)">
<!-- File name -->
<span class="files-list__row-name-text">
<!-- Keep the displayName stuck to the extension to avoid whitespace rendering issues-->
<span class="files-list__row-name-" v-text="displayName" />
<span class="files-list__row-name-ext" v-text="extension" />
</span>
</a>
</template>
<script lang="ts">
import { emit } from '@nextcloud/event-bus'
import { FileType, NodeStatus, Permission } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import Vue, { PropType } from 'vue'
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import { useRenamingStore } from '../../store/renaming.ts'
import logger from '../../logger.js'
const forbiddenCharacters = loadState('files', 'forbiddenCharacters', '') as string
export default Vue.extend({
name: 'FileEntryName',
components: {
NcTextField,
},
props: {
displayName: {
type: String,
required: true,
},
extension: {
type: String,
required: true,
},
filesListWidth: {
type: Number,
required: true,
},
nodes: {
type: Array as PropType<Node[]>,
required: true,
},
source: {
type: Object as PropType<Node>,
required: true,
},
gridMode: {
type: Boolean,
default: false,
},
},
setup() {
const renamingStore = useRenamingStore()
return {
renamingStore,
}
},
computed: {
isRenaming() {
return this.renamingStore.renamingNode === this.source
},
isRenamingSmallScreen() {
return this.isRenaming && this.filesListWidth < 512
},
newName: {
get() {
return this.renamingStore.newName
},
set(newName) {
this.renamingStore.newName = newName
},
},
renameLabel() {
const matchLabel: Record<FileType, string> = {
[FileType.File]: t('files', 'File name'),
[FileType.Folder]: t('files', 'Folder name'),
}
return matchLabel[this.source.type]
},
linkTo() {
if (this.source.attributes.failed) {
return {
title: t('files', 'This node is unavailable'),
is: 'span',
}
}
const enabledDefaultActions = this.$parent?.$refs?.actions?.enabledDefaultActions
if (enabledDefaultActions?.length > 0) {
const action = enabledDefaultActions[0]
const displayName = action.displayName([this.source], this.currentView)
return {
title: displayName,
role: 'button',
}
}
if (this.source?.permissions & Permission.READ) {
return {
download: this.source.basename,
href: this.source.source,
title: t('files', 'Download file {name}', { name: this.displayName }),
}
}
return {
is: 'span',
}
},
},
watch: {
/**
* If renaming starts, select the file name
* in the input, without the extension.
* @param renaming
*/
isRenaming(renaming: boolean) {
if (renaming) {
this.startRenaming()
}
},
},
methods: {
/**
* Check if the file name is valid and update the
* input validity using browser's native validation.
* @param event the keyup event
*/
checkInputValidity(event?: KeyboardEvent) {
const input = event.target as HTMLInputElement
const newName = this.newName.trim?.() || ''
logger.debug('Checking input validity', { newName })
try {
this.isFileNameValid(newName)
input.setCustomValidity('')
input.title = ''
} catch (e) {
input.setCustomValidity(e.message)
input.title = e.message
} finally {
input.reportValidity()
}
},
isFileNameValid(name) {
const trimmedName = name.trim()
if (trimmedName === '.' || trimmedName === '..') {
throw new Error(t('files', '"{name}" is an invalid file name.', { name }))
} else if (trimmedName.length === 0) {
throw new Error(t('files', 'File name cannot be empty.'))
} else if (trimmedName.indexOf('/') !== -1) {
throw new Error(t('files', '"/" is not allowed inside a file name.'))
} else if (trimmedName.match(OC.config.blacklist_files_regex)) {
throw new Error(t('files', '"{name}" is not an allowed filetype.', { name }))
} else if (this.checkIfNodeExists(name)) {
throw new Error(t('files', '{newName} already exists.', { newName: name }))
}
const toCheck = trimmedName.split('')
toCheck.forEach(char => {
if (forbiddenCharacters.indexOf(char) !== -1) {
throw new Error(this.t('files', '"{char}" is not allowed inside a file name.', { char }))
}
})
return true
},
checkIfNodeExists(name) {
return this.nodes.find(node => node.basename === name && node !== this.source)
},
startRenaming() {
this.$nextTick(() => {
// Using split to get the true string length
const extLength = (this.source.extension || '').split('').length
const length = this.source.basename.split('').length - extLength
const input = this.$refs.renameInput?.$refs?.inputField?.$refs?.input
if (!input) {
logger.error('Could not find the rename input')
return
}
input.setSelectionRange(0, length)
input.focus()
// Trigger a keyup event to update the input validity
input.dispatchEvent(new Event('keyup'))
})
},
stopRenaming() {
if (!this.isRenaming) {
return
}
// Reset the renaming store
this.renamingStore.$reset()
},
// Rename and move the file
async onRename() {
const oldName = this.source.basename
const oldEncodedSource = this.source.encodedSource
const newName = this.newName.trim?.() || ''
if (newName === '') {
showError(t('files', 'Name cannot be empty'))
return
}
if (oldName === newName) {
this.stopRenaming()
return
}
// Checking if already exists
if (this.checkIfNodeExists(newName)) {
showError(t('files', 'Another entry with the same name already exists'))
return
}
// Set loading state
this.loading = 'renaming'
Vue.set(this.source, 'status', NodeStatus.LOADING)
// Update node
this.source.rename(newName)
logger.debug('Moving file to', { destination: this.source.encodedSource, oldEncodedSource })
try {
await axios({
method: 'MOVE',
url: oldEncodedSource,
headers: {
Destination: this.source.encodedSource,
},
})
// Success 🎉
emit('files:node:updated', this.source)
emit('files:node:renamed', this.source)
showSuccess(t('files', 'Renamed "{oldName}" to "{newName}"', { oldName, newName }))
// Reset the renaming store
this.stopRenaming()
this.$nextTick(() => {
this.$refs.basename.focus()
})
} catch (error) {
logger.error('Error while renaming file', { error })
this.source.rename(oldName)
this.$refs.renameInput.focus()
// TODO: 409 means current folder does not exist, redirect ?
if (error?.response?.status === 404) {
showError(t('files', 'Could not rename "{oldName}", it does not exist any more', { oldName }))
return
} else if (error?.response?.status === 412) {
showError(t('files', 'The name "{newName}" is already used in the folder "{dir}". Please choose a different name.', { newName, dir: this.currentDir }))
return
}
// Unknown error
showError(t('files', 'Could not rename "{oldName}"', { oldName }))
} finally {
this.loading = false
Vue.set(this.source, 'status', undefined)
}
},
t,
},
})
</script>

View file

@ -0,0 +1,215 @@
<!--
- @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<span class="files-list__row-icon">
<template v-if="source.type === 'folder'">
<FolderOpenIcon v-if="dragover" />
<template v-else>
<FolderIcon />
<OverlayIcon :is="folderOverlay"
v-if="folderOverlay"
class="files-list__row-icon-overlay" />
</template>
</template>
<!-- Decorative image, should not be aria documented -->
<img v-else-if="previewUrl && backgroundFailed !== true"
ref="previewImg"
alt=""
class="files-list__row-icon-preview"
:class="{'files-list__row-icon-preview--loaded': backgroundFailed === false}"
:src="previewUrl"
@error="backgroundFailed = true"
@load="backgroundFailed = false">
<FileIcon v-else />
<!-- Favorite icon -->
<span v-if="isFavorite"
class="files-list__row-icon-favorite"
:aria-label="t('files', 'Favorite')">
<FavoriteIcon />
</span>
</span>
</template>
<script lang="ts">
import type { UserConfig } from '../../types.ts'
import { File, Folder, Node, FileType } from '@nextcloud/files'
import { generateUrl } from '@nextcloud/router'
import { translate as t } from '@nextcloud/l10n'
import { Type as ShareType } from '@nextcloud/sharing'
import Vue, { PropType } from 'vue'
import AccountGroupIcon from 'vue-material-design-icons/AccountGroup.vue'
import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue'
import FileIcon from 'vue-material-design-icons/File.vue'
import FolderIcon from 'vue-material-design-icons/Folder.vue'
import FolderOpenIcon from 'vue-material-design-icons/FolderOpen.vue'
import KeyIcon from 'vue-material-design-icons/Key.vue'
import LinkIcon from 'vue-material-design-icons/Link.vue'
import NetworkIcon from 'vue-material-design-icons/Network.vue'
import TagIcon from 'vue-material-design-icons/Tag.vue'
import { useUserConfigStore } from '../../store/userconfig.ts'
import FavoriteIcon from './FavoriteIcon.vue'
export default Vue.extend({
name: 'FileEntryPreview',
components: {
AccountGroupIcon,
AccountPlusIcon,
FavoriteIcon,
FileIcon,
FolderIcon,
FolderOpenIcon,
KeyIcon,
LinkIcon,
NetworkIcon,
TagIcon,
},
props: {
source: {
type: Object as PropType<Node>,
required: true,
},
dragover: {
type: Boolean,
default: false,
},
gridMode: {
type: Boolean,
default: false,
},
},
setup() {
const userConfigStore = useUserConfigStore()
return {
userConfigStore,
}
},
data() {
return {
backgroundFailed: undefined as boolean | undefined,
}
},
computed: {
fileid() {
return this.source?.fileid?.toString?.()
},
isFavorite(): boolean {
return this.source.attributes.favorite === 1
},
userConfig(): UserConfig {
return this.userConfigStore.userConfig
},
cropPreviews(): boolean {
return this.userConfig.crop_image_previews === true
},
previewUrl() {
if (this.source.type === FileType.Folder) {
return null
}
if (this.backgroundFailed === true) {
return null
}
try {
const previewUrl = this.source.attributes.previewUrl
|| generateUrl('/core/preview?fileId={fileid}', {
fileid: this.fileid,
})
const url = new URL(window.location.origin + previewUrl)
// Request tiny previews
url.searchParams.set('x', this.gridMode ? '128' : '32')
url.searchParams.set('y', this.gridMode ? '128' : '32')
url.searchParams.set('mimeFallback', 'true')
// Handle cropping
url.searchParams.set('a', this.cropPreviews === true ? '0' : '1')
return url.href
} catch (e) {
return null
}
},
folderOverlay() {
if (this.source.type !== FileType.Folder) {
return null
}
// Encrypted folders
if (this.source?.attributes?.['is-encrypted'] === 1) {
return KeyIcon
}
// System tags
if (this.source?.attributes?.['is-tag']) {
return TagIcon
}
// Link and mail shared folders
const shareTypes = Object.values(this.source?.attributes?.['share-types'] || {}).flat() as number[]
if (shareTypes.some(type => type === ShareType.SHARE_TYPE_LINK || type === ShareType.SHARE_TYPE_EMAIL)) {
return LinkIcon
}
// Shared folders
if (shareTypes.length > 0) {
return AccountPlusIcon
}
switch (this.source?.attributes?.['mount-type']) {
case 'external':
case 'external-session':
return NetworkIcon
case 'group':
return AccountGroupIcon
}
return null
},
},
methods: {
reset() {
// Reset background state
this.backgroundFailed = undefined
if (this.$refs.previewImg) {
this.$refs.previewImg.src = ''
}
},
t,
},
})
</script>

View file

@ -0,0 +1,414 @@
<!--
- @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<tr :class="{'files-list__row--visible': visible, 'files-list__row--active': isActive, 'files-list__row--dragover': dragover, 'files-list__row--loading': isLoading}"
data-cy-files-list-row
:data-cy-files-list-row-fileid="fileid"
:data-cy-files-list-row-name="source.basename"
:draggable="canDrag"
class="files-list__row"
@contextmenu="onRightClick"
@dragover="onDragOver"
@dragleave="onDragLeave"
@dragstart="onDragStart"
@dragend="onDragEnd"
@drop="onDrop">
<!-- Failed indicator -->
<span v-if="source.attributes.failed" class="files-list__row--failed" />
<!-- Checkbox -->
<FileEntryCheckbox v-if="visible"
:display-name="displayName"
:fileid="fileid"
:is-loading="isLoading"
:nodes="nodes" />
<!-- Link to file -->
<td class="files-list__row-name" data-cy-files-list-row-name>
<!-- Icon or preview -->
<FileEntryPreview ref="preview"
:dragover="dragover"
:grid-mode="true"
:source="source"
@click.native="execDefaultAction" />
<FileEntryName ref="name"
:display-name="displayName"
:extension="extension"
:files-list-width="filesListWidth"
:grid-mode="true"
:nodes="nodes"
:source="source"
@click="execDefaultAction" />
</td>
<!-- Actions -->
<FileEntryActions ref="actions"
:class="`files-list__row-actions-${uniqueId}`"
:files-list-width="filesListWidth"
:grid-mode="true"
:loading.sync="loading"
:opened.sync="openedMenu"
:source="source"
:visible="visible" />
</tr>
</template>
<script lang="ts">
import type { PropType } from 'vue'
import { extname, join } from 'path'
import { FileType, Permission, Folder, File as NcFile, NodeStatus, Node, View } from '@nextcloud/files'
import { getUploader } from '@nextcloud/upload'
import { showError } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import { vOnClickOutside } from '@vueuse/components'
import Vue from 'vue'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
import { getDragAndDropPreview } from '../utils/dragUtils.ts'
import { handleCopyMoveNodeTo } from '../actions/moveOrCopyAction.ts'
import { hashCode } from '../utils/hashUtils.ts'
import { MoveCopyAction } from '../actions/moveOrCopyActionUtils.ts'
import { useActionsMenuStore } from '../store/actionsmenu.ts'
import { useDragAndDropStore } from '../store/dragging.ts'
import { useFilesStore } from '../store/files.ts'
import { useRenamingStore } from '../store/renaming.ts'
import { useSelectionStore } from '../store/selection.ts'
import FileEntryActions from './FileEntry/FileEntryActions.vue'
import FileEntryCheckbox from './FileEntry/FileEntryCheckbox.vue'
import FileEntryName from './FileEntry/FileEntryName.vue'
import FileEntryPreview from './FileEntry/FileEntryPreview.vue'
import logger from '../logger.js'
Vue.directive('onClickOutside', vOnClickOutside)
export default Vue.extend({
name: 'FileEntryGrid',
components: {
FileEntryActions,
FileEntryCheckbox,
FileEntryName,
FileEntryPreview,
},
inheritAttrs: false,
props: {
visible: {
type: Boolean,
default: false,
},
source: {
type: [Folder, NcFile, Node] as PropType<Node>,
required: true,
},
nodes: {
type: Array as PropType<Node[]>,
required: true,
},
filesListWidth: {
type: Number,
default: 0,
},
},
setup() {
const actionsMenuStore = useActionsMenuStore()
const draggingStore = useDragAndDropStore()
const filesStore = useFilesStore()
const renamingStore = useRenamingStore()
const selectionStore = useSelectionStore()
return {
actionsMenuStore,
draggingStore,
filesStore,
renamingStore,
selectionStore,
}
},
data() {
return {
loading: '',
dragover: false,
}
},
computed: {
currentView(): View {
return this.$navigation.active as View
},
currentDir() {
// Remove any trailing slash but leave root slash
return (this.$route?.query?.dir?.toString() || '/').replace(/^(.+)\/$/, '$1')
},
currentFileId() {
return this.$route.params?.fileid || this.$route.query?.fileid || null
},
fileid() {
return this.source?.fileid?.toString?.()
},
uniqueId() {
return hashCode(this.source.source)
},
isLoading() {
return this.source.status === NodeStatus.LOADING
},
extension() {
if (this.source.attributes?.displayName) {
return extname(this.source.attributes.displayName)
}
return this.source.extension || ''
},
displayName() {
const ext = this.extension
const name = (this.source.attributes.displayName
|| this.source.basename)
// Strip extension from name if defined
return !ext ? name : name.slice(0, 0 - ext.length)
},
draggingFiles() {
return this.draggingStore.dragging
},
selectedFiles() {
return this.selectionStore.selected
},
isSelected() {
return this.selectedFiles.includes(this.fileid)
},
isRenaming() {
return this.renamingStore.renamingNode === this.source
},
isActive() {
return this.fileid === this.currentFileId?.toString?.()
},
canDrag() {
const canDrag = (node: Node): boolean => {
return (node?.permissions & Permission.UPDATE) !== 0
}
// If we're dragging a selection, we need to check all files
if (this.selectedFiles.length > 0) {
const nodes = this.selectedFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
return nodes.every(canDrag)
}
return canDrag(this.source)
},
canDrop() {
if (this.source.type !== FileType.Folder) {
return false
}
// If the current folder is also being dragged, we can't drop it on itself
if (this.draggingFiles.includes(this.fileid)) {
return false
}
return (this.source.permissions & Permission.CREATE) !== 0
},
openedMenu: {
get() {
return this.actionsMenuStore.opened === this.uniqueId
},
set(opened) {
this.actionsMenuStore.opened = opened ? this.uniqueId : null
},
},
},
watch: {
/**
* When the source changes, reset the preview
* and fetch the new one.
*/
source() {
this.resetState()
},
},
beforeDestroy() {
this.resetState()
},
methods: {
resetState() {
// Reset loading state
this.loading = ''
this.$refs.preview.reset()
// Close menu
this.openedMenu = false
},
// Open the actions menu on right click
onRightClick(event) {
// If already opened, fallback to default browser
if (this.openedMenu) {
return
}
// If the clicked row is in the selection, open global menu
const isMoreThanOneSelected = this.selectedFiles.length > 1
this.actionsMenuStore.opened = this.isSelected && isMoreThanOneSelected ? 'global' : this.uniqueId
// Prevent any browser defaults
event.preventDefault()
event.stopPropagation()
},
execDefaultAction(...args) {
this.$refs.actions.execDefaultAction(...args)
},
openDetailsIfAvailable(event) {
event.preventDefault()
event.stopPropagation()
if (sidebarAction?.enabled?.([this.source], this.currentView)) {
sidebarAction.exec(this.source, this.currentView, this.currentDir)
}
},
onDragOver(event: DragEvent) {
this.dragover = this.canDrop
if (!this.canDrop) {
event.dataTransfer.dropEffect = 'none'
return
}
// Handle copy/move drag and drop
if (event.ctrlKey) {
event.dataTransfer.dropEffect = 'copy'
} else {
event.dataTransfer.dropEffect = 'move'
}
},
onDragLeave(event: DragEvent) {
// Counter bubbling, make sure we're ending the drag
// only when we're leaving the current element
const currentTarget = event.currentTarget as HTMLElement
if (currentTarget?.contains(event.relatedTarget as HTMLElement)) {
return
}
this.dragover = false
},
async onDragStart(event: DragEvent) {
event.stopPropagation()
if (!this.canDrag) {
event.preventDefault()
event.stopPropagation()
return
}
logger.debug('Drag started')
// Reset any renaming
this.renamingStore.$reset()
// Dragging set of files, if we're dragging a file
// that is already selected, we use the entire selection
if (this.selectedFiles.includes(this.fileid)) {
this.draggingStore.set(this.selectedFiles)
} else {
this.draggingStore.set([this.fileid])
}
const nodes = this.draggingStore.dragging
.map(fileid => this.filesStore.getNode(fileid)) as Node[]
const image = await getDragAndDropPreview(nodes)
event.dataTransfer?.setDragImage(image, -10, -10)
},
onDragEnd() {
this.draggingStore.reset()
this.dragover = false
logger.debug('Drag ended')
},
async onDrop(event) {
event.preventDefault()
event.stopPropagation()
// If another button is pressed, cancel it
// This allows cancelling the drag with the right click
if (!this.canDrop || event.button !== 0) {
return
}
const isCopy = event.ctrlKey
this.dragover = false
logger.debug('Dropped', { event, selection: this.draggingFiles })
// Check whether we're uploading files
if (event.dataTransfer?.files?.length > 0) {
const uploader = getUploader()
event.dataTransfer.files.forEach((file: File) => {
uploader.upload(join(this.source.path, file.name), file)
})
logger.debug(`Uploading files to ${this.source.path}`)
return
}
const nodes = this.draggingFiles.map(fileid => this.filesStore.getNode(fileid)) as Node[]
nodes.forEach(async (node: Node) => {
Vue.set(node, 'status', NodeStatus.LOADING)
try {
// TODO: resolve potential conflicts prior and force overwrite
await handleCopyMoveNodeTo(node, this.source, isCopy ? MoveCopyAction.COPY : MoveCopyAction.MOVE)
} catch (error) {
logger.error('Error while moving file', { error })
if (isCopy) {
showError(t('files', 'Could not copy {file}. {message}', { file: node.basename, message: error.message || '' }))
} else {
showError(t('files', 'Could not move {file}. {message}', { file: node.basename, message: error.message || '' }))
}
} finally {
Vue.set(node, 'status', undefined)
}
})
// Reset selection after we dropped the files
// if the dropped files are within the selection
if (this.draggingFiles.some(fileid => this.selectedFiles.includes(fileid))) {
logger.debug('Dropped selection, resetting select store...')
this.selectionStore.reset()
}
},
t,
},
})
</script>

View file

@ -159,17 +159,16 @@ export default Vue.extend({
<style scoped lang="scss">
// Scoped row
tr {
padding-bottom: 300px;
margin-bottom: 300px;
border-top: 1px solid var(--color-border);
// Prevent hover effect on the whole row
background-color: transparent !important;
border-bottom: none !important;
}
td {
user-select: none;
// Make sure the cell colors don't apply to column headers
color: var(--color-text-maxcontrast) !important;
td {
user-select: none;
// Make sure the cell colors don't apply to column headers
color: var(--color-text-maxcontrast) !important;
}
}
</style>

View file

@ -28,10 +28,10 @@
:style="{ height: dndNoticeHeight }" />
<VirtualList ref="table"
:data-component="FileEntry"
:data-component="userConfig.grid_view ? FileEntryGrid : FileEntry"
:data-key="'source'"
:data-sources="nodes"
:item-height="56"
:grid-mode="userConfig.grid_view"
:extra-props="{
isMtimeAvailable,
isSizeAvailable,
@ -79,8 +79,9 @@
</template>
<script lang="ts">
import type { PropType } from 'vue'
import type { Node as NcNode } from '@nextcloud/files'
import type { PropType } from 'vue'
import type { UserConfig } from '../types.ts'
import { Fragment } from 'vue-frag'
import { getFileListHeaders, Folder, View, Permission } from '@nextcloud/files'
@ -89,8 +90,10 @@ import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import Vue from 'vue'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
import { useUserConfigStore } from '../store/userconfig.ts'
import DragAndDropNotice from './DragAndDropNotice.vue'
import FileEntry from './FileEntry.vue'
import FileEntryGrid from './FileEntryGrid.vue'
import FilesListHeader from './FilesListHeader.vue'
import FilesListTableFooter from './FilesListTableFooter.vue'
import FilesListTableHeader from './FilesListTableHeader.vue'
@ -129,9 +132,17 @@ export default Vue.extend({
},
},
setup() {
const userConfigStore = useUserConfigStore()
return {
userConfigStore,
}
},
data() {
return {
FileEntry,
FileEntryGrid,
headers: getFileListHeaders(),
scrollToIndex: 0,
dragover: false,
@ -140,6 +151,10 @@ export default Vue.extend({
},
computed: {
userConfig(): UserConfig {
return this.userConfigStore.userConfig
},
files() {
return this.nodes.filter(node => node.type === 'file')
},
@ -302,6 +317,14 @@ export default Vue.extend({
width: 100%;
// Necessary for virtual scrolling absolute
position: relative;
/* Hover effect on tbody lines only */
tr {
&:hover,
&:focus {
background-color: var(--color-background-dark);
}
}
}
// Before table and thead
@ -340,6 +363,7 @@ export default Vue.extend({
user-select: none;
border-bottom: 1px solid var(--color-border);
user-select: none;
height: var(--row-height);
}
td, th {
@ -465,10 +489,15 @@ export default Vue.extend({
width: var(--icon-preview-size);
height: var(--icon-preview-size);
border-radius: var(--border-radius);
background-repeat: no-repeat;
// Center and contain the preview
background-position: center;
background-size: contain;
object-fit: contain;
object-position: center;
/* Preview not loaded animation effect */
&:not(.files-list__row-icon-preview--loaded) {
background: var(--color-loading-dark);
// animation: preview-gradient-fade 1.2s ease-in-out infinite;
}
}
&-favorite {
@ -476,6 +505,16 @@ export default Vue.extend({
top: 0px;
right: -10px;
}
// Folder overlay
&-overlay {
position: absolute;
max-height: calc(var(--icon-preview-size) * 0.5);
max-width: calc(var(--icon-preview-size) * 0.5);
color: var(--color-main-background);
// better alignment with the folder icon
margin-top: 2px;
}
}
// Entry link
@ -518,6 +557,8 @@ export default Vue.extend({
.files-list__row-name-ext {
color: var(--color-text-maxcontrast);
// always show the extension
overflow: visible;
}
}
@ -541,6 +582,7 @@ export default Vue.extend({
}
.files-list__row-actions {
// take as much space as necessary
width: auto;
// Add margin to all cells after the actions
@ -581,3 +623,91 @@ export default Vue.extend({
}
}
</style>
<style lang="scss">
// Grid mode
tbody.files-list__tbody.files-list__tbody--grid {
--half-clickable-area: calc(var(--clickable-area) / 2);
--row-width: 160px;
// We use half of the clickable area as visual balance margin
--row-height: calc(var(--row-width) - var(--half-clickable-area));
--icon-preview-size: calc(var(--row-width) - var(--clickable-area));
--checkbox-padding: 0px;
display: grid;
grid-template-columns: repeat(auto-fill, var(--row-width));
grid-gap: 15px;
row-gap: 15px;
align-content: center;
align-items: center;
justify-content: space-around;
justify-items: center;
tr {
width: var(--row-width);
height: calc(var(--row-height) + var(--clickable-area));
border: none;
border-radius: var(--border-radius);
}
// Checkbox in the top left
.files-list__row-checkbox {
position: absolute;
z-index: 9;
top: 0;
left: 0;
overflow: hidden;
width: var(--clickable-area);
height: var(--clickable-area);
border-radius: var(--half-clickable-area);
}
// Star icon in the top right
.files-list__row-icon-favorite {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
width: var(--clickable-area);
height: var(--clickable-area);
}
.files-list__row-name {
display: grid;
justify-content: stretch;
width: 100%;
height: 100%;
grid-auto-rows: var(--row-height) var(--clickable-area);
span.files-list__row-icon {
width: 100%;
height: 100%;
// Visual balance, we use half of the clickable area
// as a margin around the preview
padding-top: var(--half-clickable-area);
}
a.files-list__row-name-link {
// Minus action menu
width: calc(100% - var(--clickable-area));
height: var(--clickable-area);
}
.files-list__row-name-text {
margin: 0;
padding-right: 0;
}
}
.files-list__row-actions {
position: absolute;
right: 0;
bottom: 0;
width: var(--clickable-area);
height: var(--clickable-area);
}
}
</style>

View file

@ -11,11 +11,14 @@
</thead>
<!-- Body -->
<tbody :style="tbodyStyle" class="files-list__tbody" data-cy-files-list-tbody>
<tbody :style="tbodyStyle"
class="files-list__tbody"
:class="gridMode ? 'files-list__tbody--grid' : 'files-list__tbody--list'"
data-cy-files-list-tbody>
<component :is="dataComponent"
v-for="(item, i) in renderedItems"
:key="i"
:visible="(i >= bufferItems || index <= bufferItems) && (i < shownItems - bufferItems)"
v-for="({key, item}, i) in renderedItems"
:key="key"
:visible="(i >= bufferItems - 1 || index <= bufferItems) && (i <= shownItems - bufferItems)"
:source="item"
:index="i"
v-bind="extraProps" />
@ -23,7 +26,6 @@
<!-- Footer -->
<tfoot v-show="isReady"
ref="tfoot"
class="files-list__tfoot"
data-cy-files-list-tfoot>
<slot name="footer" />
@ -32,16 +34,23 @@
</template>
<script lang="ts">
import { File, Folder, debounce } from 'debounce'
import Vue from 'vue'
import type { File, Folder, Node } from '@nextcloud/files'
import { debounce } from 'debounce'
import Vue, { PropType } from 'vue'
import filesListWidthMixin from '../mixins/filesListWidth.ts'
import logger from '../logger.js'
// Items to render before and after the visible area
const bufferItems = 3
interface RecycledPoolItem {
key: string,
item: Node,
}
export default Vue.extend({
name: 'VirtualList',
mixins: [filesListWidthMixin],
props: {
dataComponent: {
type: [Object, Function],
@ -52,26 +61,25 @@ export default Vue.extend({
required: true,
},
dataSources: {
type: Array as () => (File | Folder)[],
required: true,
},
itemHeight: {
type: Number,
type: Array as PropType<(File | Folder)[]>,
required: true,
},
extraProps: {
type: Object,
type: Object as PropType<Record<string, unknown>>,
default: () => ({}),
},
scrollToIndex: {
type: Number,
default: 0,
},
gridMode: {
type: Boolean,
default: false,
},
},
data() {
return {
bufferItems,
index: this.scrollToIndex,
beforeHeight: 0,
headerHeight: 0,
@ -86,60 +94,126 @@ export default Vue.extend({
return this.tableHeight > 0
},
// Items to render before and after the visible area
bufferItems() {
if (this.gridMode) {
return this.columnCount
}
return 3
},
itemHeight() {
// Align with css in FilesListVirtual
// 138px + 44px (name) + 15px (grid gap)
return this.gridMode ? (160 + 44 + 15) : 56
},
// Grid mode only
itemWidth() {
// 160px + 15px grid gap
return 160 + 15
},
rowCount() {
return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + (this.bufferItems / this.columnCount) * 2 + 1
},
columnCount() {
if (!this.gridMode) {
return 1
}
return Math.floor(this.filesListWidth / this.itemWidth)
},
startIndex() {
return Math.max(0, this.index - bufferItems)
return Math.max(0, this.index - this.bufferItems)
},
shownItems() {
return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + bufferItems * 2
// If in grid mode, we need to multiply the number of rows by the number of columns
if (this.gridMode) {
return this.rowCount * this.columnCount
}
return this.rowCount
},
renderedItems(): (File | Folder)[] {
renderedItems(): RecycledPoolItem[] {
if (!this.isReady) {
return []
}
return this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems)
const items = this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems) as Node[]
const oldItems = items.filter(item => Object.values(this.$_recycledPool).includes(item[this.dataKey]))
const oldItemsKeys = oldItems.map(item => item[this.dataKey] as string)
const unusedKeys = Object.keys(this.$_recycledPool).filter(key => !oldItemsKeys.includes(this.$_recycledPool[key]))
return items.map(item => {
const index = Object.values(this.$_recycledPool).indexOf(item[this.dataKey])
// If defined, let's keep the key
if (index !== -1) {
return {
key: Object.keys(this.$_recycledPool)[index],
item,
}
}
// Get and consume reusable key or generate a new one
const key = unusedKeys.pop() || Math.random().toString(36).substr(2)
this.$_recycledPool[key] = item[this.dataKey]
return { key, item }
})
},
tbodyStyle() {
const isOverScrolled = this.startIndex + this.shownItems > this.dataSources.length
const isOverScrolled = this.startIndex + this.rowCount > this.dataSources.length
const lastIndex = this.dataSources.length - this.startIndex - this.shownItems
const hiddenAfterItems = Math.min(this.dataSources.length - this.startIndex, lastIndex)
const hiddenAfterItems = Math.floor(Math.min(this.dataSources.length - this.startIndex, lastIndex) / this.columnCount)
return {
paddingTop: `${this.startIndex * this.itemHeight}px`,
paddingTop: `${Math.floor(this.startIndex / this.columnCount) * this.itemHeight}px`,
paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`,
}
},
},
watch: {
scrollToIndex() {
this.index = this.scrollToIndex
this.$el.scrollTop = this.index * this.itemHeight + this.beforeHeight
scrollToIndex(index) {
this.scrollTo(index)
},
columnCount(columnCount, oldColumnCount) {
if (oldColumnCount === 0) {
// We're initializing, the scroll position
// is handled on mounted
console.debug('VirtualList: columnCount is 0, skipping scroll')
return
}
// If the column count changes in grid view,
// update the scroll position again
this.scrollTo(this.index)
},
},
mounted() {
const before = this.$refs?.before as HTMLElement
const root = this.$el as HTMLElement
const tfoot = this.$refs?.tfoot as HTMLElement
const thead = this.$refs?.thead as HTMLElement
this.resizeObserver = new ResizeObserver(debounce(() => {
this.beforeHeight = before?.clientHeight ?? 0
this.headerHeight = thead?.clientHeight ?? 0
this.tableHeight = root?.clientHeight ?? 0
logger.debug('VirtualList resizeObserver updated')
logger.debug('VirtualList: resizeObserver updated')
this.onScroll()
}, 100, false))
this.resizeObserver.observe(before)
this.resizeObserver.observe(root)
this.resizeObserver.observe(tfoot)
this.resizeObserver.observe(thead)
if (this.scrollToIndex) {
this.scrollTo(this.scrollToIndex)
}
// Adding scroll listener AFTER the initial scroll to index
this.$el.addEventListener('scroll', this.onScroll)
if (this.scrollToIndex) {
this.$el.scrollTop = this.index * this.itemHeight + this.beforeHeight
}
this.$_recycledPool = {} as Record<string, any>
},
beforeDestroy() {
@ -149,9 +223,19 @@ export default Vue.extend({
},
methods: {
scrollTo(index: number) {
this.index = index
// Scroll to one row and a half before the index
const scrollTop = (Math.floor(index / this.columnCount) - 0.5) * this.itemHeight + this.beforeHeight
logger.debug('VirtualList: scrolling to index ' + index, { scrollTop, columnCount: this.columnCount })
this.$el.scrollTop = scrollTop
},
onScroll() {
const topScroll = this.$el.scrollTop - this.beforeHeight
const index = Math.floor(topScroll / this.itemHeight) * this.columnCount
// Max 0 to prevent negative index
this.index = Math.max(0, Math.round((this.$el.scrollTop - this.beforeHeight) / this.itemHeight))
this.index = Math.max(0, index)
this.$emit('scroll')
},
},

View file

@ -28,7 +28,7 @@ export default Vue.extend({
filesListWidth: null as number | null,
}
},
created() {
mounted() {
const fileListEl = document.querySelector('#app-content-vue')
this.$resizeObserver = new ResizeObserver((entries) => {
if (entries.length > 0 && entries[0].target === fileListEl) {

View file

@ -31,6 +31,7 @@ const userConfig = loadState('files', 'config', {
show_hidden: false,
crop_image_previews: true,
sort_favorites_first: true,
grid_view: false,
}) as UserConfig
export const useUserConfigStore = function(...args) {

View file

@ -46,6 +46,18 @@
</template>
</BreadCrumbs>
<NcButton v-if="filesListWidth >= 512"
:aria-label="gridViewButtonLabel"
:title="gridViewButtonLabel"
class="files-list__header-grid-button"
type="tertiary"
@click="toggleGridView">
<template #icon>
<ListViewIcon v-if="userConfig.grid_view" />
<ViewGridIcon v-else />
</template>
</NcButton>
<!-- Secondary loading indicator -->
<NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" />
</div>
@ -99,13 +111,15 @@ import { Type } from '@nextcloud/sharing'
import { UploadPicker } from '@nextcloud/upload'
import Vue from 'vue'
import LinkIcon from 'vue-material-design-icons/Link.vue'
import ListViewIcon from 'vue-material-design-icons/FormatListBulletedSquare.vue'
import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import LinkIcon from 'vue-material-design-icons/Link.vue'
import ShareVariantIcon from 'vue-material-design-icons/ShareVariant.vue'
import ViewGridIcon from 'vue-material-design-icons/ViewGrid.vue'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
import { useFilesStore } from '../store/files.ts'
@ -116,6 +130,7 @@ import { useUserConfigStore } from '../store/userconfig.ts'
import { useViewConfigStore } from '../store/viewConfig.ts'
import BreadCrumbs from '../components/BreadCrumbs.vue'
import FilesListVirtual from '../components/FilesListVirtual.vue'
import filesListWidthMixin from '../mixins/filesListWidth.ts'
import filesSortingMixin from '../mixins/filesSorting.ts'
import logger from '../logger.js'
@ -128,6 +143,7 @@ export default Vue.extend({
BreadCrumbs,
FilesListVirtual,
LinkIcon,
ListViewIcon,
NcAppContent,
NcButton,
NcEmptyContent,
@ -135,9 +151,11 @@ export default Vue.extend({
NcLoadingIcon,
ShareVariantIcon,
UploadPicker,
ViewGridIcon,
},
mixins: [
filesListWidthMixin,
filesSortingMixin,
],
@ -296,6 +314,12 @@ export default Vue.extend({
return Type.SHARE_TYPE_USER
},
gridViewButtonLabel() {
return this.userConfig.grid_view
? this.t('files', 'Switch to list view')
: this.t('files', 'Switch to grid view')
},
canUpload() {
return this.currentFolder && (this.currentFolder.permissions & Permission.CREATE) !== 0
},
@ -430,6 +454,10 @@ export default Vue.extend({
sidebarAction.exec(this.currentFolder, this.currentView, this.currentFolder.path)
},
toggleGridView() {
this.userConfigStore.update('grid_view', !this.userConfig.grid_view)
},
t: translate,
n: translatePlural,
},
@ -452,7 +480,7 @@ $navigationToggleSize: 50px;
.files-list {
&__header {
display: flex;
align-content: center;
align-items: center;
// Do not grow or shrink (vertically)
flex: 0 0;
// Align with the navigation toggle icon

View file

@ -38,6 +38,10 @@
@update:checked="setConfig('crop_image_previews', $event)">
{{ t('files', 'Crop image previews') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch :checked="userConfig.grid_view"
@update:checked="setConfig('grid_view', $event)">
{{ t('files', 'Enable the grid view') }}
</NcCheckboxRadioSwitch>
</NcAppSettingsSection>
<!-- Settings API-->

4
dist/files-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long