summaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorJohn Molakvoæ <skjnldsv@protonmail.com>2023-03-23 08:37:37 +0100
committerJohn Molakvoæ <skjnldsv@protonmail.com>2023-04-06 14:49:31 +0200
commit0b4da6117fff4d999cb492503a8b6fc04eb75f9d (patch)
treed13df5d3ab7ae4e104e08e708a6f1527976d7488 /apps
parent0db210a0922cc32c924d196f7d38778912547fc1 (diff)
downloadnextcloud-server-0b4da6117fff4d999cb492503a8b6fc04eb75f9d.tar.gz
nextcloud-server-0b4da6117fff4d999cb492503a8b6fc04eb75f9d.zip
feat(files): actions api
Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
Diffstat (limited to 'apps')
-rw-r--r--apps/files/js/app.js5
-rw-r--r--apps/files/src/actions/deleteAction.ts12
-rw-r--r--apps/files/src/components/CustomSvgIconRender.vue63
-rw-r--r--apps/files/src/components/FileEntry.vue305
-rw-r--r--apps/files/src/components/FilesListHeader.vue29
-rw-r--r--apps/files/src/components/FilesListVirtual.vue11
-rw-r--r--apps/files/src/mixins/fileslist-row.scss64
-rw-r--r--apps/files_trashbin/src/actions/restoreAction.ts59
-rw-r--r--apps/files_trashbin/src/css/trashbin.css3
-rw-r--r--apps/files_trashbin/src/main.ts14
10 files changed, 411 insertions, 154 deletions
diff --git a/apps/files/js/app.js b/apps/files/js/app.js
index 36afd9a80b7..8ebd506c1a3 100644
--- a/apps/files/js/app.js
+++ b/apps/files/js/app.js
@@ -144,6 +144,8 @@
}
window._nc_event_bus.emit('files:legacy-view:initialized', this);
+
+ this.navigation = OCP.Files.Navigation
},
/**
@@ -224,7 +226,8 @@
* @return view id
*/
getActiveView: function() {
- return this.navigation.active
+ return this.navigation
+ && this.navigation.active
&& this.navigation.active.id;
},
diff --git a/apps/files/src/actions/deleteAction.ts b/apps/files/src/actions/deleteAction.ts
index b1bf2cb2105..cd12c15ba10 100644
--- a/apps/files/src/actions/deleteAction.ts
+++ b/apps/files/src/actions/deleteAction.ts
@@ -23,6 +23,7 @@ import { registerFileAction, Permission, FileAction } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import TrashCan from '@mdi/svg/svg/trash-can.svg?raw'
+import logger from '../logger'
registerFileAction(new FileAction({
id: 'delete',
@@ -38,12 +39,9 @@ registerFileAction(new FileAction({
.every(permission => (permission & Permission.DELETE) !== 0)
},
async exec(node) {
- try {
- await axios.delete(node.source)
- return true
- } catch (error) {
- console.error(error)
- return false
- }
+ // No try...catch here, let the files app handle the error
+ await axios.delete(node.source)
+ return true
},
+ order: 100,
}))
diff --git a/apps/files/src/components/CustomSvgIconRender.vue b/apps/files/src/components/CustomSvgIconRender.vue
new file mode 100644
index 00000000000..f025319946f
--- /dev/null
+++ b/apps/files/src/components/CustomSvgIconRender.vue
@@ -0,0 +1,63 @@
+<!--
+ - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
+ -
+ - @author Gary Kim <gary@garykim.dev>
+ -
+ - @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="custom-svg-icon" />
+</template>
+
+<script>
+// eslint-disable-next-line import/named
+import { sanitize } from 'dompurify'
+
+export default {
+ name: 'CustomSvgIconRender',
+ props: {
+ svg: {
+ type: String,
+ required: true,
+ },
+ },
+ mounted() {
+ this.$el.innerHTML = sanitize(this.svg)
+ },
+}
+</script>
+<style lang="scss" scoped>
+.custom-svg-icon {
+ display: flex;
+ align-items: center;
+ align-self: center;
+ justify-content: center;
+ justify-self: center;
+ width: 44px;
+ height: 44px;
+ opacity: 1;
+
+ ::v-deep svg {
+ // mdi icons have a size of 24px
+ // 22px results in roughly 16px inner size
+ height: 22px;
+ width: 22px;
+ fill: currentColor;
+ }
+}
+
+</style>
diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue
index d507fe6945c..ea9615af596 100644
--- a/apps/files/src/components/FileEntry.vue
+++ b/apps/files/src/components/FileEntry.vue
@@ -19,9 +19,95 @@
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
+
+<template>
+ <Fragment>
+ <td class="files-list__row-checkbox">
+ <NcCheckboxRadioSwitch :aria-label="t('files', 'Select the row for {displayName}', { displayName })"
+ :checked.sync="selectedFiles"
+ :value="fileid.toString()"
+ name="selectedFiles" />
+ </td>
+
+ <!-- Link to file -->
+ <td class="files-list__row-name">
+ <a v-bind="linkTo">
+ <!-- Icon or preview -->
+ <span class="files-list__row-icon">
+ <FolderIcon v-if="source.type === 'folder'" />
+
+ <!-- Decorative image, should not be aria documented -->
+ <span v-else-if="previewUrl && !backgroundFailed"
+ ref="previewImg"
+ class="files-list__row-icon-preview"
+ :style="{ backgroundImage }" />
+
+ <span v-else-if="mimeUrl"
+ class="files-list__row-icon-preview files-list__row-icon-preview--mime"
+ :style="{ backgroundImage: mimeUrl }" />
+
+ <FileIcon v-else />
+ </span>
+
+ <!-- File name -->
+ {{ displayName }}
+ </a>
+ </td>
+
+ <!-- Actions -->
+ <td :class="`files-list__row-actions-${uniqueId}`" class="files-list__row-actions">
+ <!-- Inline actions -->
+ <template v-for="action in enabledInlineActions">
+ <CustomElementRender v-if="action.renderInline"
+ :key="action.id"
+ :element="action.renderInline(source, currentView)" />
+ <NcButton v-else
+ :key="action.id"
+ type="tertiary"
+ @click="onActionClick(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="loading === action.id" :size="18" />
+ <CustomSvgIconRender v-else :svg="action.iconSvgInline([source], currentView)" />
+ </template>
+ {{ action.displayName([source], currentView) }}
+ </NcButton>
+ </template>
+
+ <!-- Menu actions -->
+ <NcActions ref="actionsMenu" :force-menu="true">
+ <NcActionButton v-for="action in enabledMenuActions"
+ :key="action.id"
+ :class="'files-list__row-action-' + action.id"
+ @click="onActionClick(action)">
+ <template #icon>
+ <NcLoadingIcon v-if="loading === action.id" :size="18" />
+ <CustomSvgIconRender v-else :svg="action.iconSvgInline([source], currentView)" />
+ </template>
+ {{ action.displayName([source], currentView) }}
+ </NcActionButton>
+ </NcActions>
+ </td>
+
+ <!-- Size -->
+ <th v-if="isSizeAvailable"
+ :style="{ opacity: sizeOpacity }"
+ class="files-list__row-size">
+ <span>{{ size }}</span>
+ </th>
+
+ <!-- View columns -->
+ <td v-for="column in columns"
+ :key="column.id"
+ :class="`files-list__row-${currentView?.id}-${column.id}`"
+ class="files-list__row-column--custom">
+ <CustomElementRender :element="column.render(source)" />
+ </td>
+ </Fragment>
+</template>
+
<script lang='ts'>
import { debounce } from 'debounce'
-import { Folder, File } from '@nextcloud/files'
+import { Folder, File, getFileActions, formatFileSize } from '@nextcloud/files'
import { Fragment } from 'vue-fragment'
import { join } from 'path'
import { loadState } from '@nextcloud/initial-state'
@@ -30,14 +116,16 @@ import FileIcon from 'vue-material-design-icons/File.vue'
import FolderIcon from 'vue-material-design-icons/Folder.vue'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import Pencil from 'vue-material-design-icons/Pencil.vue'
-import TrashCan from 'vue-material-design-icons/TrashCan.vue'
-import Vue from 'vue'
+import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import Vue, { CreateElement } from 'vue'
+import { showError } from '@nextcloud/dialogs'
import { useFilesStore } from '../store/files'
import { useSelectionStore } from '../store/selection'
import CustomElementRender from './CustomElementRender.vue'
+import CustomSvgIconRender from './CustomSvgIconRender.vue'
import logger from '../logger.js'
// TODO: move to store
@@ -47,24 +135,32 @@ const userConfig = loadState('files', 'config', {})
// The preview service worker cache name (see webpack config)
const SWCacheName = 'previews'
+// The registered actions list
+const actions = getFileActions()
+
export default Vue.extend({
name: 'FileEntry',
components: {
CustomElementRender,
+ CustomSvgIconRender,
FileIcon,
FolderIcon,
Fragment,
NcActionButton,
NcActions,
+ NcButton,
NcCheckboxRadioSwitch,
- Pencil,
- TrashCan,
+ NcLoadingIcon,
},
props: {
+ isSizeAvailable: {
+ type: Boolean,
+ default: false,
+ },
source: {
- type: [File, Folder],
+ type: Object,
required: true,
},
},
@@ -80,9 +176,10 @@ export default Vue.extend({
data() {
return {
- userConfig,
- backgroundImage: '',
backgroundFailed: false,
+ backgroundImage: '',
+ loading: '',
+ userConfig,
}
},
@@ -108,6 +205,26 @@ export default Vue.extend({
return this.source.attributes.displayName
|| this.source.basename
},
+ size() {
+ const size = parseInt(this.source.size, 10) || 0
+ if (!size || size < 0) {
+ return this.t('files', 'Pending')
+ }
+ return formatFileSize(size, true)
+ },
+
+ sizeOpacity() {
+ const size = parseInt(this.source.size, 10) || 0
+ if (!size || size < 0) {
+ return 1
+ }
+
+ // Whatever theme is active, the contrast will pass WCAG AA
+ // with color main text over main background and an opacity of 0.7
+ const minOpacity = 0.7
+ const maxOpacitySize = 10 * 1024 * 1024
+ return minOpacity + (1 - minOpacity) * Math.pow((this.source.size / maxOpacitySize), 2)
+ },
linkTo() {
if (this.source.type === 'folder') {
@@ -130,7 +247,7 @@ export default Vue.extend({
return this.selectionStore.selected
},
set(selection) {
- logger.debug('Added node to selection', { selection })
+ logger.debug('Changed nodes selection', { selection })
this.selectionStore.set(selection)
},
},
@@ -154,15 +271,41 @@ export default Vue.extend({
}
return ''
},
+
+ enabledActions() {
+ return actions
+ .filter(action => !action.enabled || action.enabled([this.source], this.currentView))
+ .sort((a, b) => (a.order || 0) - (b.order || 0))
+ },
+
+ enabledMenuActions() {
+ return actions
+ .filter(action => !action.inline)
+ },
+
+ enabledInlineActions() {
+ return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
+ },
+
+ uniqueId() {
+ return this.hashCode(this.source.source)
+ },
},
watch: {
+ /**
+ * When the source changes, reset the preview
+ * and fetch the new one.
+ */
source() {
- this.resetPreview()
+ this.resetState()
this.debounceIfNotCached()
},
},
+ /**
+ * The row is mounted once and reused as we scroll.
+ */
mounted() {
// Init the debounce function on mount and
// not when the module is imported ⚠
@@ -173,6 +316,10 @@ export default Vue.extend({
this.debounceIfNotCached()
},
+ beforeDestroy() {
+ this.resetState()
+ },
+
methods: {
/**
* Get a cached note from the store
@@ -202,7 +349,7 @@ export default Vue.extend({
this.debounceGetPreview()
},
- fetchAndApplyPreview() {
+ fetchAndApplyPreview() {
logger.debug('Fetching preview', { fileId: this.source.attributes.fileid })
this.img = new Image()
this.img.onload = () => {
@@ -215,7 +362,10 @@ export default Vue.extend({
this.img.src = this.previewUrl
},
- resetPreview() {
+ resetState() {
+ // Reset loading state
+ this.loading = ''
+
// Reset the preview
this.backgroundImage = ''
this.backgroundFailed = false
@@ -227,6 +377,9 @@ export default Vue.extend({
this.img.src = ''
delete this.img
}
+
+ // Close menu
+ this.$refs.actionsMenu.closeMenu()
},
isCachedPreview(previewUrl) {
@@ -239,111 +392,31 @@ export default Vue.extend({
})
},
- t: translate,
- },
-
- /**
- * While a bit more complex, this component is pretty straightforward.
- * For performance reasons, we're using a render function instead of a template.
- */
- render(createElement) {
- // Checkbox
- const checkbox = createElement('td', {
- staticClass: 'files-list__row-checkbox',
- }, [createElement('NcCheckboxRadioSwitch', {
- attrs: {
- 'aria-label': this.t('files', 'Select the row for {displayName}', {
- displayName: this.displayName,
- }),
- checked: this.selectedFiles,
- value: this.fileid.toString(),
- name: 'selectedFiles',
- },
- on: {
- 'update:checked': ($event) => {
- this.selectedFiles = $event
- },
- },
- })])
-
- // Icon
- const iconContent = () => {
- // Folder icon
- if (this.source.type === 'folder') {
- return createElement('FolderIcon')
+ hashCode(str) {
+ let hash = 0
+ for (let i = 0, len = str.length; i < len; i++) {
+ const chr = str.charCodeAt(i)
+ hash = (hash << 5) - hash + chr
+ hash |= 0 // Convert to 32bit integer
}
- // Render cached preview or fallback to mime icon if defined
- const renderPreview = this.previewUrl && !this.backgroundFailed
- if (renderPreview || this.mimeUrl) {
- return createElement('span', {
- ref: 'previewImg',
- class: {
- 'files-list__row-icon-preview': true,
- 'files-list__row-icon-preview--mime': !renderPreview,
- },
- style: {
- backgroundImage: renderPreview
- ? this.backgroundImage
- : this.mimeUrl,
- },
- })
+ return hash
+ },
+
+ async onActionClick(action) {
+ const displayName = action.displayName([this.source], this.currentView)
+ try {
+ this.loading = action.id
+ await action.exec(this.source, this.currentView)
+ } catch (e) {
+ logger.error('Error while executing action', { action, e })
+ showError(this.t('files', 'Error while executing action "{displayName}"', { displayName }))
+ } finally {
+ this.loading = ''
}
- // Empty file icon
- return createElement('FileIcon')
- }
- const icon = createElement('td', {
- staticClass: 'files-list__row-icon',
- }, [iconContent()])
-
- // Name
- const name = createElement('td', {
- staticClass: 'files-list__row-name',
- }, [
- createElement(this.linkTo?.is || 'a', {
- attrs: this.linkTo,
- }, this.displayName),
- ])
-
- // Actions
- const actions = createElement('td', {
- staticClass: 'files-list__row-actions',
- }, [createElement('NcActions', [
- createElement('NcActionButton', [
- this.t('files', 'Rename'),
- createElement('Pencil', {
- slot: 'icon',
- }),
- ]),
- createElement('NcActionButton', [
- this.t('files', 'Delete'),
- createElement('TrashCan', {
- slot: 'icon',
- }),
- ]),
- ])])
-
- // Columns
- const columns = this.columns.map(column => {
- return createElement('td', {
- class: {
- [`files-list__row-${this.currentView?.id}-${column.id}`]: true,
- 'files-list__row-column--custom': true,
- },
- key: column.id,
- }, [createElement('CustomElementRender', {
- props: {
- element: column.render(this.source),
- },
- })])
- })
-
- return createElement('Fragment', [
- checkbox,
- icon,
- name,
- actions,
- ...columns,
- ])
+ },
+
+ t: translate,
+ formatFileSize,
},
})
</script>
diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue
index 81b56331f9c..1fe6d230a20 100644
--- a/apps/files/src/components/FilesListHeader.vue
+++ b/apps/files/src/components/FilesListHeader.vue
@@ -25,12 +25,13 @@
<NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" />
</th>
- <!-- Icon or preview -->
- <th class="files-list__row-icon" />
-
- <!-- Link to file and -->
+ <!-- Link to file -->
<th class="files-list__row-name files-list__row--sortable"
@click="toggleSortBy('basename')">
+ <!-- Icon or preview -->
+ <span class="files-list__row-icon" />
+
+ <!-- Name -->
{{ t('files', 'Name') }}
<template v-if="defaultFileSorting === 'basename'">
<MenuUp v-if="defaultFileSortingDirection === 'asc'" />
@@ -41,6 +42,17 @@
<!-- Actions -->
<th class="files-list__row-actions" />
+ <!-- Size -->
+ <th v-if="isSizeAvailable"
+ class="files-list__row-size"
+ @click="toggleSortBy('size')">
+ {{ t('files', 'Size') }}
+ <template v-if="defaultFileSorting === 'size'">
+ <MenuUp v-if="defaultFileSortingDirection === 'asc'" />
+ <MenuDown v-else />
+ </template>
+ </th>
+
<!-- Custom views columns -->
<th v-for="column in columns"
:key="column.id"
@@ -51,7 +63,6 @@
</template>
<script lang="ts">
-import { File, Folder } from '@nextcloud/files'
import { mapState } from 'pinia'
import { translate } from '@nextcloud/l10n'
import MenuDown from 'vue-material-design-icons/MenuDown.vue'
@@ -65,6 +76,8 @@ import { useSortingStore } from '../store/sorting'
import logger from '../logger.js'
import Navigation from '../services/Navigation'
+Vue.config.performance = true
+
export default Vue.extend({
name: 'FilesListHeader',
@@ -75,8 +88,12 @@ export default Vue.extend({
},
props: {
+ isSizeAvailable: {
+ type: Boolean,
+ default: false,
+ },
nodes: {
- type: [File, Folder],
+ type: Array,
required: true,
},
},
diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue
index 62a4e0e42eb..3f055f8b878 100644
--- a/apps/files/src/components/FilesListVirtual.vue
+++ b/apps/files/src/components/FilesListVirtual.vue
@@ -32,7 +32,7 @@
list-tag="tbody"
role="table">
<template #default="{ item }">
- <FileEntry :source="item" />
+ <FileEntry :is-size-available="isSizeAvailable" :source="item" />
</template>
<!-- <template #before>
@@ -42,13 +42,12 @@
</template> -->
<template #before>
- <FilesListHeader :nodes="nodes" />
+ <FilesListHeader :nodes="nodes" :is-size-available="isSizeAvailable" />
</template>
</RecycleScroller>
</template>
<script lang="ts">
-import { Folder, File } from '@nextcloud/files'
import { RecycleScroller } from 'vue-virtual-scroller'
import { translate, translatePlural } from '@nextcloud/l10n'
import Vue from 'vue'
@@ -67,7 +66,7 @@ export default Vue.extend({
props: {
nodes: {
- type: [File, Folder],
+ type: Array,
required: true,
},
},
@@ -93,6 +92,9 @@ export default Vue.extend({
summary() {
return translate('files', '{summaryFile} and {summaryFolder}', this)
},
+ isSizeAvailable() {
+ return this.nodes.some(node => node.attributes.size !== undefined)
+ },
},
mounted() {
@@ -113,6 +115,7 @@ export default Vue.extend({
<style scoped lang="scss">
.files-list {
--row-height: 55px;
+ --cell-margin: 14px;
--checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2);
--checkbox-size: 24px;
diff --git a/apps/files/src/mixins/fileslist-row.scss b/apps/files/src/mixins/fileslist-row.scss
index 6c3da968b70..9ad821eb860 100644
--- a/apps/files/src/mixins/fileslist-row.scss
+++ b/apps/files/src/mixins/fileslist-row.scss
@@ -22,12 +22,20 @@
td, th {
display: flex;
align-items: center;
- flex: 0 0 var(--row-height);
- justify-content: center;
+ flex: 0 0 auto;
+ justify-content: left;
width: var(--row-height);
height: var(--row-height);
+ margin: 0;
padding: 0;
+ color: var(--color-text-maxcontrast);
border: none;
+
+ span {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
}
.files-list__row {
@@ -37,7 +45,7 @@ td, th {
}
.files-list__row-checkbox {
- width: var(--row-height);
+ justify-content: center;
&::v-deep .checkbox-radio-switch {
display: flex;
justify-content: center;
@@ -58,8 +66,11 @@ td, th {
}
.files-list__row-icon {
- flex: 0 0 var(--icon-preview-size);
- justify-content: left;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--icon-preview-size);
+ height: 100%;
// Show same padding as the checkbox right padding for visual balance
margin-right: var(--checkbox-padding);
color: var(--color-primary-element);
@@ -74,26 +85,49 @@ td, th {
}
&-preview {
+ overflow: hidden;
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-repeat: no-repeat;
background-size: contain;
- border-radius: var(--border-radius);
- overflow: hidden;
}
}
.files-list__row-name {
- flex: 1 1 100%;
- justify-content: left;
+ // Prevent link from overflowing
+ overflow: hidden;
+ // Take as much space as possible
+ flex: 1 1 auto;
+
+ a {
+ display: flex;
+ align-items: center;
+ // Fill cell height and width
+ width: 100%;
+ height: 100%;
+ }
}
-.files-list__row-column--custom {
- overflow: hidden;
- flex: 1 1 calc(var(--row-height) * 3);
+.files-list__row-actions {
width: auto;
- min-width: var(--row-height);
- justify-content: normal;
+
+ & ~ td,
+ & ~ th {
+ // Add margin to all cells after the actions
+ margin: 0 var(--cell-margin);
+ }
+}
+
+.files-list__row-size {
+ justify-content: right;
+ width: calc(var(--row-height) * 1.5);
+ // opacity varies with the size
+ color: var(--color-main-text);
+}
+
+.files-list__row-column--custom {
+ width: calc(var(--row-height) * 2);
}
diff --git a/apps/files_trashbin/src/actions/restoreAction.ts b/apps/files_trashbin/src/actions/restoreAction.ts
new file mode 100644
index 00000000000..d65ff3f0799
--- /dev/null
+++ b/apps/files_trashbin/src/actions/restoreAction.ts
@@ -0,0 +1,59 @@
+/**
+ * @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/>.
+ *
+ */
+import { registerFileAction, Permission, FileAction } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import axios from '@nextcloud/axios'
+import History from '@mdi/svg/svg/history.svg?raw'
+import { generateRemoteUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+
+registerFileAction(new FileAction({
+ id: 'restore',
+ displayName() {
+ return t('files_trashbin', 'Restore')
+ },
+ iconSvgInline: () => History,
+ enabled(nodes, view) {
+ // Only available in the trashbin view
+ if (view.id !== 'trashbin') {
+ return false
+ }
+
+ // Only available if all nodes have read permission
+ return nodes.length > 0 && nodes
+ .map(node => node.permissions)
+ .every(permission => (permission & Permission.READ) !== 0)
+ },
+ async exec(node) {
+ // No try...catch here, let the files app handle the error
+ await axios({
+ method: 'MOVE',
+ url: node.source,
+ headers: {
+ destination: generateRemoteUrl(`dav/trashbin/${getCurrentUser()?.uid}/restore/${node.basename}`),
+ },
+ })
+ return true
+ },
+ order: 1,
+ inline: () => true,
+}))
diff --git a/apps/files_trashbin/src/css/trashbin.css b/apps/files_trashbin/src/css/trashbin.css
index dd6cd8af591..40bbdfb037b 100644
--- a/apps/files_trashbin/src/css/trashbin.css
+++ b/apps/files_trashbin/src/css/trashbin.css
@@ -1,2 +1,3 @@
.files-list__row-trashbin-deleted {
-} \ No newline at end of file
+
+}
diff --git a/apps/files_trashbin/src/main.ts b/apps/files_trashbin/src/main.ts
index d9cd2841b23..7cd6cf850f8 100644
--- a/apps/files_trashbin/src/main.ts
+++ b/apps/files_trashbin/src/main.ts
@@ -27,6 +27,9 @@ import moment from '@nextcloud/moment'
import getContents from './services/trashbin'
+// Register restore action
+import './actions/restoreAction'
+
const Navigation = window.OCP.Files.Navigation as NavigationService
Navigation.register({
id: 'trashbin',
@@ -40,13 +43,16 @@ Navigation.register({
{
id: 'deleted',
title: t('files_trashbin', 'Deleted'),
- render(mount, node) {
+ render(node) {
const deletionTime = node.attributes?.['trashbin-deletion-time']
+ const span = document.createElement('span')
if (deletionTime) {
- mount.innerText = moment.unix(deletionTime).fromNow()
- return
+ span.title = moment.unix(deletionTime).format('LLL')
+ span.textContent = moment.unix(deletionTime).fromNow()
+ return span
}
- mount.innerText = translate('files_trashbin', 'Deleted a long time ago')
+ span.textContent = translate('files_trashbin', 'Deleted a long time ago')
+ return span
},
sort(nodeA, nodeB) {
const deletionTimeA = nodeA.attributes?.['trashbin-deletion-time'] || nodeA?.mtime || 0