diff options
author | Roeland Jago Douma <roeland@famdouma.nl> | 2019-11-13 11:19:09 +0300 |
---|---|---|
committer | Roeland Jago Douma <roeland@famdouma.nl> | 2019-11-13 11:19:09 +0300 |
commit | c88707760e0f099df2f43f40ac9dc57c2267c302 (patch) | |
tree | d20996fb4049728629bc1ce17b3c8095303c70f8 | |
parent | 75c185233fe8b1963ed7e20a5539396766624816 (diff) | |
parent | 2cf2f147be079dca2a1e7bae9415a7c7a6fd3846 (diff) |
Merge remote-tracking branch 'origin/master' into enh/albums
29 files changed, 851 insertions, 147 deletions
diff --git a/appinfo/app.php b/appinfo/app.php new file mode 100644 index 00000000..12482fcd --- /dev/null +++ b/appinfo/app.php @@ -0,0 +1,24 @@ +<?php +/** + * @copyright Copyright (c) 2019 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/>. + * + */ +use OCA\Photos\AppInfo\Application; +\OC::$server->query(Application::class); diff --git a/css/icons.scss b/css/icons.scss new file mode 100644 index 00000000..ba8dcef9 --- /dev/null +++ b/css/icons.scss @@ -0,0 +1,27 @@ +/** + * @copyright Copyright (c) 2019 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/>. + * + */ + +.icon-folder.icon-dark { + @include icon-color('folder', 'filetypes', $color-black, 1, true); +} + +@include icon-black-white('photos', 'photos', 2); diff --git a/img/app.svg b/img/app.svg index 0779a5dd..04aaca9e 100644 --- a/img/app.svg +++ b/img/app.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" height="32" width="32" version="1.0"><path fill="#fff" d="M2.69 4a.9.9 0 00-.69.88v22.25c0 .46.42.87.88.87h26.25c.45 0 .87-.42.87-.87V5.22C30 4.55 29.47 4 28.97 4zM4 6h24v10l-2-2-6 8-6-6-8 8H4zm5 2a3 3 0 100 6 3 3 0 000-6z"/></svg>
\ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" height="32" width="32" version="1"><path d="M2.8 4a1.3 1.3 0 00-1.3 1.3v22.4c0 .6.7 1.3 1.3 1.3h26.4c.6 0 1.3-.7 1.3-1.3V5.3c0-.6-.7-1.3-1.3-1.3zm.7 2h25v19h-25z" fill="#fff"/><circle cx="8.5" cy="11.2" r="3" fill="#fff"/><path d="M26.4 14.5l-4.7 6.2L20 23l-1.6-1.8-4.5-4.6-6 5.7-4.7 4.3h26.2v-8.7z" fill="#fff"/></svg>
\ No newline at end of file diff --git a/img/photos.svg b/img/photos.svg new file mode 100644 index 00000000..6046f92b --- /dev/null +++ b/img/photos.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="32" width="32" version="1"><g transform="translate(-11.5 2.5)"><path d="M20.5 7.5a1 1 0 00-1 1v17c0 .5.6 1 1 1h20c.5 0 1-.5 1-1v-17c0-.4-.5-1-1-1zM21 9h19v14.5H21z"/><circle cx="24.8" cy="13" r="2.3"/><path d="M38.4 15.5L35 20.2 33.6 22l-1.2-1.4-3.5-3.5-4.5 4.3-3.6 3.3h19.9v-6.6l-2.3-2.6zM14.5.5a1 1 0 00-1 1v17c0 .5.6 1 1 1h5.9v-3H15V2h19v6.6h1.5V1.5c0-.4-.5-1-1-1h-20z"/></g></svg>
\ No newline at end of file diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 52139b4d..97ab98b4 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -28,9 +28,9 @@ use OCP\AppFramework\App; class Application extends App { - const appID = 'photos'; + const APP_ID = 'photos'; public function __construct() { - parent::__construct(self::appID); + parent::__construct(self::APP_ID); } } diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 7f9538a6..8529441e 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -30,6 +30,7 @@ use OCP\AppFramework\Controller; use OCP\AppFramework\Http\TemplateResponse; use OCP\EventDispatcher\IEventDispatcher; use OCP\IRequest; +use OCP\Util; class PageController extends Controller { @@ -58,6 +59,10 @@ class PageController extends Controller { $this->eventDispatcher->dispatch(LoadSidebar::class, new LoadSidebar()); $this->eventDispatcher->dispatch(LoadViewer::class, new LoadViewer()); + + Util::addScript('photos', 'photos'); + Util::addStyle('photos', 'icons'); + $response = new TemplateResponse($this->appName, 'main'); return $response; } diff --git a/package-lock.json b/package-lock.json index c53e7e30..7a8031b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "gallery", + "name": "photos", "version": "19.0.0", "lockfileVersion": 1, "requires": true, @@ -872,18 +872,18 @@ } }, "@nextcloud/vue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-1.0.0.tgz", - "integrity": "sha512-jYggwGf9so7g9uWP59cLspSo62uN7qX0+T096T/QBxyiEhoa+yspf9+Py/RIDylLacceKPDLTU4AdxELj63ZYQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-1.1.0.tgz", + "integrity": "sha512-SGWrNTalT/59vElPnPJZ0xQb9sui1yq5fKRiIA2w81NlAK9JQGq+ADeqdVb8sciVnLGobQJ1qERhpo3pIyOhaQ==", "requires": { "@babel/polyfill": "^7.4.4", "@nextcloud/axios": "^0.4.0", "escape-html": "^1.0.3", "hammerjs": "^2.0.8", "md5": "^2.2.1", + "v-click-outside": "^2.1.4", "v-tooltip": "^2.0.0-rc.33", "vue": "^2.6.7", - "vue-click-outside": "^1.0.7", "vue-color": "^2.7.0", "vue-multiselect": "^2.1.3", "vue-visible": "^1.0.2", @@ -1853,8 +1853,7 @@ "camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" }, "camelcase-keys": { "version": "2.1.0", @@ -9307,6 +9306,11 @@ "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==", "dev": true }, + "v-click-outside": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/v-click-outside/-/v-click-outside-2.1.5.tgz", + "integrity": "sha512-VPNCOTZK6WZy73lcWc+R7IW1uaBFEO3/Csrs5CzWVOdvE30V8Y1+BE/BtTlcEmeDGx0eqdE7bSCg55Jj37PMJg==" + }, "v-tooltip": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/v-tooltip/-/v-tooltip-2.0.2.tgz", @@ -9382,11 +9386,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.10.tgz", "integrity": "sha512-ImThpeNU9HbdZL3utgMCq0oiMzAkt1mcgy3/E6zWC/G6AaQoeuFdsl9nDhTDU3X1R6FK7nsIUuRACVcjI+A2GQ==" }, - "vue-click-outside": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/vue-click-outside/-/vue-click-outside-1.0.7.tgz", - "integrity": "sha1-zdKxYF48SUR4TheU6uShKg9wC9Y=" - }, "vue-color": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/vue-color/-/vue-color-2.7.0.tgz", @@ -9506,9 +9505,9 @@ "integrity": "sha512-yaX2its9XAJKGuQqf7LsiZHHSkxsIK8rmCOQOvEGEoF41blKRK8qr9my4qYoD6ikdLss4n8tKqYBecmaY0+WJg==" }, "vue2-datepicker": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/vue2-datepicker/-/vue2-datepicker-2.13.2.tgz", - "integrity": "sha512-bgtCdSTpFJogL37A5n2HnNPkyKVi0WTiM2+H+fYTHVYbRpSyNaPQ1Kj86A6tx3T14cv6qq4Oo8MrCxXiarDx2w==", + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/vue2-datepicker/-/vue2-datepicker-2.13.3.tgz", + "integrity": "sha512-kAiTpCLlDC88mMTW5OqhlME0ZSB1fJKlHbKSEryPIi3lRJWHn4BlRSvGUTnSmBUr/5Qidma7Pxei9vih9Luicw==", "requires": { "fecha": "^2.3.3" } diff --git a/package.json b/package.json index 825f4348..4ee8bf4a 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "@nextcloud/axios": "^0.5.0", "@nextcloud/l10n": "^0.2.1", "@nextcloud/router": "^0.1.0", - "@nextcloud/vue": "^1.0.0", + "@nextcloud/vue": "^1.1.0", + "camelcase": "^5.3.1", "cdav-library": "git+https://github.com/nextcloud/cdav-library.git", "path-posix": "^1.0.0", "qs": "^6.9.0", diff --git a/src/Gallery.vue b/src/Photos.vue index cb9a8882..49b83e13 100644 --- a/src/Gallery.vue +++ b/src/Photos.vue @@ -22,6 +22,17 @@ <template> <Content app-name="photos"> + <AppNavigation> + <AppNavigationItem :to="{name: 'root'}" + class="app-navigation__photos" + :title="t('photos', 'Your photos')" + icon="icon-photos" /> + <AppNavigationItem to="/favorites" :title="t('photos', 'Favorites')" icon="icon-favorite" /> + <AppNavigationItem :to="{name: 'albums'}" :title="t('photos', 'Your albums')" icon="icon-files-dark" /> + <AppNavigationItem :to="{name: 'shared'}" :title="t('photos', 'Shared albums')" icon="icon-share" /> + <AppNavigationItem :to="{name: 'tags'}" :title="t('photos', 'Tagged photos')" icon="icon-tag" /> + <AppNavigationItem :to="{name: 'maps'}" :title="t('photos', 'Locations')" icon="icon-address" /> + </AppNavigation> <AppContent :class="{ 'icon-loading': loading }"> <router-view v-show="!loading" :loading.sync="loading" /> @@ -34,6 +45,8 @@ <script> import Content from '@nextcloud/vue/dist/Components/Content' import AppContent from '@nextcloud/vue/dist/Components/AppContent' +import AppNavigation from '@nextcloud/vue/dist/Components/AppNavigation' +import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem' import svgplaceholder from './assets/img-placeholder.svg' export default { @@ -41,6 +54,8 @@ export default { components: { Content, AppContent, + AppNavigation, + AppNavigationItem, }, data: function() { return { @@ -50,3 +65,8 @@ export default { }, } </script> +<style lang="scss" scoped> +.app-navigation__photos::v-deep .app-navigation-entry-icon.icon-photos { + background-size: 20px; +} +</style> diff --git a/src/components/Folder.vue b/src/components/Folder.vue index 5dec6b5a..c04e17a9 100644 --- a/src/components/Folder.vue +++ b/src/components/Folder.vue @@ -23,7 +23,7 @@ <template> <router-link :class="{'folder--clear': isEmpty}" class="folder" - :to="folder.filename" + :to="to" :aria-label="ariaLabel"> <transition name="fade"> <div v-show="loaded" @@ -39,11 +39,11 @@ </transition> <div class="folder-name"> - <span :class="{'icon-white': !isEmpty}" - class="folder-name__icon icon-folder" + <span :class="[!isEmpty ? 'icon-white' : 'icon-dark', icon]" + class="folder-name__icon" role="img" /> <p :id="ariaUuid" class="folder-name__name"> - {{ folder.basename }} + {{ basename }} </p> </div> <div class="cover" role="none" /> @@ -62,10 +62,22 @@ export default { inheritAttrs: false, props: { - folder: { - type: Object, + basename: { + type: String, required: true, }, + filename: { + type: String, + required: true, + }, + id: { + type: Number, + required: true, + }, + icon: { + type: String, + default: 'icon-folder', + }, }, data() { @@ -84,7 +96,7 @@ export default { // files list of the current folder folderContent() { - return this.folders[this.folder.id] + return this.folders[this.id] }, fileList() { return this.folderContent @@ -101,10 +113,25 @@ export default { }, ariaUuid() { - return `folder-${this.folder.id}` + return `folder-${this.id}` }, ariaLabel() { - return t('photos', 'Open the "{name}" sub-directory', { name: this.folder.basename }) + return t('photos', 'Open the "{name}" sub-directory', { name: this.basename }) + }, + + /** + * We do not want encoded slashes when browsing by folder + * so we generate a new valid route object, get the final url back + * decode it and use it as a direct string, which vue-router + * does not encode afterwards + * @returns {string} + */ + to() { + const route = Object.assign({}, this.$route, { + // always remove first slash + params: { path: this.filename.substr(1) }, + }) + return decodeURIComponent(this.$router.resolve(route).resolved.path) }, }, @@ -115,9 +142,9 @@ export default { try { // get data - const { files, folders } = await request(this.folder.filename) + const { files, folders } = await request(this.filename) // this.cancelRequest('Stop!') - this.$store.dispatch('updateFolders', { id: this.folder.id, files, folders }) + this.$store.dispatch('updateFolders', { id: this.id, files, folders }) this.$store.dispatch('updateFiles', { folder: this.folder, files, folders }) } catch (error) { if (error.response && error.response.status) { @@ -215,6 +242,9 @@ $name-height: 1.2rem; .folder { // if no img, let's display the folder icon as default black &--clear { + .folder-name__icon { + opacity: .3; + } .folder-name__name { color: var(--color-main-text); text-shadow: 0 0 8px var(--color-main-background); diff --git a/src/components/Grid.vue b/src/components/Grid.vue new file mode 100644 index 00000000..85023f5a --- /dev/null +++ b/src/components/Grid.vue @@ -0,0 +1,87 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <!-- Folder content --> + <transition-group + class="photos-grid" + role="grid" + name="list" + tag="div"> + <slot /> + <div key="footer" role="none" class="photos-grid__footer-spacer" /> + </transition-group> +</template> + +<script> +export default { + name: 'Grid', +} +</script> + +<style scoped lang="scss"> +.photos-grid { + display: grid; + align-items: center; + justify-content: center; + gap: 8px; + grid-template-columns: repeat(10, 1fr); + position: relative; + + // always put one more row of grid for the spacer + &__footer-spacer { + // always add one row, so placing it on the first + // column will always add one more + grid-column: 1; + // same height as the width + padding-bottom: 100%; + } +} + +.list-move { + transition: transform var(--animation-quick); +} + +// TODO: use mixins/GridSizes as soon as node-sass supports it +// needs node-sass 5.0 (with libsass 3.6) +// https://github.com/sass/node-sass/pull/2312 +$previous: 0; +@each $size, $config in get('sizes') { + $count: map-get($config, 'count'); + $marginTop: map-get($config, 'marginTop'); + $marginW: map-get($config, 'marginW'); + + // if this is the last entry, only use min-width + $rule: '(min-width: #{$previous}px) and (max-width: #{$size}px)'; + @if $size == 'max' { + $rule: '(min-width: #{$previous}px)'; + } + + @media #{$rule} { + .photos-grid { + padding: #{$marginTop}px #{$marginW}px #{$marginW}px #{$marginW}px; + grid-template-columns: repeat($count, 1fr); + } + } + $previous: $size; +} +</style> diff --git a/src/components/Navigation.vue b/src/components/Navigation.vue index facbdf10..a0d4c92c 100644 --- a/src/components/Navigation.vue +++ b/src/components/Navigation.vue @@ -56,6 +56,10 @@ export default { type: String, required: true, }, + rootTitle: { + type: String, + default: t('photos', 'Photos'), + }, id: { type: Number, required: true, @@ -68,7 +72,7 @@ export default { }, name() { if (this.isRoot) { - return t('photos', 'Photos') + return this.rootTitle } return this.basename }, @@ -89,11 +93,26 @@ export default { } return t('photos', 'Back to {folder}', { folder: this.parentName }) }, + + /** + * We do not want encoded slashes when browsing by folder + * so we generate a new valid route object, get the final url back + * decode it and use it as a direct string, which vue-router + * does not encode afterwards + * @returns {string} + */ + to() { + const route = Object.assign({}, this.$route, { + // always remove first slash + params: { path: this.parentPath.substr(1) }, + }) + return decodeURIComponent(this.$router.resolve(route).resolved.path) + }, }, methods: { folderUp() { - this.$router.push(this.parentPath) + this.$router.push(this.to) }, }, } diff --git a/src/main.js b/src/main.js index 595ecb95..5e4a96d3 100644 --- a/src/main.js +++ b/src/main.js @@ -26,7 +26,7 @@ import { sync } from 'vuex-router-sync' import { translate, translatePlural } from '@nextcloud/l10n' import Vue from 'vue' -import Gallery from './Gallery' +import Photos from './Photos' import router from './router' import store from './store' @@ -49,8 +49,8 @@ Vue.prototype.n = translatePlural export default new Vue({ el: '#content', // eslint-disable-next-line vue/match-component-file-name - name: 'GalleryRoot', + name: 'PhotosRoot', router, store, - render: h => h(Gallery), + render: h => h(Photos), }) diff --git a/src/patchedRequest.js b/src/patchedRequest.js index 6b1eb267..ae23b19d 100644 --- a/src/patchedRequest.js +++ b/src/patchedRequest.js @@ -21,7 +21,6 @@ */ const request = require('webdav/dist/request') -const merge = require('webdav/dist/merge') const oldPrepareRequestOptions = request.prepareRequestOptions @@ -32,7 +31,7 @@ const oldPrepareRequestOptions = request.prepareRequestOptions request.prepareRequestOptions = function(requestOptions, methodOptions) { // add our cancelToken support if (methodOptions.cancelToken && typeof methodOptions.cancelToken === 'object') { - requestOptions.cancelToken = merge(requestOptions.cancelToken || {}, methodOptions.cancelToken) + requestOptions.cancelToken = Object.assign({}, requestOptions.cancelToken || {}, methodOptions.cancelToken) } // exploit old method oldPrepareRequestOptions(requestOptions, methodOptions) diff --git a/src/router/index.js b/src/router/index.js index 80dcdd5e..d4d9caa6 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -24,10 +24,17 @@ import { generateUrl } from '@nextcloud/router' import Router from 'vue-router' import Vue from 'vue' -import Grid from '../views/Grid' +import Albums from '../views/Albums' +import Tags from '../views/Tags' Vue.use(Router) +// shortcut to properly format the path prop +const props = route => ({ + // always lead current path with a slash + path: `/${route.params.path ? route.params.path : ''}`, +}) + export default new Router({ mode: 'history', // if index.php is in the url AND we got this far, then it's working: @@ -37,20 +44,59 @@ export default new Router({ routes: [ { path: '/', - component: Grid, - props: route => ({ - // always lead current path with a slash - path: `/${route.params.path ? route.params.path : ''}`, - }), + component: Albums, name: 'root', + }, + { + path: '/albums', + component: Albums, + name: 'albums', + props, + children: [ + { + path: ':path*', + name: 'albumspath', + component: Albums, + }, + ], + }, + { + path: '/shared', + component: Albums, + name: 'shared', + props, children: [ { path: ':path*', - name: 'path', - component: Grid, + name: 'sharedpath', + component: Albums, }, ], }, - { path: '*', redirect: { name: 'root' } }, + { + path: '/favorites', + component: Tags, + name: 'favorites', + }, + { + path: '/tags', + component: Tags, + name: 'tags', + props: route => ({ + tagname: route.params.tagname, + }), + children: [ + { + path: ':tagname', + name: 'tagname', + component: Tags, + }, + ], + }, + { + path: '/maps', + name: 'maps', + redirect: '', + }, ], }) diff --git a/src/services/DavClient.js b/src/services/DavClient.js index acdfdf1b..c91b57e8 100644 --- a/src/services/DavClient.js +++ b/src/services/DavClient.js @@ -24,14 +24,13 @@ import webdav from 'webdav' import axios from '@nextcloud/axios' import parseUrl from 'url-parse' import { generateRemoteUrl } from '@nextcloud/router' -import { getCurrentUser } from '@nextcloud/auth' // force our axios const patcher = webdav.getPatcher() patcher.patch('request', axios) // init webdav client -const remote = generateRemoteUrl(`dav/files/${getCurrentUser().uid}`) +const remote = generateRemoteUrl(`dav`) const client = webdav.createClient(remote) export const remotePath = parseUrl(remote).pathname diff --git a/src/services/FileList.js b/src/services/FileList.js index 2ee84328..c473aad8 100644 --- a/src/services/FileList.js +++ b/src/services/FileList.js @@ -20,12 +20,13 @@ * */ +import { getCurrentUser } from '@nextcloud/auth' import { getSingleValue, getValueForKey, parseXML, propsToStat } from 'webdav/dist/interface/dav' import { handleResponseCode, processResponsePayload } from 'webdav/dist/response' import { normaliseHREF, normalisePath } from 'webdav/dist/url' import client, { remotePath } from './DavClient' import pathPosix from 'path-posix' -import request from './DavRequest' +import { genFileInfo } from '../utils/fileUtils' /** * List files from a folder and filter out unwanted mimes @@ -35,6 +36,8 @@ import request from './DavRequest' * @returns {Array} the file list */ export default async function(path, options) { + + console.trace(); options = Object.assign({ method: 'PROPFIND', headers: { @@ -42,10 +45,11 @@ export default async function(path, options) { Depth: options.deep ? 'infinity' : 1, }, responseType: 'text', - data: request, details: true, }, options) + const prefixPath = `/files/${getCurrentUser().uid}` + /** * Fetch listing * @@ -54,7 +58,7 @@ export default async function(path, options) { * see https://github.com/perry-mitchell/webdav-client/blob/baf858a4856d44ae19ac12cb10c469b3e6c41ae4/source/interface/directoryContents.js#L11 */ let response = null - const { data } = await client.customRequest(path, options) + const { data } = await client.customRequest(prefixPath + path, options) .then(handleResponseCode) .then(res => { response = res @@ -64,14 +68,7 @@ export default async function(path, options) { .then(result => getDirectoryFiles(result, remotePath, options.details)) .then(files => processResponsePayload(response, files, options.details)) - const list = data - .map(entry => { - return Object.assign({ - id: parseInt(entry.props.fileid), - isFavorite: entry.props.favorite !== '0', - hasPreview: entry.props['has-preview'] !== 'false', - }, entry) - }) + const list = data.map(data => genFileInfo(data, prefixPath)) // filter all the files and folders let folder = {} @@ -91,6 +88,15 @@ export default async function(path, options) { return { folder, folders, files } } +/** + * Modified function to include the root requested folder + * Into the returned data + * + * @param {Object} result the request result + * @param {string} serverBasePath server base path + * @param {boolean} isDetailed detailed request + * @returns {Array} + */ function getDirectoryFiles(result, serverBasePath, isDetailed = false) { const serverBase = pathPosix.join(serverBasePath, '/') // Extract the response items (directory contents) diff --git a/src/services/FolderInfo.js b/src/services/FolderInfo.js index b95d7c67..b58ddb79 100644 --- a/src/services/FolderInfo.js +++ b/src/services/FolderInfo.js @@ -20,8 +20,10 @@ * */ +import { getCurrentUser } from '@nextcloud/auth' import client from './DavClient' import request from './DavRequest' +import { genFileInfo } from '../utils/fileUtils' /** * List files from a folder and filter out unwanted mimes @@ -33,17 +35,13 @@ export default async function(path) { // getDirectoryContents doesn't accept / for root const fixedPath = path === '/' ? '' : path + const prefixPath = `/files/${getCurrentUser().uid}` + // fetch listing - const response = await client.stat(fixedPath, { + const response = await client.stat(prefixPath + fixedPath, { data: request, details: true, }) - const entry = response.data - return Object.assign({ - id: parseInt(entry.props.fileid), - isFavorite: entry.props.favorite !== '0', - hasPreview: entry.props['has-preview'] !== 'false', - }, entry) - + return genFileInfo(response.data, prefixPath) } diff --git a/src/services/PhotoSearch.js b/src/services/PhotoSearch.js index b780ba2c..8d3789ea 100644 --- a/src/services/PhotoSearch.js +++ b/src/services/PhotoSearch.js @@ -20,8 +20,10 @@ * */ -import client from './DavClient' +import { generateRemoteUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' +import client from './DavClient' +import { genFileInfo } from '../utils/fileUtils' /** * List files from a folder and filter out unwanted mimes @@ -35,7 +37,7 @@ export default async function() { headers: { 'content-Type': 'text/xml', }, - url: '/remote.php/dav/', + url: generateRemoteUrl(`dav`), data: `<?xml version="1.0" encoding="UTF-8"?> <d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" diff --git a/src/services/SystemTags.js b/src/services/SystemTags.js new file mode 100644 index 00000000..c0244a31 --- /dev/null +++ b/src/services/SystemTags.js @@ -0,0 +1,50 @@ +/** + * @copyright Copyright (c) 2019 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/>. + * + */ + +import client from './DavClient' +import { genFileInfo } from '../utils/fileUtils' + +/** + * List system tags + * + * @param {String} path the path relative to the user root + * @param {Object} [options] optional options for axios + * @returns {Array} the file list + */ +export default async function(path, options = {}) { + const response = await client.getDirectoryContents('/systemtags/', Object.assign({}, { + data: `<?xml version="1.0"?> + <d:propfind xmlns:d="DAV:" + xmlns:oc="http://owncloud.org/ns"> + <d:prop> + <oc:id /> + <oc:display-name /> + <oc:user-visible /> + <oc:user-assignable /> + <oc:can-assign /> + </d:prop> + </d:propfind>`, + details: true, + }, options)) + + return response.data.map(data => genFileInfo(data)) +} diff --git a/src/store/folders.js b/src/store/folders.js index 559d3ef2..1075900d 100644 --- a/src/store/folders.js +++ b/src/store/folders.js @@ -20,6 +20,7 @@ * */ import Vue from 'vue' +import { sortCompare } from '../utils/fileUtils' const state = { paths: {}, @@ -39,9 +40,7 @@ const mutations = { if (files.length > 0) { const t0 = performance.now() // sort by last modified - const list = files.sort((a, b) => { - return new Date(b.lastmod).getTime() - new Date(a.lastmod).getTime() - }) + const list = files.sort((a, b) => sortCompare(a, b, 'lastmod')) // Set folder list Vue.set(state.folders, id, list.map(file => file.id)) diff --git a/src/store/index.js b/src/store/index.js index d7593570..e48270b7 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -25,12 +25,14 @@ import Vuex, { Store } from 'vuex' import files from './files' import folders from './folders' +import systemtags from './systemtags' Vue.use(Vuex) export default new Store({ modules: { files, folders, + systemtags, }, strict: process.env.NODE_ENV !== 'production', diff --git a/src/store/systemtags.js b/src/store/systemtags.js new file mode 100644 index 00000000..4a49e13e --- /dev/null +++ b/src/store/systemtags.js @@ -0,0 +1,99 @@ +/** + * @copyright Copyright (c) 2019 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/>. + * + */ +import Vue from 'vue' +import { sortCompare } from '../utils/fileUtils' + +const state = { + tags: {}, + names: {}, +} + +const mutations = { + /** + * Order and save tags + * + * @param {Object} state vuex state + * @param {Array} tags the tags list + */ + updateTags(state, tags) { + if (tags.length > 0) { + // sort by basename + const list = tags.sort((a, b) => sortCompare(a, b, 'displayName')) + + // store tag and its index + list.forEach(tag => { + Vue.set(state.tags, tag.id, tag) + Vue.set(state.tags[tag.id], 'files', []) + Vue.set(state.names, tag.displayName, tag.id) + }) + } + }, + + /** + * Update tag files list + * + * @param {Object} state vuex state + * @param {Object} data destructuring object + * @param {number} data.id current tag id + * @param {Object[]} data.files list of files + */ + updateTag(state, { id, files }) { + // sort by last modified + const list = files.sort((a, b) => sortCompare(a, b, 'lastmod')) + + // overwrite list + Vue.set(state.tags[id], 'files', list.map(file => file.id)) + }, +} + +const getters = { + tags: state => state.tags, + tagsNames: state => state.names, + tag: state => id => state.tags[id], + tagId: state => name => state.names[name], +} + +const actions = { + /** + * Update files and folders + * + * @param {Object} context vuex context + * @param {Array} tags the tag list + */ + updateTags(context, tags) { + context.commit('updateTags', tags) + }, + + /** + * Update tag files list + * + * @param {Object} context vuex context + * @param {Object} data destructuring object + * @param {number} data.id current tag id + * @param {Object[]} data.files list of files + */ + updateTag(context, { id, files }) { + context.commit('updateTag', { id, files }) + }, +} + +export default { state, mutations, getters, actions } diff --git a/src/utils/fileUtils.js b/src/utils/fileUtils.js new file mode 100644 index 00000000..ce18c08f --- /dev/null +++ b/src/utils/fileUtils.js @@ -0,0 +1,125 @@ +/** + * @copyright Copyright (c) 2019 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/>. + * + */ +import camelcase from 'camelcase' +import { isNumber } from './numberUtil' + +/** + * Get an url encoded path + * + * @param {String} path the full path + * @returns {string} url encoded file path + */ +const encodeFilePath = function(path) { + const pathSections = (path.startsWith('/') ? path : `/${path}`).split('/') + let relativePath = '' + pathSections.forEach((section) => { + if (section !== '') { + relativePath += '/' + encodeURIComponent(section) + } + }) + return relativePath +} + +/** + * Extract dir and name from file path + * + * @param {String} path the full path + * @returns {String[]} [dirPath, fileName] + */ +const extractFilePaths = function(path) { + const pathSections = path.split('/') + const fileName = pathSections[pathSections.length - 1] + const dirPath = pathSections.slice(0, pathSections.length - 1).join('/') + return [dirPath, fileName] +} + +/** + * Sorting comparison function + * + * @param {Object} fileInfo1 file 1 fileinfo + * @param {Object} fileInfo2 file 2 fileinfo + * @param {string} key key to sort with + * @param {boolean} [asc=true] sort ascending? + * @returns {number} + */ +const sortCompare = function(fileInfo1, fileInfo2, key, asc = true) { + + // favorite always first + if (fileInfo1.isFavorite && !fileInfo2.isFavorite) { + return -1 + } else if (!fileInfo1.isFavorite && fileInfo2.isFavorite) { + return 1 + } + + // if this is a number, let's sort by integer + if (isNumber(fileInfo1[key]) && isNumber(fileInfo2[key])) { + return asc + ? Number(fileInfo2[key]) - Number(fileInfo1[key]) + : Number(fileInfo1[key]) - Number(fileInfo2[key]) + } + + // else we sort by string, so let's sort directories first + if (fileInfo1.type === 'directory' && fileInfo2.type !== 'directory') { + return asc ? -1 : 1 + } else if (fileInfo1.type !== 'directory' && fileInfo2.type === 'directory') { + return asc ? 1 : -1 + } + + // if this is a date, let's sort by date + if (isNumber(new Date(fileInfo1[key]).getTime()) && isNumber(new Date(fileInfo2[key])).getTime()) { + return asc + ? new Date(fileInfo2[key]).getTime() - new Date(fileInfo1[key]).getTime() + : new Date(fileInfo1[key]).getTime() - new Date(fileInfo2[key]).getTime() + } + + // finally sort by name + return asc + ? fileInfo1[key].localeCompare(fileInfo2[key], OC.getLanguage()) + : -fileInfo1[key].localeCompare(fileInfo2[key], OC.getLanguage()) +} + +const genFileInfo = function(obj) { + const fileInfo = {} + + Object.keys(obj).forEach(key => { + const data = obj[key] + + // flatten object if any + if (!!data && typeof data === 'object') { + Object.assign(fileInfo, genFileInfo(data)) + } else { + // format key and add it to the fileInfo + if (data === 'false') { + fileInfo[camelcase(key)] = false + } else if (data === 'true') { + fileInfo[camelcase(key)] = true + } else { + fileInfo[camelcase(key)] = isNumber(data) + ? Number(data) + : data + } + } + }) + return fileInfo +} + +export { encodeFilePath, extractFilePaths, sortCompare, genFileInfo } diff --git a/src/utils/numberUtil.js b/src/utils/numberUtil.js new file mode 100644 index 00000000..0c3a96e5 --- /dev/null +++ b/src/utils/numberUtil.js @@ -0,0 +1,30 @@ +/** + * @copyright Copyright (c) 2019 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/>. + * + */ + +const isNumber = function(num) { + if (!num) { + return false + } + return Number(num).toString() === num.toString() +} + +export { isNumber } diff --git a/src/views/Grid.vue b/src/views/Albums.vue index 849fd99b..2338a3c7 100644 --- a/src/views/Grid.vue +++ b/src/views/Albums.vue @@ -33,16 +33,11 @@ </EmptyContent> <!-- Folder content --> - <transition-group v-else - class="photos-grid" - role="grid" - name="list" - tag="div"> + <Grid v-else> <Navigation v-if="folder" key="navigation" v-bind="folder" /> - <Folder v-for="dir in folderList" :key="dir.id" :folder="dir" /> + <Folder v-for="dir in folderList" :key="dir.id" v-bind="dir" /> <File v-for="file in fileList" :key="file.id" v-bind="file" /> - <div key="footer" role="none" class="photos-grid__footer-spacer" /> - </transition-group> + </Grid> </template> <script> @@ -55,16 +50,18 @@ import getPictures from '../services/FileList' import EmptyContent from './EmptyContent' import Folder from '../components/Folder' import File from '../components/File' +import Grid from '../components/Grid' import Navigation from '../components/Navigation' import cancelableRequest from '../utils/CancelableRequest' export default { - name: 'Grid', + name: 'Albums', components: { EmptyContent, File, Folder, + Grid, Navigation, }, props: { @@ -205,50 +202,3 @@ export default { } </script> - -<style lang="scss"> -.photos-grid { - display: grid; - align-items: center; - justify-content: center; - gap: 8px; - grid-template-columns: repeat(10, 1fr); - position: relative; - // always put one more row of grid for the spacer - &__footer-spacer { - // always add one row, so placing it on the first - // column will always add one more - grid-column: 1; - // same height as the width - padding-bottom: 100%; - } -} - -.list-move { - transition: transform var(--animation-quick); -} - -// TODO: use mixins/GridSizes as soon as node-sass supports it -// needs node-sass 5.0 (with libsass 3.6) -// https://github.com/sass/node-sass/pull/2312 -$previous: 0; -@each $size, $config in get('sizes') { - $count: map-get($config, 'count'); - $marginTop: map-get($config, 'marginTop'); - $marginW: map-get($config, 'marginW'); - - // if this is the last entry, only use min-width - $rule: '(min-width: #{$previous}px) and (max-width: #{$size}px)'; - @if $size == 'max' { - $rule: '(min-width: #{$previous}px)'; - } - - @media #{$rule} { - .photos-grid { - padding: #{$marginTop}px #{$marginW}px #{$marginW}px #{$marginW}px; - grid-template-columns: repeat($count, 1fr); - } - } - $previous: $size; -} -</style> diff --git a/src/views/Tags.vue b/src/views/Tags.vue new file mode 100644 index 00000000..069b99f4 --- /dev/null +++ b/src/views/Tags.vue @@ -0,0 +1,164 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <!-- Errors handlers--> + <!-- <EmptyContent v-if="error === 404" illustration-name="folder"> + {{ t('photos', 'This folder does not exists') }} + </EmptyContent> + <EmptyContent v-else-if="error"> + {{ t('photos', 'An error occurred') }} + </EmptyContent> + <EmptyContent v-else-if="!loading && isEmpty" illustration-name="empty"> + {{ t('photos', 'This folder does not contain pictures') }} + </EmptyContent> --> + + <!-- Folder content --> + <Grid v-if="isRoot"> + <Navigation v-if="tag" + key="navigation" + :basename="tagname" + :filename="'/' + tagname" + :root-title="t('photos', 'Tags')" /> + <Folder v-for="id in tagsNames" + :key="id" + v-bind="tags[id]" + :basename="tags[id].displayName" + icon="icon-tag" /> + </Grid> + <!-- <Grid v-else> + <Navigation v-if="folder" key="navigation" v-bind="folder" /> + <Folder v-for="dir in folderList" :key="dir.id" :folder="dir" /> + <File v-for="file in fileList" :key="file.id" v-bind="file" /> + </Grid> --> +</template> + +<script> +import { mapGetters } from 'vuex' + +import getSystemTags from '../services/SystemTags' + +import EmptyContent from './EmptyContent' +import Folder from '../components/Folder' +import File from '../components/File' +import Grid from '../components/Grid' +import Navigation from '../components/Navigation' + +import cancelableRequest from '../utils/CancelableRequest' + +export default { + name: 'Tags', + components: { + EmptyContent, + File, + Folder, + Grid, + Navigation, + }, + props: { + tagname: { + type: String, + default: '', + }, + loading: { + type: Boolean, + required: true, + }, + }, + + data() { + return { + error: null, + cancelRequest: () => {}, + } + }, + + computed: { + // global lists + ...mapGetters([ + 'files', + 'tags', + 'tagsNames', + ]), + + // current tag id from current path + tagId() { + return this.$store.getters.tagId(this.tagname) + }, + + // current tag + tag() { + return this.tags[this.tagId] + }, + // files list of the current tag + fileList() { + return this.tag && this.tag.files + .map(id => this.files[id]) + .filter(file => !!file) + }, + + isRoot() { + return this.tagname === '' + }, + }, + + watch: { + tagname(name) { + console.debug('changed:', name) + this.fetchRootContent() + }, + }, + + async beforeMount() { + console.debug('beforemount: GRID') + this.fetchRootContent() + }, + + methods: { + async fetchRootContent() { + console.debug('start: fetchRootContent', this.path) + // cancel any pending requests + this.cancelRequest() + + // close any potential opened viewer + OCA.Viewer.close() + + // if we don't already have some cached data let's show a loader + if (!this.tags[this.folderId]) { + this.$emit('update:loading', true) + } + this.error = null + + // init cancellable request + const { request, cancel } = cancelableRequest(getSystemTags) + this.cancelRequest = cancel + + const tags = await request() + this.$store.dispatch('updateTags', tags) + + // done loading + this.$emit('update:loading', false) + }, + }, + +} +</script> diff --git a/templates/main.php b/templates/main.php index 94e41675..1adb9af1 100644 --- a/templates/main.php +++ b/templates/main.php @@ -1,4 +1,25 @@ <?php -script('photos', 'photos'); +/** + * @copyright Copyright (c) 2019 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/>. + * + */ ?> + <div id="content"></div> diff --git a/webpack.common.js b/webpack.common.js index b9bbc35a..06c06cb3 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -14,13 +14,13 @@ module.exports = { path: path.resolve(__dirname, './js'), publicPath: '/js/', filename: `${appName}.js`, - chunkFilename: 'chunks/[name]-[hash].js' + chunkFilename: 'chunks/[name]-[hash].js', }, module: { rules: [ { test: /\.css$/, - use: ['vue-style-loader', 'css-loader', 'postcss-loader'] + use: ['vue-style-loader', 'css-loader', 'postcss-loader'], }, { test: /\.scss$/, @@ -32,34 +32,34 @@ module.exports = { loader: 'sass-loader', options: { functions: { - 'get($keys)': SassGetGridConfig - } - } - } - ] + 'get($keys)': SassGetGridConfig, + }, + }, + }, + ], }, { test: /\.(js|vue)$/, use: 'eslint-loader', exclude: /node_modules/, - enforce: 'pre' + enforce: 'pre', }, { test: /\.vue$/, loader: 'vue-loader', - exclude: /node_modules/ + exclude: /node_modules/, }, { test: /\.js$/, loader: 'babel-loader', - exclude: /node_modules(?!(\/|\\)(hot-patcher|webdav)(\/|\\))/ + exclude: /node_modules(?!(\/|\\)(hot-patcher|webdav)(\/|\\))/, }, { test: /\.svg$/, // illustrations - loader: 'svg-inline-loader' - } - ] + loader: 'svg-inline-loader', + }, + ], }, plugins: [ new VueLoaderPlugin(), @@ -68,12 +68,13 @@ module.exports = { new ModuleReplaceWebpackPlugin({ modules: [{ test: /request.js/, - replace: './src/patchedRequest.js' - }] - }) + replace: './src/patchedRequest.js', + exclude: [/patchedRequest.js$/], + }], + }), ], resolve: { extensions: ['*', '.js', '.vue'], - symlinks: false - } + symlinks: false, + }, } |