diff options
author | Marcel Klehr <mklehr@gmx.net> | 2022-08-29 11:32:32 +0200 |
---|---|---|
committer | Marcel Klehr <mklehr@gmx.net> | 2022-09-01 13:41:33 +0200 |
commit | 4ae8f27f0e6546ed8bbaa919e4a48eed51055632 (patch) | |
tree | 3b7561a66d454954fdc4c7f27ffb1a518b8fe11c | |
parent | 46512b4415e588d956cb04ac6bf58e18b891c863 (diff) |
Make tags view pretty even if recognize is not installed
Signed-off-by: Marcel Klehr <mklehr@gmx.net>
-rw-r--r-- | appinfo/routes.php | 8 | ||||
-rw-r--r-- | src/components/FolderTagPreview.vue | 14 | ||||
-rw-r--r-- | src/components/Tag.vue | 40 | ||||
-rw-r--r-- | src/components/ThingsCategory.vue | 104 | ||||
-rw-r--r-- | src/router/index.js | 25 | ||||
-rw-r--r-- | src/services/Things.js | 23 | ||||
-rw-r--r-- | src/store/systemtags.js | 30 | ||||
-rw-r--r-- | src/views/CategoryContent.vue | 199 | ||||
-rw-r--r-- | src/views/TagContent.vue | 199 | ||||
-rw-r--r-- | src/views/Tags.vue | 221 |
10 files changed, 682 insertions, 181 deletions
diff --git a/appinfo/routes.php b/appinfo/routes.php index 380a0b5e..ba81f4c6 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -78,6 +78,14 @@ return [ 'path' => '', ] ], + ['name' => 'page#index', 'url' => '/categories/{path}', 'verb' => 'GET', 'postfix' => 'categories', + 'requirements' => [ + 'path' => '.*', + ], + 'defaults' => [ + 'path' => '', + ] + ], // apis [ diff --git a/src/components/FolderTagPreview.vue b/src/components/FolderTagPreview.vue index 77a24bea..ab4e59b7 100644 --- a/src/components/FolderTagPreview.vue +++ b/src/components/FolderTagPreview.vue @@ -23,7 +23,7 @@ <template> <router-link :class="{'folder--clear': isEmpty}" class="folder" - :to="to" + :to="toLink" :aria-label="ariaLabel"> <!-- Images preview --> <transition name="fade"> @@ -74,12 +74,16 @@ export default { }, path: { type: String, - required: true, + default: '', }, fileList: { type: Array, default: () => [], }, + to: { + type: Object, + default: null, + }, }, data() { @@ -121,7 +125,10 @@ export default { * * @return {string} */ - to() { + toLink() { + if (this.to) { + return this.to + } // always remove first slash, the router // manage it automatically const regex = /^\/?(.+)/i @@ -215,6 +222,7 @@ $name-height: 1rem; // Cover management empty/full .folder { + border-radius: var(--border-radius-large); // if no img, let's display the folder icon as default black &--clear { .folder-name__icon { diff --git a/src/components/Tag.vue b/src/components/Tag.vue index 49aaa2fa..a537cc7b 100644 --- a/src/components/Tag.vue +++ b/src/components/Tag.vue @@ -22,17 +22,18 @@ --> <template> - <FolderTagPreview :id="item.injected.id" - icon="icon-tag" - :name="item.injected.displayName" - :path="item.injected.displayName" - :file-list="fileList" /> + <div class="tag"> + <FolderTagPreview :id="tag.id" + icon="icon-tag" + :name="t('recognize', tag.displayName)" + :to="{name: 'tagcontent', params: {path: tag.displayName }}" + :file-list="fileList" /> + </div> </template> <script> import { mapGetters } from 'vuex' -import getTaggedImages from '../services/TaggedImages' import FolderTagPreview from './FolderTagPreview' import AbortControllerMixin from '../mixins/AbortControllerMixin' @@ -49,7 +50,7 @@ export default { inheritAttrs: false, props: { - item: { + tag: { type: Object, required: true, }, @@ -64,7 +65,7 @@ export default { // files list of the current folder folderContent() { - return this.tags[this.item.injected.id].files + return this.tags[this.tag.id].files }, fileList() { return this.folderContent @@ -77,19 +78,16 @@ export default { }, async created() { - try { - // get data - const files = await getTaggedImages(this.item.injected.id, { - signal: this.abortController.signal, - }) - this.$store.dispatch('updateTag', { id: this.item.injected.id, files }) - this.$store.dispatch('appendFiles', files) - } catch (error) { - if (error.response && error.response.status) { - console.error('Failed to get folder content', this.item.injected.id, error.response) - } - } + this.$store.dispatch('fetchTagFiles', { + id: this.tag.id, + signal: this.abortController.signal, + }) }, - } </script> +<style scoped lang="scss"> +.tag { + height: 250px; + width: 250px; +} +</style> diff --git a/src/components/ThingsCategory.vue b/src/components/ThingsCategory.vue new file mode 100644 index 00000000..80d0af3d --- /dev/null +++ b/src/components/ThingsCategory.vue @@ -0,0 +1,104 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - @author Corentin Mors <medias@pixelswap.fr> + - + - @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/>. + - + --> + +<template> + <div v-show="folderContent.length" class="things-category"> + <FolderTagPreview :id="Object.keys(CATEGORIES).indexOf(title)" + icon="icon-tag" + :name="t('photos', title)" + :to="{name:'categorycontent', params:{category: title}}" + :file-list="fileList" /> + </div> +</template> + +<script> +import { mapGetters } from 'vuex' + +import FolderTagPreview from './FolderTagPreview' +import { CATEGORIES } from '../services/Things' + +export default { + name: 'ThingsCategory', + + components: { + FolderTagPreview, + }, + inheritAttrs: false, + + props: { + title: { + type: String, + required: true, + }, + }, + + data() { + return { + CATEGORIES, + } + }, + + computed: { + // global lists + ...mapGetters([ + 'files', + 'tags', + 'tagsNames', + ]), + + // tags list of the current category + categoryTags() { + return CATEGORIES[this.title] + .map(tagName => this.tags[this.tagsNames[tagName]]) + .filter(Boolean) + }, + + // files list of the current category + folderContent() { + return this.categoryTags.flatMap(tag => tag.files) + }, + + fileList() { + return this.folderContent + ? this.folderContent + .map(id => this.files[id]) + .filter(file => !!file) + .slice(0, 4) // only get the 4 first images + : [] + }, + }, + + async created() { + Promise.all(this.categoryTags.map(tag => + this.$store.dispatch('fetchTagFiles', { id: tag.id }) + )) + }, + +} +</script> +<style scoped lang="scss"> +.things-category { + height: 250px; + width: 250px; +} +</style> diff --git a/src/router/index.js b/src/router/index.js index 99056906..6dd62c92 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -34,6 +34,8 @@ const Folders = () => import('../views/Folders') const Albums = () => import('../views/Albums') const AlbumContent = () => import('../views/AlbumContent') const Tags = () => import('../views/Tags') +const TagContent = () => import('../views/TagContent') +const CategoryContent = () => import('../views/CategoryContent') const Timeline = () => import('../views/Timeline') const Faces = () => import('../views/Faces') const FaceContent = () => import('../views/FaceContent') @@ -129,18 +131,35 @@ const router = new Router({ }), }, { - path: '/tags/:path*', + path: '/tags/', component: Tags, name: 'tags', redirect: !areTagsInstalled ? { name: 'timeline' } : null, props: route => ({ - path: `${route.params.path ? route.params.path : ''}`, - // if path is empty + path: '', isRoot: !route.params.path, rootTitle: t('photos', 'Tagged photos'), }), }, { + path: '/tags/:path', + component: TagContent, + name: 'tagcontent', + redirect: !areTagsInstalled ? { name: 'timeline' } : null, + props: route => ({ + path: `${route.params.path ? route.params.path : ''}`, + }), + }, + { + path: '/categories/:category', + component: CategoryContent, + name: 'categorycontent', + redirect: !areTagsInstalled ? { name: 'timeline' } : null, + props: route => ({ + category: route.params.category, + }), + }, + { path: '/maps', name: 'maps', // router-link doesn't support external url, let's force the redirect diff --git a/src/services/Things.js b/src/services/Things.js new file mode 100644 index 00000000..544772a7 --- /dev/null +++ b/src/services/Things.js @@ -0,0 +1,23 @@ +export const CATEGORIES = { + Nature: ['Outdoor', 'Nature', 'Plant', 'Wildlife', 'Landscape'], + Architecture: ['Architecture', 'Historic', 'Monument'], + Food: ['Food', 'Beverage', 'Cooking', 'Vegetables', 'Fruit', 'Dining', 'Plate'], + Music: ['Music'], + Water: ['Water', 'Seaside', 'Lakeside'], + Mountains: ['Alpine', 'Mountain'], + Snow: ['Snow'], + Beach: ['Beach'], + Animals: ['Animal'], + People: ['People', 'Portrait'], +} +const t = () => {} +t('photos', 'Nature') +t('photos', 'Architecture') +t('photos', 'Food') +t('photos', 'Music') +t('photos', 'Water') +t('photos', 'Mountains') +t('photos', 'Snow') +t('photos', 'Beach') +t('photos', 'Animals') +t('photos', 'People') diff --git a/src/store/systemtags.js b/src/store/systemtags.js index bcfc8426..c8bb30ea 100644 --- a/src/store/systemtags.js +++ b/src/store/systemtags.js @@ -21,6 +21,9 @@ */ import Vue from 'vue' import { sortCompare } from '../utils/fileUtils' +import getTaggedImages from '../services/TaggedImages' +import { abortController } from '../services/RequestHandler' +import getSystemTags from '../services/SystemTags' const state = { tags: {}, @@ -118,6 +121,33 @@ const actions = { } context.commit('updateTag', { id, files }) }, + + /** + * + * @param context + * @param id.id + * @param id the tag id to fetch files for + * @return {Promise<void>} + */ + async fetchTagFiles(context, { id }) { + try { + // get data + const files = await getTaggedImages(id, { signal: abortController.signal }) + await context.dispatch('updateTag', { id, files }) + await context.dispatch('appendFiles', files) + } catch (error) { + if (error.response && error.response.status) { + console.error('Failed to get tag content', id, error.response) + } + } + }, + + async fetchAllTags(context) { + const tags = await getSystemTags('', { + signal: abortController.signal, + }) + await context.dispatch('updateTags', tags) + }, } export default { state, mutations, getters, actions } diff --git a/src/views/CategoryContent.vue b/src/views/CategoryContent.vue new file mode 100644 index 00000000..9ad1e941 --- /dev/null +++ b/src/views/CategoryContent.vue @@ -0,0 +1,199 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - @author Corentin Mors <medias@pixelswap.fr> + - @author Marcel Klehr <mklehr@gmx.net> + - + - @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/>. + - + --> + +<template> + <!-- Errors handlers--> + <EmptyContent v-if="error"> + {{ t('photos', 'An error occurred') }} + </EmptyContent> + + <!-- Folder content --> + <div v-else-if="!loading"> + <div class="photos-navigation"> + <Actions class="photos-navigation__back"> + <ActionButton @click="$router.push({name: 'tags'})"> + <template #icon> + <ArrowLeft /> + </template> + {{ t('photos', 'Back to tags overview') }} + </ActionButton> + </Actions> + <h2 class="photos-navigation__title"> + {{ t('photos', category) }} + </h2> + </div> + <EmptyContent v-if="isEmpty" key="emptycontent" illustration-name="empty"> + {{ t('photos', 'No tags yet') }} + <template #desc> + {{ t('photos', 'Photos with tags will show up here') }} + </template> + </EmptyContent> + + <FilesListViewer class="category__photos" + :use-window="true" + :file-ids="fileIds" + :base-height="isMobile ? 120 : 200" + :loading="loading"> + <File slot-scope="{file, visibility}" + :file="files[file.id]" + :allow-selection="true" + :selected="selection[file.id] === true" + :visibility="visibility" + :semaphore="semaphore" + @click="openViewer" + @select-toggled="onFileSelectToggle" /> + </FilesListViewer> + </div> +</template> + +<script> +import { mapGetters } from 'vuex' +import Actions from '@nextcloud/vue/dist/Components/Actions' +import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' +import ArrowLeft from 'vue-material-design-icons/ArrowLeft' + +import EmptyContent from '../components/EmptyContent' +import File from '../components/File' + +import FilesListViewer from '../components/FilesListViewer' +import { isMobile } from '@nextcloud/vue' +import SemaphoreWithPriority from '../utils/semaphoreWithPriority' +import FilesSelectionMixin from '../mixins/FilesSelectionMixin' +import { CATEGORIES } from '../services/Things' + +export default { + name: 'CategoryContent', + components: { + File, + FilesListViewer, + EmptyContent, + Actions, + ActionButton, + ArrowLeft, + }, + mixins: [ + isMobile, + FilesSelectionMixin, + ], + props: { + category: { + type: String, + required: true, + }, + }, + + data() { + return { + error: null, + loading: false, + semaphore: new SemaphoreWithPriority(30), + } + }, + + computed: { + // global lists + ...mapGetters([ + 'files', + 'tags', + 'tagsNames', + ]), + + // current tag id from current path + tagIds() { + return CATEGORIES[this.category] + .map(tagName => this.tagsNames[tagName]) + .filter(Boolean) + }, + + // files list of the current category + fileIds() { + return [...new Set(this.tagIds.flatMap(tagId => this.tags[tagId].files))] + }, + + isEmpty() { + return this.fileIds.length === 0 + }, + }, + + watch: { + async path() { + this.fetchContent() + }, + }, + + async beforeMount() { + this.fetchContent() + }, + + methods: { + async fetchContent() { + // close any potential opened viewer + OCA.Viewer.close() + + this.loading = true + + if (!this.tagIds.length) { + await this.$store.dispatch('fetchAllTags') + } + this.error = null + + try { + await Promise.all(this.tagIds.map(tagId => + this.$store.dispatch('fetchTagFiles', { id: tagId }) + )) + } catch (error) { + console.error(error) + this.error = true + } finally { + // done loading + this.loading = false + } + }, + + openViewer(fileId) { + const file = this.files[fileId] + OCA.Viewer.open({ + path: file.filename, + list: this.fileIds.map(fileId => this.files[fileId]), + loadMore: file.loadMore ? async () => await file.loadMore(true) : () => [], + canLoop: file.canLoop, + }) + }, + }, +} +</script> +<style scoped lang="scss"> +.photos-navigation { + display: flex; + height: 44px; + padding: 0 40px; + align-items: center; + max-width: 100%; + + h2 { + padding: 0; + margin: 0; + } +} +</style> diff --git a/src/views/TagContent.vue b/src/views/TagContent.vue new file mode 100644 index 00000000..ccb02f00 --- /dev/null +++ b/src/views/TagContent.vue @@ -0,0 +1,199 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - @author Corentin Mors <medias@pixelswap.fr> + - @author Marcel Klehr <mklehr@gmx.net> + - + - @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/>. + - + --> + +<template> + <!-- Errors handlers--> + <EmptyContent v-if="error"> + {{ t('photos', 'An error occurred') }} + </EmptyContent> + + <!-- Folder content --> + <div v-else-if="!loading"> + <div class="photos-navigation"> + <Actions class="photos-navigation__back"> + <ActionButton @click="$router.push({name: 'tags'})"> + <template #icon> + <ArrowLeft /> + </template> + {{ t('photos', 'Back to tags overview') }} + </ActionButton> + </Actions> + <h2 class="photos-navigation__title"> + {{ path }} + </h2> + </div> + <EmptyContent v-if="isEmpty" key="emptycontent" illustration-name="empty"> + {{ t('photos', 'No tags yet') }} + <template #desc> + {{ t('photos', 'Photos with tags will show up here') }} + </template> + </EmptyContent> + + <FilesListViewer class="tag__photos" + :use-window="true" + :file-ids="fileIds" + :base-height="isMobile ? 120 : 200" + :loading="loading"> + <File slot-scope="{file, visibility}" + :file="files[file.id]" + :allow-selection="true" + :selected="selection[file.id] === true" + :visibility="visibility" + :semaphore="semaphore" + @click="openViewer" + @select-toggled="onFileSelectToggle" /> + </FilesListViewer> + </div> +</template> + +<script> +import { mapGetters } from 'vuex' + +import EmptyContent from '../components/EmptyContent' +import File from '../components/File' + +import FilesListViewer from '../components/FilesListViewer' +import { isMobile } from '@nextcloud/vue' +import SemaphoreWithPriority from '../utils/semaphoreWithPriority' +import FilesSelectionMixin from '../mixins/FilesSelectionMixin' +import Actions from '@nextcloud/vue/dist/Components/Actions' +import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' +import ArrowLeft from 'vue-material-design-icons/ArrowLeft' + +export default { + name: 'TagContent', + components: { + File, + FilesListViewer, + EmptyContent, + Actions, + ActionButton, + ArrowLeft, + }, + mixins: [ + isMobile, + FilesSelectionMixin, + ], + props: { + path: { + type: String, + default: '', + }, + }, + + data() { + return { + error: null, + loading: false, + semaphore: new SemaphoreWithPriority(30), + } + }, + + computed: { + // global lists + ...mapGetters([ + 'files', + 'tags', + 'tagsNames', + ]), + + // current tag id from current path + tagId() { + return this.$store.getters.tagId(this.path) + }, + + // current tag + tag() { + return this.tags[this.tagId] + }, + + // files list of the current tag + fileIds() { + return this.tag ? this.tag.files : [] + }, + + isEmpty() { + return this.fileIds.length === 0 + }, + }, + + watch: { + async path() { + this.fetchContent() + }, + }, + + async beforeMount() { + this.fetchContent() + }, + + methods: { + async fetchContent() { + // 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.tagId]) { + this.loading = true + await this.$store.dispatch('fetchAllTags') + } + this.error = null + + try { + await this.$store.dispatch('fetchTagFiles', { id: this.tagId }) + } catch (error) { + console.error(error) + this.error = true + } finally { + // done loading + this.loading = false + } + }, + + openViewer(fileId) { + const file = this.files[fileId] + OCA.Viewer.open({ + path: file.filename, + list: this.fileIds.map(fileId => this.files[fileId]), + loadMore: file.loadMore ? async () => await file.loadMore(true) : () => [], + canLoop: file.canLoop, + }) + }, + }, +} +</script> +<style scoped lang="scss"> +.photos-navigation { + display: flex; + height: 44px; + padding: 0 40px; + align-items: center; + max-width: 100%; + + h2 { + padding: 0; + margin: 0; + } +} +</style> diff --git a/src/views/Tags.vue b/src/views/Tags.vue index fd200d3b..aec821af 100644 --- a/src/views/Tags.vue +++ b/src/views/Tags.vue @@ -22,78 +22,69 @@ --> <template> - <!-- Errors handlers--> - <EmptyContent v-if="error"> - {{ t('photos', 'An error occurred') }} - </EmptyContent> + <div> + <!-- Errors handlers--> + <EmptyContent v-if="error"> + {{ t('photos', 'An error occurred') }} + </EmptyContent> - <!-- Folder content --> - <div v-else-if="!loading"> - <Navigation key="navigation" - :basename="path" - :filename="'/' + path" - :root-title="rootTitle" /> - <EmptyContent v-if="isEmpty" key="emptycontent" illustration-name="empty"> + <EmptyContent v-if="!loading && !hasTagsWithFiles" key="emptycontent" illustration-name="empty"> {{ t('photos', 'No tags yet') }} <template #desc> {{ t('photos', 'Photos with tags will show up here') }} </template> </EmptyContent> - <div v-else class="grid-container"> - <VirtualGrid ref="virtualgrid" - :items="contentList" - :get-column-count="() => gridConfig.count" - :get-grid-gap="() => gridConfig.gap" /> + <Loader v-if="loading" class="loader" /> + + <div v-else> + <div class="grid-container"> + <div v-if="hasCategoriesWithFiles" class="things"> + <ThingsCategory v-for="category in Object.keys(CATEGORIES)" :key="category" :title="category" /> + <div v-if="!showTags" class="expand-box"> + <Button aria-label="Show more tags" @click="expandTags()"> + More + </Button> + </div> + </div> + <div v-if="showTags || !hasCategoriesWithFiles" class="tags"> + <Tag v-for="tag in tagsList" :key="tag.id" :tag="tag" /> + </div> + </div> </div> </div> </template> <script> import { mapGetters } from 'vuex' -import VirtualGrid from 'vue-virtual-grid' - -import getSystemTags from '../services/SystemTags' -import getTaggedImages from '../services/TaggedImages' +import { Button } from '@nextcloud/vue' import EmptyContent from '../components/EmptyContent' import Tag from '../components/Tag' -import File from '../components/File' -import Navigation from '../components/Navigation' -import GridConfigMixin from '../mixins/GridConfig' +import { CATEGORIES } from '../services/Things' +import ThingsCategory from '../components/ThingsCategory' +import Loader from '../components/Loader' import AbortControllerMixin from '../mixins/AbortControllerMixin' + export default { name: 'Tags', components: { - VirtualGrid, + Loader, + ThingsCategory, + Tag, EmptyContent, - Navigation, - }, - mixins: [ - GridConfigMixin, - AbortControllerMixin, - ], - props: { - rootTitle: { - type: String, - required: true, - }, - path: { - type: String, - default: '', - }, - isRoot: { - type: Boolean, - default: true, - }, + Button, }, + mixins: [AbortControllerMixin], data() { return { error: null, loading: false, + showTags: false, + CATEGORIES, } }, @@ -105,93 +96,24 @@ export default { 'tagsNames', ]), - // current tag id from current path - tagId() { - return this.$store.getters.tagId(this.path) - }, - - // current tag - tag() { - return this.tags[this.tagId] - }, - tagsList() { - return Object.values(this.tagsNames).map((tagsId) => this.tags[tagsId]) + return Object.values(this.tagsNames).map((tagsId) => this.tags[tagsId]).filter(tag => tag && tag.id) }, - // files list of the current tag - fileList() {< |