diff options
Diffstat (limited to 'apps/files/src')
22 files changed, 384 insertions, 112 deletions
diff --git a/apps/files/src/actions/deleteAction.ts b/apps/files/src/actions/deleteAction.ts index 087884b3362..a633e477b1f 100644 --- a/apps/files/src/actions/deleteAction.ts +++ b/apps/files/src/actions/deleteAction.ts @@ -27,10 +27,11 @@ import TrashCan from '@mdi/svg/svg/trash-can.svg?raw' import { registerFileAction, FileAction } from '../services/FileAction.ts' import logger from '../logger.js' +import type { Navigation } from '../services/Navigation.ts' registerFileAction(new FileAction({ id: 'delete', - displayName(nodes: Node[], view) { + displayName(nodes: Node[], view: Navigation) { return view.id === 'trashbin' ? t('files_trashbin', 'Delete permanently') : t('files', 'Delete') @@ -57,8 +58,8 @@ registerFileAction(new FileAction({ return false } }, - async execBatch(nodes: Node[], view) { - return Promise.all(nodes.map(node => this.exec(node, view))) + async execBatch(nodes: Node[], view: Navigation, dir: string) { + return Promise.all(nodes.map(node => this.exec(node, view, dir))) }, order: 100, diff --git a/apps/files/src/actions/sidebarAction.ts b/apps/files/src/actions/sidebarAction.ts new file mode 100644 index 00000000000..f56d3a9475f --- /dev/null +++ b/apps/files/src/actions/sidebarAction.ts @@ -0,0 +1,54 @@ +/** + * @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 { translate as t } from '@nextcloud/l10n' +import InformationSvg from '@mdi/svg/svg/information-variant.svg?raw' +import type { Node } from '@nextcloud/files' + +import { registerFileAction, FileAction } from '../services/FileAction.ts' +import logger from '../logger.js' + +export const ACTION_DETAILS = 'details' + +registerFileAction(new FileAction({ + id: ACTION_DETAILS, + displayName: () => t('files', 'Details'), + iconSvgInline: () => InformationSvg, + + // Sidebar currently supports user folder only, /files/USER + enabled: (files: Node[]) => !!window?.OCA?.Files?.Sidebar + && files.some(node => node.root?.startsWith('/files/')), + + async exec(node: Node) { + try { + // TODO: migrate Sidebar to use a Node instead + window?.OCA?.Files?.Sidebar?.open?.(node.path) + + return null + } catch (error) { + logger.error('Error while opening sidebar', { error }) + return false + } + }, + + default: true, + order: -50, +})) diff --git a/apps/files/src/components/FileEntry.vue b/apps/files/src/components/FileEntry.vue index 7db22482220..8dc067a407d 100644 --- a/apps/files/src/components/FileEntry.vue +++ b/apps/files/src/components/FileEntry.vue @@ -33,7 +33,7 @@ <!-- Link to file --> <td class="files-list__row-name"> - <a ref="name" v-bind="linkTo"> + <a ref="name" v-bind="linkTo" @click="execDefaultAction"> <!-- Icon or preview --> <span class="files-list__row-icon"> <FolderIcon v-if="source.type === 'folder'" /> @@ -49,6 +49,13 @@ :style="{ backgroundImage: mimeIconUrl }" /> <FileIcon v-else /> + + <!-- Favorite icon --> + <span v-if="isFavorite" + class="files-list__row-icon-favorite" + :aria-label="t('files', 'Favorite')"> + <StarIcon aria-hidden="true" :size="20" /> + </span> </span> <!-- File name --> @@ -64,8 +71,11 @@ <!-- Menu actions --> <NcActions v-if="active" ref="actionsMenu" + :boundaries-element="boundariesElement" + :container="boundariesElement" :disabled="source._loading" :force-title="true" + :force-menu="true" :inline="enabledInlineActions.length" :open.sync="openedMenu"> <NcActionButton v-for="action in enabledMenuActions" @@ -84,7 +94,8 @@ <!-- Size --> <td v-if="isSizeAvailable" :style="{ opacity: sizeOpacity }" - class="files-list__row-size"> + class="files-list__row-size" + @click="openDetailsIfAvailable"> <span>{{ size }}</span> </td> @@ -92,7 +103,8 @@ <td v-for="column in columns" :key="column.id" :class="`files-list__row-${currentView?.id}-${column.id}`" - class="files-list__row-column-custom"> + class="files-list__row-column-custom" + @click="openDetailsIfAvailable"> <CustomElementRender v-if="active" :current-view="currentView" :render="column.render" @@ -115,9 +127,12 @@ 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 StarIcon from 'vue-material-design-icons/Star.vue' import Vue from 'vue' +import { ACTION_DETAILS } from '../actions/sidebarAction.ts' import { getFileActions } from '../services/FileAction.ts' +import { hashCode } from '../utils/hashUtils.ts' import { isCachedPreview } from '../services/PreviewService.ts' import { useActionsMenuStore } from '../store/actionsmenu.ts' import { useFilesStore } from '../store/files.ts' @@ -144,6 +159,7 @@ export default Vue.extend({ NcActions, NcCheckboxRadioSwitch, NcLoadingIcon, + StarIcon, }, props: { @@ -192,6 +208,7 @@ export default Vue.extend({ return { backgroundFailed: false, backgroundImage: '', + boundariesElement: document.querySelector('.app-content > .files-list'), loading: '', } }, @@ -204,7 +221,6 @@ export default Vue.extend({ currentView() { return this.$navigation.active }, - columns() { // Hide columns if the list is too small if (this.filesListWidth < 512) { @@ -217,7 +233,6 @@ export default Vue.extend({ // Remove any trailing slash but leave root slash return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') }, - fileid() { return this.source?.fileid?.toString?.() }, @@ -225,6 +240,7 @@ export default Vue.extend({ return this.source.attributes.displayName || this.source.basename }, + size() { const size = parseInt(this.source.size, 10) || 0 if (typeof size !== 'number' || size < 0) { @@ -232,7 +248,6 @@ export default Vue.extend({ } return formatFileSize(size, true) }, - sizeOpacity() { const size = parseInt(this.source.size, 10) || 0 if (!size || size < 0) { @@ -255,6 +270,16 @@ export default Vue.extend({ to, } } + + if (this.enabledDefaultActions.length > 0) { + const action = this.enabledDefaultActions[0] + const displayName = action.displayName([this.source], this.currentView) + return { + title: displayName, + role: 'button', + } + } + return { href: this.source.source, // TODO: Use first action title ? @@ -272,7 +297,6 @@ export default Vue.extend({ cropPreviews() { return this.userConfig.crop_image_previews }, - previewUrl() { try { const url = new URL(window.location.origin + this.source.attributes.previewUrl) @@ -280,13 +304,12 @@ export default Vue.extend({ url.searchParams.set('x', '32') url.searchParams.set('y', '32') // Handle cropping - url.searchParams.set('a', this.cropPreviews === true ? '1' : '0') + url.searchParams.set('a', this.cropPreviews === true ? '0' : '1') return url.href } catch (e) { return null } }, - mimeIconUrl() { const mimeType = this.source.mime || 'application/octet-stream' const mimeIconUrl = window.OC?.MimeType?.getIconUrl?.(mimeType) @@ -301,29 +324,38 @@ export default Vue.extend({ .filter(action => !action.enabled || action.enabled([this.source], this.currentView)) .sort((a, b) => (a.order || 0) - (b.order || 0)) }, - enabledInlineActions() { if (this.filesListWidth < 768) { return [] } return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView)) }, - enabledMenuActions() { if (this.filesListWidth < 768) { + // If we have a default action, do not render the first one + if (this.enabledDefaultActions.length > 0) { + return this.enabledActions.slice(1) + } return this.enabledActions } - return [ + const actions = [ ...this.enabledInlineActions, ...this.enabledActions.filter(action => !action.inline), ] - }, - uniqueId() { - return this.hashCode(this.source.source) - }, + // If we have a default action, do not render the first one + if (this.enabledDefaultActions.length > 0) { + return actions.slice(1) + } + return actions + }, + enabledDefaultActions() { + return [ + ...this.enabledActions.filter(action => action.default), + ] + }, openedMenu: { get() { return this.actionsMenuStore.opened === this.uniqueId @@ -332,6 +364,14 @@ export default Vue.extend({ this.actionsMenuStore.opened = opened ? this.uniqueId : null }, }, + + uniqueId() { + return hashCode(this.source.source) + }, + + isFavorite() { + return this.source.attributes.favorite === 1 + }, }, watch: { @@ -457,16 +497,6 @@ export default Vue.extend({ } }, - 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 { @@ -474,7 +504,13 @@ export default Vue.extend({ this.loading = action.id Vue.set(this.source, '_loading', true) - const success = await action.exec(this.source, this.currentView) + const success = await action.exec(this.source, this.currentView, this.dir) + + // If the action returns null, we stay silent + if (success === null) { + return + } + if (success) { showSuccess(this.t('files', '"{displayName}" action executed successfully', { displayName })) return @@ -489,6 +525,28 @@ export default Vue.extend({ Vue.set(this.source, '_loading', false) } }, + execDefaultAction(event) { + // Do not execute the default action on the folder, navigate instead + if (this.source.type === 'folder') { + return + } + + if (this.enabledDefaultActions.length > 0) { + event.preventDefault() + event.stopPropagation() + // Execute the first default action if any + this.enabledDefaultActions[0].exec(this.source, this.currentView, this.dir) + } + }, + + openDetailsIfAvailable(event) { + const detailsAction = this.enabledDefaultActions.find(action => action.id === ACTION_DETAILS) + if (detailsAction) { + event.preventDefault() + event.stopPropagation() + detailsAction.exec(this.source, this.currentView) + } + }, onSelectionChange(selection) { const newSelectedIndex = this.index diff --git a/apps/files/src/components/FilesListHeader.vue b/apps/files/src/components/FilesListHeader.vue index 9e3fe0d46de..2d848d0eefe 100644 --- a/apps/files/src/components/FilesListHeader.vue +++ b/apps/files/src/components/FilesListHeader.vue @@ -173,7 +173,7 @@ export default Vue.extend({ onToggleAll(selected) { if (selected) { - const selection = this.nodes.map(node => node.attributes.fileid.toString()) + const selection = this.nodes.map(node => node.fileid.toString()) logger.debug('Added all nodes to selection', { selection }) this.selectionStore.setLastIndex(null) this.selectionStore.set(selection) diff --git a/apps/files/src/components/FilesListHeaderActions.vue b/apps/files/src/components/FilesListHeaderActions.vue index c9f0c66be03..f8c60a5cd1b 100644 --- a/apps/files/src/components/FilesListHeaderActions.vue +++ b/apps/files/src/components/FilesListHeaderActions.vue @@ -103,6 +103,10 @@ export default Vue.extend({ }, computed: { + dir() { + // Remove any trailing slash but leave root slash + return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1') + }, enabledActions() { return actions .filter(action => action.execBatch) @@ -165,13 +169,20 @@ export default Vue.extend({ }) // Dispatch action execution - const results = await action.execBatch(this.nodes, this.currentView) + const results = await action.execBatch(this.nodes, this.currentView, this.dir) + + // Check if all actions returned null + if (!results.some(result => result !== null)) { + // If the actions returned null, we stay silent + this.selectionStore.reset() + return + } // Handle potential failures - if (results.some(result => result !== true)) { + if (results.some(result => result === false)) { // Remove the failed ids from the selection const failedIds = selectionIds - .filter((fileid, index) => results[index] !== true) + .filter((fileid, index) => results[index] === false) this.selectionStore.set(failedIds) showError(this.t('files', '"{displayName}" failed on some elements ', { displayName })) diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index ad0ba2069ff..866fc6da00d 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -139,7 +139,7 @@ export default Vue.extend({ methods: { getFileId(node) { - return node.attributes.fileid + return node.fileid }, t: translate, @@ -233,22 +233,24 @@ export default Vue.extend({ } .files-list__row-icon { + position: relative; display: flex; + overflow: visible; align-items: center; + // No shrinking or growing allowed + flex: 0 0 var(--icon-preview-size); 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); - // No shrinking or growing allowed - flex: 0 0 var(--icon-preview-size); & > span { justify-content: flex-start; } - svg { + &> span:not(.files-list__row-icon-favorite) svg { width: var(--icon-preview-size); height: var(--icon-preview-size); } @@ -263,6 +265,13 @@ export default Vue.extend({ background-position: center; background-size: contain; } + + &-favorite { + position: absolute; + top: 4px; + right: -8px; + color: #ffcc00; + } } .files-list__row-name { diff --git a/apps/files/src/main.js b/apps/files/src/main.ts index a8464f0ee0d..195357d0e0a 100644 --- a/apps/files/src/main.js +++ b/apps/files/src/main.ts @@ -1,27 +1,37 @@ import './templates.js' import './legacy/filelistSearch.js' -import './actions/deleteAction.ts' - -import processLegacyFilesViews from './legacy/navigationMapper.js' +import './actions/deleteAction' +import './actions/sidebarAction' 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 NavigationService from './services/Navigation' +import NavigationView from './views/Navigation.vue' +import processLegacyFilesViews from './legacy/navigationMapper.js' +import registerPreviewServiceWorker from './services/ServiceWorker.js' +import router from './router/router.js' +import RouterService from './services/RouterService' import SettingsModel from './models/Setting.js' +import SettingsService from './services/Settings.js' -import router from './router/router.js' +declare global { + interface Window { + OC: any; + OCA: any; + OCP: any; + } +} // Init private and public Files namespace window.OCA.Files = window.OCA.Files ?? {} window.OCP.Files = window.OCP.Files ?? {} +// Expose router +const Router = new RouterService(router) +Object.assign(window.OCP.Files, { Router }) + // Init Pinia store Vue.use(PiniaVuePlugin) const pinia = createPinia() @@ -57,7 +67,7 @@ const FilesList = new ListView({ }) FilesList.$mount('#app-content-vue') -// Init legacy files views +// Init legacy and new files views processLegacyFilesViews() // Register preview service worker diff --git a/apps/files/src/mixins/filesSorting.ts b/apps/files/src/mixins/filesSorting.ts index 8930587ffab..2f79a3eb171 100644 --- a/apps/files/src/mixins/filesSorting.ts +++ b/apps/files/src/mixins/filesSorting.ts @@ -21,18 +21,14 @@ */ import Vue from 'vue' +import { mapState } from 'pinia' import { useViewConfigStore } from '../store/viewConfig' -import type { Navigation } from '../services/Navigation' +import type { Navigation } from '../services/Navigation' export default Vue.extend({ - setup() { - const viewConfigStore = useViewConfigStore() - return { - viewConfigStore, - } - }, - computed: { + ...mapState(useViewConfigStore, ['getConfig', 'setSortingBy', 'toggleSortingDirection']), + currentView(): Navigation { return this.$navigation.active }, @@ -41,7 +37,7 @@ export default Vue.extend({ * Get the sorting mode for the current view */ sortingMode(): string { - return this.viewConfigStore.getConfig(this.currentView.id)?.sorting_mode + return this.getConfig(this.currentView.id)?.sorting_mode as string || this.currentView?.defaultSortKey || 'basename' }, @@ -50,7 +46,7 @@ export default Vue.extend({ * Get the sorting direction for the current view */ isAscSorting(): boolean { - const sortingDirection = this.viewConfigStore.getConfig(this.currentView.id)?.sorting_direction + const sortingDirection = this.getConfig(this.currentView.id)?.sorting_direction return sortingDirection === 'asc' }, }, @@ -59,11 +55,11 @@ export default Vue.extend({ toggleSortBy(key: string) { // If we're already sorting by this key, flip the direction if (this.sortingMode === key) { - this.viewConfigStore.toggleSortingDirection(this.currentView.id) + this.toggleSortingDirection(this.currentView.id) return } // else sort ASC by this new key - this.viewConfigStore.setSortingBy(key, this.currentView.id) + this.setSortingBy(key, this.currentView.id) }, }, }) diff --git a/apps/files/src/router/router.js b/apps/files/src/router/router.js index cf5e5ec5ea8..0d833cd6464 100644 --- a/apps/files/src/router/router.js +++ b/apps/files/src/router/router.js @@ -22,7 +22,7 @@ import Vue from 'vue' import Router from 'vue-router' import { generateUrl } from '@nextcloud/router' -import { stringify } from 'query-string' +import queryString from 'query-string' Vue.use(Router) @@ -49,7 +49,7 @@ const router = new Router({ // Custom stringifyQuery to prevent encoding of slashes in the url stringifyQuery(query) { - const result = stringify(query).replace(/%2F/gmi, '/') + const result = queryString.stringify(query).replace(/%2F/gmi, '/') return result ? ('?' + result) : '' }, }) diff --git a/apps/files/src/services/FileAction.ts b/apps/files/src/services/FileAction.ts index 8c1d325e645..70d6405c804 100644 --- a/apps/files/src/services/FileAction.ts +++ b/apps/files/src/services/FileAction.ts @@ -20,8 +20,9 @@ * */ -import { Node } from '@nextcloud/files' +import type { Node } from '@nextcloud/files' import logger from '../logger' +import type { Navigation } from './Navigation' declare global { interface Window { @@ -48,13 +49,14 @@ interface FileActionData { * @returns true if the action was executed, false otherwise * @throws Error if the action failed */ - exec: (file: Node, view) => Promise<boolean>, + exec: (file: Node, view: Navigation, dir: string) => Promise<boolean|null>, /** * Function executed on multiple files action - * @returns true if the action was executed, false otherwise + * @returns true if the action was executed successfully, + * false otherwise and null if the action is silent/undefined. * @throws Error if the action failed */ - execBatch?: (files: Node[], view) => Promise<boolean[]> + execBatch?: (files: Node[], view: Navigation, dir: string) => Promise<(boolean|null)[]> /** This action order in the list */ order?: number, /** Make this action the default */ diff --git a/apps/files/src/services/Navigation.ts b/apps/files/src/services/Navigation.ts index e86266013d7..b2ae3b0b973 100644 --- a/apps/files/src/services/Navigation.ts +++ b/apps/files/src/services/Navigation.ts @@ -126,6 +126,13 @@ export default class { this._views.push(view) } + remove(id: string) { + const index = this._views.findIndex(view => view.id === id) + if (index !== -1) { + this._views.splice(index, 1) + } + } + get views(): Navigation[] { return this._views } diff --git a/apps/files/src/services/RouterService.ts b/apps/files/src/services/RouterService.ts new file mode 100644 index 00000000000..978e009514e --- /dev/null +++ b/apps/files/src/services/RouterService.ts @@ -0,0 +1,71 @@ +/** + * @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 { Route } from 'vue-router'; +import type VueRouter from 'vue-router'; +import type { Dictionary } from 'vue-router/types/router'; +import type { Location } from 'vue-router/types/router'; + +export default class RouterService { + + private _router: VueRouter; + + constructor(router: VueRouter) { + this._router = router + } + + /** + * Trigger a route change on the files app + * + * @param path the url path, eg: '/trashbin?dir=/Deleted' + * @param replace replace the current history + * @see https://router.vuejs.org/guide/essentials/navigation.html#navigate-to-a-different-location + */ + goTo(path: string, replace: boolean = false): Promise<Route> { + return this._router.push({ + path, + replace, + }) + } + + /** + * Trigger a route change on the files App + * + * @param name the route name + * @param params the route parameters + * @param query the url query parameters + * @param replace replace the current history + * @see https://router.vuejs.org/guide/essentials/navigation.html#navigate-to-a-different-location + */ + goToRoute( + name?: string, + params?: Dictionary<string>, + query?: Dictionary<string | (string | null)[] | null | undefined>, + replace?: boolean, + ): Promise<Route> { + return this._router.push({ + name, + query, + params, + replace, + } as Location) + } +} diff --git a/apps/files/src/store/files.ts b/apps/files/src/store/files.ts index 11e4fc970a4..bd7d3202dd9 100644 --- a/apps/files/src/store/files.ts +++ b/apps/files/src/store/files.ts @@ -25,11 +25,11 @@ import type { FilesStore, RootsStore, RootOptions, Service, FilesState } from '. import { defineStore } from 'pinia' import { subscribe } from '@nextcloud/event-bus' -import Vue from 'vue' import logger from '../logger' -import { FileId } from '../types' +import type { FileId } from '../types' +import Vue from 'vue' -export const useFilesStore = () => { +export const useFilesStore = function() { const store = defineStore('files', { state: (): FilesState => ({ files: {} as FilesStore, @@ -59,11 +59,11 @@ export const useFilesStore = () => { updateNodes(nodes: Node[]) { // Update the store all at once const files = nodes.reduce((acc, node) => { - if (!node.attributes.fileid) { + if (!node.fileid) { logger.warn('Trying to update/set a node without fileid', node) return acc } - acc[node.attributes.fileid] = node + acc[node.fileid] = node return acc }, {} as FilesStore) @@ -88,7 +88,7 @@ export const useFilesStore = () => { } }) - const fileStore = store() + const fileStore = store(...arguments) // Make sure we only register the listeners once if (!fileStore._initialized) { // subscribe('files:node:created', fileStore.onCreatedNode) diff --git a/apps/files/src/store/keyboard.ts b/apps/files/src/store/keyboard.ts index 1ba8285b960..bdce7d55075 100644 --- a/apps/files/src/store/keyboard.ts +++ b/apps/files/src/store/keyboard.ts @@ -28,7 +28,7 @@ import Vue from 'vue' * special keys states. Useful for checking the * current status of a key when executing a method. */ -export const useKeyboardStore = () => { +export const useKeyboardStore = function() { const store = defineStore('keyboard', { state: () => ({ altKey: false, @@ -50,7 +50,7 @@ export const useKeyboardStore = () => { } }) - const keyboardStore = store() + const keyboardStore = store(...arguments) // Make sure we only register the listeners once if (!keyboardStore._initialized) { window.addEventListener('keydown', keyboardStore.onEvent) diff --git a/apps/files/src/store/paths.ts b/apps/files/src/store/paths.ts index bcd7375518c..ecff97bf00c 100644 --- a/apps/files/src/store/paths.ts +++ b/apps/files/src/store/paths.ts @@ -23,21 +23,23 @@ import type { PathOptions, ServicesState } from '../types.ts' import { defineStore } from 'pinia' -import Vue from 'vue' import { subscribe } from '@nextcloud/event-bus' -import { FileId } from '../types' +import type { FileId, PathsStore } from '../types' +import Vue from 'vue' -export const usePathsStore = () => { +export const usePathsStore = function() { const store = defineStore('paths', { - state: (): ServicesState => ({}), + state: () => ({ + paths: {} as ServicesState + } as PathsStore), getters: { getPath: (state) => { return (service: string, path: string): FileId|undefined => { - if (!state[service]) { + if (!state.paths[service]) { return undefined } - return state[service][path] + return state.paths[service][path] } }, }, @@ -45,17 +47,17 @@ export const usePathsStore = () => { actions: { addPath(payload: PathOptions) { // If it doesn't exists, init the service state - if (!this[payload.service]) { - Vue.set(this, payload.service, {}) + if (!this.paths[payload.service]) { + Vue.set(this.paths, payload.service, {}) } // Now we can set the provided path - Vue.set(this[payload.service], payload.path, payload.fileid) + Vue.set(this.paths[payload.service], payload.path, payload.fileid) }, } }) - const pathsStore = store() + const pathsStore = store(...arguments) // Make sure we only register the listeners once if (!pathsStore._initialized) { // TODO: watch folders to update paths? diff --git a/apps/files/src/store/userconfig.ts b/apps/files/src/store/userconfig.ts index c81b7b4d77f..42821951dbf 100644 --- a/apps/files/src/store/userconfig.ts +++ b/apps/files/src/store/userconfig.ts @@ -33,7 +33,7 @@ const userConfig = loadState('files', 'config', { crop_image_previews: true, }) as UserConfig -export const useUserConfigStore = () => { +export const useUserConfigStore = function() { const store = defineStore('userconfig', { state: () => ({ userConfig, @@ -60,7 +60,7 @@ export const useUserConfigStore = () => { } }) - const userConfigStore = store() + const userConfigStore = store(...arguments) // Make sure we only register the listeners once if (!userConfigStore._initialized) { diff --git a/apps/files/src/store/viewConfig.ts b/apps/files/src/store/viewConfig.ts index d7a5ab1daa6..607596dfd68 100644 --- a/apps/files/src/store/viewConfig.ts +++ b/apps/files/src/store/viewConfig.ts @@ -27,12 +27,12 @@ import { loadState } from '@nextcloud/initial-state' import axios from '@nextcloud/axios' import Vue from 'vue' -import { ViewConfigs, ViewConfigStore, ViewId } from '../types.ts' -import { ViewConfig } from '../types' +import type { ViewConfigs, ViewConfigStore, ViewId } from '../types' +import type { ViewConfig } from '../types' const viewConfig = loadState('files', 'viewConfigs', {}) as ViewConfigs -export const useViewConfigStore = () => { +export const useViewConfigStore = function() { const store = defineStore('viewconfig', { state: () => ({ viewConfig, @@ -46,7 +46,7 @@ export const useViewConfigStore = () => { /** * Update the view config local store */ - onUpdate(view: ViewId, key: string, value: boolean) { + onUpdate(view: ViewId, key: string, value: string | number | boolean) { if (!this.viewConfig[view]) { Vue.set(this.viewConfig, view, {}) } @@ -56,7 +56,7 @@ export const useViewConfigStore = () => { /** * Update the view config local store AND on server side */ - async update(view: ViewId, key: string, value: boolean) { + async update(view: ViewId, key: string, value: string | number | boolean) { axios.put(generateUrl(`/apps/files/api/v1/views/${view}/${key}`), { value, }) @@ -88,7 +88,7 @@ export const useViewConfigStore = () => { } }) - const viewConfigStore = store() + const viewConfigStore = store(...arguments) // Make sure we only register the listeners once if (!viewConfigStore._initialized) { diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts index cca6fb9111f..c04a9538827 100644 --- a/apps/files/src/types.ts +++ b/apps/files/src/types.ts @@ -49,13 +49,17 @@ export interface RootOptions { // Paths store export type ServicesState = { - [service: Service]: PathsStore + [service: Service]: PathConfig } -export type PathsStore = { +export type PathConfig = { [path: string]: number } +export type PathsStore = { + paths: ServicesState +} + export interface PathOptions { service: Service path: string @@ -91,4 +95,4 @@ export interface ViewConfigs { } export interface ViewConfigStore { viewConfig: ViewConfigs -}
\ No newline at end of file +} diff --git a/apps/files/src/utils/hashUtils.ts b/apps/files/src/utils/hashUtils.ts new file mode 100644 index 00000000000..55cf8b9f51a --- /dev/null +++ b/apps/files/src/utils/hashUtils.ts @@ -0,0 +1,28 @@ +/** + * @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/>. + * + */ + +export const hashCode = function(str: string): number { + return str.split('').reduce(function(a, b) { + a = ((a << 5) - a) + b.charCodeAt(0) + return a & a + }, 0) +} diff --git a/apps/files/src/views/FilesList.vue b/apps/files/src/views/FilesList.vue index c11b5820308..f2a20c18f28 100644 --- a/apps/files/src/views/FilesList.vue +++ b/apps/files/src/views/FilesList.vue @@ -166,7 +166,7 @@ export default Vue.extend({ return [] } - const customColumn = this.currentView.columns + const customColumn = (this.currentView?.columns || []) .find(column => column.id === this.sortingMode) // Custom column must provide their own sorting methods @@ -268,16 +268,16 @@ export default Vue.extend({ this.filesStore.updateNodes(contents) // Define current directory children - folder._children = contents.map(node => node.attributes.fileid) + folder._children = contents.map(node => node.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) { + if (folder.fileid) { this.filesStore.updateNodes([folder]) - this.pathsStore.addPath({ service: currentView.id, fileid: folder.attributes.fileid, path: dir }) + this.pathsStore.addPath({ service: currentView.id, fileid: folder.fileid, path: dir }) } else { // If we're here, the view API messed up logger.error('Invalid root folder returned', { dir, folder, currentView }) @@ -286,7 +286,7 @@ export default Vue.extend({ // 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) }) + this.pathsStore.addPath({ service: currentView.id, fileid: node.fileid, path: join(dir, node.basename) }) }) } catch (error) { logger.error('Error while fetching content', { error }) diff --git a/apps/files/src/views/Navigation.vue b/apps/files/src/views/Navigation.vue index cc714964c9b..e164880003a 100644 --- a/apps/files/src/views/Navigation.vue +++ b/apps/files/src/views/Navigation.vue @@ -44,7 +44,7 @@ :title="child.name" :to="generateToNavigation(child)"> <!-- Sanitized icon as svg if provided --> - <NcIconSvgWrapper v-if="view.icon" slot="icon" :svg="view.icon" /> + <NcIconSvgWrapper v-if="child.icon" slot="icon" :svg="child.icon" /> </NcAppNavigationItem> </NcAppNavigationItem> </template> @@ -175,7 +175,6 @@ export default { this.Navigation.setActive(view) logger.debug('Navigation changed', { id: view.id, view }) - // debugger this.showView(view, oldView) }, }, diff --git a/apps/files/src/views/Sidebar.vue b/apps/files/src/views/Sidebar.vue index 5c3967b1c93..9b43570e345 100644 --- a/apps/files/src/views/Sidebar.vue +++ b/apps/files/src/views/Sidebar.vue @@ -36,10 +36,16 @@ @closed="handleClosed"> <!-- TODO: create a standard to allow multiple elements here? --> <template v-if="fileInfo" #description> - <LegacyView v-for="view in views" - :key="view.cid" - :component="view" - :file-info="fileInfo" /> + <div class="sidebar__description"> + <SystemTags v-if="isSystemTagsEnabled" + v-show="showTags" + :file-id="fileInfo.id" + @has-tags="value => showTags = value" /> + <LegacyView v-for="view in views" + :key="view.cid" + :component="view" + :file-info="fileInfo" /> + </div> </template> <!-- Actions menu --> @@ -96,22 +102,25 @@ import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' import FileInfo from '../services/FileInfo.js' import SidebarTab from '../components/SidebarTab.vue' import LegacyView from '../components/LegacyView.vue' +import SystemTags from '../../../systemtags/src/components/SystemTags.vue' export default { name: 'Sidebar', components: { + LegacyView, NcActionButton, NcAppSidebar, NcEmptyContent, - LegacyView, SidebarTab, + SystemTags, }, data() { return { // reactive state Sidebar: OCA.Files.Sidebar.state, + showTags: false, error: null, loading: true, fileInfo: null, @@ -410,9 +419,7 @@ export default { * Toggle the tags selector */ toggleTags() { - if (OCA.SystemTags && OCA.SystemTags.View) { - OCA.SystemTags.View.toggle() - } + this.showTags = !this.showTags }, /** @@ -505,7 +512,7 @@ export default { </script> <style lang="scss" scoped> .app-sidebar { - &--has-preview::v-deep { + &--has-preview:deep { .app-sidebar-header__figure { background-size: cover; } @@ -525,6 +532,12 @@ export default { height: 100% !important; } + :deep { + .app-sidebar-header__description { + margin: 0 16px 4px 16px !important; + } + } + .svg-icon { ::v-deep svg { width: 20px; @@ -533,4 +546,11 @@ export default { } } } + +.sidebar__description { + display: flex; + flex-direction: column; + width: 100%; + gap: 8px 0; +} </style> |