parent
36b6a7c771
commit
887c9e05de
@ -0,0 +1,54 @@ |
|||||||
|
/** |
||||||
|
* @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/>.
|
||||||
|
* |
||||||
|
*/ |
||||||
|
|
||||||
|
import { loadState } from '@nextcloud/initial-state' |
||||||
|
import logger from '../logger.js' |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch and register the legacy files views |
||||||
|
*/ |
||||||
|
export default function() { |
||||||
|
const legacyViews = Object.values(loadState('files', 'navigation', {})) |
||||||
|
|
||||||
|
if (legacyViews.length > 0) { |
||||||
|
logger.debug('Legacy files views detected. Processing...', legacyViews) |
||||||
|
legacyViews.forEach(view => { |
||||||
|
registerLegacyView(view) |
||||||
|
if (view.sublist) { |
||||||
|
view.sublist.forEach(subview => registerLegacyView({ ...subview, parent: view.id })) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const registerLegacyView = function({ id, name, order, icon, parent, classes = '', expanded }) { |
||||||
|
OCP.Files.Navigation.register({ |
||||||
|
id, |
||||||
|
name, |
||||||
|
iconClass: icon ? `icon-${icon}` : 'nav-icon-' + id, |
||||||
|
order, |
||||||
|
parent, |
||||||
|
legacy: true, |
||||||
|
sticky: classes.includes('pinned'), |
||||||
|
expanded: expanded === true, |
||||||
|
}) |
||||||
|
} |
@ -1,3 +1,31 @@ |
|||||||
import './files-app-settings' |
import './files-app-settings.js' |
||||||
import './templates' |
import './templates.js' |
||||||
import './legacy/filelistSearch' |
import './legacy/filelistSearch.js' |
||||||
|
import processLegacyFilesViews from './legacy/navigationMapper.js' |
||||||
|
|
||||||
|
import Vue from 'vue' |
||||||
|
import NavigationService from './services/Navigation.ts' |
||||||
|
import NavigationView from './views/Navigation.vue' |
||||||
|
|
||||||
|
import router from './router/router.js' |
||||||
|
|
||||||
|
// Init Files App Navigation Service
|
||||||
|
const Navigation = new NavigationService() |
||||||
|
|
||||||
|
// Assign Navigation Service to the global OCP.Files
|
||||||
|
window.OCP.Files = window.OCP.Files ?? {} |
||||||
|
Object.assign(window.OCP.Files, { Navigation }) |
||||||
|
|
||||||
|
// Init Navigation View
|
||||||
|
const View = Vue.extend(NavigationView) |
||||||
|
const FilesNavigationRoot = new View({ |
||||||
|
name: 'FilesNavigationRoot', |
||||||
|
propsData: { |
||||||
|
Navigation, |
||||||
|
}, |
||||||
|
router, |
||||||
|
}) |
||||||
|
FilesNavigationRoot.$mount('#app-navigation-files') |
||||||
|
|
||||||
|
// Init legacy files views
|
||||||
|
processLegacyFilesViews() |
||||||
|
@ -0,0 +1,52 @@ |
|||||||
|
/** |
||||||
|
* @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/>.
|
||||||
|
* |
||||||
|
*/ |
||||||
|
import Vue from 'vue' |
||||||
|
import Router from 'vue-router' |
||||||
|
import { generateUrl } from '@nextcloud/router' |
||||||
|
|
||||||
|
Vue.use(Router) |
||||||
|
|
||||||
|
export default new Router({ |
||||||
|
mode: 'history', |
||||||
|
|
||||||
|
// if index.php is in the url AND we got this far, then it's working:
|
||||||
|
// let's keep using index.php in the url
|
||||||
|
base: generateUrl('/apps/files', ''), |
||||||
|
linkActiveClass: 'active', |
||||||
|
|
||||||
|
routes: [ |
||||||
|
{ |
||||||
|
path: '/', |
||||||
|
// Pretending we're using the default view
|
||||||
|
alias: '/files', |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: '/:view/:fileId?', |
||||||
|
name: 'filelist', |
||||||
|
props: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: '/not-found', |
||||||
|
name: 'notfound', |
||||||
|
}, |
||||||
|
], |
||||||
|
}) |
@ -0,0 +1,217 @@ |
|||||||
|
/** |
||||||
|
* @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/>. |
||||||
|
* |
||||||
|
*/ |
||||||
|
import type Node from '@nextcloud/files/dist/files/node' |
||||||
|
import isSvg from 'is-svg' |
||||||
|
|
||||||
|
import logger from '../logger' |
||||||
|
|
||||||
|
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; |
||||||
|
/** 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 |
||||||
|
} |
||||||
|
|
||||||
|
export interface Navigation { |
||||||
|
/** Unique view ID */ |
||||||
|
id: string |
||||||
|
/** Translated view name */ |
||||||
|
name: string |
||||||
|
/** Method return the content of the provided path */ |
||||||
|
getFiles: (path: string) => Node[] |
||||||
|
/** The view icon as an inline svg */ |
||||||
|
icon: string |
||||||
|
/** The view order */ |
||||||
|
order: number |
||||||
|
/** This view column(s). Name and actions are |
||||||
|
by default always included */ |
||||||
|
columns?: Column[] |
||||||
|
/** The empty view element to render your empty content into */ |
||||||
|
emptyView?: (div: HTMLDivElement) => void |
||||||
|
/** The parent unique ID */ |
||||||
|
parent?: string |
||||||
|
/** This view is sticky (sent at the bottom) */ |
||||||
|
sticky?: boolean |
||||||
|
/** This view has children and is expanded or not */ |
||||||
|
expanded?: boolean |
||||||
|
|
||||||
|
/** |
||||||
|
* This view is sticky a legacy view. |
||||||
|
* Here until all the views are migrated to Vue. |
||||||
|
* @deprecated It will be removed in a near future |
||||||
|
*/ |
||||||
|
legacy?: boolean |
||||||
|
/** |
||||||
|
* An icon class.
|
||||||
|
* @deprecated It will be removed in a near future |
||||||
|
*/ |
||||||
|
iconClass?: string |
||||||
|
} |
||||||
|
|
||||||
|
export default class { |
||||||
|
|
||||||
|
private _views: Navigation[] = [] |
||||||
|
private _currentView: Navigation | null = null |
||||||
|
|
||||||
|
constructor() { |
||||||
|
logger.debug('Navigation service initialized') |
||||||
|
} |
||||||
|
|
||||||
|
register(view: Navigation) { |
||||||
|
try { |
||||||
|
isValidNavigation(view) |
||||||
|
isUniqueNavigation(view, this._views) |
||||||
|
} catch (e) { |
||||||
|
if (e instanceof Error) { |
||||||
|
logger.error(e.message, { view }) |
||||||
|
} |
||||||
|
throw e |
||||||
|
} |
||||||
|
|
||||||
|
if (view.legacy) { |
||||||
|
logger.warn('Legacy view detected, please migrate to Vue') |
||||||
|
} |
||||||
|
|
||||||
|
if (view.iconClass) { |
||||||
|
view.legacy = true |
||||||
|
} |
||||||
|
|
||||||
|
this._views.push(view) |
||||||
|
} |
||||||
|
|
||||||
|
get views(): Navigation[] { |
||||||
|
return this._views |
||||||
|
} |
||||||
|
|
||||||
|
setActive(view: Navigation | null) { |
||||||
|
this._currentView = view |
||||||
|
} |
||||||
|
|
||||||
|
get active(): Navigation | null { |
||||||
|
return this._currentView |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Make sure the given view is unique |
||||||
|
* and not already registered. |
||||||
|
*/ |
||||||
|
const isUniqueNavigation = function(view: Navigation, views: Navigation[]): boolean { |
||||||
|
if (views.find(search => search.id === view.id)) { |
||||||
|
throw new Error(`Navigation id ${view.id} is already registered`) |
||||||
|
} |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Typescript cannot validate an interface. |
||||||
|
* Please keep in sync with the Navigation interface requirements. |
||||||
|
*/ |
||||||
|
const isValidNavigation = function(view: Navigation): boolean { |
||||||
|
if (!view.id || typeof view.id !== 'string') { |
||||||
|
throw new Error('Navigation id is required and must be a string') |
||||||
|
} |
||||||
|
|
||||||
|
if (!view.name || typeof view.name !== 'string') { |
||||||
|
throw new Error('Navigation name is required and must be a string') |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Legacy handle their content and icon differently |
||||||
|
* 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.icon || typeof view.icon !== 'string' || !isSvg(view.icon)) { |
||||||
|
throw new Error('Navigation icon is required and must be a valid svg string') |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (!('order' in view) || typeof view.order !== 'number') { |
||||||
|
throw new Error('Navigation order is required and must be a number') |
||||||
|
} |
||||||
|
|
||||||
|
// Optional properties
|
||||||
|
if (view.columns) { |
||||||
|
view.columns.forEach(isValidColumn) |
||||||
|
} |
||||||
|
|
||||||
|
if (view.emptyView && typeof view.emptyView !== 'function') { |
||||||
|
throw new Error('Navigation emptyView must be a function') |
||||||
|
} |
||||||
|
|
||||||
|
if (view.parent && typeof view.parent !== 'string') { |
||||||
|
throw new Error('Navigation parent must be a string') |
||||||
|
} |
||||||
|
|
||||||
|
if ('sticky' in view && typeof view.sticky !== 'boolean') { |
||||||
|
throw new Error('Navigation sticky must be a boolean') |
||||||
|
} |
||||||
|
|
||||||
|
if ('expanded' in view && typeof view.expanded !== 'boolean') { |
||||||
|
throw new Error('Navigation expanded must be a boolean') |
||||||
|
} |
||||||
|
|
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Typescript cannot validate an interface. |
||||||
|
* Please keep in sync with the Column interface requirements. |
||||||
|
*/ |
||||||
|
const isValidColumn = function(column: Column): boolean { |
||||||
|
if (!column.id || typeof column.id !== 'string') { |
||||||
|
throw new Error('Column id is required') |
||||||
|
} |
||||||
|
|
||||||
|
if (!column.title || typeof column.title !== 'string') { |
||||||
|
throw new Error('Column title is required') |
||||||
|
} |
||||||
|
|
||||||
|
if (!column.property || typeof column.property !== 'string') { |
||||||
|
throw new Error('Column property is required') |
||||||
|
} |
||||||
|
|
||||||
|
// Optional properties
|
||||||
|
if (column.sortFunction && typeof column.sortFunction !== 'function') { |
||||||
|
throw new Error('Column sortFunction must be a function') |
||||||
|
} |
||||||
|
|
||||||
|
if (column.summary && typeof column.summary !== 'function') { |
||||||
|
throw new Error('Column summary must be a function') |
||||||
|
} |
||||||
|
|
||||||
|
return true |
||||||
|
} |
@ -0,0 +1,156 @@ |
|||||||
|
<!-- |
||||||
|
- @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> |
||||||
|
<NcAppNavigation> |
||||||
|
<NcAppNavigationItem v-for="view in parentViews" |
||||||
|
:key="view.id" |
||||||
|
:allow-collapse="true" |
||||||
|
:to="{name: 'filelist', params: { view: view.id }}" |
||||||
|
:icon="view.iconClass" |
||||||
|
:open="view.expanded" |
||||||
|
:pinned="view.sticky" |
||||||
|
:title="view.name" |
||||||
|
@update:open="onToggleExpand(view)"> |
||||||
|
<NcAppNavigationItem v-for="child in childViews[view.id]" |
||||||
|
:key="child.id" |
||||||
|
:to="{name: 'filelist', params: { view: child.id }}" |
||||||
|
:icon="child.iconClass" |
||||||
|
:title="child.name" /> |
||||||
|
</NcAppNavigationItem> |
||||||
|
</NcAppNavigation> |
||||||
|
</template> |
||||||
|
|
||||||
|
<script> |
||||||
|
import { emit } from '@nextcloud/event-bus' |
||||||
|
import { generateUrl } from '@nextcloud/router' |
||||||
|
import axios from '@nextcloud/axios' |
||||||
|
import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js' |
||||||
|
import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' |
||||||
|
|
||||||
|
import Navigation from '../services/Navigation.ts' |
||||||
|
import logger from '../logger.js' |
||||||
|
|
||||||
|
export default { |
||||||
|
name: 'Navigation', |
||||||
|
|
||||||
|
components: { |
||||||
|
NcAppNavigation, |
||||||
|
NcAppNavigationItem, |
||||||
|
}, |
||||||
|
|
||||||
|
props: { |
||||||
|
// eslint-disable-next-line vue/prop-name-casing |
||||||
|
Navigation: { |
||||||
|
type: Navigation, |
||||||
|
required: true, |
||||||
|
}, |
||||||
|
}, |
||||||
|
|
||||||
|
data() { |
||||||
|
return { |
||||||
|
key: 'value', |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
computed: { |
||||||
|
currentViewId() { |
||||||
|
return this.$route.params.view || 'files' |
||||||
|
}, |
||||||
|
currentView() { |
||||||
|
return this.views.find(view => view.id === this.currentViewId) |
||||||
|
}, |
||||||
|
|
||||||
|
/** @return {Navigation[]} */ |
||||||
|
views() { |
||||||
|
return this.Navigation.views |
||||||
|
}, |
||||||
|
parentViews() { |
||||||
|
return this.views |
||||||
|
// filter child views |
||||||
|
.filter(view => !view.parent) |
||||||
|
// sort views by order |
||||||
|
.sort((a, b) => { |
||||||
|
return a.order - b.order |
||||||
|
}) |
||||||
|
}, |
||||||
|
childViews() { |
||||||
|
return this.views |
||||||
|
// filter parent views |
||||||
|
.filter(view => !!view.parent) |
||||||
|
// create a map of parents and their children |
||||||
|
.reduce((list, view) => { |
||||||
|
list[view.parent] = [...(list[view.parent] || []), view] |
||||||
|
// Sort children by order |
||||||
|
list[view.parent].sort((a, b) => { |
||||||
|
return a.order - b.order |
||||||
|
}) |
||||||
|
return list |
||||||
|
}, {}) |
||||||
|
}, |
||||||
|
}, |
||||||
|
|
||||||
|
watch: { |
||||||
|
currentView(view, oldView) { |
||||||
|
logger.debug('View changed', { view }) |
||||||
|
this.showView(view, oldView) |
||||||
|
}, |
||||||
|
}, |
||||||
|
|
||||||
|
beforeMount() { |
||||||
|
if (this.currentView) { |
||||||
|
logger.debug('Navigation mounted. Showing requested view', { view: this.currentView }) |
||||||
|
this.showView(this.currentView) |
||||||
|
} |
||||||
|
}, |
||||||
|
|
||||||
|
methods: { |
||||||
|
/** |
||||||
|
* @param {Navigation} view the new active view |
||||||
|
* @param {Navigation} oldView the old active view |
||||||
|
*/ |
||||||
|
showView(view, oldView) { |
||||||
|
if (view.legacy) { |
||||||
|
document.querySelectorAll('#app-content .viewcontainer').forEach(el => { |
||||||
|
el.classList.add('hidden') |
||||||
|
}) |
||||||
|
document.querySelector('#app-content #app-content-' + this.currentView.id + '.viewcontainer').classList.remove('hidden') |
||||||
|
} |
||||||
|
this.Navigation.setActive(view) |
||||||
|
emit('files:view:changed', view) |
||||||
|
}, |
||||||
|
|
||||||
|
onToggleExpand(view) { |
||||||
|
// Invert state |
||||||
|
view.expanded = !view.expanded |
||||||
|
axios.post(generateUrl(`/apps/files/api/v1/toggleShowFolder/${view.id}`), { show: view.expanded }) |
||||||
|
}, |
||||||
|
}, |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<style scoped lang="scss"> |
||||||
|
// TODO: remove when https://github.com/nextcloud/nextcloud-vue/pull/3539 is in |
||||||
|
.app-navigation::v-deep .app-navigation-entry-icon { |
||||||
|
background-repeat: no-repeat; |
||||||
|
background-position: center; |
||||||
|
} |
||||||
|
</style> |
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue