diff options
-rw-r--r-- | src/App.vue | 1 | ||||
-rw-r--r-- | src/components/FeedItem.vue | 56 | ||||
-rw-r--r-- | src/components/ItemSkeleton.vue | 28 | ||||
-rw-r--r-- | src/components/Starred.vue | 20 | ||||
-rw-r--r-- | src/components/VirtualScroll.vue | 135 | ||||
-rw-r--r-- | src/store/item.ts | 20 | ||||
-rw-r--r-- | src/types/FeedItem.ts | 4 |
7 files changed, 254 insertions, 10 deletions
diff --git a/src/App.vue b/src/App.vue index 0f5c1a0dc..98c3e393c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -24,6 +24,7 @@ export default Vue.extend({ async created() { await this.$store.dispatch(ACTIONS.FETCH_FOLDERS) await this.$store.dispatch(ACTIONS.FETCH_FEEDS) + await this.$store.dispatch(ACTIONS.FETCH_STARRED) }, }) </script> diff --git a/src/components/FeedItem.vue b/src/components/FeedItem.vue new file mode 100644 index 000000000..880178e16 --- /dev/null +++ b/src/components/FeedItem.vue @@ -0,0 +1,56 @@ +<template> + <div style="padding: 5px 10px; display: flex;" @click="expand()"> + <div style="padding: 0px 5px;"> + <EarthIcon /> + </div> + <div style="flex-grow: 1; overflow: hidden; text-overflow: ellipsis;"> + <span style="text-overflow: ellipsis;" :style="{ 'white-space': !isExpanded ? 'nowrap' : 'normal' }"> + {{ item.title }} + </span> + </div> + <div class="button-container" style="display: flex; flex-direction: row;"> + <StarIcon /> + <EyeOutline /> + <ShareVariant /> + </div> + </div> +</template> + +<script> +import EarthIcon from 'vue-material-design-icons/Earth.vue' +import StarIcon from 'vue-material-design-icons/Star.vue' +import EyeOutline from 'vue-material-design-icons/EyeOutline.vue' +import ShareVariant from 'vue-material-design-icons/ShareVariant.vue' + +export default { + name: 'FeedItem', + components: { + EarthIcon, + StarIcon, + EyeOutline, + ShareVariant, + }, + props: { + item: { + type: Object, + required: true, + }, + }, + data: () => { + return { + expanded: false, + } + }, + computed: { + isExpanded() { + return this.expanded + }, + }, + methods: { + expand() { + this.expanded = !this.expanded + }, + }, +} + +</script> diff --git a/src/components/ItemSkeleton.vue b/src/components/ItemSkeleton.vue new file mode 100644 index 000000000..3b3b3f50c --- /dev/null +++ b/src/components/ItemSkeleton.vue @@ -0,0 +1,28 @@ +<!-- + - Copyright (c) 2020. The Nextcloud Bookmarks contributors. + - + - This file is licensed under the Affero General Public License version 3 or later. See the COPYING file. + --> + +<template> + <div :class="{ + item: true, + }" + :style="{ background: 'var(--color-placeholder-dark)', height: '45px'}"> + <div class="item__labels" /> + <div class="item__actions" /> + </div> +</template> +<script> + +export default { + name: 'ItemSkeleton', + components: { + }, + computed: { + viewMode() { + return this.$store.state.settings.viewMode + }, + }, +} +</script> diff --git a/src/components/Starred.vue b/src/components/Starred.vue index d0f51ab45..b35e472d1 100644 --- a/src/components/Starred.vue +++ b/src/components/Starred.vue @@ -1,18 +1,24 @@ <template> - <div> - Starred Items: - <div v-for="item in items.starredItems" :key="item.id"> - {{ item.title }} - </div> + <div style="margin-top: 40px;"> + <VirtualScroll :reached-end="items.starredLoaded"> + <template v-if="items.starredItems && items.starredItems.length > 0"> + <template v-for="item in items.starredItems"> + <FeedItem :key="item.id" :item="item" /> + </template> + </template> + </VirtualScroll> </div> </template> <script lang="ts"> -import { FEED_ITEM_ACTION_TYPES } from '../store/item' import { mapState } from 'vuex' +import FeedItem from './FeedItem.vue' +import VirtualScroll from './VirtualScroll.vue' export default { components: { + VirtualScroll, + FeedItem, }, props: { @@ -24,7 +30,7 @@ export default { // }, }, async created() { - await this.$store.dispatch(FEED_ITEM_ACTION_TYPES.FETCH_STARRED) + // TODO: ? }, methods: { async fetchStarred() { diff --git a/src/components/VirtualScroll.vue b/src/components/VirtualScroll.vue new file mode 100644 index 000000000..cf5aeb24e --- /dev/null +++ b/src/components/VirtualScroll.vue @@ -0,0 +1,135 @@ +<!-- + - Copyright (c) 2022. The Nextcloud Bookmarks contributors. + - + - This file is licensed under the Affero General Public License version 3 or later. See the COPYING file. + --> +<script> +import ItemSkeleton from './ItemSkeleton.vue' + +const GRID_ITEM_HEIGHT = 200 + 10 +// const GRID_ITEM_WIDTH = 250 + 10 +const LIST_ITEM_HEIGHT = 45 + 1 + +export default { + name: 'VirtualScroll', + props: { + reachedEnd: { + type: Boolean, + required: true, + }, + }, + data() { + return { + viewport: { width: 0, height: 0 }, + scrollTop: 0, + scrollHeight: 500, + initialLoadingSkeleton: false, + initialLoadingTimeout: null, + } + }, + computed: { + fetching() { + return this.$store.state.items.fetchingItems + }, + }, + watch: { + newBookmark() { + this.$el.scrollTop = 0 + }, + newFolder() { + this.$el.scrollTop = 0 + }, + }, + mounted() { + this.onScroll() + window.addEventListener('resize', this.onScroll) + }, + destroyed() { + window.removeEventListener('resize', this.onScroll) + }, + methods: { + onScroll() { + this.scrollTop = this.$el.scrollTop + this.scrollHeight = this.$el.scrollHeight + }, + }, + render(h) { + let children = [] + let renderedItems = 0 + let upperPaddingItems = 0 + let lowerPaddingItems = 0 + let itemHeight = 1 + const padding = GRID_ITEM_HEIGHT + if (this.$slots.default && this.$el && this.$el.getBoundingClientRect) { + const childComponents = this.$slots.default.filter(child => !!child.componentOptions) + const viewport = this.$el.getBoundingClientRect() + itemHeight = LIST_ITEM_HEIGHT + renderedItems = Math.floor((viewport.height + padding + padding) / itemHeight) + upperPaddingItems = Math.floor(Math.max(this.scrollTop - padding, 0) / itemHeight) + children = childComponents.slice(upperPaddingItems, upperPaddingItems + renderedItems) + renderedItems = children.length + lowerPaddingItems = Math.max(childComponents.length - upperPaddingItems - renderedItems, 0) + } + + if (!this.reachedEnd && lowerPaddingItems === 0) { + if (!this.fetching) { + this.$emit('load-more') + } + if (upperPaddingItems + renderedItems + lowerPaddingItems === 0) { + if (!this.initialLoadingSkeleton) { + // The first 350ms don't display skeletons + this.initialLoadingTimeout = setTimeout(() => { + this.initialLoadingSkeleton = true + this.$forceUpdate() + }, 350) + return h('div', { class: 'virtual-scroll' }) + } + } + + children = [...children, ...Array(40).fill(0).map(() => + h(ItemSkeleton), + )] + } + + if (upperPaddingItems + renderedItems + lowerPaddingItems > 0) { + this.initialLoadingSkeleton = false + if (this.initialLoadingTimeout) { + clearTimeout(this.initialLoadingTimeout) + } + } + + const scrollTop = this.scrollTop + this.$nextTick(() => { + this.$el.scrollTop = scrollTop + }) + + return h('div', { + class: 'virtual-scroll', + on: { scroll: () => this.onScroll() }, + }, + [ + h('div', { class: 'upper-padding', style: { height: Math.max((upperPaddingItems) * itemHeight, 0) + 'px' } }), + h('div', { class: 'container-window', style: { height: Math.max((renderedItems) * itemHeight, 0) + 'px' } }, children), + h('div', { class: 'lower-padding', style: { height: Math.max((lowerPaddingItems) * itemHeight, 0) + 'px' } }), + ]) + }, +} +</script> + +<style scoped> +.virtual-scroll { + height: calc(100vh - 50px - 50px - 10px); + position: relative; + overflow-y: scroll; +} + +.bookmarkslist--gridview .container-window { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-content: start; + gap: 10px; + padding: 0 10px; + padding-top: 10px; +} +</style> diff --git a/src/store/item.ts b/src/store/item.ts index 09a6b6f96..d108e34a7 100644 --- a/src/store/item.ts +++ b/src/store/item.ts @@ -3,17 +3,24 @@ import axios from '@nextcloud/axios' import { ActionParams } from '../store' import { FEED_ITEM_MUTATION_TYPES } from '../types/MutationTypes' import { API_ROUTES } from '../types/ApiRoutes' +import { FeedItem } from '../types/FeedItem' export const FEED_ITEM_ACTION_TYPES = { FETCH_STARRED: 'FETCH_STARRED', } export type ItemState = { - allItems: any[]; - starredItems: any[]; + fetchingItems: boolean; + starredLoaded: boolean; + + allItems: FeedItem[]; + starredItems: FeedItem[]; } const state: ItemState = { + fetchingItems: false, + starredLoaded: false, + allItems: [], starredItems: [], } @@ -26,6 +33,7 @@ const getters = { export const actions = { async [FEED_ITEM_ACTION_TYPES.FETCH_STARRED]({ commit }: ActionParams) { + state.fetchingItems = true const response = await axios.get(API_ROUTES.ITEMS, { params: { limit: 40, @@ -33,15 +41,21 @@ export const actions = { search: '', showAll: false, type: 2, + offset: 0, }, }) commit(FEED_ITEM_MUTATION_TYPES.SET_STARRED, response.data.items) + + if (response.data.items.length < 40) { + state.starredLoaded = true + } + state.fetchingItems = false }, } export const mutations = { - [FEED_ITEM_MUTATION_TYPES.SET_STARRED](state: ItemState, items: any[]) { + [FEED_ITEM_MUTATION_TYPES.SET_STARRED](state: ItemState, items: FeedItem[]) { items.forEach(it => { state.starredItems.push(it) }) diff --git a/src/types/FeedItem.ts b/src/types/FeedItem.ts new file mode 100644 index 000000000..7ffe461c7 --- /dev/null +++ b/src/types/FeedItem.ts @@ -0,0 +1,4 @@ +export type FeedItem = { + id: string; + title: string; +}; |