diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/Photos.vue | 99 | ||||
-rw-r--r-- | src/components/File.vue | 318 | ||||
-rw-r--r-- | src/components/PhotosHeader.vue | 204 | ||||
-rw-r--r-- | src/components/TiledLayout.vue | 100 | ||||
-rw-r--r-- | src/components/TiledRows.vue | 41 | ||||
-rw-r--r-- | src/components/VirtualScrolling.vue | 283 | ||||
-rw-r--r-- | src/router/index.js | 50 | ||||
-rw-r--r-- | src/services/DavRequest.js | 1 | ||||
-rw-r--r-- | src/services/FileActions.js | 103 | ||||
-rw-r--r-- | src/services/PhotoSearch.js | 31 | ||||
-rw-r--r-- | src/services/TiledLayout.js | 150 | ||||
-rw-r--r-- | src/services/TiledLayout.spec.js | 115 | ||||
-rw-r--r-- | src/services/logger.js | 37 | ||||
-rw-r--r-- | src/store/files.js | 111 | ||||
-rw-r--r-- | src/utils/semaphoreWithPriority.js | 79 | ||||
-rw-r--r-- | src/views/Timeline.vue | 352 |
16 files changed, 1805 insertions, 269 deletions
diff --git a/src/Photos.vue b/src/Photos.vue index 98fabfce..7835fbfa 100644 --- a/src/Photos.vue +++ b/src/Photos.vue @@ -24,24 +24,63 @@ <Content app-name="photos"> <AppNavigation> <template #list> - <AppNavigationItem :to="{name: 'timeline'}" - class="app-navigation__photos" - :title="t('photos', 'Your photos')" - icon="icon-yourphotos" - exact /> - <AppNavigationItem to="/videos" :title="t('photos', 'Your videos')" icon="icon-video" /> - <AppNavigationItem to="/favorites" :title="t('photos', 'Favorites')" icon="icon-favorite" /> - <AppNavigationItem :to="{name: 'thisday'}" :title="t('photos', 'On this day')" icon="icon-calendar-dark" /> - <AppNavigationItem :to="{name: 'albums'}" :title="t('photos', 'Your folders')" icon="icon-files-dark" /> - <AppNavigationItem :to="{name: 'shared'}" :title="t('photos', 'Shared with you')" icon="icon-share" /> + <AppNavigationItem :to="{name: 'all_media'}" + class="app-navigation__all_media" + :title="t('photos', 'All media')" + exact> + <template #icon> + <ImageIcon /> + </template> + </AppNavigationItem> + <AppNavigationItem to="/photos" :title="t('photos', 'Photos')"> + <template #icon> + <Camera /> + </template> + </AppNavigationItem> + <AppNavigationItem to="/videos" :title="t('photos', 'Videos')"> + <template #icon> + <VideoIcon /> + </template> + </AppNavigationItem> + <AppNavigationItem :to="{name: 'albums'}" :title="t('photos', 'Albums')"> + <template #icon> + <FolderMultipleImage /> + </template> + </AppNavigationItem> + <AppNavigationItem :to="{name: 'folders'}" :title="t('photos', 'Folders')"> + <template #icon> + <Folder /> + </template> + </AppNavigationItem> + <AppNavigationItem to="/favorites" :title="t('photos', 'Favorites')"> + <template #icon> + <Star /> + </template> + </AppNavigationItem> + <AppNavigationItem :to="{name: 'thisday'}" :title="t('photos', 'On this day')"> + <template #icon> + <CalendarToday /> + </template> + </AppNavigationItem> + <AppNavigationItem :to="{name: 'shared'}" :title="t('photos', 'Shared with you')"> + <template #icon> + <ShareVariant /> + </template> + </AppNavigationItem> <AppNavigationItem v-if="areTagsInstalled" :to="{name: 'tags'}" - :title="t('photos', 'Tagged photos')" - icon="icon-tag" /> + :title="t('photos', 'Tagged photos')"> + <template #icon> + <Tag /> + </template> + </AppNavigationItem> <AppNavigationItem v-if="showLocationMenuEntry" :to="{name: 'maps'}" - :title="t('photos', 'Locations')" - icon="icon-address" /> + :title="t('photos', 'Locations')"> + <template #icon> + <MapMarker /> + </template> + </AppNavigationItem> </template> <template #footer> <AppNavigationSettings :title="t('photos', 'Photos settings')"> @@ -49,8 +88,8 @@ </AppNavigationSettings> </template> </AppNavigation> - <AppContent :class="{ 'icon-loading': loading }"> - <router-view v-show="!loading" :loading.sync="loading" /> + <AppContent> + <router-view /> <!-- svg img loading placeholder (linked to the File component) --> <!-- eslint-disable-next-line vue/no-v-html (because it's an SVG file) --> @@ -68,18 +107,29 @@ import { getCurrentUser } from '@nextcloud/auth' import { generateUrl } from '@nextcloud/router' import { loadState } from '@nextcloud/initial-state' +import Camera from 'vue-material-design-icons/Camera.vue' +import ImageIcon from 'vue-material-design-icons/Image.vue' +import VideoIcon from 'vue-material-design-icons/Video.vue' +import FolderMultipleImage from 'vue-material-design-icons/FolderMultipleImage.vue' +import Folder from 'vue-material-design-icons/Folder.vue' +import Star from 'vue-material-design-icons/Star.vue' +import CalendarToday from 'vue-material-design-icons/CalendarToday.vue' +import Tag from 'vue-material-design-icons/Tag.vue' +import MapMarker from 'vue-material-design-icons/MapMarker.vue' +import ShareVariant from 'vue-material-design-icons/ShareVariant.vue' + 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 AppNavigationSettings from '@nextcloud/vue/dist/Components/AppNavigationSettings' -import CroppedLayoutSettings from './components/Settings/CroppedLayoutSettings' +import CroppedLayoutSettings from './components/Settings/CroppedLayoutSettings.vue' import svgplaceholder from './assets/file-placeholder.svg' import imgplaceholder from './assets/image.svg' import videoplaceholder from './assets/video.svg' -import isMapsInstalled from './services/IsMapsInstalled' -import areTagsInstalled from './services/AreTagsInstalled' +import isMapsInstalled from './services/IsMapsInstalled.js' +import areTagsInstalled from './services/AreTagsInstalled.js' export default { name: 'Photos', @@ -90,10 +140,19 @@ export default { AppNavigation, AppNavigationItem, AppNavigationSettings, + ImageIcon, + Camera, + VideoIcon, + FolderMultipleImage, + Folder, + Star, + CalendarToday, + Tag, + MapMarker, + ShareVariant, }, data() { return { - loading: false, svgplaceholder, imgplaceholder, videoplaceholder, diff --git a/src/components/File.vue b/src/components/File.vue index c69a6273..87ff8053 100644 --- a/src/components/File.vue +++ b/src/components/File.vue @@ -21,49 +21,73 @@ --> <template> - <a :class="{ - 'file--cropped': croppedLayout, - }" - class="file" - :href="davPath" - :aria-label="ariaLabel" - @click.prevent="openViewer"> - <div v-if="item.injected.mime.includes('video') && item.injected.hasPreview" class="icon-video-white" /> - <!-- image and loading placeholder --> - <transition-group name="fade" class="transition-group"> - <img v-if="!error" - ref="img" - :key="`${item.injected.basename}-img`" - :src="src" - :alt="item.injected.basename" - :aria-describedby="ariaUuid" - @load="onLoad" - @error="onError"> - - <svg v-if="!loaded || error" - :key="`${item.injected.basename}-svg`" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 32 32" - fill="url(#placeholder__gradient)"> - <use v-if="isImage" href="#placeholder--img" /> - <use v-else href="#placeholder--video" /> - </svg> - </transition-group> - - <!-- image name and cover --> - <p :id="ariaUuid" class="hidden-visually">{{ item.injected.basename }}</p> - <div class="cover" role="none" /> - </a> + <div class="file-container" + :class="{selected}"> + <a class="file" + :href="davPath" + :aria-label="ariaLabel" + @click.prevent="emitClick"> + + <div v-if="item.mime.includes('video') && item.hasPreview" class="icon-video-white" /> + + <!-- image and loading placeholder --> + <div class="images-container"> + <img v-if="visibility !== 'none' && canLoad && !error" + ref="imgNear" + :key="`${item.basename}-near`" + :src="srcNear" + :alt="item.basename" + :aria-describedby="ariaDescription" + @load="onLoad" + @error="onError"> + + <img v-if="visibility === 'visible' && canLoad && !error" + ref="imgVisible" + :key="`${item.basename}-visible`" + :src="srcVisible" + :alt="item.basename" + :aria-describedby="ariaDescription" + @load="onLoad" + @error="onError"> + + <div v-if="visibility === 'none' || !loaded || error" + :key="`${item.basename}-placeholder`" + class="loading-overlay"> + <svg xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 32 32" + fill="url(#placeholder__gradient)"> + <use v-if="isImage" href="#placeholder--img" /> + <use v-else href="#placeholder--video" /> + </svg> + </div> + </div> + + <!-- image name --> + <p :id="ariaDescription" class="hidden-visually">{{ item.basename }}</p> + </a> + + <CheckboxRadioSwitch v-if="allowSelection" + class="selection-checkbox" + :checked="selected" + @update:checked="onToggle"> + <span class="input-label">{{ t('photos', 'Select image {imageName}', {imageName: item.basename}) }}</span> + </CheckboxRadioSwitch> + </div> </template> <script> import { generateRemoteUrl, generateUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' +import { CheckboxRadioSwitch } from '@nextcloud/vue' -import UserConfig from '../mixins/UserConfig' +import UserConfig from '../mixins/UserConfig.js' +import SemaphoreWithPriority from '../utils/semaphoreWithPriority.js' export default { name: 'File', + components: { + CheckboxRadioSwitch, + }, mixins: [UserConfig], inheritAttrs: false, props: { @@ -71,58 +95,132 @@ export default { type: Object, required: true, }, + selected: { + type: Boolean, + required: true, + }, + allowSelection: { + type: Boolean, + default: true, + }, + visibility: { + type: String, + required: true, + }, + semaphore: { + type: SemaphoreWithPriority, + required: true, + }, }, data() { return { loaded: false, error: false, + canLoad: false, + semaphoreSymbol: null, + isDestroyed: false, } }, computed: { + /** @return {string} */ davPath() { - return generateRemoteUrl(`dav/files/${getCurrentUser().uid}`) + this.item.injected.filename + return generateRemoteUrl(`dav/files/${getCurrentUser().uid}`) + this.item.filename }, - ariaUuid() { - return `image-${this.item.injected.fileid}` + /** @return {string} */ + ariaDescription() { + return `image-description-${this.item.fileid}` }, + /** @return {string} */ ariaLabel() { - return t('photos', 'Open the full size "{name}" image', { name: this.item.injected.basename }) + return t('photos', 'Open the full size "{name}" image', { name: this.item.basename }) }, + /** @return {boolean} */ isImage() { - return this.item.injected.mime.startsWith('image') + return this.item.mime.startsWith('image') }, + /** @return {string} */ decodedEtag() { - return this.item.injected.etag.replace('"', '').replace('"', '') + return this.item.etag.replace('"', '').replace('"', '') + }, + /** @return {string} */ + srcVisible() { + return this.getItemURL(512) }, - src() { - return generateUrl(`/core/preview?fileId=${this.item.injected.fileid}&c=${this.decodedEtag}&x=${250}&y=${250}&forceIcon=0&a=${this.croppedLayout ? '0' : '1'}`) + /** @return {string} */ + srcNear() { + return this.getItemURL(64) }, }, + async mounted() { + // Don't render the component right away as it is useless if the user is only scrolling + await new Promise((resolve) => { + setTimeout(async () => { resolve() }, 250) + }) + + this.semaphoreSymbol = await this.semaphore.acquire(() => { + switch (this.visibility) { + case 'visible': + return 1 + case 'near': + return 2 + default: + return 3 + } + }, this.item.fileid) + + this.canLoad = true + if (this.visibility === 'none' || this.isDestroyed) { + this.releaseSemaphore() + } + }, + beforeDestroy() { + this.isDestroyed = true + this.releaseSemaphore() + // cancel any pending load - this.$refs.src = '' + if (this.$refs.imgNear !== undefined) { + this.$refs.imgNear.src = '' + } + if (this.$refs.srcVisible !== undefined) { + this.$refs.srcVisible.src = '' + } }, methods: { - openViewer() { - OCA.Viewer.open({ - path: this.item.injected.filename, - list: this.item.injected.list, - loadMore: this.item.injected.loadMore ? async () => await this.item.injected.loadMore(true) : () => [], - canLoop: this.item.injected.canLoop, - }) + emitClick() { + this.$emit('click', this.item.fileid) }, /** When the image is fully loaded by browser we remove the placeholder */ onLoad() { this.loaded = true + this.releaseSemaphore() }, onError() { this.error = true + this.releaseSemaphore() + }, + + onToggle(value) { + this.$emit('select-toggled', { id: this.item.fileid, value }) + }, + + getItemURL(size) { + return generateUrl(`/core/preview?fileId=${this.item.fileid}&c=${this.decodedEtag}&x=${size}&y=${size}&forceIcon=0&a=1`) + + }, + + releaseSemaphore() { + if (this.semaphoreSymbol === null) { + return + } + this.semaphore.release(this.semaphoreSymbol) + this.semaphoreSymbol = null }, }, @@ -130,37 +228,109 @@ export default { </script> <style lang="scss" scoped> -@import '../mixins/FileFolder'; +.file-container { + background: lightgray; + position: relative; + border: 2px solid white; // Use border so create a separation between images. + height: 100%; + width: 100%; -.transition-group { - display: contents; -} + // Selection border. + &.selected, &:focus-within { + &::after { + position: absolute; + top: 0; + left: 0; + z-index: 2; + width: 100%; + height: 100%; + content: ''; + outline: var(--color-primary) solid 4px; + outline-offset: -4px; + pointer-events: none; + } + } -.icon-video-white { - position: absolute; - top: 10px; - right: 10px; - z-index: 20; -} + // Reveal checkbox on hover. + &:hover, &.selected, &:focus-within { + .selection-checkbox { + display: flex; + } + } -img { - position: absolute; - width: 100%; - height: 100%; - z-index: 10; + .selection-checkbox { + display: none; + position: absolute; + top: 8px; + // Fancy calculation to render the checkbox in the middle of narrow images. + right: min(22px, calc(50% - 7px)); + z-index: 1; + width: fit-content; + + // Make the checkbox background round on hover. + ::v-deep .checkbox-radio-switch__label { + padding: 10px; - color: transparent; // should be diplayed on error + &::after { + content: ''; + background: var(--color-primary-light); + width: 16px; + height: 16px; + position: absolute; + left: 1px; + z-index: -1; + } - object-fit: contain; + .checkbox-radio-switch__icon { + margin: 0; + } + } - .file--cropped & { - object-fit: cover; + .input-label { + position: fixed; + z-index: -1; + top: -5000px; + left: -5000px; + } } -} -svg { - position: absolute; - width: 70%; - height: 70%; + .file { + width: 100%; + height: 100%; + box-sizing: border-box; + + .images-container { + display: contents; + + .icon-video-white { + position: absolute; + top: 10px; + right: 10px; + z-index: 20; + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + } + + .loading-overlay { + position: absolute; + height: 100%; + width: 100%; + display: flex; + align-content: center; + align-items: center; + justify-content: center; + + svg { + width: 70%; + height: 70%; + } + } + } + } } </style> diff --git a/src/components/PhotosHeader.vue b/src/components/PhotosHeader.vue new file mode 100644 index 00000000..eb151851 --- /dev/null +++ b/src/components/PhotosHeader.vue @@ -0,0 +1,204 @@ +<!-- + - @copyright Copyright (c) 2019 Louis Chemineau <louis@chmn.me> + - + - @author Louis Chemineau <louis@chmn.me> + - + - @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 class="photos-header"> + <Actions v-if="selection.length === 0" :menu-title="t('photos', 'Add')" :primary="true"> + <Plus slot="icon" /> + <!-- TODO: uncomment when implementing upload --> + <!-- <ActionButton :close-after-click="true" :title="t('photos', 'Upload media')" @click="openUploader"> + <FileUpload slot="icon" /> + </ActionButton> --> + <!-- TODO: uncomment when implementing albums --> + <!-- <ActionButton :close-after-click="true" :title="t('photos', 'Create new album')" @click="createNewAlbum"> + <PlusBoxMultiple slot="icon" /> + </ActionButton> --> + </Actions> + + <template v-else> + <!-- TODO: uncomment when implementing albums --> + <!-- <Actions :force-title="true" :menu-title="t('photos', 'Add to album')" :primary="true"> + <ActionButton :close-after-click="true" + :primary="true" + :title="t('photos', 'Add to album')" + @click="openAlbumPicker"> + <Plus slot="icon" /> + </ActionButton> + </Actions> --> + <Actions :force-menu="true"> + <ActionButton :close-after-click="true" :title="t('photos', 'Download')" @click="downloadSelection"> + <DownloadOutline slot="icon" /> + </ActionButton> + <ActionButton v-if="shouldFavorite" + :close-after-click="true" + :title="t('photos', 'Favorite')" + @click="favoriteSelection"> + <Star slot="icon" /> + </ActionButton> + <ActionButton v-else + :close-after-click="true" + :title="t('photos', 'Remove from favorites')" + @click="unFavoriteSelection"> + <Star slot="icon" /> + </ActionButton> + <ActionButton :close-after-click="true" :title="t('photos', 'Delete')" @click="deleteSelection"> + <TrashCan slot="icon" /> + </ActionButton> + </Actions> + </template> + + <Loading v-if="loadingCount > 0" class="loading-icon" /> + </div> +</template> +<script> + +import { mapActions } from 'vuex' + +import Plus from 'vue-material-design-icons/Plus' +import TrashCan from 'vue-material-design-icons/TrashCan.vue' +// import PlusBoxMultiple from 'vue-material-design-icons/PlusBoxMultiple' +// import FileUpload from 'vue-material-design-icons/FileUpload.vue' +import Star from 'vue-material-design-icons/Star.vue' +import DownloadOutline from 'vue-material-design-icons/DownloadOutline.vue' +import Loading from 'vue-material-design-icons/Loading' + +import { Actions, ActionButton } from '@nextcloud/vue' + +import logger from '../services/logger.js' + +export default { + name: 'PhotosHeader', + components: { + Actions, + ActionButton, + Loading, + Plus, + TrashCan, + // FileUpload, + // PlusBoxMultiple, + Star, + DownloadOutline, + }, + props: { + selection: { + type: Array, + default: () => [], + }, + }, + + data() { + return { + loadingCount: 0, + } + }, + + computed: { + /** @type {boolean} */ + shouldFavorite() { + // Favorite all selection if at least one file is not on the favorites. + return this.selection.some((fileId) => this.$store.state.files.files[fileId].favorite === 0) + }, + }, + + methods: { + ...mapActions(['deleteFiles', 'toggleFavoriteForFiles', 'downloadFiles']), + + // TODO: uncomment when implementing upload + // openUploader() { + + // }, + + // TODO: uncomment when implementing albums + // createNewAlbum() { + + // }, + // openAlbumPicker() { + + // }, + // async moveSelectionToAlbum() { + + // }, + + async favoriteSelection() { + try { + this.loadingCount++ + await this.toggleFavoriteForFiles({ fileIds: this.selection, favoriteState: true }) + } catch (error) { + logger.error(error) + } finally { + this.loadingCount-- + } + }, + + async unFavoriteSelection() { + try { + this.loadingCount++ + await this.toggleFavoriteForFiles({ fileIds: this.selection, favoriteState: false }) + } catch (error) { + logger.error(error) + } finally { + this.loadingCount-- + } + }, + + async deleteSelection() { + try { + this.loadingCount++ + const items = this.selection + this.$emit('uncheck-items', items) + await this.deleteFiles(items) + } catch (error) { + logger.error(error) + } finally { + this.loadingCount-- + } + }, + + async downloadSelection() { + try { + this.loadingCount++ + await this.downloadFiles(this.selection) + } catch (error) { + logger.error(error) + } finally { + this.loadingCount-- + } + }, + }, +} +</script> + +<style lang="scss" scoped> +.photos-header { + display: flex; + margin: 16px 0; + padding-left: 32px; + + & > * { + margin: 0 16px; + } + + .loading-icon::v-deep svg { + animation: rotate var(--animation-duration, 0.8s) linear infinite; + color: var(--color-loading-dark); + } +} +</style> diff --git a/src/components/TiledLayout.vue b/src/components/TiledLayout.vue new file mode 100644 index 00000000..0bb379c8 --- /dev/null +++ b/src/components/TiledLayout.vue @@ -0,0 +1,100 @@ +<!-- + - @copyright Copyright (c) 2019 Louis Chemineau <louis@chmn.me> + - + - @author Louis Chemineau <louis@chmn.me> + - + - @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 ref="tiledLayoutContainer" + class="tiled-container"> + <!-- Slot to allow changing the rows before passing them to TiledRows --> + <!-- Useful for partially rendering rows like in VirtualScrolling --> + <slot :rows="rows"> + <!-- Default rendering --> + <TiledRows :rows="rows" /> + </slot> + </div> +</template> + +<script> +import { splitItemsInRows } from '../services/TiledLayout.js' +import TiledRows from './TiledRows.vue' + +export default { + name: 'TiledLayout', + + components: { + TiledRows, + }, + + props: { + items: { + type: Array, + required: true, + }, + }, + + data() { + return { + containerWidth: 0, + /** @type {ResizeObserver} */ + resizeObserver: null, + } + }, + + computed: { + /** @return {import('../services/TiledLayout').TiledRow[]} */ + rows() { + return splitItemsInRows(this.items, this.containerWidth) + }, + }, + + mounted() { + this.resizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + const cr = entry.contentRect + if (entry.target.classList.contains('tiled-container')) { + this.containerWidth = cr.width + } + } + }) + + this.resizeObserver.observe(this.$refs.tiledLayoutContainer) + }, + + beforeDestroy() { + this.resizeObserver.disconnect() + }, +} +</script> + +<style scoped lang="scss"> +.photos-header { + height: 50px; +} + +.tiled-container { + margin: 0 24px; + + .tiled-row { + display: flex; + justify-content: space-around; + width: fit-content; // Prevent solitary image to be rendered in the middle because of the flex layout. + } +} +</style> diff --git a/src/components/TiledRows.vue b/src/components/TiledRows.vue new file mode 100644 index 00000000..d5c4a665 --- /dev/null +++ b/src/components/TiledRows.vue @@ -0,0 +1,41 @@ +<!-- + - @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me> + - + - @author Louis Chemineau <louis@chmn.me> + - + - @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 functional> + <ul> + <div v-for="row of props.rows" + :key="row.key" + class="tiled-row" + :style="{height: `${row.height}px`}"> + <li v-for="item of row.items" + :key="item.id" + :style="{ width: item.ratio ? `${row.height * item.ratio}px` : '100%', height: `${row.height}px`}"> + <slot :row="row" :item="item" /> + </li> + </div> + </ul> +</template> +<style lang="scss" scoped> +.tiled-row { + display: flex; +} +</style> diff --git a/src/components/VirtualScrolling.vue b/src/components/VirtualScrolling.vue new file mode 100644 index 00000000..60b30a3c --- /dev/null +++ b/src/components/VirtualScrolling.vue @@ -0,0 +1,283 @@ +<!-- + - @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me> + - + - @author Louis Chemineau <louis@chmn.me> + - + - @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-if="!useWindow && containerElement === null" ref="container" class="vs-container"> + <div ref="rowsContainer" + class="vs-rows-container" + :style="rowsContainerStyle"> + <slot :rendered-rows="visibleRows" /> + </div> + </div> + <div v-else + ref="rowsContainer" + class="vs-rows-container" + :style="rowsContainerStyle"> + <slot :rendered-rows="visibleRows" /> + </div> +</template> + +<script> +/** + * @typedef {object} Row + * @property {number} height - The height of the row. + */ + +/** + * @typedef {Row} VisibleRow + * @property {'none'|'near'|'visible'} visibility - The visibility state of the row + * @property {boolean} shouldRender - Whether the row should be renderer in the DOM + */ + +export default { + name: 'VirtualScrolling', + + props: { + rows: { + type: Array, + required: true, + }, + + containerElement: { + type: HTMLElement, + default: null, + }, + + useWindow: { + type: Boolean, + default: false, + }, + + renderWindowRatio: { + type: Number, + default: 6, + }, + + willBeVisibleWindowRatio: { + type: Number, + default: 4, + }, + + visibleWindowRatio: { + type: Number, + // A little bit more than the container's height to include items at its edges. + default: 0, + }, + + bottomBufferRatio: { + type: Number, + default: 5, + }, + }, + + data() { + return { + scrollPosition: 0, + containerHeight: 0, + rowsContainerHeight: 0, + /** @type {ResizeObserver} */ + resizeObserver: null, + } + }, + + computed: { + /** + * @return {VisibleRow[]} + */ + visibleRows() { + // Optimisation: get those computed properties once to not go through vue's internal every time we need them. + const scrollPosition = this.scrollPosition + const containerHeight = this.containerHeight + + // Optimisation: different windows to hint the items how they should render themselves. + // This will be forwarded with the visibility props. + const shouldRenderedWindow = containerHeight * this.renderWindowRatio + const willBeVisibleWindow = containerHeight * this.willBeVisibleWindowRatio + const visibleWindow = containerHeight * this.visibleWindowRatio + + let currentRowTopDistanceFromTop = 0 + let currentBottomTopDistanceFromTop = 0 + + // Compute whether a row should be included in the DOM (shouldRender) + // And how visible the row is. + return this.rows + .reduce((visibleRows, row) => { + currentRowTopDistanceFromTop = currentBottomTopDistanceFromTop + currentBottomTopDistanceFromTop += row.height + + if (currentRowTopDistanceFromTop < scrollPosition - shouldRenderedWindow || scrollPosition + containerHeight + shouldRenderedWindow < currentRowTopDistanceFromTop) { + return visibleRows + } + + let visibility = 'none' + + if (scrollPosition - willBeVisibleWindow < currentRowTopDistanceFromTop && currentRowTopDistanceFromTop < scrollPosition + containerHeight + willBeVisibleWindow) { + visibility = 'near' + + if (scrollPosition - visibleWindow < currentRowTopDistanceFromTop && currentRowTopDistanceFromTop < scrollPosition + containerHeight + visibleWindow) { + visibility = 'visible' + } + + if (scrollPosition - visibleWindow < currentBottomTopDistanceFromTop && currentBottomTopDistanceFromTop < scrollPosition + containerHeight + visibleWindow) { + visibility = 'visible' + } + } + + return [ + ...visibleRows, + { + ...row, + visibility, + }, + ] + }, []) + }, + + /** + * Total height of all the rows. + * + * @return {number} + */ + rowsHeight() { + return this.rows + .map(row => row.height) + .reduce((totalHeight, rowHeight) => totalHeight + rowHeight, 0) + }, + + /** + * @return {number} + */ + paddingTop() { + if (this.visibleRows.length === 0) { + return 0 + } + + const firstVisibleRowIndex = this.rows.findIndex(row => row.items === this.visibleRows[0].items) + + return this.rows + .map(row => row.height) + .slice(0, firstVisibleRowIndex) + .reduce((totalHeight, rowHeight) => totalHeight + rowHeight, 0) + }, + + /** + * padding-top is used to replace not included item in the container. + * + * @return {object} + */ + rowsContainerStyle() { + return { + height: `${this.rowsHeight}px`, + paddingTop: `${this.paddingTop}px`, + } + }, + + /** + * Whether the user is near the bottom. + * If true, then the need-content event will be emitted. + * + * @return {boolean} + */ + isNearBottom() { + const buffer = this.containerHeight * this.bottomBufferRatio + return this.scrollPosition + this.containerHeight >= this.rowsHeight - buffer + }, + + /** + * @return {HTMLElement} + */ + container() { + if (this.containerElement !== null) { + return this.containerElement + } else if (this.useWindow) { + return window + } else { + return this.$refs.container + } + }, + }, + + watch: { + isNearBottom(value) { + if (value) { + this.$emit('need-content') + } + }, + + rows() { + // Re-emit need-content when rows is updated and isNearBottom is still true. + // If the height of added rows is under `bottomBufferRatio`, `isNearBottom` will still be true so we need more content. + if (this.isNearBottom) { + this.$emit('need-content') + } + }, + }, + + mounted() { + this.resizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + const cr = entry.contentRect + if (entry.target.classList.contains('vs-container')) { + this.containerHeight = cr.height + } + if (entry.target.classList.contains('vs-rows-container')) { + this.rowsContainerHeight = cr.height + } + } + }) + + if (this.useWindow) { + window.addEventListener('resize', this.updateContainerSize) + this.containerHeight = window.innerHeight + } else { + this.resizeObserver.observe(this.container) + } + + this.resizeObserver.observe(this.$refs.rowsContainer) + this.container.addEventListener('scroll', this.updateScrollPosition) + }, + + beforeDestroy() { + if (this.useWindow) { + window.removeEventListener('resize', this.updateContainerSize) + } + + this.resizeObserver.disconnect() + this.container.removeEventListener('scroll', this.updateScrollPosition) + }, + + methods: { + updateScrollPosition() { + this.scrollPosition = this.container.scrollY + }, + updateContainerSize() { + this.containerHeight = window.innerHeight + }, + }, +} +</script> + +<style scoped lang="scss"> +.vs-container { + overflow-y: scroll; + height: 100%; +} +</style> diff --git a/src/router/index.js b/src/router/index.js index a27f628b..30498676 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -24,9 +24,9 @@ import { generateUrl } from '@nextcloud/router' import Router from 'vue-router' import Vue from 'vue' -import isMapsInstalled from '../services/IsMapsInstalled' -import areTagsInstalled from '../services/AreTagsInstalled' -import { videoMimes } from '../services/AllowedMimes' +import isMapsInstalled from '../services/IsMapsInstalled.js' +import areTagsInstalled from '../services/AreTagsInstalled.js' +import { imageMimes, videoMimes } from '../services/AllowedMimes.js' const Albums = () => import('../views/Albums') const Tags = () => import('../views/Tags') @@ -60,9 +60,27 @@ export default new Router({ { path: '/', component: Timeline, - name: 'timeline', + name: 'all_media', props: route => ({ - rootTitle: t('photos', 'Your photos'), + rootTitle: t('photos', 'All media'), + }), + }, + { + path: '/photos', + component: Timeline, + name: 'photos', + props: route => ({ + rootTitle: t('photos', 'Photos'), + mimesType: imageMimes, + }), + }, + { + path: '/videos', + component: Timeline, + name: 'videos', + props: route => ({ + rootTitle: t('photos', 'Videos'), + mimesType: videoMimes, }), }, { @@ -73,28 +91,30 @@ export default new Router({ path: parsePathParams(route.params.path), // if path is empty isRoot: !route.params.path, - rootTitle: t('photos', 'Your folders'), + rootTitle: t('photos', 'Albums'), }), }, { - path: '/shared/:path*', + path: '/folders/:path*', component: Albums, - name: 'shared', + name: 'folders', props: route => ({ path: parsePathParams(route.params.path), // if path is empty isRoot: !route.params.path, - rootTitle: t('photos', 'Shared with you'), - showShared: true, + rootTitle: t('photos', 'Folders'), }), }, { - path: '/videos', - component: Timeline, - name: 'videos', + path: '/shared/:path*', + component: Albums, + name: 'shared', props: route => ({ - rootTitle: t('photos', 'Your videos'), - mimesType: videoMimes, + path: parsePathParams(route.params.path), + // if path is empty + isRoot: !route.params.path, + rootTitle: t('photos', 'Shared with you'), + showShared: true, }), }, { diff --git a/src/services/DavRequest.js b/src/services/DavRequest.js index 68507981..a3be7657 100644 --- a/src/services/DavRequest.js +++ b/src/services/DavRequest.js @@ -26,6 +26,7 @@ const props = ` <d:getcontenttype /> <d:getcontentlength /> <nc:has-preview /> + <nc:file-metadata-size /> <oc:favorite /> <d:resourcetype />` diff --git a/src/services/FileActions.js b/src/services/FileActions.js new file mode 100644 index 00000000..dc88ee97 --- /dev/null +++ b/src/services/FileActions.js @@ -0,0 +1,103 @@ +/** + * @copyright Copyright (c) 2019 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 { encodePath } from '@nextcloud/paths' +import { generateUrl } from '@nextcloud/router' +import { showError } from '@nextcloud/dialogs' +import axios from '@nextcloud/axios' +import { getCurrentUser } from '@nextcloud/auth' + +import client from './DavClient.js' +import logger from './logger.js' + +/** + * Delete a file + * + * @param {number} fileName - The file's id + */ +export async function deleteFile(fileName) { + try { + await client.deleteFile(`/files/${getCurrentUser()?.uid}/${fileName}`) + } catch (error) { + logger.error(t('photos', 'Failed to delete {fileName}.', { fileName }), error) + showError(t('photos', 'Failed to delete {fileName}.', { fileName })) + } +} + +/** + * Favorite a file + * + * @param {string} fileName - The file's name + * @param {boolean} favoriteState - The new favorite state + */ +export async function favoriteFile(fileName, favoriteState) { + let encodedPath = encodePath(fileName) + while (encodedPath[0] === '/') { + encodedPath = encodedPath.substring(1) + } + + try { + return axios.post( + `${generateUrl('/apps/files/api/v1/files/')}${encodedPath}`, + { + tags: favoriteState ? ['_$!<Favorite>!$_'] : [], + }, + ) + } catch (error) { + logger.error(t('photos', 'Failed to favorite {fileName}.', { fileName }), error) + showError(t('photos', 'Failed to favorite {fileName}.', { fileName })) + } +} + +/** + * Download a file + * + * @param {string[]} fileNames - The file's names + */ +export async function downloadFiles(fileNames) { + const randomToken = Math.random().toString(36).substring(2) + + const params = new URLSearchParams() + params.append('files', JSON.stringify(fileNames)) + params.append('downloadStartSecret', randomToken) + + const downloadURL = generateUrl(`/apps/files/ajax/download.php?${params}`) + + window.location = `${downloadURL}downloadStartSecret=${randomToken}` + + return new Promise((resolve) => { + const waitForCookieInterval = setInterval( + () => { + const cookieIsSet = document.cookie + .split(';') + .map(cookie => cookie.split('=')) + .findIndex(([cookieName, cookieValue]) => cookieName === 'ocDownloadStarted' && cookieValue === randomToken) + + if (cookieIsSet) { + clearInterval(waitForCookieInterval) + resolve(true) + } + }, + 50 + ) + }) +} diff --git a/src/services/PhotoSearch.js b/src/services/PhotoSearch.js index d217bf33..c96da565 100644 --- a/src/services/PhotoSearch.js +++ b/src/services/PhotoSearch.js @@ -20,12 +20,11 @@ * */ -import { genFileInfo } from '../utils/fileUtils' +import { genFileInfo } from '../utils/fileUtils.js' import { getCurrentUser } from '@nextcloud/auth' -import { allMimes } from './AllowedMimes' -import client from './DavClient' -import { props } from './DavRequest' -import { sizes } from '../assets/grid-sizes' +import { allMimes } from './AllowedMimes.js' +import client from './DavClient.js' +import { props } from './DavRequest.js' import moment from '@nextcloud/moment' /** @@ -33,21 +32,23 @@ import moment from '@nextcloud/moment' * * @param {boolean} [onlyFavorites=false] not used * @param {object} [options] used for the cancellable requests - * @param {number} [options.page=0] which page to start (starts at 0) - * @param {number} [options.perPage] how many to display per page default is 5 times the max number per line from the grid-sizes config file + * @param {number} [options.firstResult=0] Index of the first result that we want (starts at 0) + * @param {number} [options.nbResults=200] The number of file to fetch + * @param {string[]} [options.mimesType=allMimes] Mime type of the files * @param {boolean} [options.full=false] get full data of the files * @param {boolean} [options.onThisDay=false] get only items from this day of year - * @return {Array} the file list + * @return {Promise<object[]>} the file list */ export default async function(onlyFavorites = false, options = {}) { // default function options - options = Object.assign({}, { - page: 0, // start at the first page - perPage: sizes.max.count * 10, // ten rows of the max width - mimesType: allMimes, // all mimes types + options = { + firstResult: 0, + nbResults: 200, + mimesType: allMimes, onThisDay: false, - }, options) + ...options, + } const prefixPath = `/files/${getCurrentUser().uid}` @@ -132,8 +133,8 @@ export default async function(onlyFavorites = false, options = {}) { </d:order> </d:orderby> <d:limit> - <d:nresults>${options.perPage}</d:nresults> - <ns:firstresult>${options.page * options.perPage}</ns:firstresult> + <d:nresults>${options.nbResults}</d:nresults> + <ns:firstresult>${options.firstResult}</ns:firstresult> </d:limit> </d:basicsearch> </d:searchrequest>`, diff --git a/src/services/TiledLayout.js b/src/services/TiledLayout.js new file mode 100644 index 00000000..dd2af83a --- /dev/null +++ b/src/services/TiledLayout.js @@ -0,0 +1,150 @@ +/** + * @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me> + * + * @author Louis Chemineau <louis@chmn.me> + * + * @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/>. + * + */ + +/** + * @typedef {object} TiledItem + * @property {string} id + * @property {number} [width] Real width of the item. + * @property {number} height Real height of the item. + * @property {number} [ratio] The aspect ratio of the item. + * @property {boolean} [sectionHeader] Whether this row is a section header. + */ + +/** + * @typedef {object} TiledRow + * @property {TiledItem[]} items - + * @property {number} height - + * @property {string} key - + */ + +const BASE_ROW_HEIGHT = 200 + +/** + * Split items in rows of equal width. + * The last row will not be forced to match containerWidth. + * + * @param {TiledItem[]} items The list of item to split in row of equal width. + * @param {number} containerWidth The width of a row. + * @return {TiledRow[]} + */ +export function splitItemsInRows(items, containerWidth) { + if (containerWidth === 0) { + return [] + } + + const rows = [] + let rowNumber = 0 + let currentItem = 0 + + while (currentItem < items.length) { + /** @type { TiledItem[] } */ + const rowItems = [] + + // Fill the row with new items as long as the width is less than containerWidth. + do { + // @ts-ignore - We know that items.shift() is not undefined as we always check that items.length > 0. + rowItems.push(items[currentItem++]) + } while ( + currentItem < items.length + && !items[currentItem - 1].sectionHeader && !items[currentItem].sectionHeader + && computeRowWidth([...rowItems, items[currentItem]]) <= containerWidth + ) + + rows[rowNumber] = { + items: rowItems, + height: computeRowHeight( + rowItems, + containerWidth, + items.length === currentItem || items[currentItem].sectionHeader === true + ), + // Key to help vue to keep track of the row in VirtualScrolling. + key: rowItems.map(item => item.id).join('-'), + } + + rowNumber += 1 + } + + return rows +} + +/** + * Compute the row width based on its items with the assumption that their height is BASE_ROW_HEIGHT. + * + * @param {TiledItem[]} items The list of items in the row. + * @return {number} The width of the row + */ +function computeRowWidth(items) { + return items + .map(item => BASE_ROW_HEIGHT * item.ratio) + .reduce((sum, itemWidth) => sum + itemWidth) +} + +/** + * Compute the row height based on its items and on the container's width. + * + * Math time ! + * With Rn the aspect ratio of item n + * Wn the width of item n + * Hn the height of item n + * Wc the width of the container + * Hr the height of the row + * For n items we want: Wc = W1 + W2 + ... + Wn + * We know Rn = Wn / Hn + * So Wn = Rn * Hn + * So Wc = (R1 * H1) + (R2 * H2) + ... + (Rn * Hn) + * But we also want Hr === H1 === H2 === ... === Hn + * So Wc = (R1 * Hr) + (R2 * Hr) + ... + (Rn * Hr) + * So Wc = Hr * (R1 + R2 + ... + Rn) + * So Hr = Wc / (R1 + R2 + ... + Rn) + * + * @param {TiledItem[]} items The list of items in the row. + * @param {number} containerWidth The width of the row. + * @param {boolean} isLastRow Whether we are computing the height for the last row. + * @return {number} The height of the row + */ +function computeRowHeight(items, containerWidth, isLastRow) { + // Exception 1: there is only one item and its width it is a sectionHeader, meaning take the full width. + if (items.length === 1 && items[0].sectionHeader) { + return items[0].height + } + + const sumOfItemsRatio = items + .map(item => item.ratio) + .reduce((sum, itemRatio) => sum + itemRatio + ) + + let rowHeight = containerWidth / sumOfItemsRatio + + // Exception 2: there is only one item which is larger than containerWidth. + // Limit its height so that itemWidth === containerWidth + if (items.length === 1 && items[0].width > containerWidth) { + rowHeight = containerWidth / items[0].ratio + } + + // Exception 3: we reached the last row. + // Force the items width to match containerWidth, and limit their heigh to BASE_ROW_HEIGHT. + if (isLastRow) { + rowHeight = Math.min(BASE_ROW_HEIGHT + 20, rowHeight) + } + + return rowHeight +} diff --git a/src/services/TiledLayout.spec.js b/src/services/TiledLayout.spec.js new file mode 100644 index 00000000..8057b96b --- /dev/null +++ b/src/services/TiledLayout.spec.js @@ -0,0 +1,115 @@ +/** + * @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me> + * + * @author Louis Chemineau <louis@chmn.me> + * + * @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 { splitItemsInRows } from './TiledLayout.js' + +/** @type {import('./TiledLayout.js').TiledItem} */ +const sectionHeader1 = { + id: '202204', + sectionHeader: true, + height: 75, +} + +/** @type {import('./TiledLayout.js').TiledItem} */ +const squareImage1 = { + id: 'squareImage1', + width: 200, + height: 250, + ratio: 200 / 250, +} + +/** @type {import('./TiledLayout.js').TiledItem} */ +const sectionHeader2 = { + id: '202205', + sectionHeader: true, + height: 75, +} + +/** @type {import('./TiledLayout.js').TiledItem} */ +const squareImage2 = { + id: 'squareImage2', + width: 200, + height: 250, + ratio: 200 / 250, +} + +/** @type {import('./TiledLayout.js').TiledItem} */ +const squareImage3 = { + id: 'squareImage3', + width: 200, + height: 250, + ratio: 200 / 250, +} + +/** @type {import('./TiledLayout.js').TiledItem} */ +const tallImage1 = { + id: 'tallImage1', + width: 200, + height: 10000, + ratio: 200 / 10000, +} + +/** @type {import('./TiledLayout.js').TiledItem} */ +const wideImage1 = { + id: 'wideImage1', + width: 10000, + height: 250, + ratio: 10000 / 250, +} + +const items = [sectionHeader1, squareImage1, sectionHeader2, wideImage1, squareImage2, squareImage3, tallImage1] + +/** @type {import('./TiledLayout.js').TiledRow[]} */ +const expectedLayout = [ + + { + items: [sectionHeader1], + height: 75, + key: '202204', + }, + { + items: [squareImage1], + height: 220, + key: 'squareImage1', + }, + { + items: [sectionHeader2], + height: 75, + key: '202205', + }, + { + items: [wideImage1], + height: 50, + key: 'wideImage1', + }, + { + items: [squareImage2, squareImage3, tallImage1], + height: 220, + key: 'squareImage2-squareImage3-tallImage1', + }, +] + +describe('TileLayout', () => { + test('Adding permissions', () => { + expect(splitItemsInRows(items, 2000)).toStrictEqual(expectedLayout) + }) +}) diff --git a/src/services/logger.js b/src/services/logger.js new file mode 100644 index 00000000..9b777b4e --- /dev/null +++ b/src/services/logger.js @@ -0,0 +1,37 @@ +/** + * @copyright 2019 Louis Chemineau <mlouis@chmn.me> + * + * @author Louis Chemineau <mlouis@chmn.me> + * + * @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 { getCurrentUser } from '@nextcloud/auth' +import { getLoggerBuilder } from '@nextcloud/logger' + +const getLogger = user => { + if (user === null) { + return getLoggerBuilder() + .setApp('photos') + .build() + } + return getLoggerBuilder() + .setApp('photos') + .setUid(user.uid) + .build() +} + +export default getLogger(getCurrentUser()) diff --git a/src/store/files.js b/src/store/files.js index 27f5cb03..fa5ee4fe 100644 --- a/src/store/files.js +++ b/src/store/files.js @@ -20,6 +20,11 @@ * */ import Vue from 'vue' +import moment from '@nextcloud/moment' + +import { deleteFile, favoriteFile, downloadFiles } from '../services/FileActions.js' +import logger from '../services/logger.js' +import Semaphore from '../utils/semaphoreWithPriority.js' const state = { files: {}, @@ -31,17 +36,31 @@ const mutations = { * Append or update given files * * @param {object} state the store mutations - * @param {Array} files the store mutations + * @param {Array} newFiles the store mutations */ - updateFiles(state, files) { - files.forEach(file => { + updateFiles(state, newFiles) { + newFiles.forEach(file => { if (state.nomediaPaths.some(nomediaPath => file.filename.startsWith(nomediaPath))) { return } if (file.fileid >= 0) { - Vue.set(state.files, file.fileid, file) + if (file.fileMetadataSize) { + file.fileMetadataSizeParsed = JSON.parse(file.fileMetadataSize.replace(/"/g, '"')) + } + file.fileMetadataSizeParsed.width = file.fileMetadataSizeParsed?.width ?? 256 + file.fileMetadataSizeParsed.height = file.fileMetadataSizeParsed?.height ?? 256 } + + // Precalculate dates as it is expensive. + file.timestamp = moment(file.lastmod).unix() // For sorting + file.month = moment(file.lastmod).format('YYYYMM') // For grouping by month + file.day = moment(file.lastmod).format('MMDD') // For On this day }) + + state.files = { + ...state.files, + ...newFiles.reduce((files, file) => ({ ...files, [file.fileid]: file }), {}), + } }, /** @@ -71,6 +90,28 @@ const mutations = { setNomediaPaths(state, paths) { state.nomediaPaths = paths }, + + /** + * Delete a file + * + * @param {object} state the store mutations + * @param {number} fileId - The id of the file + */ + deleteFile(state, fileId) { + Vue.delete(state.files, fileId) + }, + + /** + * Favorite a list of files + * + * @param {object} state the store mutations + * @param {object} params - + * @param {number} params.fileId - The id of the file + * @param {boolean} params.favoriteState - The ew state of the favorite property + */ + favoriteFile(state, { fileId, favoriteState }) { + Vue.set(state.files[fileId], 'favorite', favoriteState ? 1 : 0) + }, } const getters = { @@ -111,9 +152,69 @@ const actions = { * @param {Array} paths list of files */ setNomediaPaths(context, paths) { - console.debug('Ignored paths', { paths }) + logger.debug('Ignored paths', { paths }) context.commit('setNomediaPaths', paths) }, + + /** + * Delete a list of files + * + * @param {object} context the store mutations + * @param {number[]} fileIds - The ids of the files + */ + deleteFiles(context, fileIds) { + const semaphore = new Semaphore(5) + + const files = fileIds.map(fileId => state.files[fileId]).reduce((files, file) => ({ ...files, [file.fileid]: file }), {}) + fileIds.forEach(fileId => context.commit('deleteFile', fileId)) + + const promises = fileIds + .map(async (fileId) => { + const symbol = await semaphore.acquire() + try { + await deleteFile(files[fileId].filename) + } catch (error) { + console.error(error) + context.dispatch('appendFiles', [files[fileId]]) + } finally { + semaphore.release(symbol) + } + }) + + return Promise.all(promises) + }, + + /** + * Favorite a list of files + * + * @param {object} context the store mutations + * @param {object} params - + * @param {number[]} params.fileIds - The ids of the files + * @param {boolean} params.favoriteState - The favorite state to set + */ + toggleFavoriteForFiles(context, { fileIds, favoriteState }) { + const semaphore = new Semaphore(5) + + const promises = fileIds + .map(async (fileId) => { + await semaphore.acquire() + await favoriteFile(state.files[fileId].filename, favoriteState) + context.commit('favoriteFile', { fileId, favoriteState }) + return semaphore.release() + }) + + return Promise.all(promises) + }, + + /** + * Download a list of files + * + * @param {object} context the store mutations + * @param {number[]} fileIds - The ids of the files + */ + downloadFiles(context, fileIds) { + downloadFiles(fileIds.map(fileId => context.state.files[fileId].filename)) + }, } export default { state, mutations, getters, actions } diff --git a/src/utils/semaphoreWithPriority.js b/src/utils/semaphoreWithPriority.js new file mode 100644 index 00000000..154f4ec9 --- /dev/null +++ b/src/utils/semaphoreWithPriority.js @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2018 + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +/** + * + * @param {number} capacity - The number of simultaneous access to the ressource. + */ +export default class SemaphoreWithPriority { + + #capacity = 0 + /** @type {{symbol: symbol, priority: Function, resolve: Function}[]} */ + #queue = [] + #active = [] + + constructor(capacity) { + this.#capacity = capacity + + } + + /** + * @param {Function} priority - A function that return a priority. This function will be call when looking for a next job to run so keep it quick. + * @param {string} info - An additional string to initialise the Symbol and help debugging. + */ + async acquire(priority = () => 1, info = '') { + const symbol = Symbol(info) + + return new Promise((resolve) => { + this.#queue.push({ symbol, priority, resolve }) + if (this.#active.length < this.#capacity) { + this.#callNextJob() + } + }) + } + + /** + * + * @param {symbol} symbol - The symbole returned by the acquire method. + */ + release(symbol) { + const symbolIndex = this.#active.indexOf(symbol) + if (symbolIndex === -1) { + throw new Error("Can't release non active symbol") + } + this.#active.splice(symbolIndex, 1) + + if (this.#queue.length > 0 && this.#active.length < this.#capacity) { + this.#callNextJob() + } + } + + #callNextJob() { + const prioritizedQueue = {} + + for (const item of this.#queue) { + const itemPriority = item.priority() + prioritizedQueue[itemPriority] = prioritizedQueue[itemPriority] ?? [] + prioritizedQueue[itemPriority].push(item) + } + + const highestPriority = Object.keys(prioritizedQueue).sort()[0] + const nextJob = prioritizedQueue[highestPriority][0] + const jobIndex = this.#queue.indexOf(nextJob) + if (jobIndex === -1) { + throw new Error("Can't call non existant job") + } + this.#queue.splice(jobIndex, 1) + + this.#active.push(nextJob.symbol) + nextJob.resolve(nextJob.symbol) + } + +} diff --git a/src/views/Timeline.vue b/src/views/Timeline.vue index 4aded998..725d4488 100644 --- a/src/views/Timeline.vue +++ b/src/views/Timeline.vue @@ -38,52 +38,110 @@ :filename="'/'" :root-title="rootTitle" /> - <EmptyContent v-if="isEmpty" illustration-name="empty"> - {{ t('photos', 'No photos in here') }} - </EmptyContent> - - <div class="grid-container"> - <VirtualGrid ref="virtualgrid" - :items="contentList" - :update-function="getContent" - :get-column-count="() => gridConfig.count" - :get-grid-gap="() => gridConfig.gap" - :update-trigger-margin="700" - :loader="loaderComponent" /> + <div v-else class="photos-container"> + <PhotosHeader :selection="selection" @uncheck-items="onUncheckItems" /> + + <Loader v-if="nbFetchedFiles === 0 && loading" /> + + <TiledLayout ref="tiledLayout" :items="itemsList"> + <VirtualScrolling slot-scope="{rows}" + :use-window="true" + :rows="rows" + @need-content="getContent"> + <TiledRows slot-scope="{renderedRows}" :rows="renderedRows"> + <template slot-scope="{row, item}"> + <h3 v-if="item.sectionHeader" class="section-header"> + <!-- TODO: uncomment to activate section selection --> + <!-- <CheckboxRadioSwitch v-if="allowSelection" + class="selection-checkbox" + :checked="selectedSections[item.id]" + @update:checked="(value) => onSectionToggle(item.id)"> --> + <b>{{ item.id | dateMonth }}</b> + {{ item.id | dateYear }} + <!-- </CheckboxRadioSwitch> --> + </h3> + + <File v-else + :item="files[item.id]" + :allow-selection="true" + :selected="selectedItems[item.id] === true" + :visibility="row.visibility" + :semaphore="semaphore" + @click="openViewer" + @select-toggled="onItemSelectToggle" /> + </template> + </TiledRows> + </VirtualScrolling> + </TiledLayout> + + <Loader v-if="loading" /> + + <EmptyContent v-if="isEmpty" illustration-name="empty"> + {{ t('photos', 'No photos in here') }} + </EmptyContent> </div> </div> </template> <script> -import moment from '@nextcloud/moment' import { mapGetters } from 'vuex' -import getPhotos from '../services/PhotoSearch' +import moment from '@nextcloud/moment' + +import cancelableRequest from '../utils/CancelableRequest.js' -import EmptyContent from '../components/EmptyContent' -import File from '../components/File' -import SeparatorVirtualGrid from '../components/SeparatorVirtualGrid' -import VirtualGrid from 'vue-virtual-grid' -import Navigation from '../components/Navigation' -import Loader from '../components/Loader' +import getPhotos from '../services/PhotoSearch.js' +import { allMimes } from '../services/AllowedMimes.js' +import logger from '../services/logger.js' -import cancelableRequest from '../utils/CancelableRequest' -import GridConfigMixin from '../mixins/GridConfig' -import { allMimes } from '../services/AllowedMimes' +import TiledLayout from '../components/TiledLayout.vue' +import TiledRows from '../components/TiledRows.vue' +import VirtualScrolling from '../components/VirtualScrolling.vue' +import PhotosHeader from '../components/PhotosHeader.vue' +import EmptyContent from '../components/EmptyContent.vue' +import File from '../components/File.vue' +import Navigation from '../components/Navigation.vue' +import Loader from '../components/Loader.vue' +import SemaphoreWithPriority from '../utils/semaphoreWithPriority.js' export default { name: 'Timeline', components: { EmptyContent, - VirtualGrid, Navigation, + TiledLayout, + TiledRows, + VirtualScrolling, + PhotosHeader, + Loader, + File, }, - mixins: [GridConfigMixin], - props: { - loading: { - type: Boolean, - required: true, + + filters: { + /** + * @param {string} date - In the following format: YYYYMM + */ + dateMonth(date) { + return moment(date, 'YYYYMM').format('MMMM') + }, + /** + * @param {string} date - In the following format: YYYYMM + */ + dateYear(date) { + return moment(date, 'YYYYMM').format('YYYY') }, + }, + + beforeRouteLeave(from, to, next) { + // cancel any pending requests + if (this.cancelRequest) { + this.cancelRequest('Changed view') + } + this.resetState() + return next() + }, + + props: { onlyFavorites: { type: Boolean, default: false, @@ -108,11 +166,14 @@ export default { data() { return { + loading: false, cancelRequest: null, done: false, error: null, - page: 0, - loaderComponent: Loader, + selectedItems: {}, + nbFetchedFiles: 0, + semaphore: new SemaphoreWithPriority(30), + semaphoreSymbol: null, } }, @@ -120,66 +181,88 @@ export default { // global lists ...mapGetters([ 'files', - 'timeline', ]), - // list of loaded medias + + /** + * List of loaded medias. + * + * @return {import('../services/TiledLayout').TiledItem[]} + */ fileList() { - return this.timeline - .map((fileId) => this.files[fileId]) - .filter((file) => !!file) + const today = moment().format('DDMM') + return Object.values(this.files) + .filter(file => !this.onlyFavorites || file.favorite === 1) + .filter(file => this.mimesType.includes(file.mime)) + .filter(file => !this.onThisDay || file.day === today) + .map(file => ({ + id: file.fileid, + width: file.fileMetadataSizeParsed.width, + height: file.fileMetadataSizeParsed.height, + ratio: file.fileMetadataSizeParsed.width / file.fileMetadataSizeParsed.height, + })) + }, + + /** + * @return {Object<string, import('../services/TiledLayout').TiledItem[]>} + */ + fileListByMonth() { + const itemsByMonth = {} + for (const item of this.fileList) { + itemsByMonth[this.files[item.id].month] = itemsByMonth[this.files[item.id].month] ?? [] + itemsByMonth[this.files[item.id].month].push(item) + } + return itemsByMonth }, - // list of displayed content in the grid (titles + medias) - contentList() { - /** - * The goal of this flat map is to return an array of images separated by titles (months) - * ie: [{month1}, {image1}, {image2}, {month2}, {image3}, {image4}, {image5}] - * First we get the current month+year of the image - * We compare it to the previous image month+year - * If there is a difference we have to insert a title object before the current image - * If it's equal we just add the current image to the array - * Note: the injected param of objects are used to pass custom params to the grid lib - * In our case injected could be an image/video (aka file) or a title (year/month) - * Note2: titles are rendered full width and images are rendered on 1 column and 256x256 ratio - */ - let lastSection = '' - return this.fileList.flatMap((file, index) => { - const finalArray = [] - const currentSection = this.getFormatedDate(file.lastmod, 'YYYY MMMM') - if (lastSection !== currentSection) { - finalArray.push({ - id: `title-${index}`, - injected: { - year: this.getFormatedDate(file.lastmod, 'YYYY'), - month: this.getFormatedDate(file.lastmod, 'MMMM'), - onThisDay: this.onThisDay ? Math.round(moment(Date.now()).diff(moment(file.lastmod), 'years', true)) : false, + + /** + * @return {import('../services/TiledLayout').TiledItem[]} + */ + itemsList() { + const fileListByMonth = this.fileListByMonth + return Object + .keys(fileListByMonth) + .sort((date1, date2) => date1 > date2 ? -1 : 1) + .flatMap((month) => { + // Insert month item in the list. + return [ + { + id: month, + sectionHeader: true, + height: 75, }, - height: 90, - columnSpan: 0, // means full width - newRow: true, - renderComponent: SeparatorVirtualGrid, - }) - lastSection = currentSection // we keep track of the last section for the next batch - } - finalArray.push({ - id: `img-${file.fileid}`, - injected: { - ...file, - list: this.fileList, - loadMore: this.getContent, - canLoop: false, - }, - width: 256, - height: 256, - columnSpan: 1, - renderComponent: File, + ...fileListByMonth[month].sort((item1, item2) => this.files[item1.id].timestamp > this.files[item2.id].timestamp ? -1 : 1), + ] }) - return finalArray - }) }, - // is current folder empty? + + /** + * Is current folder empty? + * + * @return {boolean} + */ isEmpty() { return this.fileList.length === 0 }, + + /** + * Is current folder empty? + * + * @type {Object<string, boolean>} + */ + selectedSections() { + return Object.entries(this.fileListByMonth) + .reduce((selectedSections, [month, items]) => { + return { + ...selectedSections, + [month]: !items.some((item) => this.selectedItems[item.id] !== true), + } + }, {}) + }, + + /** @return {import('../services/TiledLayout').TiledItem[]} */ + selection() { + return Object.keys(this.selectedItems).filter(fileId => this.selectedItems[fileId]) + }, }, watch: { @@ -189,6 +272,7 @@ export default { this.cancelRequest('Changed view') } this.resetState() + this.getContent() }, async onThisDay() { // reset component @@ -197,13 +281,8 @@ export default { }, }, - beforeRouteLeave(from, to, next) { - // cancel any pending requests - if (this.cancelRequest) { - this.cancelRequest('Changed view') - } - this.resetState() - next() + mounted() { + this.getContent() }, beforeDestroy() { @@ -216,36 +295,27 @@ export default { methods: { /** * Return next batch of data depending on global offset - * - * @param {boolean} doReturn Returns a Promise with the list instead of a boolean - * @return {Promise<boolean>} Returns a Promise with a boolean that stops infinite loading */ - async getContent(doReturn) { - if (this.done) { - return Promise.resolve(true) - } - - // cancel any pending requests - if (this.cancelRequest) { - this.cancelRequest('Changed view') + async getContent() { + if (this.done || this.loading) { + return } - // if we don't already have some cached data let's show a loader - if (this.timeline.length === 0) { - this.$emit('update:loading', true) - } + try { + this.loading = true + this.semaphoreSymbol = await this.semaphore.acquire(() => 0, 'timeline') - // done loading even with errors - const { request, cancel } = cancelableRequest(getPhotos) - this.cancelRequest = cancel + const { request, cancel } = cancelableRequest(getPhotos) + this.cancelRequest = cancel - const numberOfImagesPerBatch = this.gridConfig.count * 5 // loading 5 rows + const numberOfImagesPerBatch = 1000 - try { // Load next batch of images const files = await request(this.onlyFavorites, { - page: this.page, - perPage: numberOfImagesPerBatch, + // We reuse already fetched files in the store when moving from one tab to another, but to make sure that we have all the files, we keep an internal counter (nbFetchedFiles). + // Some files will be fetched twice, but we have less loading time when switching between tabs. + firstResult: this.nbFetchedFiles, + nbResults: numberOfImagesPerBatch, mimesType: this.mimesType, onThisDay: this.onThisDay, }) @@ -255,16 +325,9 @@ export default { this.done = true } - this.$store.dispatch('updateTimeline', files) - this.$store.dispatch('appendFiles', files) - - this.page += 1 + this.nbFetchedFiles += files.length - if (doReturn) { - return Promise.resolve(files) - } - - return Promise.resolve(false) + this.$store.dispatch('appendFiles', files) } catch (error) { if (error.response && error.response.status) { if (error.response.status === 404) { @@ -278,12 +341,12 @@ export default { } // cancelled request, moving on... - console.error('Error fetching timeline', error) - return Promise.resolve(true) + logger.error('Error fetching timeline', error) } finally { - // done loading even with errors - this.$emit('update:loading', false) + this.loading = false this.cancelRequest = null + await this.semaphore.release(this.semaphoreSymbol) + this.semaphoreSymbol = null } }, @@ -291,32 +354,41 @@ export default { * Reset this component data to a pristine state */ resetState() { - this.$store.dispatch('resetTimeline') this.done = false this.error = null - this.page = 0 this.lastSection = '' - this.$emit('update:loading', true) - if (this.$refs.virtualgrid) { - this.$refs.virtualgrid.resetGrid() - } + this.loading = false + this.nbFetchedFiles = 0 + this.$refs.tiledLayout?.$el.scrollTo(0, 0) }, - getFormatedDate(string, format) { - return moment(string).format(format) + onItemSelectToggle({ id, value }) { + this.$set(this.selectedItems, id, value) }, - }, + // onSectionToggle(sectionId) { + // const shouldCheck = !this.selectedSections[sectionId] + // this.fileListByMonth[sectionId].forEach(item => this.$set(this.selectedItems, item.id, shouldCheck)) + // }, + onUncheckItems(itemIds) { + itemIds.forEach(itemId => this.$set(this.selectedItems, itemId, false)) + }, + + openViewer(itemId) { + const item = this.files[itemId] + OCA.Viewer.open({ + path: item.filename, + list: this.itemsList.filter(item => !item.sectionHeader).map(item => this.files[item.id]), + loadMore: item.loadMore ? async () => await item.loadMore(true) : () => [], + canLoop: item.canLoop, + }) + }, + }, } </script> - <style lang="scss" scoped> -@import '../mixins/GridSizes'; - -.grid-container { - @include grid-sizes using ($marginTop, $marginW) { - padding: 0px #{$marginW}px 256px #{$marginW}px; + .section-header { + padding: 32px 0 16px 2px; } -} </style> |