mirror of
https://github.com/nextcloud/server.git
synced 2026-06-10 17:23:59 -04:00
Merge pull request #36534 from nextcloud/feat/files2vue-trashbin
[F2V] migrate files_trashbin to vue
This commit is contained in:
commit
2e6e154629
164 changed files with 8430 additions and 10698 deletions
|
|
@ -20,19 +20,18 @@
|
|||
*
|
||||
*/
|
||||
|
||||
import { createClient, getPatcher } from 'webdav'
|
||||
import axios from '@nextcloud/axios'
|
||||
|
||||
import { createClient } from 'webdav'
|
||||
import { getRootPath } from '../utils/davUtils.js'
|
||||
|
||||
// Add this so the server knows it is an request from the browser
|
||||
axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
|
||||
|
||||
// force our axios
|
||||
const patcher = getPatcher()
|
||||
patcher.patch('request', axios)
|
||||
import { getRequestToken } from '@nextcloud/auth'
|
||||
|
||||
// init webdav client
|
||||
const client = createClient(getRootPath())
|
||||
const client = createClient(getRootPath(), {
|
||||
headers: {
|
||||
// Add this so the server knows it is an request from the browser
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
// Inject user auth
|
||||
requesttoken: getRequestToken() ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
export default client
|
||||
|
|
|
|||
|
|
@ -20,12 +20,15 @@
|
|||
*
|
||||
*/
|
||||
|
||||
import { parseXML, prepareFileFromProps } from 'webdav/dist/node/tools/dav.js'
|
||||
import { processResponsePayload } from 'webdav/dist/node/response.js'
|
||||
import { decodeHtmlEntities } from '../utils/decodeHtmlEntities.js'
|
||||
import { parseXML, type DAVResult, type FileStat } from 'webdav'
|
||||
|
||||
// https://github.com/perry-mitchell/webdav-client/issues/339
|
||||
import { processResponsePayload } from '../../../../node_modules/webdav/dist/node/response.js'
|
||||
import { prepareFileFromProps } from '../../../../node_modules/webdav/dist/node/tools/dav.js'
|
||||
import client from './DavClient.js'
|
||||
|
||||
export const DEFAULT_LIMIT = 20
|
||||
|
||||
/**
|
||||
* Retrieve the comments list
|
||||
*
|
||||
|
|
@ -33,13 +36,13 @@ export const DEFAULT_LIMIT = 20
|
|||
* @param {string} data.commentsType the ressource type
|
||||
* @param {number} data.ressourceId the ressource ID
|
||||
* @param {object} [options] optional options for axios
|
||||
* @param {number} [options.offset] the pagination offset
|
||||
* @return {object[]} the comments list
|
||||
*/
|
||||
export default async function({ commentsType, ressourceId }, options = {}) {
|
||||
let response = null
|
||||
export const getComments = async function({ commentsType, ressourceId }, options: { offset: number }) {
|
||||
const ressourcePath = ['', commentsType, ressourceId].join('/')
|
||||
|
||||
return await client.customRequest(ressourcePath, Object.assign({
|
||||
const response = await client.customRequest(ressourcePath, Object.assign({
|
||||
method: 'REPORT',
|
||||
data: `<?xml version="1.0"?>
|
||||
<oc:filter-comments
|
||||
|
|
@ -51,42 +54,30 @@ export default async function({ commentsType, ressourceId }, options = {}) {
|
|||
<oc:offset>${options.offset || 0}</oc:offset>
|
||||
</oc:filter-comments>`,
|
||||
}, options))
|
||||
// See example on how it's done normally
|
||||
// https://github.com/perry-mitchell/webdav-client/blob/9de2da4a2599e06bd86c2778145b7ade39fe0b3c/source/interface/stat.js#L19
|
||||
// Waiting for proper REPORT integration https://github.com/perry-mitchell/webdav-client/issues/207
|
||||
.then(res => {
|
||||
response = res
|
||||
return res.data
|
||||
})
|
||||
.then(parseXML)
|
||||
.then(xml => processMultistatus(xml, true))
|
||||
.then(comments => processResponsePayload(response, comments, true))
|
||||
.then(response => response.data)
|
||||
|
||||
const responseData = await response.text()
|
||||
const result = await parseXML(responseData)
|
||||
const stat = getDirectoryFiles(result, true)
|
||||
return processResponsePayload(response, stat, true)
|
||||
}
|
||||
|
||||
// https://github.com/perry-mitchell/webdav-client/blob/9de2da4a2599e06bd86c2778145b7ade39fe0b3c/source/interface/directoryContents.js#L32
|
||||
/**
|
||||
* @param {any} result -
|
||||
* @param {any} isDetailed -
|
||||
*/
|
||||
function processMultistatus(result, isDetailed = false) {
|
||||
// https://github.com/perry-mitchell/webdav-client/blob/8d9694613c978ce7404e26a401c39a41f125f87f/source/operations/directoryContents.ts
|
||||
const getDirectoryFiles = function(
|
||||
result: DAVResult,
|
||||
isDetailed = false,
|
||||
): Array<FileStat> {
|
||||
// Extract the response items (directory contents)
|
||||
const {
|
||||
multistatus: { response: responseItems },
|
||||
} = result
|
||||
|
||||
// Map all items to a consistent output structure (results)
|
||||
return responseItems.map(item => {
|
||||
// Each item should contain a stat object
|
||||
const {
|
||||
propstat: { prop: props },
|
||||
} = item
|
||||
// Decode HTML entities
|
||||
const decodedProps = {
|
||||
...props,
|
||||
// Decode twice to handle potentially double-encoded entities
|
||||
// FIXME Remove this once https://github.com/nextcloud/server/issues/29306 is resolved
|
||||
actorDisplayName: decodeHtmlEntities(props.actorDisplayName, 2),
|
||||
message: decodeHtmlEntities(props.message, 2),
|
||||
}
|
||||
return prepareFileFromProps(decodedProps, decodedProps.id.toString(), isDetailed)
|
||||
|
||||
return prepareFileFromProps(props, props.id.toString(), isDetailed)
|
||||
})
|
||||
}
|
||||
|
|
@ -20,15 +20,6 @@
|
|||
*
|
||||
*/
|
||||
|
||||
import axios from '@nextcloud/axios'
|
||||
|
||||
/**
|
||||
* Create a cancel token
|
||||
*
|
||||
* @return {import('axios').CancelTokenSource}
|
||||
*/
|
||||
const createCancelToken = () => axios.CancelToken.source()
|
||||
|
||||
/**
|
||||
* Creates a cancelable axios 'request object'.
|
||||
*
|
||||
|
|
@ -36,10 +27,8 @@ const createCancelToken = () => axios.CancelToken.source()
|
|||
* @return {object}
|
||||
*/
|
||||
const cancelableRequest = function(request) {
|
||||
/**
|
||||
* Generate an axios cancel token
|
||||
*/
|
||||
const cancelToken = createCancelToken()
|
||||
const controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
|
||||
/**
|
||||
* Execute the request
|
||||
|
|
@ -48,15 +37,16 @@ const cancelableRequest = function(request) {
|
|||
* @param {object} [options] optional config for the request
|
||||
*/
|
||||
const fetch = async function(url, options) {
|
||||
return request(
|
||||
const response = await request(
|
||||
url,
|
||||
Object.assign({ cancelToken: cancelToken.token }, options)
|
||||
Object.assign({ signal }, options)
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
return {
|
||||
request: fetch,
|
||||
cancel: cancelToken.cancel,
|
||||
abort: () => controller.abort(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ import MessageReplyTextIcon from 'vue-material-design-icons/MessageReplyText.vue
|
|||
import AlertCircleOutlineIcon from 'vue-material-design-icons/AlertCircleOutline.vue'
|
||||
|
||||
import Comment from '../components/Comment.vue'
|
||||
import getComments, { DEFAULT_LIMIT } from '../services/GetComments.js'
|
||||
import { getComments, DEFAULT_LIMIT } from '../services/GetComments.ts'
|
||||
import cancelableRequest from '../utils/cancelableRequest.js'
|
||||
|
||||
Vue.use(VTooltip)
|
||||
|
|
@ -206,14 +206,14 @@ export default {
|
|||
this.error = ''
|
||||
|
||||
// Init cancellable request
|
||||
const { request, cancel } = cancelableRequest(getComments)
|
||||
this.cancelRequest = cancel
|
||||
const { request, abort } = cancelableRequest(getComments)
|
||||
this.cancelRequest = abort
|
||||
|
||||
// Fetch comments
|
||||
const comments = await request({
|
||||
const { data: comments } = await request({
|
||||
commentsType: this.commentsType,
|
||||
ressourceId: this.ressourceId,
|
||||
}, { offset: this.offset })
|
||||
}, { offset: this.offset }) || { data: [] }
|
||||
|
||||
this.logger.debug(`Processed ${comments.length} comments`, { comments })
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
*/
|
||||
import { getClient } from '../dav/client.js'
|
||||
import logger from './logger.js'
|
||||
import { parseXML } from 'webdav/dist/node/tools/dav.js'
|
||||
import { parseXML } from 'webdav'
|
||||
|
||||
import {
|
||||
slotsToVavailability,
|
||||
|
|
|
|||
|
|
@ -133,6 +133,11 @@ $application->registerRoutes(
|
|||
'url' => '/directEditing/{token}',
|
||||
'verb' => 'GET'
|
||||
],
|
||||
[
|
||||
'name' => 'api#serviceWorker',
|
||||
'url' => '/preview-service-worker.js',
|
||||
'verb' => 'GET'
|
||||
],
|
||||
[
|
||||
'name' => 'view#index',
|
||||
'url' => '/{view}',
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@
|
|||
* Initializes the files app
|
||||
*/
|
||||
initialize: function() {
|
||||
this.navigation = OCP.Files.Navigation;
|
||||
this.$showHiddenFiles = $('input#showhiddenfilesToggle');
|
||||
var showHidden = $('#showHiddenFiles').val() === "1";
|
||||
this.$showHiddenFiles.prop('checked', showHidden);
|
||||
|
|
@ -117,7 +116,9 @@
|
|||
},
|
||||
],
|
||||
sorting: {
|
||||
mode: $('#defaultFileSorting').val(),
|
||||
mode: $('#defaultFileSorting').val() === 'basename'
|
||||
? 'name'
|
||||
: $('#defaultFileSorting').val(),
|
||||
direction: $('#defaultFileSortingDirection').val()
|
||||
},
|
||||
config: this._filesConfig,
|
||||
|
|
@ -135,8 +136,6 @@
|
|||
OC.Plugins.attach('OCA.Files.App', this);
|
||||
|
||||
this._setupEvents();
|
||||
// trigger URL change event handlers
|
||||
this._onPopState({ ...OC.Util.History.parseUrlQuery(), view: this.navigation?.active?.id });
|
||||
|
||||
this._debouncedPersistShowHiddenFilesState = _.debounce(this._persistShowHiddenFilesState, 1200);
|
||||
this._debouncedPersistCropImagePreviewsState = _.debounce(this._persistCropImagePreviewsState, 1200);
|
||||
|
|
@ -145,6 +144,10 @@
|
|||
OCP.WhatsNew.query(); // for Nextcloud server
|
||||
sessionStorage.setItem('WhatsNewServerCheck', Date.now());
|
||||
}
|
||||
|
||||
window._nc_event_bus.emit('files:legacy-view:initialized', this);
|
||||
|
||||
this.navigation = OCP.Files.Navigation
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -225,7 +228,8 @@
|
|||
* @return view id
|
||||
*/
|
||||
getActiveView: function() {
|
||||
return this.navigation.active
|
||||
return this.navigation
|
||||
&& this.navigation.active
|
||||
&& this.navigation.active.id;
|
||||
},
|
||||
|
||||
|
|
@ -314,7 +318,7 @@
|
|||
view: 'files'
|
||||
}, params);
|
||||
|
||||
var lastId = this.navigation.active;
|
||||
var lastId = this.getActiveView();
|
||||
if (!this.navigation.views.find(view => view.id === params.view)) {
|
||||
params.view = 'files';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2181,8 +2181,10 @@
|
|||
|
||||
if (persist && OC.getCurrentUser().uid) {
|
||||
$.post(OC.generateUrl('/apps/files/api/v1/sorting'), {
|
||||
mode: sort,
|
||||
direction: direction
|
||||
// Compatibility with new files-to-vue API
|
||||
mode: sort === 'name' ? 'basename' : sort,
|
||||
direction: direction,
|
||||
view: 'files'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -42,10 +42,12 @@ use OCA\Files\Service\TagService;
|
|||
use OCA\Files\Service\UserConfig;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\ContentSecurityPolicy;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\Http\FileDisplayResponse;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\Http\Response;
|
||||
use OCP\AppFramework\Http\StreamResponse;
|
||||
use OCP\Files\File;
|
||||
use OCP\Files\Folder;
|
||||
use OCP\Files\NotFoundException;
|
||||
|
|
@ -279,20 +281,29 @@ class ApiController extends Controller {
|
|||
*
|
||||
* @param string $mode
|
||||
* @param string $direction
|
||||
* @return Response
|
||||
* @return JSONResponse
|
||||
* @throws \OCP\PreConditionNotMetException
|
||||
*/
|
||||
public function updateFileSorting($mode, $direction) {
|
||||
$allowedMode = ['name', 'size', 'mtime'];
|
||||
public function updateFileSorting($mode, string $direction = 'asc', string $view = 'files'): JSONResponse {
|
||||
$allowedDirection = ['asc', 'desc'];
|
||||
if (!in_array($mode, $allowedMode) || !in_array($direction, $allowedDirection)) {
|
||||
$response = new Response();
|
||||
$response->setStatus(Http::STATUS_UNPROCESSABLE_ENTITY);
|
||||
return $response;
|
||||
if (!in_array($direction, $allowedDirection)) {
|
||||
return new JSONResponse(['message' => 'Invalid direction parameter'], Http::STATUS_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
$this->config->setUserValue($this->userSession->getUser()->getUID(), 'files', 'file_sorting', $mode);
|
||||
$this->config->setUserValue($this->userSession->getUser()->getUID(), 'files', 'file_sorting_direction', $direction);
|
||||
return new Response();
|
||||
|
||||
$userId = $this->userSession->getUser()->getUID();
|
||||
|
||||
$sortingJson = $this->config->getUserValue($userId, 'files', 'files_sorting_configs', '{}');
|
||||
$sortingConfig = json_decode($sortingJson, true) ?: [];
|
||||
$sortingConfig[$view] = [
|
||||
'mode' => $mode,
|
||||
'direction' => $direction,
|
||||
];
|
||||
|
||||
$this->config->setUserValue($userId, 'files', 'files_sorting_configs', json_encode($sortingConfig));
|
||||
return new JSONResponse([
|
||||
'message' => 'ok',
|
||||
'data' => $sortingConfig,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -417,4 +428,22 @@ class ApiController extends Controller {
|
|||
$node = $this->userFolder->get($folderpath);
|
||||
return $node->getType();
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
* @NoCSRFRequired
|
||||
*/
|
||||
public function serviceWorker(): StreamResponse {
|
||||
$response = new StreamResponse(__DIR__ . '/../../../../dist/preview-service-worker.js');
|
||||
$response->setHeaders([
|
||||
'Content-Type' => 'application/javascript',
|
||||
'Service-Worker-Allowed' => '/'
|
||||
]);
|
||||
$policy = new ContentSecurityPolicy();
|
||||
$policy->addAllowedWorkerSrcDomain("'self'");
|
||||
$policy->addAllowedScriptDomain("'self'");
|
||||
$policy->addAllowedConnectDomain("'self'");
|
||||
$response->setContentSecurityPolicy($policy);
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -249,6 +249,10 @@ class ViewController extends Controller {
|
|||
$this->initialState->provideInitialState('navigation', $navItems);
|
||||
$this->initialState->provideInitialState('config', $this->userConfig->getConfigs());
|
||||
|
||||
// File sorting user config
|
||||
$filesSortingConfig = json_decode($this->config->getUserValue($userId, 'files', 'files_sorting_configs', '{}'), true);
|
||||
$this->initialState->provideInitialState('filesSortingConfig', $filesSortingConfig);
|
||||
|
||||
// render the container content for every navigation item
|
||||
foreach ($navItems as $item) {
|
||||
$content = '';
|
||||
|
|
@ -292,8 +296,8 @@ class ViewController extends Controller {
|
|||
$params['ownerDisplayName'] = $storageInfo['ownerDisplayName'] ?? '';
|
||||
$params['isPublic'] = false;
|
||||
$params['allowShareWithLink'] = $this->shareManager->shareApiAllowLinks() ? 'yes' : 'no';
|
||||
$params['defaultFileSorting'] = $this->config->getUserValue($userId, 'files', 'file_sorting', 'name');
|
||||
$params['defaultFileSortingDirection'] = $this->config->getUserValue($userId, 'files', 'file_sorting_direction', 'asc');
|
||||
$params['defaultFileSorting'] = $filesSortingConfig['files']['mode'] ?? 'basename';
|
||||
$params['defaultFileSortingDirection'] = $filesSortingConfig['files']['direction'] ?? 'asc';
|
||||
$params['showgridview'] = $this->config->getUserValue($userId, 'files', 'show_grid', false);
|
||||
$showHidden = (bool) $this->config->getUserValue($userId, 'files', 'show_hidden', false);
|
||||
$params['showHiddenFiles'] = $showHidden ? 1 : 0;
|
||||
|
|
|
|||
65
apps/files/src/actions/deleteAction.ts
Normal file
65
apps/files/src/actions/deleteAction.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* @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 { emit } from '@nextcloud/event-bus'
|
||||
import { Permission, Node } 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 { registerFileAction, FileAction } from '../services/FileAction.ts'
|
||||
import logger from '../logger.js'
|
||||
|
||||
registerFileAction(new FileAction({
|
||||
id: 'delete',
|
||||
displayName(nodes: Node[], view) {
|
||||
return view.id === 'trashbin'
|
||||
? t('files_trashbin', 'Delete permanently')
|
||||
: t('files', 'Delete')
|
||||
},
|
||||
iconSvgInline: () => TrashCan,
|
||||
|
||||
enabled(nodes: Node[]) {
|
||||
return nodes.length > 0 && nodes
|
||||
.map(node => node.permissions)
|
||||
.every(permission => (permission & Permission.DELETE) !== 0)
|
||||
},
|
||||
|
||||
async exec(node: Node) {
|
||||
try {
|
||||
await axios.delete(node.source)
|
||||
|
||||
// Let's delete even if it's moved to the trashbin
|
||||
// since it has been removed from the current view
|
||||
// and changing the view will trigger a reload anyway.
|
||||
emit('files:file:deleted', node)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Error while deleting a file', { error, source: node.source, node })
|
||||
return false
|
||||
}
|
||||
},
|
||||
async execBatch(nodes: Node[], view) {
|
||||
return Promise.all(nodes.map(node => this.exec(node, view)))
|
||||
},
|
||||
|
||||
order: 100,
|
||||
}))
|
||||
90
apps/files/src/components/BreadCrumbs.vue
Normal file
90
apps/files/src/components/BreadCrumbs.vue
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<template>
|
||||
<NcBreadcrumbs data-cy-files-content-breadcrumbs>
|
||||
<!-- Current path sections -->
|
||||
<NcBreadcrumb v-for="(section, index) in sections"
|
||||
:key="section.dir"
|
||||
:aria-label="ariaLabel(section)"
|
||||
:title="ariaLabel(section)"
|
||||
v-bind="section"
|
||||
@click.native="onClick(section.to)">
|
||||
<template v-if="index === 0" #icon>
|
||||
<Home :size="20" />
|
||||
</template>
|
||||
</NcBreadcrumb>
|
||||
</NcBreadcrumbs>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { basename } from 'path'
|
||||
import Home from 'vue-material-design-icons/Home.vue'
|
||||
import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js'
|
||||
import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js'
|
||||
import Vue from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'BreadCrumbs',
|
||||
|
||||
components: {
|
||||
Home,
|
||||
NcBreadcrumbs,
|
||||
NcBreadcrumb,
|
||||
},
|
||||
|
||||
props: {
|
||||
path: {
|
||||
type: String,
|
||||
default: '/',
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
dirs() {
|
||||
const cumulativePath = (acc) => (value) => (acc += `${value}/`)
|
||||
// Generate a cumulative path for each path segment: ['/', '/foo', '/foo/bar', ...] etc
|
||||
const paths = this.path.split('/').filter(Boolean).map(cumulativePath('/'))
|
||||
// Strip away trailing slash
|
||||
return ['/', ...paths.map(path => path.replace(/^(.+)\/$/, '$1'))]
|
||||
},
|
||||
|
||||
sections() {
|
||||
return this.dirs.map(dir => {
|
||||
const to = { ...this.$route, query: { dir } }
|
||||
return {
|
||||
dir,
|
||||
exact: true,
|
||||
name: basename(dir),
|
||||
to,
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onClick(to) {
|
||||
if (to?.query?.dir === this.$route.query.dir) {
|
||||
this.$emit('reload')
|
||||
}
|
||||
},
|
||||
|
||||
ariaLabel(section) {
|
||||
if (section?.to?.query?.dir === this.$route.query.dir) {
|
||||
return t('files', 'Reload current directory')
|
||||
}
|
||||
return t('files', 'Go to the "{dir}" directory', section)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.breadcrumb {
|
||||
// Take as much space as possible
|
||||
flex: 1 1 100% !important;
|
||||
width: 100%;
|
||||
|
||||
::v-deep a {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
65
apps/files/src/components/CustomElementRender.vue
Normal file
65
apps/files/src/components/CustomElementRender.vue
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<!--
|
||||
- @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 />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* This component is used to render custom
|
||||
* elements provided by an API. Vue doesn't allow
|
||||
* to directly render an HTMLElement, so we can do
|
||||
* this magic here.
|
||||
*/
|
||||
export default {
|
||||
name: 'CustomElementRender',
|
||||
props: {
|
||||
source: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
currentView: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
render: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
element() {
|
||||
return this.render(this.source, this.currentView)
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
element() {
|
||||
this.$el.replaceWith(this.element)
|
||||
this.$el = this.element
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$el.replaceWith(this.element)
|
||||
this.$el = this.element
|
||||
},
|
||||
}
|
||||
</script>
|
||||
68
apps/files/src/components/CustomSvgIconRender.vue
Normal file
68
apps/files/src/components/CustomSvgIconRender.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<!--
|
||||
- @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,
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
svg() {
|
||||
this.$el.innerHTML = sanitize(this.svg)
|
||||
},
|
||||
},
|
||||
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>
|
||||
488
apps/files/src/components/FileEntry.vue
Normal file
488
apps/files/src/components/FileEntry.vue
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
<!--
|
||||
- @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>
|
||||
<Fragment>
|
||||
<td class="files-list__row-checkbox">
|
||||
<NcCheckboxRadioSwitch v-if="active"
|
||||
: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 ref="name" 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="mimeIconUrl"
|
||||
class="files-list__row-icon-preview files-list__row-icon-preview--mime"
|
||||
:style="{ backgroundImage: mimeIconUrl }" />
|
||||
|
||||
<FileIcon v-else />
|
||||
</span>
|
||||
|
||||
<!-- File name -->
|
||||
<span class="files-list__row-name-text">{{ displayName }}</span>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td :class="`files-list__row-actions-${uniqueId}`" class="files-list__row-actions">
|
||||
<!-- Inline actions -->
|
||||
<!-- TODO: implement CustomElementRender -->
|
||||
|
||||
<!-- Menu actions -->
|
||||
<NcActions v-if="active"
|
||||
ref="actionsMenu"
|
||||
:force-title="true"
|
||||
:inline="enabledInlineActions.length">
|
||||
<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 -->
|
||||
<td v-if="isSizeAvailable"
|
||||
:style="{ opacity: sizeOpacity }"
|
||||
class="files-list__row-size">
|
||||
<span>{{ size }}</span>
|
||||
</td>
|
||||
|
||||
<!-- 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 v-if="active"
|
||||
:current-view="currentView"
|
||||
:render="column.render"
|
||||
:source="source" />
|
||||
</td>
|
||||
</Fragment>
|
||||
</template>
|
||||
|
||||
<script lang='ts'>
|
||||
import { debounce } from 'debounce'
|
||||
import { formatFileSize } from '@nextcloud/files'
|
||||
import { Fragment } from 'vue-fragment'
|
||||
import { join } from 'path'
|
||||
import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||
import { translate } from '@nextcloud/l10n'
|
||||
import CancelablePromise from 'cancelable-promise'
|
||||
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 NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
|
||||
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
|
||||
import Vue from 'vue'
|
||||
|
||||
import { isCachedPreview } from '../services/PreviewService.ts'
|
||||
import { getFileActions } from '../services/FileAction.ts'
|
||||
import { useFilesStore } from '../store/files.ts'
|
||||
import { useSelectionStore } from '../store/selection.ts'
|
||||
import { useUserConfigStore } from '../store/userconfig.ts'
|
||||
import CustomElementRender from './CustomElementRender.vue'
|
||||
import CustomSvgIconRender from './CustomSvgIconRender.vue'
|
||||
import logger from '../logger.js'
|
||||
|
||||
// The registered actions list
|
||||
const actions = getFileActions()
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'FileEntry',
|
||||
|
||||
components: {
|
||||
CustomElementRender,
|
||||
CustomSvgIconRender,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
Fragment,
|
||||
NcActionButton,
|
||||
NcActions,
|
||||
NcCheckboxRadioSwitch,
|
||||
NcLoadingIcon,
|
||||
},
|
||||
|
||||
props: {
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSizeAvailable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
source: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const filesStore = useFilesStore()
|
||||
const selectionStore = useSelectionStore()
|
||||
const userConfigStore = useUserConfigStore()
|
||||
return {
|
||||
filesStore,
|
||||
selectionStore,
|
||||
userConfigStore,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
backgroundFailed: false,
|
||||
backgroundImage: '',
|
||||
loading: '',
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
userConfig() {
|
||||
return this.userConfigStore.userConfig
|
||||
},
|
||||
|
||||
currentView() {
|
||||
return this.$navigation.active
|
||||
},
|
||||
|
||||
columns() {
|
||||
return this.currentView?.columns || []
|
||||
},
|
||||
|
||||
dir() {
|
||||
// Remove any trailing slash but leave root slash
|
||||
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
|
||||
},
|
||||
|
||||
fileid() {
|
||||
return this.source.attributes.fileid
|
||||
},
|
||||
displayName() {
|
||||
return this.source.attributes.displayName
|
||||
|| this.source.basename
|
||||
},
|
||||
size() {
|
||||
const size = parseInt(this.source.size, 10) || 0
|
||||
if (typeof size !== 'number' || 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') {
|
||||
const to = { ...this.$route, query: { dir: join(this.dir, this.source.basename) } }
|
||||
return {
|
||||
is: 'router-link',
|
||||
title: this.t('files', 'Open folder {name}', { name: this.displayName }),
|
||||
to,
|
||||
}
|
||||
}
|
||||
return {
|
||||
href: this.source.source,
|
||||
// TODO: Use first action title ?
|
||||
title: this.t('files', 'Download file {name}', { name: this.displayName }),
|
||||
}
|
||||
},
|
||||
|
||||
selectedFiles: {
|
||||
get() {
|
||||
return this.selectionStore.selected
|
||||
},
|
||||
set(selection) {
|
||||
logger.debug('Changed nodes selection', { selection })
|
||||
this.selectionStore.set(selection)
|
||||
},
|
||||
},
|
||||
|
||||
cropPreviews() {
|
||||
return this.userConfig.crop_image_previews
|
||||
},
|
||||
|
||||
previewUrl() {
|
||||
try {
|
||||
const url = new URL(window.location.origin + this.source.attributes.previewUrl)
|
||||
// Request tiny previews
|
||||
url.searchParams.set('x', '32')
|
||||
url.searchParams.set('y', '32')
|
||||
// Handle cropping
|
||||
url.searchParams.set('a', this.cropPreviews === true ? '1' : '0')
|
||||
return url.href
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
mimeIconUrl() {
|
||||
const mimeType = this.source.mime || 'application/octet-stream'
|
||||
const mimeIconUrl = window.OC?.MimeType?.getIconUrl?.(mimeType)
|
||||
if (mimeIconUrl) {
|
||||
return `url(${mimeIconUrl})`
|
||||
}
|
||||
return ''
|
||||
},
|
||||
|
||||
enabledActions() {
|
||||
return actions
|
||||
.filter(action => !action.enabled || action.enabled([this.source], this.currentView))
|
||||
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
||||
},
|
||||
|
||||
enabledInlineActions() {
|
||||
return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
|
||||
},
|
||||
|
||||
enabledMenuActions() {
|
||||
return [
|
||||
...this.enabledInlineActions,
|
||||
...actions.filter(action => !action.inline),
|
||||
]
|
||||
},
|
||||
|
||||
uniqueId() {
|
||||
return this.hashCode(this.source.source)
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
active(active, before) {
|
||||
if (active === false && before === true) {
|
||||
this.resetState()
|
||||
|
||||
// When the row is not active anymore
|
||||
// remove the display from the row to prevent
|
||||
// keyboard interaction with it.
|
||||
this.$el.parentNode.style.display = 'none'
|
||||
return
|
||||
}
|
||||
|
||||
// Restore default tabindex
|
||||
this.$el.parentNode.style.display = ''
|
||||
},
|
||||
|
||||
/**
|
||||
* When the source changes, reset the preview
|
||||
* and fetch the new one.
|
||||
*/
|
||||
previewUrl() {
|
||||
this.clearImg()
|
||||
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 to
|
||||
// avoid sharing between recycled components
|
||||
this.debounceGetPreview = debounce(function() {
|
||||
this.fetchAndApplyPreview()
|
||||
}, 150, false)
|
||||
|
||||
// Fetch the preview on init
|
||||
this.debounceIfNotCached()
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.resetState()
|
||||
},
|
||||
|
||||
methods: {
|
||||
async debounceIfNotCached() {
|
||||
if (!this.previewUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we already have this preview cached
|
||||
const isCached = await isCachedPreview(this.previewUrl)
|
||||
if (isCached) {
|
||||
this.backgroundImage = `url(${this.previewUrl})`
|
||||
this.backgroundFailed = false
|
||||
return
|
||||
}
|
||||
|
||||
// We don't have this preview cached or it expired, requesting it
|
||||
this.debounceGetPreview()
|
||||
},
|
||||
|
||||
fetchAndApplyPreview() {
|
||||
// Ignore if no preview
|
||||
if (!this.previewUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
// If any image is being processed, reset it
|
||||
if (this.previewPromise) {
|
||||
this.clearImg()
|
||||
}
|
||||
|
||||
// Store the promise to be able to cancel it
|
||||
this.previewPromise = new CancelablePromise((resolve, reject, onCancel) => {
|
||||
const img = new Image()
|
||||
// If active, load the preview with higher priority
|
||||
img.fetchpriority = this.active ? 'high' : 'auto'
|
||||
img.onload = () => {
|
||||
this.backgroundImage = `url(${this.previewUrl})`
|
||||
this.backgroundFailed = false
|
||||
resolve(img)
|
||||
}
|
||||
img.onerror = () => {
|
||||
this.backgroundFailed = true
|
||||
reject(img)
|
||||
}
|
||||
img.src = this.previewUrl
|
||||
|
||||
// Image loading has been canceled
|
||||
onCancel(() => {
|
||||
img.onerror = null
|
||||
img.onload = null
|
||||
img.src = ''
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
resetState() {
|
||||
// Reset loading state
|
||||
this.loading = ''
|
||||
|
||||
// Reset the preview
|
||||
this.clearImg()
|
||||
|
||||
// Close menu
|
||||
this.$refs?.actionsMenu?.closeMenu?.()
|
||||
},
|
||||
|
||||
clearImg() {
|
||||
this.backgroundImage = ''
|
||||
this.backgroundFailed = false
|
||||
|
||||
if (this.previewPromise) {
|
||||
this.previewPromise.cancel()
|
||||
this.previewPromise = null
|
||||
}
|
||||
},
|
||||
|
||||
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
|
||||
}
|
||||
return hash
|
||||
},
|
||||
|
||||
async onActionClick(action) {
|
||||
const displayName = action.displayName([this.source], this.currentView)
|
||||
try {
|
||||
this.loading = action.id
|
||||
const success = await action.exec(this.source, this.currentView)
|
||||
if (success) {
|
||||
showSuccess(this.t('files', '"{displayName}" action executed successfully', { displayName }))
|
||||
return
|
||||
}
|
||||
showError(this.t('files', '"{displayName}" action failed', { displayName }))
|
||||
} catch (e) {
|
||||
logger.error('Error while executing action', { action, e })
|
||||
showError(this.t('files', '"{displayName}" action failed', { displayName }))
|
||||
} finally {
|
||||
this.loading = ''
|
||||
}
|
||||
},
|
||||
|
||||
t: translate,
|
||||
formatFileSize,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang='scss'>
|
||||
@import '../mixins/fileslist-row.scss';
|
||||
|
||||
/* Hover effect on tbody lines only */
|
||||
tr {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: var(--color-background-dark);
|
||||
}
|
||||
}
|
||||
|
||||
/* Preview not loaded animation effect */
|
||||
.files-list__row-icon-preview:not([style*='background']) {
|
||||
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>
|
||||
160
apps/files/src/components/FilesListFooter.vue
Normal file
160
apps/files/src/components/FilesListFooter.vue
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<!--
|
||||
- @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>
|
||||
<tr>
|
||||
<th class="files-list__row-checkbox">
|
||||
<span class="hidden-visually">{{ t('files', 'Total rows summary') }}</span>
|
||||
</th>
|
||||
|
||||
<!-- Link to file -->
|
||||
<td class="files-list__row-name">
|
||||
<!-- Icon or preview -->
|
||||
<span class="files-list__row-icon" />
|
||||
|
||||
<!-- Summary -->
|
||||
<span>{{ summary }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="files-list__row-actions" />
|
||||
|
||||
<!-- Size -->
|
||||
<td v-if="isSizeAvailable" class="files-list__column files-list__row-size">
|
||||
<span>{{ totalSize }}</span>
|
||||
</td>
|
||||
|
||||
<!-- Custom views columns -->
|
||||
<th v-for="column in columns"
|
||||
:key="column.id"
|
||||
:class="classForColumn(column)">
|
||||
<span>{{ column.summary?.(nodes, currentView) }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { formatFileSize } from '@nextcloud/files'
|
||||
import { translate } from '@nextcloud/l10n'
|
||||
import Vue from 'vue'
|
||||
|
||||
import { useFilesStore } from '../store/files.ts'
|
||||
import { usePathsStore } from '../store/paths.ts'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'FilesListFooter',
|
||||
|
||||
components: {
|
||||
},
|
||||
|
||||
props: {
|
||||
isSizeAvailable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
nodes: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
summary: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const pathsStore = usePathsStore()
|
||||
const filesStore = useFilesStore()
|
||||
return {
|
||||
filesStore,
|
||||
pathsStore,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
currentView() {
|
||||
return this.$navigation.active
|
||||
},
|
||||
|
||||
dir() {
|
||||
// Remove any trailing slash but leave root slash
|
||||
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
|
||||
},
|
||||
|
||||
currentFolder() {
|
||||
if (!this.currentView?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.dir === '/') {
|
||||
return this.filesStore.getRoot(this.currentView.id)
|
||||
}
|
||||
const fileId = this.pathsStore.getPath(this.currentView.id, this.dir)
|
||||
return this.filesStore.getNode(fileId)
|
||||
},
|
||||
|
||||
columns() {
|
||||
return this.currentView?.columns || []
|
||||
},
|
||||
|
||||
totalSize() {
|
||||
// If we have the size already, let's use it
|
||||
if (this.currentFolder?.size) {
|
||||
return formatFileSize(this.currentFolder.size, true)
|
||||
}
|
||||
|
||||
// Otherwise let's compute it
|
||||
return formatFileSize(this.nodes.reduce((total, node) => total + node.size || 0, 0), true)
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
classForColumn(column) {
|
||||
return {
|
||||
'files-list__row-column-custom': true,
|
||||
[`files-list__row-${this.currentView.id}-${column.id}`]: true,
|
||||
}
|
||||
},
|
||||
|
||||
t: translate,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '../mixins/fileslist-row.scss';
|
||||
|
||||
// Scoped row
|
||||
tr {
|
||||
padding-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;
|
||||
}
|
||||
|
||||
</style>
|
||||
220
apps/files/src/components/FilesListHeader.vue
Normal file
220
apps/files/src/components/FilesListHeader.vue
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
<!--
|
||||
- @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>
|
||||
<tr>
|
||||
<th class="files-list__column files-list__row-checkbox">
|
||||
<NcCheckboxRadioSwitch v-bind="selectAllBind" @update:checked="onToggleAll" />
|
||||
</th>
|
||||
|
||||
<!-- Actions multiple if some are selected -->
|
||||
<FilesListHeaderActions v-if="!isNoneSelected"
|
||||
:current-view="currentView"
|
||||
:selected-nodes="selectedNodes" />
|
||||
|
||||
<!-- Columns display -->
|
||||
<template v-else>
|
||||
<!-- Link to file -->
|
||||
<th class="files-list__column files-list__row-name files-list__column--sortable"
|
||||
@click.stop.prevent="toggleSortBy('basename')">
|
||||
<!-- Icon or preview -->
|
||||
<span class="files-list__row-icon" />
|
||||
|
||||
<!-- Name -->
|
||||
<FilesListHeaderButton :name="t('files', 'Name')" mode="basename" />
|
||||
</th>
|
||||
|
||||
<!-- Actions -->
|
||||
<th class="files-list__row-actions" />
|
||||
|
||||
<!-- Size -->
|
||||
<th v-if="isSizeAvailable"
|
||||
:class="{'files-list__column--sortable': isSizeAvailable}"
|
||||
class="files-list__column files-list__row-size">
|
||||
<FilesListHeaderButton :name="t('files', 'Size')" mode="size" />
|
||||
</th>
|
||||
|
||||
<!-- Custom views columns -->
|
||||
<th v-for="column in columns"
|
||||
:key="column.id"
|
||||
:class="classForColumn(column)">
|
||||
<FilesListHeaderButton v-if="!!column.sort" :name="column.title" :mode="column.id" />
|
||||
<span v-else>
|
||||
{{ column.title }}
|
||||
</span>
|
||||
</th>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { mapState } from 'pinia'
|
||||
import { translate } from '@nextcloud/l10n'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
|
||||
import Vue from 'vue'
|
||||
|
||||
import { useFilesStore } from '../store/files.ts'
|
||||
import { useSelectionStore } from '../store/selection.ts'
|
||||
import { useSortingStore } from '../store/sorting.ts'
|
||||
import FilesListHeaderActions from './FilesListHeaderActions.vue'
|
||||
import FilesListHeaderButton from './FilesListHeaderButton.vue'
|
||||
import logger from '../logger.js'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'FilesListHeader',
|
||||
|
||||
components: {
|
||||
FilesListHeaderButton,
|
||||
NcCheckboxRadioSwitch,
|
||||
FilesListHeaderActions,
|
||||
},
|
||||
|
||||
provide() {
|
||||
return {
|
||||
toggleSortBy: this.toggleSortBy,
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
isSizeAvailable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
nodes: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const filesStore = useFilesStore()
|
||||
const selectionStore = useSelectionStore()
|
||||
const sortingStore = useSortingStore()
|
||||
return {
|
||||
filesStore,
|
||||
selectionStore,
|
||||
sortingStore,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(useSortingStore, ['filesSortingConfig']),
|
||||
|
||||
currentView() {
|
||||
return this.$navigation.active
|
||||
},
|
||||
|
||||
columns() {
|
||||
return this.currentView?.columns || []
|
||||
},
|
||||
|
||||
dir() {
|
||||
// Remove any trailing slash but leave root slash
|
||||
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
|
||||
},
|
||||
|
||||
selectAllBind() {
|
||||
const label = this.isNoneSelected || this.isSomeSelected
|
||||
? this.t('files', 'Select all')
|
||||
: this.t('files', 'Unselect all')
|
||||
return {
|
||||
'aria-label': label,
|
||||
checked: this.isAllSelected,
|
||||
indeterminate: this.isSomeSelected,
|
||||
title: label,
|
||||
}
|
||||
},
|
||||
|
||||
selectedNodes() {
|
||||
return this.selectionStore.selected
|
||||
},
|
||||
|
||||
isAllSelected() {
|
||||
return this.selectedNodes.length === this.nodes.length
|
||||
},
|
||||
|
||||
isNoneSelected() {
|
||||
return this.selectedNodes.length === 0
|
||||
},
|
||||
|
||||
isSomeSelected() {
|
||||
return !this.isAllSelected && !this.isNoneSelected
|
||||
},
|
||||
|
||||
sortingMode() {
|
||||
return this.sortingStore.getSortingMode(this.currentView.id)
|
||||
|| this.currentView.defaultSortKey
|
||||
|| 'basename'
|
||||
},
|
||||
isAscSorting() {
|
||||
return this.sortingStore.isAscSorting(this.currentView.id) === true
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
classForColumn(column) {
|
||||
return {
|
||||
'files-list__column': true,
|
||||
'files-list__column--sortable': !!column.sort,
|
||||
'files-list__row-column-custom': true,
|
||||
[`files-list__row-${this.currentView.id}-${column.id}`]: true,
|
||||
}
|
||||
},
|
||||
|
||||
onToggleAll(selected) {
|
||||
if (selected) {
|
||||
const selection = this.nodes.map(node => node.attributes.fileid.toString())
|
||||
logger.debug('Added all nodes to selection', { selection })
|
||||
this.selectionStore.set(selection)
|
||||
} else {
|
||||
logger.debug('Cleared selection')
|
||||
this.selectionStore.reset()
|
||||
}
|
||||
},
|
||||
|
||||
toggleSortBy(key) {
|
||||
// If we're already sorting by this key, flip the direction
|
||||
if (this.sortingMode === key) {
|
||||
this.sortingStore.toggleSortingDirection(this.currentView.id)
|
||||
return
|
||||
}
|
||||
// else sort ASC by this new key
|
||||
this.sortingStore.setSortingBy(key, this.currentView.id)
|
||||
},
|
||||
|
||||
t: translate,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '../mixins/fileslist-row.scss';
|
||||
.files-list__column {
|
||||
user-select: none;
|
||||
// Make sure the cell colors don't apply to column headers
|
||||
color: var(--color-text-maxcontrast) !important;
|
||||
|
||||
&--sortable {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
168
apps/files/src/components/FilesListHeaderActions.vue
Normal file
168
apps/files/src/components/FilesListHeaderActions.vue
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<!--
|
||||
- @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>
|
||||
<th class="files-list__column files-list__row-actions-batch" colspan="2">
|
||||
<NcActions ref="actionsMenu"
|
||||
:disabled="!!loading"
|
||||
:force-title="true"
|
||||
:inline="3">
|
||||
<NcActionButton v-for="action in enabledActions"
|
||||
:key="action.id"
|
||||
:class="'files-list__row-actions-batch-' + action.id"
|
||||
@click="onActionClick(action)">
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="loading === action.id" :size="18" />
|
||||
<CustomSvgIconRender v-else :svg="action.iconSvgInline(nodes, currentView)" />
|
||||
</template>
|
||||
{{ action.displayName(nodes, currentView) }}
|
||||
</NcActionButton>
|
||||
</NcActions>
|
||||
</th>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||
import { translate } from '@nextcloud/l10n'
|
||||
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
|
||||
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
|
||||
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
|
||||
import Vue from 'vue'
|
||||
|
||||
import { getFileActions } from '../services/FileAction.ts'
|
||||
import { useFilesStore } from '../store/files.ts'
|
||||
import { useSelectionStore } from '../store/selection.ts'
|
||||
import CustomSvgIconRender from './CustomSvgIconRender.vue'
|
||||
import logger from '../logger.js'
|
||||
|
||||
// The registered actions list
|
||||
const actions = getFileActions()
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'FilesListHeaderActions',
|
||||
|
||||
components: {
|
||||
CustomSvgIconRender,
|
||||
NcActions,
|
||||
NcActionButton,
|
||||
NcLoadingIcon,
|
||||
},
|
||||
|
||||
props: {
|
||||
currentView: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
selectedNodes: {
|
||||
type: Array,
|
||||
default: () => ([]),
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const filesStore = useFilesStore()
|
||||
const selectionStore = useSelectionStore()
|
||||
return {
|
||||
filesStore,
|
||||
selectionStore,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
loading: null,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
enabledActions() {
|
||||
return actions
|
||||
.filter(action => action.execBatch)
|
||||
.filter(action => !action.enabled || action.enabled(this.nodes, this.currentView))
|
||||
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
||||
},
|
||||
|
||||
nodes() {
|
||||
return this.selectedNodes
|
||||
.map(fileid => this.getNode(fileid))
|
||||
.filter(node => node)
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Get a cached note from the store
|
||||
*
|
||||
* @param {number} fileId the file id to get
|
||||
* @return {Folder|File}
|
||||
*/
|
||||
getNode(fileId) {
|
||||
return this.filesStore.getNode(fileId)
|
||||
},
|
||||
|
||||
async onActionClick(action) {
|
||||
const displayName = action.displayName(this.nodes, this.currentView)
|
||||
const selectionIds = this.selectedNodes
|
||||
try {
|
||||
this.loading = action.id
|
||||
const results = await action.execBatch(this.nodes, this.currentView)
|
||||
|
||||
if (results.some(result => result !== true)) {
|
||||
// Remove the failed ids from the selection
|
||||
const failedIds = selectionIds
|
||||
.filter((fileid, index) => results[index] !== true)
|
||||
this.selectionStore.set(failedIds)
|
||||
|
||||
showError(this.t('files', '"{displayName}" failed on some elements ', { displayName }))
|
||||
return
|
||||
}
|
||||
|
||||
// Show success message and clear selection
|
||||
showSuccess(this.t('files', '"{displayName}" batch action executed successfully', { displayName }))
|
||||
this.selectionStore.reset()
|
||||
} catch (e) {
|
||||
logger.error('Error while executing action', { action, e })
|
||||
showError(this.t('files', '"{displayName}" action failed', { displayName }))
|
||||
} finally {
|
||||
this.loading = null
|
||||
}
|
||||
},
|
||||
|
||||
t: translate,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.files-list__row-actions-batch {
|
||||
flex: 1 1 100% !important;
|
||||
|
||||
// Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged
|
||||
::v-deep .button-vue__wrapper {
|
||||
width: 100%;
|
||||
span.button-vue__text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
145
apps/files/src/components/FilesListHeaderButton.vue
Normal file
145
apps/files/src/components/FilesListHeaderButton.vue
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<!--
|
||||
- @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>
|
||||
<NcButton :aria-label="sortAriaLabel(name)"
|
||||
:class="{'files-list__column-sort-button--active': sortingMode === mode}"
|
||||
class="files-list__column-sort-button"
|
||||
type="tertiary"
|
||||
@click.stop.prevent="toggleSortBy(mode)">
|
||||
<!-- Sort icon before text as size is align right -->
|
||||
<MenuUp v-if="sortingMode !== mode || isAscSorting" slot="icon" />
|
||||
<MenuDown v-else slot="icon" />
|
||||
{{ name }}
|
||||
</NcButton>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { mapState } from 'pinia'
|
||||
import { translate } from '@nextcloud/l10n'
|
||||
import MenuDown from 'vue-material-design-icons/MenuDown.vue'
|
||||
import MenuUp from 'vue-material-design-icons/MenuUp.vue'
|
||||
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
|
||||
import Vue from 'vue'
|
||||
|
||||
import { useSortingStore } from '../store/sorting.ts'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'FilesListHeaderButton',
|
||||
|
||||
components: {
|
||||
MenuDown,
|
||||
MenuUp,
|
||||
NcButton,
|
||||
},
|
||||
|
||||
inject: ['toggleSortBy'],
|
||||
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const sortingStore = useSortingStore()
|
||||
return {
|
||||
sortingStore,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(useSortingStore, ['filesSortingConfig']),
|
||||
|
||||
currentView() {
|
||||
return this.$navigation.active
|
||||
},
|
||||
|
||||
sortingMode() {
|
||||
return this.sortingStore.getSortingMode(this.currentView.id)
|
||||
|| this.currentView.defaultSortKey
|
||||
|| 'basename'
|
||||
},
|
||||
isAscSorting() {
|
||||
return this.sortingStore.isAscSorting(this.currentView.id) === true
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
sortAriaLabel(column) {
|
||||
const direction = this.isAscSorting
|
||||
? this.t('files', 'ascending')
|
||||
: this.t('files', 'descending')
|
||||
return this.t('files', 'Sort list by {column} ({direction})', {
|
||||
column,
|
||||
direction,
|
||||
})
|
||||
},
|
||||
|
||||
t: translate,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.files-list__column-sort-button {
|
||||
// Compensate for cells margin
|
||||
margin: 0 calc(var(--cell-margin) * -1);
|
||||
// Reverse padding
|
||||
padding: 0 4px 0 16px !important;
|
||||
|
||||
// Icon after text
|
||||
.button-vue__wrapper {
|
||||
flex-direction: row-reverse;
|
||||
// Take max inner width for text overflow ellipsis
|
||||
// Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-vue__icon {
|
||||
transition-timing-function: linear;
|
||||
transition-duration: .1s;
|
||||
transition-property: opacity;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// Remove when https://github.com/nextcloud/nextcloud-vue/pull/3936 is merged
|
||||
.button-vue__text {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&--active,
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
.button-vue__icon {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
183
apps/files/src/components/FilesListVirtual.vue
Normal file
183
apps/files/src/components/FilesListVirtual.vue
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<!--
|
||||
- @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>
|
||||
<RecycleScroller ref="recycleScroller"
|
||||
class="files-list"
|
||||
key-field="source"
|
||||
:items="nodes"
|
||||
:item-size="55"
|
||||
:table-mode="true"
|
||||
item-class="files-list__row"
|
||||
item-tag="tr"
|
||||
list-class="files-list__body"
|
||||
list-tag="tbody"
|
||||
role="table">
|
||||
<template #default="{ item, active, index }">
|
||||
<!-- File row -->
|
||||
<FileEntry :active="active"
|
||||
:index="index"
|
||||
:is-size-available="isSizeAvailable"
|
||||
:source="item" />
|
||||
</template>
|
||||
|
||||
<template #before>
|
||||
<!-- Accessibility description -->
|
||||
<caption class="hidden-visually">
|
||||
{{ currentView.caption || '' }}
|
||||
{{ t('files', 'This list is not fully rendered for performances reasons. The files will be rendered as you navigate through the list.') }}
|
||||
</caption>
|
||||
|
||||
<!-- Thead-->
|
||||
<FilesListHeader :is-size-available="isSizeAvailable" :nodes="nodes" />
|
||||
</template>
|
||||
|
||||
<template #after>
|
||||
<!-- Tfoot-->
|
||||
<FilesListFooter :is-size-available="isSizeAvailable" :nodes="nodes" :summary="summary" />
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { RecycleScroller } from 'vue-virtual-scroller'
|
||||
import { translate, translatePlural } from '@nextcloud/l10n'
|
||||
import Vue from 'vue'
|
||||
|
||||
import FileEntry from './FileEntry.vue'
|
||||
import FilesListFooter from './FilesListFooter.vue'
|
||||
import FilesListHeader from './FilesListHeader.vue'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'FilesListVirtual',
|
||||
|
||||
components: {
|
||||
RecycleScroller,
|
||||
FileEntry,
|
||||
FilesListHeader,
|
||||
FilesListFooter,
|
||||
},
|
||||
|
||||
props: {
|
||||
currentView: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
nodes: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
FileEntry,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
files() {
|
||||
return this.nodes.filter(node => node.type === 'file')
|
||||
},
|
||||
|
||||
summaryFile() {
|
||||
const count = this.files.length
|
||||
return translatePlural('files', '{count} file', '{count} files', count, { count })
|
||||
},
|
||||
summaryFolder() {
|
||||
const count = this.nodes.length - this.files.length
|
||||
return translatePlural('files', '{count} folder', '{count} folders', count, { count })
|
||||
},
|
||||
summary() {
|
||||
return translate('files', '{summaryFile} and {summaryFolder}', this)
|
||||
},
|
||||
isSizeAvailable() {
|
||||
return this.nodes.some(node => node.attributes.size !== undefined)
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// Make the root recycle scroller a table for proper semantics
|
||||
const slots = this.$el.querySelectorAll('.vue-recycle-scroller__slot')
|
||||
slots[0].setAttribute('role', 'thead')
|
||||
slots[1].setAttribute('role', 'tfoot')
|
||||
},
|
||||
|
||||
methods: {
|
||||
getFileId(node) {
|
||||
return node.attributes.fileid
|
||||
},
|
||||
|
||||
t: translate,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<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;
|
||||
--clickable-area: 44px;
|
||||
--icon-preview-size: 32px;
|
||||
|
||||
display: block;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
|
||||
&::v-deep {
|
||||
// Table head, body and footer
|
||||
tbody, .vue-recycle-scroller__slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
// Necessary for virtual scrolling absolute
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Table header
|
||||
.vue-recycle-scroller__slot[role='thead'] {
|
||||
// Pinned on top when scrolling
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
top: 0;
|
||||
height: var(--row-height);
|
||||
background-color: var(--color-main-background);
|
||||
}
|
||||
|
||||
/**
|
||||
* Common row styling. tr are handled by
|
||||
* vue-virtual-scroller, so we need to
|
||||
* have those rules in here.
|
||||
*/
|
||||
tr {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -1,10 +1,17 @@
|
|||
import './templates.js'
|
||||
import './legacy/filelistSearch.js'
|
||||
import './actions/deleteAction.ts'
|
||||
|
||||
import processLegacyFilesViews from './legacy/navigationMapper.js'
|
||||
|
||||
import Vue from 'vue'
|
||||
import { createPinia, PiniaVuePlugin } from 'pinia'
|
||||
|
||||
import NavigationService from './services/Navigation.ts'
|
||||
import registerPreviewServiceWorker from './services/ServiceWorker.js'
|
||||
|
||||
import NavigationView from './views/Navigation.vue'
|
||||
import FilesListView from './views/FilesList.vue'
|
||||
|
||||
import SettingsService from './services/Settings.js'
|
||||
import SettingsModel from './models/Setting.js'
|
||||
|
|
@ -15,9 +22,14 @@ import router from './router/router.js'
|
|||
window.OCA.Files = window.OCA.Files ?? {}
|
||||
window.OCP.Files = window.OCP.Files ?? {}
|
||||
|
||||
// Init Pinia store
|
||||
Vue.use(PiniaVuePlugin)
|
||||
const pinia = createPinia()
|
||||
|
||||
// Init Navigation Service
|
||||
const Navigation = new NavigationService()
|
||||
Object.assign(window.OCP.Files, { Navigation })
|
||||
Vue.prototype.$navigation = Navigation
|
||||
|
||||
// Init Files App Settings Service
|
||||
const Settings = new SettingsService()
|
||||
|
|
@ -32,8 +44,21 @@ const FilesNavigationRoot = new View({
|
|||
Navigation,
|
||||
},
|
||||
router,
|
||||
pinia,
|
||||
})
|
||||
FilesNavigationRoot.$mount('#app-navigation-files')
|
||||
|
||||
// Init content list view
|
||||
const ListView = Vue.extend(FilesListView)
|
||||
const FilesList = new ListView({
|
||||
name: 'FilesListRoot',
|
||||
router,
|
||||
pinia,
|
||||
})
|
||||
FilesList.$mount('#app-content-vue')
|
||||
|
||||
// Init legacy files views
|
||||
processLegacyFilesViews()
|
||||
|
||||
// Register preview service worker
|
||||
registerPreviewServiceWorker()
|
||||
|
|
|
|||
170
apps/files/src/mixins/fileslist-row.scss
Normal file
170
apps/files/src/mixins/fileslist-row.scss
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠
|
||||
* This file is for every column styling that must be
|
||||
* shared between BOTH the files list AND the list header.
|
||||
* ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠ ⚠
|
||||
*/
|
||||
td, th {
|
||||
display: flex;
|
||||
align-items: 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;
|
||||
|
||||
// Columns should try to add any text
|
||||
// node wrapped in a span. That should help
|
||||
// with the ellipsis on overflow.
|
||||
span {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.files-list__row-checkbox {
|
||||
justify-content: center;
|
||||
&::v-deep .checkbox-radio-switch {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
--icon-size: var(--checkbox-size);
|
||||
|
||||
label.checkbox-radio-switch__label {
|
||||
width: var(--clickable-area);
|
||||
height: var(--clickable-area);
|
||||
margin: 0;
|
||||
padding: calc((var(--clickable-area) - var(--checkbox-size)) / 2);
|
||||
}
|
||||
|
||||
.checkbox-radio-switch__icon {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.files-list__row-icon {
|
||||
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);
|
||||
|
||||
& > span {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&::v-deep svg {
|
||||
width: var(--icon-preview-size);
|
||||
height: var(--icon-preview-size);
|
||||
}
|
||||
|
||||
&-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-size: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.files-list__row-name {
|
||||
// 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%;
|
||||
|
||||
// Keyboard indicator a11y
|
||||
&:focus .files-list__row-name-text,
|
||||
&:focus-visible .files-list__row-name-text {
|
||||
outline: 2px solid var(--color-main-text) !important;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.files-list__row-name-text {
|
||||
// Make some space for the outline
|
||||
padding: 5px 10px;
|
||||
margin-left: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
.files-list__row-actions {
|
||||
width: auto;
|
||||
|
||||
// Add margin to all cells after the actions
|
||||
& ~ td,
|
||||
& ~ th {
|
||||
margin: 0 var(--cell-margin);
|
||||
}
|
||||
|
||||
&::v-deep > button {
|
||||
.button-vue__text {
|
||||
// Remove bold from default button styling
|
||||
font-weight: normal;
|
||||
}
|
||||
&:not(:hover, :focus, :active) .button-vue__wrapper {
|
||||
// Also apply color-text-maxcontrast to non-active button
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.files-list__row-size {
|
||||
// Right align text
|
||||
justify-content: flex-end;
|
||||
width: calc(var(--row-height) * 1.5);
|
||||
// opacity varies with the size
|
||||
color: var(--color-main-text);
|
||||
|
||||
// Icon is before text since size is right aligned
|
||||
::v-deep .files-list__column-sort-button {
|
||||
padding: 0 16px 0 4px !important;
|
||||
.button-vue__wrapper {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.files-list__row-column-custom {
|
||||
width: calc(var(--row-height) * 2);
|
||||
}
|
||||
184
apps/files/src/services/FileAction.ts
Normal file
184
apps/files/src/services/FileAction.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
/**
|
||||
* @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 { Node } from '@nextcloud/files'
|
||||
import logger from '../logger'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
OC: any;
|
||||
_nc_fileactions: FileAction[] | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: remove and move to @nextcloud/files
|
||||
* @see https://github.com/nextcloud/nextcloud-files/pull/608
|
||||
*/
|
||||
interface FileActionData {
|
||||
/** Unique ID */
|
||||
id: string
|
||||
/** Translatable string displayed in the menu */
|
||||
displayName: (files: Node[], view) => string
|
||||
/** Svg as inline string. <svg><path fill="..." /></svg> */
|
||||
iconSvgInline: (files: Node[], view) => string
|
||||
/** Condition wether this action is shown or not */
|
||||
enabled?: (files: Node[], view) => boolean
|
||||
/**
|
||||
* Function executed on single file action
|
||||
* @returns true if the action was executed, false otherwise
|
||||
* @throws Error if the action failed
|
||||
*/
|
||||
exec: (file: Node, view) => Promise<boolean>,
|
||||
/**
|
||||
* Function executed on multiple files action
|
||||
* @returns true if the action was executed, false otherwise
|
||||
* @throws Error if the action failed
|
||||
*/
|
||||
execBatch?: (files: Node[], view) => Promise<boolean[]>
|
||||
/** This action order in the list */
|
||||
order?: number,
|
||||
/** Make this action the default */
|
||||
default?: boolean,
|
||||
/**
|
||||
* If true, the renderInline function will be called
|
||||
*/
|
||||
inline?: (file: Node, view) => boolean,
|
||||
/**
|
||||
* If defined, the returned html element will be
|
||||
* appended before the actions menu.
|
||||
*/
|
||||
renderInline?: (file: Node, view) => HTMLElement,
|
||||
}
|
||||
|
||||
export class FileAction {
|
||||
|
||||
private _action: FileActionData
|
||||
|
||||
constructor(action: FileActionData) {
|
||||
this.validateAction(action)
|
||||
this._action = action
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this._action.id
|
||||
}
|
||||
|
||||
get displayName() {
|
||||
return this._action.displayName
|
||||
}
|
||||
|
||||
get iconSvgInline() {
|
||||
return this._action.iconSvgInline
|
||||
}
|
||||
|
||||
get enabled() {
|
||||
return this._action.enabled
|
||||
}
|
||||
|
||||
get exec() {
|
||||
return this._action.exec
|
||||
}
|
||||
|
||||
get execBatch() {
|
||||
return this._action.execBatch
|
||||
}
|
||||
|
||||
get order() {
|
||||
return this._action.order
|
||||
}
|
||||
|
||||
get default() {
|
||||
return this._action.default
|
||||
}
|
||||
|
||||
get inline() {
|
||||
return this._action.inline
|
||||
}
|
||||
|
||||
get renderInline() {
|
||||
return this._action.renderInline
|
||||
}
|
||||
|
||||
private validateAction(action: FileActionData) {
|
||||
if (!action.id || typeof action.id !== 'string') {
|
||||
throw new Error('Invalid id')
|
||||
}
|
||||
|
||||
if (!action.displayName || typeof action.displayName !== 'function') {
|
||||
throw new Error('Invalid displayName function')
|
||||
}
|
||||
|
||||
if (!action.iconSvgInline || typeof action.iconSvgInline !== 'function') {
|
||||
throw new Error('Invalid iconSvgInline function')
|
||||
}
|
||||
|
||||
if (!action.exec || typeof action.exec !== 'function') {
|
||||
throw new Error('Invalid exec function')
|
||||
}
|
||||
|
||||
// Optional properties --------------------------------------------
|
||||
if ('enabled' in action && typeof action.enabled !== 'function') {
|
||||
throw new Error('Invalid enabled function')
|
||||
}
|
||||
|
||||
if ('execBatch' in action && typeof action.execBatch !== 'function') {
|
||||
throw new Error('Invalid execBatch function')
|
||||
}
|
||||
|
||||
if ('order' in action && typeof action.order !== 'number') {
|
||||
throw new Error('Invalid order')
|
||||
}
|
||||
|
||||
if ('default' in action && typeof action.default !== 'boolean') {
|
||||
throw new Error('Invalid default')
|
||||
}
|
||||
|
||||
if ('inline' in action && typeof action.inline !== 'function') {
|
||||
throw new Error('Invalid inline function')
|
||||
}
|
||||
|
||||
if ('renderInline' in action && typeof action.renderInline !== 'function') {
|
||||
throw new Error('Invalid renderInline function')
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const registerFileAction = function(action: FileAction): void {
|
||||
if (typeof window._nc_fileactions === 'undefined') {
|
||||
window._nc_fileactions = []
|
||||
logger.debug('FileActions initialized')
|
||||
}
|
||||
|
||||
// Check duplicates
|
||||
if (window._nc_fileactions.find(search => search.id === action.id)) {
|
||||
logger.error(`FileAction ${action.id} already registered`, { action })
|
||||
return
|
||||
}
|
||||
|
||||
window._nc_fileactions.push(action)
|
||||
}
|
||||
|
||||
export const getFileActions = function(): FileAction[] {
|
||||
return window._nc_fileactions || []
|
||||
}
|
||||
|
|
@ -19,25 +19,29 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
import type Node from '@nextcloud/files/dist/files/node'
|
||||
/* eslint-disable */
|
||||
import type { Folder, Node } from '@nextcloud/files'
|
||||
import isSvg from 'is-svg'
|
||||
|
||||
import logger from '../logger.js'
|
||||
|
||||
export type ContentsWithRoot = {
|
||||
folder: Folder,
|
||||
contents: Node[]
|
||||
}
|
||||
|
||||
export interface Column {
|
||||
/** Unique column ID */
|
||||
id: string
|
||||
/** Translated column title */
|
||||
title: string
|
||||
/** Property key from Node main or additional attributes.
|
||||
Will be used if no custom sort function is provided.
|
||||
Sorting will be done by localCompare */
|
||||
property: string
|
||||
/** Special function used to sort Nodes between them */
|
||||
sortFunction?: (nodeA: Node, nodeB: Node) => number;
|
||||
/** The content of the cell. The element will be appended within */
|
||||
render: (node: Node, view: Navigation) => HTMLElement
|
||||
/** Function used to sort Nodes between them */
|
||||
sort?: (nodeA: Node, nodeB: Node) => number
|
||||
/** Custom summary of the column to display at the end of the list.
|
||||
Will not be displayed if nothing is provided */
|
||||
summary?: (node: Node[]) => string
|
||||
summary?: (node: Node[], view: Navigation) => string
|
||||
}
|
||||
|
||||
export interface Navigation {
|
||||
|
|
@ -45,8 +49,15 @@ export interface Navigation {
|
|||
id: string
|
||||
/** Translated view name */
|
||||
name: string
|
||||
/** Method return the content of the provided path */
|
||||
getFiles: (path: string) => Node[]
|
||||
/**
|
||||
* Method return the content of the provided path
|
||||
* This ideally should be a cancellable promise.
|
||||
* promise.cancel(reason) will be called when the directory
|
||||
* change and the promise is not resolved yet.
|
||||
* You _must_ also return the current directory
|
||||
* information alongside with its content.
|
||||
*/
|
||||
getContents: (path: string) => Promise<ContentsWithRoot>
|
||||
/** The view icon as an inline svg */
|
||||
icon: string
|
||||
/** The view order */
|
||||
|
|
@ -63,6 +74,12 @@ export interface Navigation {
|
|||
/** This view has children and is expanded or not */
|
||||
expanded?: boolean
|
||||
|
||||
/**
|
||||
* Will be used as default if the user
|
||||
* haven't customized their sorting column
|
||||
* */
|
||||
defaultSortKey?: string
|
||||
|
||||
/**
|
||||
* This view is sticky a legacy view.
|
||||
* Here until all the views are migrated to Vue.
|
||||
|
|
@ -150,8 +167,8 @@ const isValidNavigation = function(view: Navigation): boolean {
|
|||
* TODO: remove when support for legacy views is removed
|
||||
*/
|
||||
if (!view.legacy) {
|
||||
if (!view.getFiles || typeof view.getFiles !== 'function') {
|
||||
throw new Error('Navigation getFiles is required and must be a function')
|
||||
if (!view.getContents || typeof view.getContents !== 'function') {
|
||||
throw new Error('Navigation getContents is required and must be a function')
|
||||
}
|
||||
|
||||
if (!view.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) {
|
||||
|
|
@ -184,6 +201,10 @@ const isValidNavigation = function(view: Navigation): boolean {
|
|||
throw new Error('Navigation expanded must be a boolean')
|
||||
}
|
||||
|
||||
if (view.defaultSortKey && typeof view.defaultSortKey !== 'string') {
|
||||
throw new Error('Navigation defaultSortKey must be a string')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -193,19 +214,19 @@ const isValidNavigation = function(view: Navigation): boolean {
|
|||
*/
|
||||
const isValidColumn = function(column: Column): boolean {
|
||||
if (!column.id || typeof column.id !== 'string') {
|
||||
throw new Error('Column id is required')
|
||||
throw new Error('A column id is required')
|
||||
}
|
||||
|
||||
if (!column.title || typeof column.title !== 'string') {
|
||||
throw new Error('Column title is required')
|
||||
throw new Error('A column title is required')
|
||||
}
|
||||
|
||||
if (!column.property || typeof column.property !== 'string') {
|
||||
throw new Error('Column property is required')
|
||||
if (!column.render || typeof column.render !== 'function') {
|
||||
throw new Error('A render function is required')
|
||||
}
|
||||
|
||||
// Optional properties
|
||||
if (column.sortFunction && typeof column.sortFunction !== 'function') {
|
||||
if (column.sort && typeof column.sort !== 'function') {
|
||||
throw new Error('Column sortFunction must be a function')
|
||||
}
|
||||
|
||||
|
|
|
|||
37
apps/files/src/services/PreviewService.ts
Normal file
37
apps/files/src/services/PreviewService.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
|
||||
// The preview service worker cache name (see webpack config)
|
||||
const SWCacheName = 'previews'
|
||||
|
||||
/**
|
||||
* Check if the preview is already cached by the service worker
|
||||
*/
|
||||
export const isCachedPreview = function(previewUrl: string) {
|
||||
return caches.open(SWCacheName)
|
||||
.then(function(cache) {
|
||||
return cache.match(previewUrl)
|
||||
.then(function(response) {
|
||||
return !!response
|
||||
})
|
||||
})
|
||||
}
|
||||
40
apps/files/src/services/ServiceWorker.js
Normal file
40
apps/files/src/services/ServiceWorker.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
|
||||
*
|
||||
* @author Gary Kim <gary@garykim.dev>
|
||||
*
|
||||
* @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 { generateUrl } from '@nextcloud/router'
|
||||
import logger from '../logger.js'
|
||||
|
||||
export default () => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
// Use the window load event to keep the page load performant
|
||||
window.addEventListener('load', async () => {
|
||||
try {
|
||||
const url = generateUrl('/apps/files/preview-service-worker.js', {}, { noRewrite: true })
|
||||
const registration = await navigator.serviceWorker.register(url, { scope: '/' })
|
||||
logger.debug('SW registered: ', { registration })
|
||||
} catch (error) {
|
||||
logger.error('SW registration failed: ', { error })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
logger.debug('Service Worker is not enabled on this browser.')
|
||||
}
|
||||
}
|
||||
115
apps/files/src/store/files.ts
Normal file
115
apps/files/src/store/files.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
/* eslint-disable */
|
||||
import type { Folder, Node } from '@nextcloud/files'
|
||||
import type { FilesStore, RootsStore, RootOptions, Service, FilesState } from '../types.ts'
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { subscribe } from '@nextcloud/event-bus'
|
||||
import Vue from 'vue'
|
||||
import logger from '../logger'
|
||||
|
||||
export const useFilesStore = () => {
|
||||
const store = defineStore('files', {
|
||||
state: (): FilesState => ({
|
||||
files: {} as FilesStore,
|
||||
roots: {} as RootsStore,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
/**
|
||||
* Get a file or folder by id
|
||||
*/
|
||||
getNode: (state) => (id: number): Node|undefined => state.files[id],
|
||||
|
||||
/**
|
||||
* Get a list of files or folders by their IDs
|
||||
* Does not return undefined values
|
||||
*/
|
||||
getNodes: (state) => (ids: number[]): Node[] => ids
|
||||
.map(id => state.files[id])
|
||||
.filter(Boolean),
|
||||
/**
|
||||
* Get a file or folder by id
|
||||
*/
|
||||
getRoot: (state) => (service: Service): Folder|undefined => state.roots[service],
|
||||
},
|
||||
|
||||
actions: {
|
||||
updateNodes(nodes: Node[]) {
|
||||
// Update the store all at once
|
||||
const files = nodes.reduce((acc, node) => {
|
||||
if (!node.attributes.fileid) {
|
||||
logger.warn('Trying to update/set a node without fileid', node)
|
||||
return acc
|
||||
}
|
||||
acc[node.attributes.fileid] = node
|
||||
return acc
|
||||
}, {} as FilesStore)
|
||||
|
||||
Vue.set(this, 'files', {...this.files, ...files})
|
||||
},
|
||||
|
||||
deleteNodes(nodes: Node[]) {
|
||||
nodes.forEach(node => {
|
||||
if (node.fileid) {
|
||||
Vue.delete(this.files, node.fileid)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
setRoot({ service, root }: RootOptions) {
|
||||
Vue.set(this.roots, service, root)
|
||||
},
|
||||
|
||||
onCreatedNode() {
|
||||
// TODO: do something
|
||||
},
|
||||
|
||||
onDeletedNode(node: Node) {
|
||||
this.deleteNodes([node])
|
||||
},
|
||||
|
||||
onMovedNode() {
|
||||
// TODO: do something
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const fileStore = store()
|
||||
// Make sure we only register the listeners once
|
||||
if (!fileStore.initialized) {
|
||||
subscribe('files:file:created', fileStore.onCreatedNode)
|
||||
subscribe('files:file:deleted', fileStore.onDeletedNode)
|
||||
subscribe('files:file:moved', fileStore.onMovedNode)
|
||||
// subscribe('files:file:updated', fileStore.onUpdatedNode)
|
||||
|
||||
subscribe('files:folder:created', fileStore.onCreatedNode)
|
||||
subscribe('files:folder:deleted', fileStore.onDeletedNode)
|
||||
subscribe('files:folder:moved', fileStore.onMovedNode)
|
||||
// subscribe('files:folder:updated', fileStore.onUpdatedNode)
|
||||
|
||||
fileStore.initialized = true
|
||||
}
|
||||
|
||||
return fileStore
|
||||
}
|
||||
69
apps/files/src/store/paths.ts
Normal file
69
apps/files/src/store/paths.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
/* eslint-disable */
|
||||
import type { PathOptions, ServicesState } from '../types.ts'
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import Vue from 'vue'
|
||||
import { subscribe } from '@nextcloud/event-bus'
|
||||
|
||||
export const usePathsStore = () => {
|
||||
const store = defineStore('paths', {
|
||||
state: (): ServicesState => ({}),
|
||||
|
||||
getters: {
|
||||
getPath: (state) => {
|
||||
return (service: string, path: string): number|undefined => {
|
||||
if (!state[service]) {
|
||||
return undefined
|
||||
}
|
||||
return state[service][path]
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
addPath(payload: PathOptions) {
|
||||
// If it doesn't exists, init the service state
|
||||
if (!this[payload.service]) {
|
||||
Vue.set(this, payload.service, {})
|
||||
}
|
||||
|
||||
// Now we can set the provided path
|
||||
Vue.set(this[payload.service], payload.path, payload.fileid)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const pathsStore = store()
|
||||
// Make sure we only register the listeners once
|
||||
if (!pathsStore.initialized) {
|
||||
// TODO: watch folders to update paths?
|
||||
// subscribe('files:folder:created', pathsStore.onCreatedNode)
|
||||
// subscribe('files:folder:deleted', pathsStore.onDeletedNode)
|
||||
// subscribe('files:folder:moved', pathsStore.onMovedNode)
|
||||
|
||||
pathsStore.initialized = true
|
||||
}
|
||||
|
||||
return pathsStore
|
||||
}
|
||||
46
apps/files/src/store/selection.ts
Normal file
46
apps/files/src/store/selection.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
/* eslint-disable */
|
||||
import { defineStore } from 'pinia'
|
||||
import Vue from 'vue'
|
||||
|
||||
export const useSelectionStore = defineStore('selection', {
|
||||
state: () => ({
|
||||
selected: [] as number[]
|
||||
}),
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Set the selection of fileIds
|
||||
*/
|
||||
set(selection = [] as number[]) {
|
||||
Vue.set(this, 'selected', selection)
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset the selection
|
||||
*/
|
||||
reset() {
|
||||
Vue.set(this, 'selected', [])
|
||||
}
|
||||
}
|
||||
})
|
||||
80
apps/files/src/store/sorting.ts
Normal file
80
apps/files/src/store/sorting.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
/* eslint-disable */
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { defineStore } from 'pinia'
|
||||
import Vue from 'vue'
|
||||
import axios from '@nextcloud/axios'
|
||||
import type { direction, SortingStore } from '../types.ts'
|
||||
|
||||
const saveUserConfig = (mode: string, direction: direction, view: string) => {
|
||||
return axios.post(generateUrl('/apps/files/api/v1/sorting'), {
|
||||
mode,
|
||||
direction,
|
||||
view,
|
||||
})
|
||||
}
|
||||
|
||||
const filesSortingConfig = loadState('files', 'filesSortingConfig', {}) as SortingStore
|
||||
|
||||
export const useSortingStore = defineStore('sorting', {
|
||||
state: () => ({
|
||||
filesSortingConfig,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isAscSorting: (state) => (view: string = 'files') => state.filesSortingConfig[view]?.direction !== 'desc',
|
||||
getSortingMode: (state) => (view: string = 'files') => state.filesSortingConfig[view]?.mode,
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Set the sorting key AND sort by ASC
|
||||
* The key param must be a valid key of a File object
|
||||
* If not found, will be searched within the File attributes
|
||||
*/
|
||||
setSortingBy(key: string = 'basename', view: string = 'files') {
|
||||
const config = this.filesSortingConfig[view] || {}
|
||||
config.mode = key
|
||||
config.direction = 'asc'
|
||||
|
||||
// Save new config
|
||||
Vue.set(this.filesSortingConfig, view, config)
|
||||
saveUserConfig(config.mode, config.direction, view)
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle the sorting direction
|
||||
*/
|
||||
toggleSortingDirection(view: string = 'files') {
|
||||
const config = this.filesSortingConfig[view] || { 'direction': 'asc' }
|
||||
const newDirection = config.direction === 'asc' ? 'desc' : 'asc'
|
||||
config.direction = newDirection
|
||||
|
||||
// Save new config
|
||||
Vue.set(this.filesSortingConfig, view, config)
|
||||
saveUserConfig(config.mode, config.direction, view)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
75
apps/files/src/store/userconfig.ts
Normal file
75
apps/files/src/store/userconfig.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
/* eslint-disable */
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { defineStore } from 'pinia'
|
||||
import Vue from 'vue'
|
||||
import axios from '@nextcloud/axios'
|
||||
import type { UserConfig, UserConfigStore } from '../types.ts'
|
||||
import { emit, subscribe } from '@nextcloud/event-bus'
|
||||
|
||||
const userConfig = loadState('files', 'config', {
|
||||
show_hidden: false,
|
||||
crop_image_previews: true,
|
||||
}) as UserConfig
|
||||
|
||||
export const useUserConfigStore = () => {
|
||||
const store = defineStore('userconfig', {
|
||||
state: () => ({
|
||||
userConfig,
|
||||
} as UserConfigStore),
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Update the user config local store
|
||||
*/
|
||||
onUpdate(key: string, value: boolean) {
|
||||
Vue.set(this.userConfig, key, value)
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the user config local store AND on server side
|
||||
*/
|
||||
async update(key: string, value: boolean) {
|
||||
await axios.post(generateUrl('/apps/files/api/v1/config/' + key), {
|
||||
value,
|
||||
})
|
||||
|
||||
emit('files:config:updated', { key, value })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const userConfigStore = store()
|
||||
|
||||
// Make sure we only register the listeners once
|
||||
if (!userConfigStore.initialized) {
|
||||
subscribe('files:config:updated', function({ key, value }: { key: string, value: boolean }) {
|
||||
userConfigStore.onUpdate(key, value)
|
||||
})
|
||||
userConfigStore.initialized = true
|
||||
}
|
||||
|
||||
return userConfigStore
|
||||
}
|
||||
|
||||
81
apps/files/src/types.ts
Normal file
81
apps/files/src/types.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
/* eslint-disable */
|
||||
import type { Folder } from '@nextcloud/files'
|
||||
import type { Node } from '@nextcloud/files'
|
||||
|
||||
// Global definitions
|
||||
export type Service = string
|
||||
|
||||
// Files store
|
||||
export type FilesState = {
|
||||
files: FilesStore,
|
||||
roots: RootsStore,
|
||||
}
|
||||
|
||||
export type FilesStore = {
|
||||
[fileid: number]: Node
|
||||
}
|
||||
|
||||
export type RootsStore = {
|
||||
[service: Service]: Folder
|
||||
}
|
||||
|
||||
export interface RootOptions {
|
||||
root: Folder
|
||||
service: Service
|
||||
}
|
||||
|
||||
// Paths store
|
||||
export type ServicesState = {
|
||||
[service: Service]: PathsStore
|
||||
}
|
||||
|
||||
export type PathsStore = {
|
||||
[path: string]: number
|
||||
}
|
||||
|
||||
export interface PathOptions {
|
||||
service: Service
|
||||
path: string
|
||||
fileid: number
|
||||
}
|
||||
|
||||
// Sorting store
|
||||
export type direction = 'asc' | 'desc'
|
||||
|
||||
export interface SortingConfig {
|
||||
mode: string
|
||||
direction: direction
|
||||
}
|
||||
|
||||
export interface SortingStore {
|
||||
[key: string]: SortingConfig
|
||||
}
|
||||
|
||||
// User config store
|
||||
export interface UserConfig {
|
||||
[key: string]: boolean
|
||||
}
|
||||
export interface UserConfigStore {
|
||||
userConfig: UserConfig
|
||||
}
|
||||
360
apps/files/src/views/FilesList.vue
Normal file
360
apps/files/src/views/FilesList.vue
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
<!--
|
||||
- @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>
|
||||
<NcAppContent v-show="!currentView?.legacy"
|
||||
:class="{'app-content--hidden': currentView?.legacy}"
|
||||
data-cy-files-content>
|
||||
<div class="files-list__header">
|
||||
<!-- Current folder breadcrumbs -->
|
||||
<BreadCrumbs :path="dir" @reload="fetchContent" />
|
||||
|
||||
<!-- Secondary loading indicator -->
|
||||
<NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" />
|
||||
</div>
|
||||
|
||||
<!-- Initial loading -->
|
||||
<NcLoadingIcon v-if="loading && !isRefreshing"
|
||||
class="files-list__loading-icon"
|
||||
:size="38"
|
||||
:title="t('files', 'Loading current folder')" />
|
||||
|
||||
<!-- Empty content placeholder -->
|
||||
<NcEmptyContent v-else-if="!loading && isEmptyDir"
|
||||
:title="t('files', 'No files in here')"
|
||||
:description="t('files', 'No files or folders have been deleted yet')"
|
||||
data-cy-files-content-empty>
|
||||
<template #action>
|
||||
<NcButton v-if="dir !== '/'"
|
||||
aria-label="t('files', 'Go to the previous folder')"
|
||||
type="primary"
|
||||
:to="toPreviousDir">
|
||||
{{ t('files', 'Go back') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
<template #icon>
|
||||
<TrashCan />
|
||||
</template>
|
||||
</NcEmptyContent>
|
||||
|
||||
<!-- File list -->
|
||||
<FilesListVirtual v-else
|
||||
ref="filesListVirtual"
|
||||
:current-view="currentView"
|
||||
:nodes="dirContents" />
|
||||
</NcAppContent>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Folder, File, Node } from '@nextcloud/files'
|
||||
import { join } from 'path'
|
||||
import { orderBy } from 'natural-orderby'
|
||||
import { translate } from '@nextcloud/l10n'
|
||||
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 NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
|
||||
import TrashCan from 'vue-material-design-icons/TrashCan.vue'
|
||||
import Vue from 'vue'
|
||||
|
||||
import Navigation, { ContentsWithRoot } from '../services/Navigation.ts'
|
||||
import { useFilesStore } from '../store/files.ts'
|
||||
import { usePathsStore } from '../store/paths.ts'
|
||||
import { useSelectionStore } from '../store/selection.ts'
|
||||
import { useSortingStore } from '../store/sorting.ts'
|
||||
import BreadCrumbs from '../components/BreadCrumbs.vue'
|
||||
import FilesListVirtual from '../components/FilesListVirtual.vue'
|
||||
import logger from '../logger.js'
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'FilesList',
|
||||
|
||||
components: {
|
||||
BreadCrumbs,
|
||||
FilesListVirtual,
|
||||
NcAppContent,
|
||||
NcButton,
|
||||
NcEmptyContent,
|
||||
NcLoadingIcon,
|
||||
TrashCan,
|
||||
},
|
||||
|
||||
setup() {
|
||||
const pathsStore = usePathsStore()
|
||||
const filesStore = useFilesStore()
|
||||
const selectionStore = useSelectionStore()
|
||||
const sortingStore = useSortingStore()
|
||||
return {
|
||||
filesStore,
|
||||
pathsStore,
|
||||
selectionStore,
|
||||
sortingStore,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
promise: null,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
/** @return {Navigation} */
|
||||
currentView() {
|
||||
return this.$navigation.active
|
||||
|| this.$navigation.views.find(view => view.id === 'files')
|
||||
},
|
||||
|
||||
/**
|
||||
* The current directory query.
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
dir() {
|
||||
// Remove any trailing slash but leave root slash
|
||||
return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
|
||||
},
|
||||
|
||||
/**
|
||||
* The current folder.
|
||||
*
|
||||
* @return {Folder|undefined}
|
||||
*/
|
||||
currentFolder() {
|
||||
if (!this.currentView?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.dir === '/') {
|
||||
return this.filesStore.getRoot(this.currentView.id)
|
||||
}
|
||||
const fileId = this.pathsStore.getPath(this.currentView.id, this.dir)
|
||||
return this.filesStore.getNode(fileId)
|
||||
},
|
||||
|
||||
sortingMode() {
|
||||
return this.sortingStore.getSortingMode(this.currentView.id)
|
||||
|| this.currentView.defaultSortKey
|
||||
|| 'basename'
|
||||
},
|
||||
isAscSorting() {
|
||||
return this.sortingStore.isAscSorting(this.currentView.id) === true
|
||||
},
|
||||
|
||||
/**
|
||||
* The current directory contents.
|
||||
*
|
||||
* @return {Node[]}
|
||||
*/
|
||||
dirContents() {
|
||||
if (!this.currentView) {
|
||||
return []
|
||||
}
|
||||
|
||||
const customColumn = this.currentView.columns
|
||||
.find(column => column.id === this.sortingMode)
|
||||
|
||||
// Custom column must provide their own sorting methods
|
||||
if (customColumn?.sort && typeof customColumn.sort === 'function') {
|
||||
const results = [...(this.currentFolder?.children || []).map(this.getNode).filter(file => file)]
|
||||
.sort(customColumn.sort)
|
||||
return this.isAscSorting ? results : results.reverse()
|
||||
}
|
||||
|
||||
return orderBy(
|
||||
[...(this.currentFolder?.children || []).map(this.getNode).filter(file => file)],
|
||||
[
|
||||
// Sort folders first if sorting by name
|
||||
...this.sortingMode === 'basename' ? [v => v.type !== 'folder'] : [],
|
||||
// Use sorting mode
|
||||
v => v[this.sortingMode],
|
||||
// Fallback to name
|
||||
v => v.basename,
|
||||
],
|
||||
this.isAscSorting ? ['asc', 'asc', 'asc'] : ['desc', 'desc', 'desc'],
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* The current directory is empty.
|
||||
*/
|
||||
isEmptyDir() {
|
||||
return this.dirContents.length === 0
|
||||
},
|
||||
|
||||
/**
|
||||
* We are refreshing the current directory.
|
||||
* But we already have a cached version of it
|
||||
* that is not empty.
|
||||
*/
|
||||
isRefreshing() {
|
||||
return this.currentFolder !== undefined
|
||||
&& !this.isEmptyDir
|
||||
&& this.loading
|
||||
},
|
||||
|
||||
/**
|
||||
* Route to the previous directory.
|
||||
*/
|
||||
toPreviousDir() {
|
||||
const dir = this.dir.split('/').slice(0, -1).join('/') || '/'
|
||||
return { ...this.$route, query: { dir } }
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
currentView(newView, oldView) {
|
||||
if (newView?.id === oldView?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug('View changed', { newView, oldView })
|
||||
this.selectionStore.reset()
|
||||
this.fetchContent()
|
||||
},
|
||||
|
||||
dir(newDir, oldDir) {
|
||||
logger.debug('Directory changed', { newDir, oldDir })
|
||||
// TODO: preserve selection on browsing?
|
||||
this.selectionStore.reset()
|
||||
this.fetchContent()
|
||||
|
||||
// Scroll to top, force virtual scroller to re-render
|
||||
if (this.$refs?.filesListVirtual?.$el) {
|
||||
this.$refs.filesListVirtual.$el.scrollTop = 0
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async fetchContent() {
|
||||
if (this.currentView?.legacy) {
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
const dir = this.dir
|
||||
const currentView = this.currentView
|
||||
|
||||
// If we have a cancellable promise ongoing, cancel it
|
||||
if (typeof this.promise?.cancel === 'function') {
|
||||
this.promise.cancel()
|
||||
logger.debug('Cancelled previous ongoing fetch')
|
||||
}
|
||||
|
||||
// Fetch the current dir contents
|
||||
/** @type {Promise<ContentsWithRoot>} */
|
||||
this.promise = currentView.getContents(dir)
|
||||
try {
|
||||
const { folder, contents } = await this.promise
|
||||
logger.debug('Fetched contents', { dir, folder, contents })
|
||||
|
||||
// Update store
|
||||
this.filesStore.updateNodes(contents)
|
||||
|
||||
// Define current directory children
|
||||
folder.children = contents.map(node => node.attributes.fileid)
|
||||
|
||||
// If we're in the root dir, define the root
|
||||
if (dir === '/') {
|
||||
this.filesStore.setRoot({ service: currentView.id, root: folder })
|
||||
} else
|
||||
// Otherwise, add the folder to the store
|
||||
if (folder.attributes.fileid) {
|
||||
this.filesStore.updateNodes([folder])
|
||||
this.pathsStore.addPath({ service: currentView.id, fileid: folder.attributes.fileid, path: dir })
|
||||
} else {
|
||||
// If we're here, the view API messed up
|
||||
logger.error('Invalid root folder returned', { dir, folder, currentView })
|
||||
}
|
||||
|
||||
// Update paths store
|
||||
const folders = contents.filter(node => node.type === 'folder')
|
||||
folders.forEach(node => {
|
||||
this.pathsStore.addPath({ service: currentView.id, fileid: node.attributes.fileid, path: join(dir, node.basename) })
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error while fetching content', { error })
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a cached note from the store
|
||||
*
|
||||
* @param {number} fileId the file id to get
|
||||
* @return {Folder|File}
|
||||
*/
|
||||
getNode(fileId) {
|
||||
return this.filesStore.getNode(fileId)
|
||||
},
|
||||
|
||||
t: translate,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-content {
|
||||
// Virtual list needs to be full height and is scrollable
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
max-height: 100%;
|
||||
|
||||
// TODO: remove after all legacy views are migrated
|
||||
// Hides the legacy app-content if shown view is not legacy
|
||||
&:not(&--hidden)::v-deep + #app-content {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
$margin: 4px;
|
||||
$navigationToggleSize: 50px;
|
||||
|
||||
.files-list {
|
||||
&__header {
|
||||
display: flex;
|
||||
align-content: center;
|
||||
// Do not grow or shrink (vertically)
|
||||
flex: 0 0;
|
||||
// Align with the navigation toggle icon
|
||||
margin: $margin $margin $margin $navigationToggleSize;
|
||||
> * {
|
||||
// Do not grow or shrink (horizontally)
|
||||
// Only the breadcrumbs shrinks
|
||||
flex: 0 0;
|
||||
}
|
||||
}
|
||||
&__refresh-icon {
|
||||
flex: 0 0 44px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
&__loading-icon {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -2,21 +2,21 @@ import * as InitialState from '@nextcloud/initial-state'
|
|||
import * as L10n from '@nextcloud/l10n'
|
||||
import FolderSvg from '@mdi/svg/svg/folder.svg'
|
||||
import ShareSvg from '@mdi/svg/svg/share-variant.svg'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
import NavigationService from '../services/Navigation'
|
||||
import NavigationService from '../services/Navigation.ts'
|
||||
import NavigationView from './Navigation.vue'
|
||||
import router from '../router/router.js'
|
||||
|
||||
describe('Navigation renders', () => {
|
||||
const Navigation = new NavigationService()
|
||||
const Navigation = new NavigationService() as NavigationService
|
||||
|
||||
before(() => {
|
||||
cy.stub(InitialState, 'loadState')
|
||||
.returns({
|
||||
used: 1024 * 1024 * 1024,
|
||||
used: 1000 * 1000 * 1000,
|
||||
quota: -1,
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
it('renders', () => {
|
||||
|
|
@ -24,6 +24,11 @@ describe('Navigation renders', () => {
|
|||
propsData: {
|
||||
Navigation,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({
|
||||
createSpy: cy.spy,
|
||||
})],
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('[data-cy-files-navigation]').should('be.visible')
|
||||
|
|
@ -33,13 +38,13 @@ describe('Navigation renders', () => {
|
|||
})
|
||||
|
||||
describe('Navigation API', () => {
|
||||
const Navigation = new NavigationService()
|
||||
const Navigation = new NavigationService() as NavigationService
|
||||
|
||||
it('Check API entries rendering', () => {
|
||||
Navigation.register({
|
||||
id: 'files',
|
||||
name: 'Files',
|
||||
getFiles: () => [],
|
||||
getContents: () => Promise.resolve(),
|
||||
icon: FolderSvg,
|
||||
order: 1,
|
||||
})
|
||||
|
|
@ -48,6 +53,11 @@ describe('Navigation API', () => {
|
|||
propsData: {
|
||||
Navigation,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({
|
||||
createSpy: cy.spy,
|
||||
})],
|
||||
},
|
||||
router,
|
||||
})
|
||||
|
||||
|
|
@ -61,7 +71,7 @@ describe('Navigation API', () => {
|
|||
Navigation.register({
|
||||
id: 'sharing',
|
||||
name: 'Sharing',
|
||||
getFiles: () => [],
|
||||
getContents: () => Promise.resolve(),
|
||||
icon: ShareSvg,
|
||||
order: 2,
|
||||
})
|
||||
|
|
@ -70,6 +80,11 @@ describe('Navigation API', () => {
|
|||
propsData: {
|
||||
Navigation,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({
|
||||
createSpy: cy.spy,
|
||||
})],
|
||||
},
|
||||
router,
|
||||
})
|
||||
|
||||
|
|
@ -83,7 +98,7 @@ describe('Navigation API', () => {
|
|||
Navigation.register({
|
||||
id: 'sharingin',
|
||||
name: 'Shared with me',
|
||||
getFiles: () => [],
|
||||
getContents: () => Promise.resolve(),
|
||||
parent: 'sharing',
|
||||
icon: ShareSvg,
|
||||
order: 1,
|
||||
|
|
@ -93,6 +108,11 @@ describe('Navigation API', () => {
|
|||
propsData: {
|
||||
Navigation,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({
|
||||
createSpy: cy.spy,
|
||||
})],
|
||||
},
|
||||
router,
|
||||
})
|
||||
|
||||
|
|
@ -120,7 +140,7 @@ describe('Navigation API', () => {
|
|||
Navigation.register({
|
||||
id: 'files',
|
||||
name: 'Files',
|
||||
getFiles: () => [],
|
||||
getContents: () => Promise.resolve(),
|
||||
icon: FolderSvg,
|
||||
order: 1,
|
||||
})
|
||||
|
|
@ -151,6 +171,11 @@ describe('Quota rendering', () => {
|
|||
propsData: {
|
||||
Navigation,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({
|
||||
createSpy: cy.spy,
|
||||
})],
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('[data-cy-files-navigation-settings-quota]').should('not.exist')
|
||||
|
|
@ -160,7 +185,7 @@ describe('Quota rendering', () => {
|
|||
cy.stub(InitialState, 'loadState')
|
||||
.as('loadStateStats')
|
||||
.returns({
|
||||
used: 1024 * 1024 * 1024,
|
||||
used: 1000 * 1000 * 1000,
|
||||
quota: -1,
|
||||
})
|
||||
|
||||
|
|
@ -168,6 +193,11 @@ describe('Quota rendering', () => {
|
|||
propsData: {
|
||||
Navigation,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({
|
||||
createSpy: cy.spy,
|
||||
})],
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
|
||||
|
|
@ -179,8 +209,8 @@ describe('Quota rendering', () => {
|
|||
cy.stub(InitialState, 'loadState')
|
||||
.as('loadStateStats')
|
||||
.returns({
|
||||
used: 1024 * 1024 * 1024,
|
||||
quota: 5 * 1024 * 1024 * 1024,
|
||||
used: 1000 * 1000 * 1000,
|
||||
quota: 5 * 1000 * 1000 * 1000,
|
||||
relative: 20, // percent
|
||||
})
|
||||
|
||||
|
|
@ -188,6 +218,11 @@ describe('Quota rendering', () => {
|
|||
propsData: {
|
||||
Navigation,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({
|
||||
createSpy: cy.spy,
|
||||
})],
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
|
||||
|
|
@ -200,8 +235,8 @@ describe('Quota rendering', () => {
|
|||
cy.stub(InitialState, 'loadState')
|
||||
.as('loadStateStats')
|
||||
.returns({
|
||||
used: 5 * 1024 * 1024 * 1024,
|
||||
quota: 1024 * 1024 * 1024,
|
||||
used: 5 * 1000 * 1000 * 1000,
|
||||
quota: 1000 * 1000 * 1000,
|
||||
relative: 500, // percent
|
||||
})
|
||||
|
||||
|
|
@ -209,6 +244,11 @@ describe('Quota rendering', () => {
|
|||
propsData: {
|
||||
Navigation,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({
|
||||
createSpy: cy.spy,
|
||||
})],
|
||||
},
|
||||
})
|
||||
|
||||
cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
|
||||
|
|
|
|||
|
|
@ -32,13 +32,20 @@
|
|||
:title="view.name"
|
||||
:to="generateToNavigation(view)"
|
||||
@update:open="onToggleExpand(view)">
|
||||
<!-- Sanitized icon as svg if provided -->
|
||||
<NcIconSvgWrapper v-if="view.icon" slot="icon" :svg="view.icon" />
|
||||
|
||||
<!-- Child views if any -->
|
||||
<NcAppNavigationItem v-for="child in childViews[view.id]"
|
||||
:key="child.id"
|
||||
:data-cy-files-navigation-item="child.id"
|
||||
:exact="true"
|
||||
:icon="child.iconClass"
|
||||
:title="child.name"
|
||||
:to="generateToNavigation(child)" />
|
||||
:to="generateToNavigation(child)">
|
||||
<!-- Sanitized icon as svg if provided -->
|
||||
<NcIconSvgWrapper v-if="view.icon" slot="icon" :svg="view.icon" />
|
||||
</NcAppNavigationItem>
|
||||
</NcAppNavigationItem>
|
||||
</template>
|
||||
|
||||
|
|
@ -74,6 +81,7 @@ import axios from '@nextcloud/axios'
|
|||
import Cog from 'vue-material-design-icons/Cog.vue'
|
||||
import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js'
|
||||
import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
|
||||
|
||||
import logger from '../logger.js'
|
||||
import Navigation from '../services/Navigation.ts'
|
||||
|
|
@ -86,10 +94,11 @@ export default {
|
|||
|
||||
components: {
|
||||
Cog,
|
||||
NavigationQuota,
|
||||
NcAppNavigation,
|
||||
NcAppNavigationItem,
|
||||
NcIconSvgWrapper,
|
||||
SettingsModal,
|
||||
NavigationQuota,
|
||||
},
|
||||
|
||||
props: {
|
||||
|
|
@ -151,7 +160,17 @@ export default {
|
|||
|
||||
watch: {
|
||||
currentView(view, oldView) {
|
||||
logger.debug('View changed', { id: view.id, view })
|
||||
// If undefined, it means we're initializing the view
|
||||
// This is handled by the legacy-view:initialized event
|
||||
// TODO: remove when legacy views are dropped
|
||||
if (view?.id === oldView?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
this.Navigation.setActive(view)
|
||||
logger.debug('Navigation changed', { id: view.id, view })
|
||||
|
||||
// debugger
|
||||
this.showView(view, oldView)
|
||||
},
|
||||
},
|
||||
|
|
@ -163,6 +182,12 @@ export default {
|
|||
}
|
||||
|
||||
subscribe('files:legacy-navigation:changed', this.onLegacyNavigationChanged)
|
||||
|
||||
// TODO: remove this once the legacy navigation is gone
|
||||
subscribe('files:legacy-view:initialized', () => {
|
||||
logger.debug('Legacy view initialized', { ...this.currentView })
|
||||
this.showView(this.currentView)
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
|
@ -174,7 +199,7 @@ export default {
|
|||
// Closing any opened sidebar
|
||||
window?.OCA?.Files?.Sidebar?.close?.()
|
||||
|
||||
if (view.legacy) {
|
||||
if (view?.legacy) {
|
||||
const newAppContent = document.querySelector('#app-content #app-content-' + this.currentView.id + '.viewcontainer')
|
||||
document.querySelectorAll('#app-content .viewcontainer').forEach(el => {
|
||||
el.classList.add('hidden')
|
||||
|
|
@ -188,7 +213,6 @@ export default {
|
|||
logger.debug('Triggering legacy navigation event', params)
|
||||
window.jQuery(newAppContent).trigger(new window.jQuery.Event('show', params))
|
||||
window.jQuery(newAppContent).trigger(new window.jQuery.Event('urlChanged', params))
|
||||
|
||||
}
|
||||
|
||||
this.Navigation.setActive(view)
|
||||
|
|
|
|||
|
|
@ -26,11 +26,11 @@
|
|||
@update:open="onClose">
|
||||
<!-- Settings API-->
|
||||
<NcAppSettingsSection id="settings" :title="t('files', 'Files settings')">
|
||||
<NcCheckboxRadioSwitch :checked.sync="show_hidden"
|
||||
<NcCheckboxRadioSwitch :checked="userConfig.show_hidden"
|
||||
@update:checked="setConfig('show_hidden', $event)">
|
||||
{{ t('files', 'Show hidden files') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcCheckboxRadioSwitch :checked.sync="crop_image_previews"
|
||||
<NcCheckboxRadioSwitch :checked="userConfig.crop_image_previews"
|
||||
@update:checked="setConfig('crop_image_previews', $event)">
|
||||
{{ t('files', 'Crop image previews') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
|
@ -86,18 +86,11 @@ import Clipboard from 'vue-material-design-icons/Clipboard.vue'
|
|||
import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
|
||||
import Setting from '../components/Setting.vue'
|
||||
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||
import { translate } from '@nextcloud/l10n'
|
||||
import axios from '@nextcloud/axios'
|
||||
|
||||
const userConfig = loadState('files', 'config', {
|
||||
show_hidden: false,
|
||||
crop_image_previews: true,
|
||||
})
|
||||
import { useUserConfigStore } from '../store/userconfig.ts'
|
||||
|
||||
export default {
|
||||
name: 'Settings',
|
||||
|
|
@ -117,11 +110,15 @@ export default {
|
|||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const userConfigStore = useUserConfigStore()
|
||||
return {
|
||||
userConfigStore,
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
|
||||
...userConfig,
|
||||
|
||||
// Settings API
|
||||
settings: window.OCA?.Files?.Settings?.settings || [],
|
||||
|
||||
|
|
@ -133,6 +130,12 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
userConfig() {
|
||||
return this.userConfigStore.userConfig
|
||||
},
|
||||
},
|
||||
|
||||
beforeMount() {
|
||||
// Update the settings API entries state
|
||||
this.settings.forEach(setting => setting.open())
|
||||
|
|
@ -149,10 +152,7 @@ export default {
|
|||
},
|
||||
|
||||
setConfig(key, value) {
|
||||
emit('files:config:updated', { key, value })
|
||||
axios.post(generateUrl('/apps/files/api/v1/config/' + key), {
|
||||
value,
|
||||
})
|
||||
this.userConfigStore.update(key, value)
|
||||
},
|
||||
|
||||
async copyCloudId() {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
<div id="app-navigation-files" role="navigation"></div>
|
||||
<div class="hidden">
|
||||
<ul class="with-icon" tabindex="0">
|
||||
|
||||
<?php
|
||||
|
||||
$pinned = 0;
|
||||
foreach ($_['navigationItems'] as $item) {
|
||||
$pinned = NavigationListElements($item, $l, $pinned);
|
||||
}
|
||||
$pinned = 0;
|
||||
foreach ($_['navigationItems'] as $item) {
|
||||
$pinned = NavigationListElements($item, $l, $pinned);
|
||||
}
|
||||
?>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
<?php /** @var \OCP\IL10N $l */ ?>
|
||||
<?php $_['appNavigation']->printPage(); ?>
|
||||
|
||||
<!-- New files vue container -->
|
||||
<div id="app-content-vue" class="hidden"></div>
|
||||
|
||||
<div id="app-content" tabindex="0">
|
||||
|
||||
<input type="checkbox" class="hidden-visually" id="showgridview"
|
||||
|
|
@ -8,8 +12,6 @@
|
|||
<label id="view-toggle" for="showgridview" tabindex="0" class="button <?php p($_['showgridview'] ? 'icon-toggle-filelist' : 'icon-toggle-pictures') ?>"
|
||||
title="<?php p($_['showgridview'] ? $l->t('Show list view') : $l->t('Show grid view'))?>"></label>
|
||||
|
||||
<!-- New files vue container -->
|
||||
<div id="app-content-vue" class="hidden"></div>
|
||||
|
||||
<!-- Legacy views -->
|
||||
<?php foreach ($_['appContents'] as $content) { ?>
|
||||
|
|
|
|||
|
|
@ -206,37 +206,44 @@ class ApiControllerTest extends TestCase {
|
|||
$mode = 'mtime';
|
||||
$direction = 'desc';
|
||||
|
||||
$this->config->expects($this->exactly(2))
|
||||
->method('setUserValue')
|
||||
->withConsecutive(
|
||||
[$this->user->getUID(), 'files', 'file_sorting', $mode],
|
||||
[$this->user->getUID(), 'files', 'file_sorting_direction', $direction],
|
||||
);
|
||||
$sortingConfig = [];
|
||||
$sortingConfig['files'] = [
|
||||
'mode' => $mode,
|
||||
'direction' => $direction,
|
||||
];
|
||||
|
||||
$expected = new HTTP\Response();
|
||||
$this->config->expects($this->once())
|
||||
->method('setUserValue')
|
||||
->with($this->user->getUID(), 'files', 'files_sorting_configs', json_encode($sortingConfig));
|
||||
|
||||
$expected = new HTTP\JSONResponse([
|
||||
'message' => 'ok',
|
||||
'data' => $sortingConfig
|
||||
]);
|
||||
$actual = $this->apiController->updateFileSorting($mode, $direction);
|
||||
$this->assertEquals($expected, $actual);
|
||||
}
|
||||
|
||||
public function invalidSortingModeData() {
|
||||
return [
|
||||
['color', 'asc'],
|
||||
['name', 'size'],
|
||||
['foo', 'bar']
|
||||
['size'],
|
||||
['bar']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider invalidSortingModeData
|
||||
*/
|
||||
public function testUpdateInvalidFileSorting($mode, $direction) {
|
||||
public function testUpdateInvalidFileSorting($direction) {
|
||||
$this->config->expects($this->never())
|
||||
->method('setUserValue');
|
||||
|
||||
$expected = new Http\Response(null);
|
||||
$expected = new Http\JSONResponse([
|
||||
'message' => 'Invalid direction parameter'
|
||||
]);
|
||||
$expected->setStatus(Http::STATUS_UNPROCESSABLE_ENTITY);
|
||||
|
||||
$result = $this->apiController->updateFileSorting($mode, $direction);
|
||||
$result = $this->apiController->updateFileSorting('basename', $direction);
|
||||
|
||||
$this->assertEquals($expected, $result);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -266,19 +266,6 @@ class ViewControllerTest extends TestCase {
|
|||
'expanded' => false,
|
||||
'unread' => 0,
|
||||
],
|
||||
'trashbin' => [
|
||||
'id' => 'trashbin',
|
||||
'appname' => 'files_trashbin',
|
||||
'script' => 'list.php',
|
||||
'order' => 50,
|
||||
'name' => \OC::$server->getL10N('files_trashbin')->t('Deleted files'),
|
||||
'active' => false,
|
||||
'icon' => '',
|
||||
'type' => 'link',
|
||||
'classes' => 'pinned',
|
||||
'expanded' => false,
|
||||
'unread' => 0,
|
||||
],
|
||||
'shareoverview' => [
|
||||
'id' => 'shareoverview',
|
||||
'appname' => 'files_sharing',
|
||||
|
|
@ -339,7 +326,7 @@ class ViewControllerTest extends TestCase {
|
|||
'owner' => 'MyName',
|
||||
'ownerDisplayName' => 'MyDisplayName',
|
||||
'isPublic' => false,
|
||||
'defaultFileSorting' => 'name',
|
||||
'defaultFileSorting' => 'basename',
|
||||
'defaultFileSortingDirection' => 'asc',
|
||||
'showHiddenFiles' => 0,
|
||||
'cropImagePreviews' => 1,
|
||||
|
|
@ -363,10 +350,6 @@ class ViewControllerTest extends TestCase {
|
|||
'id' => 'systemtagsfilter',
|
||||
'content' => null,
|
||||
],
|
||||
'trashbin' => [
|
||||
'id' => 'trashbin',
|
||||
'content' => null,
|
||||
],
|
||||
'sharingout' => [
|
||||
'id' => 'sharingout',
|
||||
'content' => null,
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ describe('OC.Upload tests', function() {
|
|||
expect(failStub.calledOnce).toEqual(true);
|
||||
expect(failStub.getCall(0).args[1].textStatus).toEqual('notenoughspace');
|
||||
expect(failStub.getCall(0).args[1].errorThrown).toEqual(
|
||||
'Not enough free space, you are uploading 5 KB but only 1000 B is left'
|
||||
'Not enough free space, you are uploading 5 KB but only 1 KB is left'
|
||||
);
|
||||
setTimeout(done, 0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -539,7 +539,7 @@ describe('OCA.Files.FileList tests', function() {
|
|||
expect($summary.find('.fileinfo').text()).toEqual('2 files');
|
||||
expect($summary.find('.dirinfo').hasClass('hidden')).toEqual(false);
|
||||
expect($summary.find('.fileinfo').hasClass('hidden')).toEqual(false);
|
||||
expect($summary.find('.filesize').text()).toEqual('69 KB');
|
||||
expect($summary.find('.filesize').text()).toEqual('70 KB');
|
||||
expect(fileList.isEmpty).toEqual(false);
|
||||
});
|
||||
it('Shows empty content when removing last file', function() {
|
||||
|
|
@ -609,7 +609,7 @@ describe('OCA.Files.FileList tests', function() {
|
|||
expect($summary.find('.fileinfo').text()).toEqual('1 file');
|
||||
expect($summary.find('.dirinfo').hasClass('hidden')).toEqual(false);
|
||||
expect($summary.find('.fileinfo').hasClass('hidden')).toEqual(false);
|
||||
expect($summary.find('.filesize').text()).toEqual('57 KB');
|
||||
expect($summary.find('.filesize').text()).toEqual('58 KB');
|
||||
expect(fileList.isEmpty).toEqual(false);
|
||||
expect($('.files-filestable thead th').hasClass('hidden')).toEqual(false);
|
||||
expect($('.emptyfilelist.emptycontent').hasClass('hidden')).toEqual(true);
|
||||
|
|
@ -1138,7 +1138,7 @@ describe('OCA.Files.FileList tests', function() {
|
|||
expect($summary.hasClass('hidden')).toEqual(false);
|
||||
expect($summary.find('.dirinfo').text()).toEqual('1 folder');
|
||||
expect($summary.find('.fileinfo').text()).toEqual('3 files');
|
||||
expect($summary.find('.filesize').text()).toEqual('69 KB');
|
||||
expect($summary.find('.filesize').text()).toEqual('70 KB');
|
||||
});
|
||||
it('shows headers, summary and hide empty content message after setting files', function(){
|
||||
fileList.setFiles(testFiles);
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ describe('OCA.Files.FileSummary tests', function() {
|
|||
expect($container.hasClass('hidden')).toEqual(false);
|
||||
expect($container.find('.dirinfo').text()).toEqual('5 folders');
|
||||
expect($container.find('.fileinfo').text()).toEqual('2 files');
|
||||
expect($container.find('.filesize').text()).toEqual('250 KB');
|
||||
expect($container.find('.filesize').text()).toEqual('256 KB');
|
||||
});
|
||||
it('hides summary when no files or folders', function() {
|
||||
var s = new FileSummary($container);
|
||||
|
|
@ -68,7 +68,7 @@ describe('OCA.Files.FileSummary tests', function() {
|
|||
expect($container.hasClass('hidden')).toEqual(false);
|
||||
expect($container.find('.dirinfo').text()).toEqual('6 folders');
|
||||
expect($container.find('.fileinfo').text()).toEqual('3 files');
|
||||
expect($container.find('.filesize').text()).toEqual('500 KB');
|
||||
expect($container.find('.filesize').text()).toEqual('512 KB');
|
||||
expect(s.summary.totalDirs).toEqual(6);
|
||||
expect(s.summary.totalFiles).toEqual(3);
|
||||
expect(s.summary.totalSize).toEqual(512100);
|
||||
|
|
@ -86,7 +86,7 @@ describe('OCA.Files.FileSummary tests', function() {
|
|||
expect($container.hasClass('hidden')).toEqual(false);
|
||||
expect($container.find('.dirinfo').text()).toEqual('4 folders');
|
||||
expect($container.find('.fileinfo').text()).toEqual('1 file');
|
||||
expect($container.find('.filesize').text()).toEqual('125 KB');
|
||||
expect($container.find('.filesize').text()).toEqual('128 KB');
|
||||
expect(s.summary.totalDirs).toEqual(4);
|
||||
expect(s.summary.totalFiles).toEqual(1);
|
||||
expect(s.summary.totalSize).toEqual(127900);
|
||||
|
|
@ -104,7 +104,7 @@ describe('OCA.Files.FileSummary tests', function() {
|
|||
expect($container.find('.dirinfo').text()).toEqual('5 folders');
|
||||
expect($container.find('.fileinfo').text()).toEqual('2 files');
|
||||
expect($container.find('.filter').text()).toEqual(' match "foo"');
|
||||
expect($container.find('.filesize').text()).toEqual('250 KB');
|
||||
expect($container.find('.filesize').text()).toEqual('256 KB');
|
||||
});
|
||||
it('hides filtered summary when no files or folders', function() {
|
||||
var s = new FileSummary($container);
|
||||
|
|
@ -133,7 +133,7 @@ describe('OCA.Files.FileSummary tests', function() {
|
|||
expect($container.find('.dirinfo').text()).toEqual('6 folders');
|
||||
expect($container.find('.fileinfo').text()).toEqual('3 files');
|
||||
expect($container.find('.filter').text()).toEqual(' match "foo"');
|
||||
expect($container.find('.filesize').text()).toEqual('500 KB');
|
||||
expect($container.find('.filesize').text()).toEqual('512 KB');
|
||||
expect(s.summary.totalDirs).toEqual(6);
|
||||
expect(s.summary.totalFiles).toEqual(3);
|
||||
expect(s.summary.totalSize).toEqual(512103);
|
||||
|
|
@ -155,7 +155,7 @@ describe('OCA.Files.FileSummary tests', function() {
|
|||
expect($container.find('.dirinfo').text()).toEqual('4 folders');
|
||||
expect($container.find('.fileinfo').text()).toEqual('1 file');
|
||||
expect($container.find('.filter').text()).toEqual(' match "foo"');
|
||||
expect($container.find('.filesize').text()).toEqual('125 KB');
|
||||
expect($container.find('.filesize').text()).toEqual('128 KB');
|
||||
expect(s.summary.totalDirs).toEqual(4);
|
||||
expect(s.summary.totalFiles).toEqual(1);
|
||||
expect(s.summary.totalSize).toEqual(127903);
|
||||
|
|
@ -215,7 +215,7 @@ describe('OCA.Files.FileSummary tests', function() {
|
|||
expect($container.find('.fileinfo').text()).toEqual('1 file');
|
||||
expect($container.find('.hiddeninfo').hasClass('hidden')).toEqual(false);
|
||||
expect($container.find('.hiddeninfo').text()).toEqual(' (including 1 hidden)');
|
||||
expect($container.find('.filesize').text()).toEqual('750 KB');
|
||||
expect($container.find('.filesize').text()).toEqual('768 KB');
|
||||
});
|
||||
it('does not render hidden count section when hidden files exist but are visible', function() {
|
||||
window._nc_event_bus.emit('files:config:updated', { key: 'show_hidden', value: true });
|
||||
|
|
@ -228,7 +228,7 @@ describe('OCA.Files.FileSummary tests', function() {
|
|||
expect($container.find('.dirinfo').text()).toEqual('2 folders');
|
||||
expect($container.find('.fileinfo').text()).toEqual('1 file');
|
||||
expect($container.find('.hiddeninfo').hasClass('hidden')).toEqual(true);
|
||||
expect($container.find('.filesize').text()).toEqual('750 KB');
|
||||
expect($container.find('.filesize').text()).toEqual('768 KB');
|
||||
});
|
||||
it('does not render hidden count section when no hidden files exist', function() {
|
||||
window._nc_event_bus.emit('files:config:updated', { key: 'show_hidden', value: false });
|
||||
|
|
@ -240,7 +240,7 @@ describe('OCA.Files.FileSummary tests', function() {
|
|||
expect($container.find('.dirinfo').text()).toEqual('1 folder');
|
||||
expect($container.find('.fileinfo').text()).toEqual('1 file');
|
||||
expect($container.find('.hiddeninfo').hasClass('hidden')).toEqual(true);
|
||||
expect($container.find('.filesize').text()).toEqual('250 KB');
|
||||
expect($container.find('.filesize').text()).toEqual('256 KB');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ describe('OCA.Files.MainFileInfoDetailView tests', function() {
|
|||
view.setFileInfo(testFileInfo);
|
||||
expect(view.$el.find('.fileName h3').text()).toEqual('One.txt');
|
||||
expect(view.$el.find('.fileName h3').attr('title')).toEqual('One.txt');
|
||||
expect(view.$el.find('.size').text()).toEqual('117.7 MB');
|
||||
expect(view.$el.find('.size').text()).toEqual('123.5 MB');
|
||||
expect(view.$el.find('.size').attr('title')).toEqual('123456789 bytes');
|
||||
expect(view.$el.find('.date').text()).toEqual('seconds ago');
|
||||
expect(view.$el.find('.date').attr('title')).toEqual(dateExpected);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ return array(
|
|||
'OCA\\Files_Trashbin\\Expiration' => $baseDir . '/../lib/Expiration.php',
|
||||
'OCA\\Files_Trashbin\\Helper' => $baseDir . '/../lib/Helper.php',
|
||||
'OCA\\Files_Trashbin\\Hooks' => $baseDir . '/../lib/Hooks.php',
|
||||
'OCA\\Files_Trashbin\\Listeners\\LoadAdditionalScripts' => $baseDir . '/../lib/Listeners/LoadAdditionalScripts.php',
|
||||
'OCA\\Files_Trashbin\\Migration\\Version1010Date20200630192639' => $baseDir . '/../lib/Migration/Version1010Date20200630192639.php',
|
||||
'OCA\\Files_Trashbin\\Sabre\\AbstractTrash' => $baseDir . '/../lib/Sabre/AbstractTrash.php',
|
||||
'OCA\\Files_Trashbin\\Sabre\\AbstractTrashFile' => $baseDir . '/../lib/Sabre/AbstractTrashFile.php',
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class ComposerStaticInitFiles_Trashbin
|
|||
'OCA\\Files_Trashbin\\Expiration' => __DIR__ . '/..' . '/../lib/Expiration.php',
|
||||
'OCA\\Files_Trashbin\\Helper' => __DIR__ . '/..' . '/../lib/Helper.php',
|
||||
'OCA\\Files_Trashbin\\Hooks' => __DIR__ . '/..' . '/../lib/Hooks.php',
|
||||
'OCA\\Files_Trashbin\\Listeners\\LoadAdditionalScripts' => __DIR__ . '/..' . '/../lib/Listeners/LoadAdditionalScripts.php',
|
||||
'OCA\\Files_Trashbin\\Migration\\Version1010Date20200630192639' => __DIR__ . '/..' . '/../lib/Migration/Version1010Date20200630192639.php',
|
||||
'OCA\\Files_Trashbin\\Sabre\\AbstractTrash' => __DIR__ . '/..' . '/../lib/Sabre/AbstractTrash.php',
|
||||
'OCA\\Files_Trashbin\\Sabre\\AbstractTrashFile' => __DIR__ . '/..' . '/../lib/Sabre/AbstractTrashFile.php',
|
||||
|
|
|
|||
|
|
@ -26,8 +26,10 @@
|
|||
namespace OCA\Files_Trashbin\AppInfo;
|
||||
|
||||
use OCA\DAV\Connector\Sabre\Principal;
|
||||
use OCA\Files\Event\LoadAdditionalScriptsEvent;
|
||||
use OCA\Files_Trashbin\Capabilities;
|
||||
use OCA\Files_Trashbin\Expiration;
|
||||
use OCA\Files_Trashbin\Listeners\LoadAdditionalScripts;
|
||||
use OCA\Files_Trashbin\Trash\ITrashManager;
|
||||
use OCA\Files_Trashbin\Trash\TrashManager;
|
||||
use OCA\Files_Trashbin\UserMigration\TrashbinMigrator;
|
||||
|
|
@ -55,6 +57,11 @@ class Application extends App implements IBootstrap {
|
|||
$context->registerServiceAlias('principalBackend', Principal::class);
|
||||
|
||||
$context->registerUserMigrator(TrashbinMigrator::class);
|
||||
|
||||
$context->registerEventListener(
|
||||
LoadAdditionalScriptsEvent::class,
|
||||
LoadAdditionalScripts::class
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(IBootContext $context): void {
|
||||
|
|
@ -68,18 +75,6 @@ class Application extends App implements IBootstrap {
|
|||
\OCP\Util::connectHook('OC_Filesystem', 'post_write', 'OCA\Files_Trashbin\Hooks', 'post_write_hook');
|
||||
// pre and post-rename, disable trash logic for the copy+unlink case
|
||||
\OCP\Util::connectHook('OC_Filesystem', 'delete', 'OCA\Files_Trashbin\Trashbin', 'ensureFileScannedHook');
|
||||
|
||||
\OCA\Files\App::getNavigationManager()->add(function () {
|
||||
$l = \OC::$server->getL10N(self::APP_ID);
|
||||
return [
|
||||
'id' => 'trashbin',
|
||||
'appname' => self::APP_ID,
|
||||
'script' => 'list.php',
|
||||
'order' => 50,
|
||||
'name' => $l->t('Deleted files'),
|
||||
'classes' => 'pinned',
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function registerTrashBackends(IServerContainer $serverContainer, ILogger $logger, IAppManager $appManager, ITrashManager $trashManager) {
|
||||
|
|
|
|||
|
|
@ -89,8 +89,9 @@ class PreviewController extends Controller {
|
|||
*/
|
||||
public function getPreview(
|
||||
int $fileId = -1,
|
||||
int $x = 128,
|
||||
int $y = 128
|
||||
int $x = 32,
|
||||
int $y = 32,
|
||||
bool $a = false,
|
||||
) {
|
||||
if ($fileId === -1 || $x === 0 || $y === 0) {
|
||||
return new DataResponse([], Http::STATUS_BAD_REQUEST);
|
||||
|
|
@ -118,7 +119,7 @@ class PreviewController extends Controller {
|
|||
$mimeType = $this->mimeTypeDetector->detectPath($file->getName());
|
||||
}
|
||||
|
||||
$f = $this->previewManager->getPreview($file, $x, $y, true, IPreview::MODE_FILL, $mimeType);
|
||||
$f = $this->previewManager->getPreview($file, $x, $y, $a, IPreview::MODE_FILL, $mimeType);
|
||||
$response = new Http\FileDisplayResponse($f, Http::STATUS_OK, ['Content-Type' => $f->getMimeType()]);
|
||||
|
||||
// Cache previews for 24H
|
||||
|
|
|
|||
41
apps/files_trashbin/lib/Listeners/LoadAdditionalScripts.php
Normal file
41
apps/files_trashbin/lib/Listeners/LoadAdditionalScripts.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright Copyright (c) 2022, 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/>.
|
||||
*
|
||||
*/
|
||||
namespace OCA\Files_Trashbin\Listeners;
|
||||
|
||||
use OCA\Files_Trashbin\AppInfo\Application;
|
||||
use OCA\Files\Event\LoadAdditionalScriptsEvent;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\Util;
|
||||
|
||||
class LoadAdditionalScripts implements IEventListener {
|
||||
public function handle(Event $event): void {
|
||||
if (!($event instanceof LoadAdditionalScriptsEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Util::addScript(Application::APP_ID, 'main');
|
||||
}
|
||||
}
|
||||
78
apps/files_trashbin/src/actions/restoreAction.ts
Normal file
78
apps/files_trashbin/src/actions/restoreAction.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* @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 { emit } from '@nextcloud/event-bus'
|
||||
import { generateRemoteUrl } from '@nextcloud/router'
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { Permission, Node } 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 { registerFileAction, FileAction } from '../../../files/src/services/FileAction.ts'
|
||||
import logger from '../../../files/src/logger.js'
|
||||
|
||||
registerFileAction(new FileAction({
|
||||
id: 'restore',
|
||||
displayName() {
|
||||
return t('files_trashbin', 'Restore')
|
||||
},
|
||||
iconSvgInline: () => History,
|
||||
|
||||
enabled(nodes: Node[], 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: Node) {
|
||||
try {
|
||||
const destination = generateRemoteUrl(`dav/trashbin/${getCurrentUser()?.uid}/restore/${node.basename}`)
|
||||
await axios({
|
||||
method: 'MOVE',
|
||||
url: node.source,
|
||||
headers: {
|
||||
destination,
|
||||
},
|
||||
})
|
||||
|
||||
// Let's pretend the file is deleted since
|
||||
// we don't know the restored location
|
||||
emit('files:file:deleted', node)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
return false
|
||||
}
|
||||
},
|
||||
async execBatch(nodes: Node[], view) {
|
||||
return Promise.all(nodes.map(node => this.exec(node, view)))
|
||||
},
|
||||
|
||||
order: 1,
|
||||
inline: () => true,
|
||||
}))
|
||||
72
apps/files_trashbin/src/main.ts
Normal file
72
apps/files_trashbin/src/main.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* @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 type NavigationService from '../../files/src/services/Navigation.ts'
|
||||
import type { Navigation } from '../../files/src/services/Navigation.ts'
|
||||
|
||||
import { translate as t, translate } from '@nextcloud/l10n'
|
||||
import DeleteSvg from '@mdi/svg/svg/delete.svg?raw'
|
||||
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',
|
||||
name: t('files_trashbin', 'Deleted files'),
|
||||
caption: t('files_trashbin', 'List of files that have been deleted.'),
|
||||
|
||||
icon: DeleteSvg,
|
||||
order: 50,
|
||||
sticky: true,
|
||||
|
||||
defaultSortKey: 'deleted',
|
||||
|
||||
columns: [
|
||||
{
|
||||
id: 'deleted',
|
||||
title: t('files_trashbin', 'Deleted'),
|
||||
render(node) {
|
||||
const deletionTime = node.attributes?.['trashbin-deletion-time']
|
||||
const span = document.createElement('span')
|
||||
if (deletionTime) {
|
||||
span.title = moment.unix(deletionTime).format('LLL')
|
||||
span.textContent = moment.unix(deletionTime).fromNow()
|
||||
return span
|
||||
}
|
||||
|
||||
// Unknown deletion time
|
||||
span.textContent = translate('files_trashbin', 'A long time ago')
|
||||
return span
|
||||
},
|
||||
sort(nodeA, nodeB) {
|
||||
const deletionTimeA = nodeA.attributes?.['trashbin-deletion-time'] || nodeA?.mtime || 0
|
||||
const deletionTimeB = nodeB.attributes?.['trashbin-deletion-time'] || nodeB?.mtime || 0
|
||||
return deletionTimeB - deletionTimeA
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
getContents,
|
||||
} as Navigation)
|
||||
33
apps/files_trashbin/src/services/client.ts
Normal file
33
apps/files_trashbin/src/services/client.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* @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 { createClient } from 'webdav'
|
||||
import { generateRemoteUrl } from '@nextcloud/router'
|
||||
import { getCurrentUser, getRequestToken } from '@nextcloud/auth'
|
||||
|
||||
export const rootPath = `/trashbin/${getCurrentUser()?.uid}/trash`
|
||||
export const rootUrl = generateRemoteUrl('dav' + rootPath)
|
||||
const client = createClient(rootUrl, {
|
||||
headers: {
|
||||
requesttoken: getRequestToken(),
|
||||
},
|
||||
})
|
||||
export default client
|
||||
98
apps/files_trashbin/src/services/trashbin.ts
Normal file
98
apps/files_trashbin/src/services/trashbin.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
/* eslint-disable */
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { File, Folder, parseWebdavPermissions } from '@nextcloud/files'
|
||||
import { generateRemoteUrl, generateUrl } from '@nextcloud/router'
|
||||
|
||||
import type { FileStat, ResponseDataDetailed } from 'webdav'
|
||||
import type { ContentsWithRoot } from '../../../files/src/services/Navigation.ts'
|
||||
|
||||
import client, { rootPath } from './client'
|
||||
|
||||
const data = `<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:"
|
||||
xmlns:oc="http://owncloud.org/ns"
|
||||
xmlns:nc="http://nextcloud.org/ns">
|
||||
<d:prop>
|
||||
<nc:trashbin-filename />
|
||||
<nc:trashbin-deletion-time />
|
||||
<nc:trashbin-original-location />
|
||||
<nc:trashbin-title />
|
||||
<d:getlastmodified />
|
||||
<d:getetag />
|
||||
<d:getcontenttype />
|
||||
<d:resourcetype />
|
||||
<oc:fileid />
|
||||
<oc:permissions />
|
||||
<oc:size />
|
||||
<d:getcontentlength />
|
||||
</d:prop>
|
||||
</d:propfind>`
|
||||
|
||||
|
||||
const resultToNode = function(node: FileStat): File | Folder {
|
||||
const permissions = parseWebdavPermissions(node.props?.permissions)
|
||||
const owner = getCurrentUser()?.uid as string
|
||||
const previewUrl = generateUrl('/apps/files_trashbin/preview?fileId={fileid}&x=32&y=32', node.props)
|
||||
|
||||
const nodeData = {
|
||||
id: node.props?.fileid as number || 0,
|
||||
source: generateRemoteUrl('dav' + rootPath + node.filename),
|
||||
mtime: new Date(node.lastmod),
|
||||
mime: node.mime as string,
|
||||
size: node.props?.size as number || 0,
|
||||
permissions,
|
||||
owner,
|
||||
root: rootPath,
|
||||
attributes: {
|
||||
...node,
|
||||
...node.props,
|
||||
// Override displayed name on the list
|
||||
displayName: node.props?.['trashbin-filename'],
|
||||
previewUrl,
|
||||
},
|
||||
}
|
||||
|
||||
return node.type === 'file'
|
||||
? new File(nodeData)
|
||||
: new Folder(nodeData)
|
||||
}
|
||||
|
||||
export const getContents = async (path: string = '/'): Promise<ContentsWithRoot> => {
|
||||
// TODO: use only one request when webdav-client supports it
|
||||
// @see https://github.com/perry-mitchell/webdav-client/pull/334
|
||||
const rootResponse = await client.stat(path, {
|
||||
details: true,
|
||||
data,
|
||||
}) as ResponseDataDetailed<FileStat>
|
||||
|
||||
const contentsResponse = await client.getDirectoryContents(path, {
|
||||
details: true,
|
||||
data,
|
||||
}) as ResponseDataDetailed<FileStat[]>
|
||||
|
||||
return {
|
||||
folder: resultToNode(rootResponse.data) as Folder,
|
||||
contents: contentsResponse.data.map(resultToNode),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2014
|
||||
*
|
||||
* This file is licensed under the Affero General Public License version 3
|
||||
* or later.
|
||||
*
|
||||
* See the COPYING-README file.
|
||||
*
|
||||
*/
|
||||
#app-content-trashbin tbody tr[data-type="file"] td a.name,
|
||||
#app-content-trashbin tbody tr[data-type="file"] td a.name span.nametext,
|
||||
#app-content-trashbin tbody tr[data-type="file"] td a.name span.nametext span {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#app-content-trashbin .summary :last-child {
|
||||
padding: 0;
|
||||
}
|
||||
#app-content-trashbin .files-filestable .summary .filesize {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ class PreviewControllerTest extends TestCase {
|
|||
|
||||
$this->overwriteService(ITimeFactory::class, $this->time);
|
||||
|
||||
$res = $this->controller->getPreview(42, 10, 10);
|
||||
$res = $this->controller->getPreview(42, 10, 10, true);
|
||||
$expected = new FileDisplayResponse($preview, Http::STATUS_OK, ['Content-Type' => 'previewMime']);
|
||||
$expected->cacheFor(3600 * 24);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
/**
|
||||
* @copyright 2014 Vincent Petry <pvince81@owncloud.com>
|
||||
*
|
||||
* @author Vincent Petry <vincent@nextcloud.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/>.
|
||||
*
|
||||
*/
|
||||
|
||||
describe('OCA.Trashbin.App tests', function() {
|
||||
var App = OCA.Trashbin.App;
|
||||
|
||||
beforeEach(function() {
|
||||
$('#testArea').append(
|
||||
'<div id="app-navigation">' +
|
||||
'<ul><li data-id="files"><a>Files</a></li>' +
|
||||
'<li data-id="trashbin"><a>Trashbin</a></li>' +
|
||||
'</div>' +
|
||||
'<div id="app-content">' +
|
||||
'<div id="app-content-files" class="hidden">' +
|
||||
'</div>' +
|
||||
'<div id="app-content-trashbin" class="hidden">' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
App.initialize($('#app-content-trashbin'));
|
||||
});
|
||||
afterEach(function() {
|
||||
App._initialized = false;
|
||||
App.fileList = null;
|
||||
});
|
||||
|
||||
describe('initialization', function() {
|
||||
it('creates a custom filelist instance', function() {
|
||||
App.initialize();
|
||||
expect(App.fileList).toBeDefined();
|
||||
expect(App.fileList.$el.is('#app-content-trashbin')).toEqual(true);
|
||||
});
|
||||
|
||||
it('registers custom file actions', function() {
|
||||
var fileActions;
|
||||
App.initialize();
|
||||
|
||||
fileActions = App.fileList.fileActions;
|
||||
|
||||
expect(fileActions.actions.all).toBeDefined();
|
||||
expect(fileActions.actions.all.Restore).toBeDefined();
|
||||
expect(fileActions.actions.all.Delete).toBeDefined();
|
||||
|
||||
expect(fileActions.actions.all.Rename).not.toBeDefined();
|
||||
expect(fileActions.actions.all.Download).not.toBeDefined();
|
||||
|
||||
expect(fileActions.defaults.dir).toEqual('Open');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,397 +0,0 @@
|
|||
/**
|
||||
* @copyright 2014 Vincent Petry <pvince81@owncloud.com>
|
||||
*
|
||||
* @author Abijeet <abijeetpatro@gmail.com>
|
||||
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
* @author Jan C. Borchardt <hey@jancborchardt.net>
|
||||
* @author Jan-Christoph Borchardt <hey@jancborchardt.net>
|
||||
* @author John Molakvoæ <skjnldsv@protonmail.com>
|
||||
* @author Robin Appelman <robin@icewind.nl>
|
||||
* @author Vincent Petry <vincent@nextcloud.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/>.
|
||||
*
|
||||
*/
|
||||
|
||||
describe('OCA.Trashbin.FileList tests', function () {
|
||||
var testFiles, alertStub, notificationStub, fileList, client;
|
||||
|
||||
beforeEach(function () {
|
||||
alertStub = sinon.stub(OC.dialogs, 'alert');
|
||||
notificationStub = sinon.stub(OC.Notification, 'show');
|
||||
|
||||
client = new OC.Files.Client({
|
||||
host: 'localhost',
|
||||
port: 80,
|
||||
root: '/remote.php/dav/trashbin/user',
|
||||
useHTTPS: OC.getProtocol() === 'https'
|
||||
});
|
||||
|
||||
// init parameters and test table elements
|
||||
$('#testArea').append(
|
||||
'<div id="app-content">' +
|
||||
// set this but it shouldn't be used (could be the one from the
|
||||
// files app)
|
||||
'<input type="hidden" id="permissions" value="31"></input>' +
|
||||
// dummy controls
|
||||
'<div class="files-controls">' +
|
||||
' <div class="actions creatable"></div>' +
|
||||
' <div class="notCreatable"></div>' +
|
||||
'</div>' +
|
||||
// dummy table
|
||||
// TODO: at some point this will be rendered by the fileList class itself!
|
||||
'<table class="files-filestable list-container view-grid">' +
|
||||
'<thead><tr><th class="hidden column-name">' +
|
||||
'<input type="checkbox" id="select_all_trash" class="select-all">' +
|
||||
'<span class="name">Name</span>' +
|
||||
'<span class="selectedActions hidden">' +
|
||||
'<a href="" class="actions-selected"><span class="icon icon-more"></span><span>Actions</span>' +
|
||||
'</span>' +
|
||||
'</th></tr></thead>' +
|
||||
'<tbody class="files-fileList"></tbody>' +
|
||||
'<tfoot></tfoot>' +
|
||||
'</table>' +
|
||||
'<div class="emptyfilelist emptycontent">Empty content message</div>' +
|
||||
'</div>'
|
||||
);
|
||||
|
||||
testFiles = [{
|
||||
id: 1,
|
||||
type: 'file',
|
||||
name: 'One.txt.d11111',
|
||||
displayName: 'One.txt',
|
||||
mtime: 11111000,
|
||||
mimetype: 'text/plain',
|
||||
etag: 'abc'
|
||||
}, {
|
||||
id: 2,
|
||||
type: 'file',
|
||||
name: 'Two.jpg.d22222',
|
||||
displayName: 'Two.jpg',
|
||||
mtime: 22222000,
|
||||
mimetype: 'image/jpeg',
|
||||
etag: 'def',
|
||||
}, {
|
||||
id: 3,
|
||||
type: 'file',
|
||||
name: 'Three.pdf.d33333',
|
||||
displayName: 'Three.pdf',
|
||||
mtime: 33333000,
|
||||
mimetype: 'application/pdf',
|
||||
etag: '123',
|
||||
}, {
|
||||
id: 4,
|
||||
type: 'dir',
|
||||
mtime: 99999000,
|
||||
name: 'somedir.d99999',
|
||||
displayName: 'somedir',
|
||||
mimetype: 'httpd/unix-directory',
|
||||
etag: '456'
|
||||
}];
|
||||
|
||||
// register file actions like the trashbin App does
|
||||
var fileActions = OCA.Trashbin.App._createFileActions(fileList);
|
||||
fileList = new OCA.Trashbin.FileList(
|
||||
$('#app-content'), {
|
||||
fileActions: fileActions,
|
||||
multiSelectMenu: [{
|
||||
name: 'restore',
|
||||
displayName: t('files', 'Restore'),
|
||||
iconClass: 'icon-history',
|
||||
},
|
||||
{
|
||||
name: 'delete',
|
||||
displayName: t('files', 'Delete'),
|
||||
iconClass: 'icon-delete',
|
||||
}
|
||||
],
|
||||
client: client
|
||||
}
|
||||
);
|
||||
});
|
||||
afterEach(function () {
|
||||
testFiles = undefined;
|
||||
fileList.destroy();
|
||||
fileList = undefined;
|
||||
|
||||
notificationStub.restore();
|
||||
alertStub.restore();
|
||||
});
|
||||
describe('Initialization', function () {
|
||||
it('Sorts by mtime by default', function () {
|
||||
expect(fileList._sort).toEqual('mtime');
|
||||
expect(fileList._sortDirection).toEqual('desc');
|
||||
});
|
||||
it('Always returns read and delete permission', function () {
|
||||
expect(fileList.getDirectoryPermissions()).toEqual(OC.PERMISSION_READ | OC.PERMISSION_DELETE);
|
||||
});
|
||||
});
|
||||
describe('Breadcrumbs', function () {
|
||||
beforeEach(function () {
|
||||
var data = {
|
||||
status: 'success',
|
||||
data: {
|
||||
files: testFiles,
|
||||
permissions: 1
|
||||
}
|
||||
};
|
||||
fakeServer.respondWith(/\/index\.php\/apps\/files_trashbin\/ajax\/list.php\?dir=%2Fsubdir/, [
|
||||
200, {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
JSON.stringify(data)
|
||||
]);
|
||||
});
|
||||
it('links the breadcrumb to the trashbin view', function () {
|
||||
fileList.changeDirectory('/subdir', false, true);
|
||||
fakeServer.respond();
|
||||
var $crumbs = fileList.$el.find('.files-controls .crumb');
|
||||
expect($crumbs.length).toEqual(3);
|
||||
expect($crumbs.eq(1).find('a').text()).toEqual('Home');
|
||||
expect($crumbs.eq(1).find('a').attr('href'))
|
||||
.toEqual(OC.getRootPath() + '/index.php/apps/files?view=trashbin&dir=/');
|
||||
expect($crumbs.eq(2).find('a').text()).toEqual('subdir');
|
||||
expect($crumbs.eq(2).find('a').attr('href'))
|
||||
.toEqual(OC.getRootPath() + '/index.php/apps/files?view=trashbin&dir=/subdir');
|
||||
});
|
||||
});
|
||||
describe('Rendering rows', function () {
|
||||
it('renders rows with the correct data when in root', function () {
|
||||
// dir listing is false when in root
|
||||
fileList.setFiles(testFiles);
|
||||
var $rows = fileList.$el.find('tbody tr');
|
||||
var $tr = $rows.eq(0);
|
||||
expect($rows.length).toEqual(4);
|
||||
expect($tr.attr('data-id')).toEqual('1');
|
||||
expect($tr.attr('data-type')).toEqual('file');
|
||||
expect($tr.attr('data-file')).toEqual('One.txt.d11111');
|
||||
expect($tr.attr('data-size')).not.toBeDefined();
|
||||
expect($tr.attr('data-etag')).toEqual('abc');
|
||||
expect($tr.attr('data-permissions')).toEqual('9'); // read and delete
|
||||
expect($tr.attr('data-mime')).toEqual('text/plain');
|
||||
expect($tr.attr('data-mtime')).toEqual('11111000');
|
||||
expect($tr.find('a.name').attr('href')).toEqual('#');
|
||||
|
||||
expect($tr.find('.nametext').text().trim()).toEqual('One.txt');
|
||||
|
||||
expect(fileList.findFileEl('One.txt.d11111')[0]).toEqual($tr[0]);
|
||||
});
|
||||
it('renders rows with the correct data when in root after calling setFiles with the same data set', function () {
|
||||
// dir listing is false when in root
|
||||
fileList.setFiles(testFiles);
|
||||
fileList.setFiles(fileList.files);
|
||||
var $rows = fileList.$el.find('tbody tr');
|
||||
var $tr = $rows.eq(0);
|
||||
expect($rows.length).toEqual(4);
|
||||
expect($tr.attr('data-id')).toEqual('1');
|
||||
expect($tr.attr('data-type')).toEqual('file');
|
||||
expect($tr.attr('data-file')).toEqual('One.txt.d11111');
|
||||
expect($tr.attr('data-size')).not.toBeDefined();
|
||||
expect($tr.attr('data-etag')).toEqual('abc');
|
||||
expect($tr.attr('data-permissions')).toEqual('9'); // read and delete
|
||||
expect($tr.attr('data-mime')).toEqual('text/plain');
|
||||
expect($tr.attr('data-mtime')).toEqual('11111000');
|
||||
expect($tr.find('a.name').attr('href')).toEqual('#');
|
||||
|
||||
expect($tr.find('.nametext').text().trim()).toEqual('One.txt');
|
||||
|
||||
expect(fileList.findFileEl('One.txt.d11111')[0]).toEqual($tr[0]);
|
||||
});
|
||||
it('renders rows with the correct data when in subdirectory', function () {
|
||||
fileList.setFiles(testFiles.map(function (file) {
|
||||
file.name = file.displayName;
|
||||
return file;
|
||||
}));
|
||||
var $rows = fileList.$el.find('tbody tr');
|
||||
var $tr = $rows.eq(0);
|
||||
expect($rows.length).toEqual(4);
|
||||
expect($tr.attr('data-id')).toEqual('1');
|
||||
expect($tr.attr('data-type')).toEqual('file');
|
||||
expect($tr.attr('data-file')).toEqual('One.txt');
|
||||
expect($tr.attr('data-size')).not.toBeDefined();
|
||||
expect($tr.attr('data-etag')).toEqual('abc');
|
||||
expect($tr.attr('data-permissions')).toEqual('9'); // read and delete
|
||||
expect($tr.attr('data-mime')).toEqual('text/plain');
|
||||
expect($tr.attr('data-mtime')).toEqual('11111000');
|
||||
expect($tr.find('a.name').attr('href')).toEqual('#');
|
||||
|
||||
expect($tr.find('.nametext').text().trim()).toEqual('One.txt');
|
||||
|
||||
expect(fileList.findFileEl('One.txt')[0]).toEqual($tr[0]);
|
||||
});
|
||||
it('does not render a size column', function () {
|
||||
expect(fileList.$el.find('tbody tr .filesize').length).toEqual(0);
|
||||
});
|
||||
});
|
||||
describe('File actions', function () {
|
||||
describe('Deleting single files', function () {
|
||||
// TODO: checks ajax call
|
||||
// TODO: checks spinner
|
||||
// TODO: remove item after delete
|
||||
// TODO: bring back item if delete failed
|
||||
});
|
||||
describe('Restoring single files', function () {
|
||||
// TODO: checks ajax call
|
||||
// TODO: checks spinner
|
||||
// TODO: remove item after restore
|
||||
// TODO: bring back item if restore failed
|
||||
});
|
||||
});
|
||||
describe('file previews', function () {
|
||||
// TODO: check that preview URL is going through files_trashbin
|
||||
});
|
||||
describe('loading file list', function () {
|
||||
// TODO: check that ajax URL is going through files_trashbin
|
||||
});
|
||||
describe('breadcrumbs', function () {
|
||||
// TODO: test label + URL
|
||||
});
|
||||
describe('elementToFile', function () {
|
||||
var $tr;
|
||||
|
||||
beforeEach(function () {
|
||||
fileList.setFiles(testFiles);
|
||||
$tr = fileList.findFileEl('One.txt.d11111');
|
||||
});
|
||||
|
||||
it('converts data attributes to file info structure', function () {
|
||||
var fileInfo = fileList.elementToFile($tr);
|
||||
expect(fileInfo.id).toEqual(1);
|
||||
expect(fileInfo.name).toEqual('One.txt.d11111');
|
||||
expect(fileInfo.displayName).toEqual('One.txt');
|
||||
expect(fileInfo.mtime).toEqual(11111000);
|
||||
expect(fileInfo.etag).toEqual('abc');
|
||||
expect(fileInfo.permissions).toEqual(OC.PERMISSION_READ | OC.PERMISSION_DELETE);
|
||||
expect(fileInfo.mimetype).toEqual('text/plain');
|
||||
expect(fileInfo.type).toEqual('file');
|
||||
});
|
||||
});
|
||||
describe('Global Actions', function () {
|
||||
beforeEach(function () {
|
||||
fileList.setFiles(testFiles);
|
||||
fileList.findFileEl('One.txt.d11111').find('input:checkbox').click();
|
||||
fileList.findFileEl('Three.pdf.d33333').find('input:checkbox').click();
|
||||
fileList.findFileEl('somedir.d99999').find('input:checkbox').click();
|
||||
fileList.$el.find('.actions-selected').click();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
fileList.$el.find('.actions-selected').click();
|
||||
});
|
||||
|
||||
describe('Delete', function () {
|
||||
it('Shows trashbin actions', function () {
|
||||
// visible because a few files were selected
|
||||
expect($('.selectedActions').is(':visible')).toEqual(true);
|
||||
expect($('.selectedActions .item-delete').is(':visible')).toEqual(true);
|
||||
expect($('.selectedActions .item-restore').is(':visible')).toEqual(true);
|
||||
|
||||
// check
|
||||
fileList.$el.find('.select-all').click();
|
||||
|
||||
// stays visible
|
||||
expect($('.selectedActions').is(':visible')).toEqual(true);
|
||||
expect($('.selectedActions .item-delete').is(':visible')).toEqual(true);
|
||||
expect($('.selectedActions .item-restore').is(':visible')).toEqual(true);
|
||||
|
||||
// uncheck
|
||||
fileList.$el.find('.select-all').click();
|
||||
|
||||
// becomes hidden now
|
||||
expect($('.selectedActions').is(':visible')).toEqual(false);
|
||||
expect($('.selectedActions .item-delete').is(':visible')).toEqual(false);
|
||||
expect($('.selectedActions .item-restore').is(':visible')).toEqual(false);
|
||||
});
|
||||
it('Deletes selected files when "Delete" clicked', function (done) {
|
||||
var request;
|
||||
var promise = fileList._onClickDeleteSelected({
|
||||
preventDefault: function () {
|
||||
}
|
||||
});
|
||||
var files = ["One.txt.d11111", "Three.pdf.d33333", "somedir.d99999"];
|
||||
expect(fakeServer.requests.length).toEqual(files.length);
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
request = fakeServer.requests[i];
|
||||
expect(request.url).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/trash/' + files[i]);
|
||||
request.respond(200);
|
||||
}
|
||||
return promise.then(function () {
|
||||
expect(fileList.findFileEl('One.txt.d11111').length).toEqual(0);
|
||||
expect(fileList.findFileEl('Three.pdf.d33333').length).toEqual(0);
|
||||
expect(fileList.findFileEl('somedir.d99999').length).toEqual(0);
|
||||
expect(fileList.findFileEl('Two.jpg.d22222').length).toEqual(1);
|
||||
}).then(done, done);
|
||||
});
|
||||
it('Deletes all files when all selected when "Delete" clicked', function (done) {
|
||||
var request;
|
||||
$('.select-all').click();
|
||||
var promise = fileList._onClickDeleteSelected({
|
||||
preventDefault: function () {
|
||||
}
|
||||
});
|
||||
expect(fakeServer.requests.length).toEqual(1);
|
||||
request = fakeServer.requests[0];
|
||||
expect(request.url).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/trash');
|
||||
request.respond(200);
|
||||
return promise.then(function () {
|
||||
expect(fileList.isEmpty).toEqual(true);
|
||||
}).then(done, done);
|
||||
});
|
||||
});
|
||||
describe('Restore', function () {
|
||||
it('Restores selected files when "Restore" clicked', function (done) {
|
||||
var request;
|
||||
var promise = fileList._onClickRestoreSelected({
|
||||
preventDefault: function () {
|
||||
}
|
||||
});
|
||||
var files = ["One.txt.d11111", "Three.pdf.d33333", "somedir.d99999"];
|
||||
expect(fakeServer.requests.length).toEqual(files.length);
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
request = fakeServer.requests[i];
|
||||
expect(request.url).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/trash/' + files[i]);
|
||||
expect(request.requestHeaders.Destination).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/restore/' + files[i]);
|
||||
request.respond(200);
|
||||
}
|
||||
return promise.then(function() {
|
||||
expect(fileList.findFileEl('One.txt.d11111').length).toEqual(0);
|
||||
expect(fileList.findFileEl('Three.pdf.d33333').length).toEqual(0);
|
||||
expect(fileList.findFileEl('somedir.d99999').length).toEqual(0);
|
||||
expect(fileList.findFileEl('Two.jpg.d22222').length).toEqual(1);
|
||||
}).then(done, done);
|
||||
});
|
||||
it('Restores all files when all selected when "Restore" clicked', function (done) {
|
||||
var request;
|
||||
$('.select-all').click();
|
||||
var promise = fileList._onClickRestoreSelected({
|
||||
preventDefault: function () {
|
||||
}
|
||||
});
|
||||
var files = ["One.txt.d11111", "Two.jpg.d22222", "Three.pdf.d33333", "somedir.d99999"];
|
||||
expect(fakeServer.requests.length).toEqual(files.length);
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
request = fakeServer.requests[i];
|
||||
expect(request.url).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/trash/' + files[i]);
|
||||
expect(request.requestHeaders.Destination).toEqual(OC.getRootPath() + '/remote.php/dav/trashbin/user/restore/' + files[i]);
|
||||
request.respond(200);
|
||||
}
|
||||
return promise.then(function() {
|
||||
expect(fileList.isEmpty).toEqual(true);
|
||||
}).then(done, done);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -19,16 +19,19 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { createClient, getPatcher } from 'webdav'
|
||||
import { createClient } from 'webdav'
|
||||
import { generateRemoteUrl } from '@nextcloud/router'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { getRequestToken } from '@nextcloud/auth'
|
||||
|
||||
const rootPath = 'dav'
|
||||
|
||||
// force our axios
|
||||
const patcher = getPatcher()
|
||||
patcher.patch('request', axios)
|
||||
|
||||
// init webdav client on default dav endpoint
|
||||
const remote = generateRemoteUrl(rootPath)
|
||||
export default createClient(remote)
|
||||
export default createClient(remote, {
|
||||
headers: {
|
||||
// Add this so the server knows it is an request from the browser
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
// Inject user auth
|
||||
requesttoken: getRequestToken() ?? '',
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export default {
|
|||
|
||||
/**
|
||||
* Return the mtime of the first version to display "Initial version" label
|
||||
*
|
||||
* @return {number}
|
||||
*/
|
||||
initialVersionMtime() {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ module.exports = {
|
|||
'@babel/preset-env',
|
||||
{
|
||||
useBuiltIns: false,
|
||||
modules: 'auto',
|
||||
},
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -67,8 +67,10 @@ $expectedFiles = [
|
|||
'contribute',
|
||||
'core',
|
||||
'cron.php',
|
||||
'cypress',
|
||||
'custom.d.ts',
|
||||
'cypress.config.ts',
|
||||
'cypress.d.ts',
|
||||
'cypress',
|
||||
'dist',
|
||||
'index.html',
|
||||
'index.php',
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
/**
|
||||
* ownCloud
|
||||
*
|
||||
* @author Vincent Petry
|
||||
* @copyright 2015 Vincent Petry <pvince81@owncloud.com>
|
||||
*
|
||||
* This library is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
* License as published by the Free Software Foundation; either
|
||||
* version 3 of the License, or any later version.
|
||||
*
|
||||
* This library 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 library. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
describe('Apps base tests', function() {
|
||||
describe('Sidebar utility functions', function() {
|
||||
beforeEach(function() {
|
||||
$('#testArea').append('<div id="content"><div id="app-content">Content</div><div id="app-sidebar">The sidebar</div></div>');
|
||||
jQuery.fx.off = true;
|
||||
});
|
||||
afterEach(function() {
|
||||
jQuery.fx.off = false;
|
||||
});
|
||||
it('shows sidebar', function() {
|
||||
var $el = $('#app-sidebar');
|
||||
OC.Apps.showAppSidebar();
|
||||
expect($el.hasClass('disappear')).toEqual(false);
|
||||
});
|
||||
it('hides sidebar', function() {
|
||||
var $el = $('#app-sidebar');
|
||||
OC.Apps.showAppSidebar();
|
||||
OC.Apps.hideAppSidebar();
|
||||
expect($el.hasClass('disappear')).toEqual(true);
|
||||
});
|
||||
it('triggers appresize event when visibility changed', function() {
|
||||
var eventStub = sinon.stub();
|
||||
$('#content').on('appresized', eventStub);
|
||||
OC.Apps.showAppSidebar();
|
||||
expect(eventStub.calledOnce).toEqual(true);
|
||||
OC.Apps.hideAppSidebar();
|
||||
expect(eventStub.calledTwice).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
/**
|
||||
* @copyright Bernhard Posselt 2014
|
||||
*
|
||||
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
|
||||
* @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 $ from 'jquery'
|
||||
|
||||
let dynamicSlideToggleEnabled = false
|
||||
|
||||
const Apps = {
|
||||
enableDynamicSlideToggle() {
|
||||
dynamicSlideToggleEnabled = true
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the #app-sidebar and add .with-app-sidebar to subsequent siblings
|
||||
*
|
||||
* @param {object} [$el] sidebar element to show, defaults to $('#app-sidebar')
|
||||
*/
|
||||
Apps.showAppSidebar = function($el) {
|
||||
const $appSidebar = $el || $('#app-sidebar')
|
||||
$appSidebar.removeClass('disappear').show()
|
||||
$('#app-content').trigger(new $.Event('appresized'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the #app-sidebar and removes .with-app-sidebar from subsequent
|
||||
* siblings
|
||||
*
|
||||
* @param {object} [$el] sidebar element to hide, defaults to $('#app-sidebar')
|
||||
*/
|
||||
Apps.hideAppSidebar = function($el) {
|
||||
const $appSidebar = $el || $('#app-sidebar')
|
||||
$appSidebar.hide().addClass('disappear')
|
||||
$('#app-content').trigger(new $.Event('appresized'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a way to slide down a target area through a button and slide it
|
||||
* up if the user clicks somewhere else. Used for the news app settings and
|
||||
* add new field.
|
||||
*
|
||||
* Usage:
|
||||
* <button data-apps-slide-toggle=".slide-area">slide</button>
|
||||
* <div class=".slide-area" class="hidden">I'm sliding up</div>
|
||||
*/
|
||||
export const registerAppsSlideToggle = () => {
|
||||
let buttons = $('[data-apps-slide-toggle]')
|
||||
|
||||
if (buttons.length === 0) {
|
||||
$('#app-navigation').addClass('without-app-settings')
|
||||
}
|
||||
|
||||
$(document).click(function(event) {
|
||||
|
||||
if (dynamicSlideToggleEnabled) {
|
||||
buttons = $('[data-apps-slide-toggle]')
|
||||
}
|
||||
|
||||
buttons.each(function(index, button) {
|
||||
|
||||
const areaSelector = $(button).data('apps-slide-toggle')
|
||||
const area = $(areaSelector)
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function hideArea() {
|
||||
area.slideUp(OC.menuSpeed * 4, function() {
|
||||
area.trigger(new $.Event('hide'))
|
||||
})
|
||||
area.removeClass('opened')
|
||||
$(button).removeClass('opened')
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function showArea() {
|
||||
area.slideDown(OC.menuSpeed * 4, function() {
|
||||
area.trigger(new $.Event('show'))
|
||||
})
|
||||
area.addClass('opened')
|
||||
$(button).addClass('opened')
|
||||
const input = $(areaSelector + ' [autofocus]')
|
||||
if (input.length === 1) {
|
||||
input.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// do nothing if the area is animated
|
||||
if (!area.is(':animated')) {
|
||||
|
||||
// button toggles the area
|
||||
if ($(button).is($(event.target).closest('[data-apps-slide-toggle]'))) {
|
||||
if (area.is(':visible')) {
|
||||
hideArea()
|
||||
} else {
|
||||
showArea()
|
||||
}
|
||||
|
||||
// all other areas that have not been clicked but are open
|
||||
// should be slid up
|
||||
} else {
|
||||
const closest = $(event.target).closest(areaSelector)
|
||||
if (area.is(':visible') && closest[0] !== area[0]) {
|
||||
hideArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
export default Apps
|
||||
|
|
@ -30,7 +30,6 @@ import {
|
|||
processAjaxError,
|
||||
registerXHRForErrorProcessing,
|
||||
} from './xhr-error.js'
|
||||
import Apps from './apps.js'
|
||||
import { AppConfig, appConfig } from './appconfig.js'
|
||||
import { appSettings } from './appsettings.js'
|
||||
import appswebroots from './appswebroots.js'
|
||||
|
|
@ -141,7 +140,6 @@ export default {
|
|||
|
||||
addScript,
|
||||
addStyle,
|
||||
Apps,
|
||||
AppConfig,
|
||||
appConfig,
|
||||
appSettings,
|
||||
|
|
|
|||
|
|
@ -35,11 +35,9 @@ import OC from './OC/index.js'
|
|||
import './globals.js'
|
||||
import './jquery/index.js'
|
||||
import { initCore } from './init.js'
|
||||
import { registerAppsSlideToggle } from './OC/apps.js'
|
||||
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
initCore()
|
||||
registerAppsSlideToggle()
|
||||
|
||||
// fallback to hashchange when no history support
|
||||
if (window.history.pushState) {
|
||||
|
|
|
|||
31
custom.d.ts
vendored
Normal file
31
custom.d.ts
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
declare module '*.svg' {
|
||||
const content: any
|
||||
export default content
|
||||
}
|
||||
|
||||
declare module '*.vue' {
|
||||
import Vue from 'vue'
|
||||
export default Vue
|
||||
}
|
||||
|
||||
34
cypress.d.ts
vendored
Normal file
34
cypress.d.ts
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
/* eslint-disable */
|
||||
import { mount } from 'cypress/vue2'
|
||||
|
||||
type MountParams = Parameters<typeof mount>;
|
||||
type OptionsParam = MountParams[1];
|
||||
|
||||
declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
mount: typeof mount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,21 +19,9 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
/* eslint-disable */
|
||||
import { mount } from 'cypress/vue2'
|
||||
|
||||
// Augment the Cypress namespace to include type definitions for
|
||||
// your custom command.
|
||||
// Alternatively, can be defined in cypress/support/component.d.ts
|
||||
// with a <reference path="./component" /> at the top of your spec.
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
mount: typeof mount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Example use:
|
||||
// cy.mount(MyComponent)
|
||||
Cypress.Commands.add('mount', (component, optionsOrProps) => {
|
||||
|
|
|
|||
4
dist/comments-comments-app.js
vendored
4
dist/comments-comments-app.js
vendored
File diff suppressed because one or more lines are too long
22
dist/comments-comments-app.js.LICENSE.txt
vendored
22
dist/comments-comments-app.js.LICENSE.txt
vendored
|
|
@ -41,25 +41,3 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* @copyright Copyright (c) 2021 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/>.
|
||||
*
|
||||
*/
|
||||
|
|
|
|||
2
dist/comments-comments-app.js.map
vendored
2
dist/comments-comments-app.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-common.js
vendored
4
dist/core-common.js
vendored
File diff suppressed because one or more lines are too long
43
dist/core-common.js.LICENSE.txt
vendored
43
dist/core-common.js.LICENSE.txt
vendored
|
|
@ -25,6 +25,12 @@ object-assign
|
|||
@license MIT
|
||||
*/
|
||||
|
||||
/*!
|
||||
* pinia v2.0.33
|
||||
* (c) 2023 Eduardo San Martin Morote
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/*!
|
||||
* vue-router v3.6.5
|
||||
* (c) 2022 Evan You
|
||||
|
|
@ -369,6 +375,10 @@ object-assign
|
|||
|
||||
/*! For license information please see NcAppSidebarTab.js.LICENSE.txt */
|
||||
|
||||
/*! For license information please see NcBreadcrumb.js.LICENSE.txt */
|
||||
|
||||
/*! For license information please see NcBreadcrumbs.js.LICENSE.txt */
|
||||
|
||||
/*! For license information please see NcButton.js.LICENSE.txt */
|
||||
|
||||
/*! For license information please see NcCheckboxRadioSwitch.js.LICENSE.txt */
|
||||
|
|
@ -824,6 +834,28 @@ object-assign
|
|||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* @copyright Copyright (c) 2022 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/>.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Lodash <https://lodash.com/>
|
||||
|
|
@ -833,6 +865,17 @@ object-assign
|
|||
* Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
|
||||
*/
|
||||
|
||||
/**
|
||||
* natural-orderby v3.0.2
|
||||
*
|
||||
* Copyright (c) Olaf Ennen
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE.md file in the root directory of this source tree.
|
||||
*
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license nested-property https://github.com/cosmosio/nested-property
|
||||
*
|
||||
|
|
|
|||
2
dist/core-common.js.map
vendored
2
dist/core-common.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-login.js
vendored
4
dist/core-login.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-login.js.map
vendored
2
dist/core-login.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-main.js
vendored
4
dist/core-main.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-main.js.map
vendored
2
dist/core-main.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-profile.js
vendored
4
dist/core-profile.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-profile.js.map
vendored
2
dist/core-profile.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-recommendedapps.js
vendored
4
dist/core-recommendedapps.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-recommendedapps.js.map
vendored
2
dist/core-recommendedapps.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-unified-search.js
vendored
4
dist/core-unified-search.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-unified-search.js.map
vendored
2
dist/core-unified-search.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/core-unsupported-browser.js
vendored
4
dist/core-unsupported-browser.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-unsupported-browser.js.map
vendored
2
dist/core-unsupported-browser.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/dashboard-main.js
vendored
4
dist/dashboard-main.js
vendored
File diff suppressed because one or more lines are too long
2
dist/dashboard-main.js.map
vendored
2
dist/dashboard-main.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/dav-settings-admin-caldav.js
vendored
4
dist/dav-settings-admin-caldav.js
vendored
File diff suppressed because one or more lines are too long
2
dist/dav-settings-admin-caldav.js.map
vendored
2
dist/dav-settings-admin-caldav.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/dav-settings-personal-availability.js
vendored
4
dist/dav-settings-personal-availability.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
dist/files-main.js
vendored
4
dist/files-main.js
vendored
File diff suppressed because one or more lines are too long
22
dist/files-main.js.LICENSE.txt
vendored
22
dist/files-main.js.LICENSE.txt
vendored
|
|
@ -131,3 +131,25 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* @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/>.
|
||||
*
|
||||
*/
|
||||
|
|
|
|||
2
dist/files-main.js.map
vendored
2
dist/files-main.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/files-personal-settings.js
vendored
4
dist/files-personal-settings.js
vendored
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue