Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/photos.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Photos.vue99
-rw-r--r--src/components/File.vue318
-rw-r--r--src/components/PhotosHeader.vue204
-rw-r--r--src/components/TiledLayout.vue100
-rw-r--r--src/components/TiledRows.vue41
-rw-r--r--src/components/VirtualScrolling.vue283
-rw-r--r--src/router/index.js50
-rw-r--r--src/services/DavRequest.js1
-rw-r--r--src/services/FileActions.js103
-rw-r--r--src/services/PhotoSearch.js31
-rw-r--r--src/services/TiledLayout.js150
-rw-r--r--src/services/TiledLayout.spec.js115
-rw-r--r--src/services/logger.js37
-rw-r--r--src/store/files.js111
-rw-r--r--src/utils/semaphoreWithPriority.js79
-rw-r--r--src/views/Timeline.vue352
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('&quot;', '').replace('&quot;', '')
+ return this.item.etag.replace('&quot;', '').replace('&quot;', '')
+ },
+ /** @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(/&quot;/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>