diff options
author | Devlin Junker <devlin.junker@gmail.com> | 2023-08-25 07:42:12 -0700 |
---|---|---|
committer | Benjamin Brahmer <info@b-brahmer.de> | 2023-08-28 07:03:07 +0200 |
commit | bb154d0a49d30932bb8b2aedfbcbf34f3aeee46c (patch) | |
tree | 07d38f0efe5dab33726c449bcbfcc7c6285285f6 | |
parent | d4d20e91e5cdd801f837cbd296e1e798cbcfbf12 (diff) |
add Feed component and filtering in FeedItemDisplayList
Signed-off-by: Devlin Junker <devlin.junker@gmail.com>
-rw-r--r-- | src/components/Feed.vue | 85 | ||||
-rw-r--r-- | src/components/FeedItemDisplay.vue | 9 | ||||
-rw-r--r-- | src/components/FeedItemDisplayList.vue | 145 | ||||
-rw-r--r-- | src/components/FeedItemRow.vue | 12 | ||||
-rw-r--r-- | src/components/Sidebar.vue | 28 | ||||
-rw-r--r-- | src/components/Starred.vue | 40 | ||||
-rw-r--r-- | src/components/Unread.vue | 30 | ||||
-rw-r--r-- | src/dataservices/item.service.ts | 23 | ||||
-rw-r--r-- | src/routes/index.ts | 8 | ||||
-rw-r--r-- | src/store/item.ts | 11 | ||||
-rw-r--r-- | src/types/FeedItem.ts | 1 |
11 files changed, 321 insertions, 71 deletions
diff --git a/src/components/Feed.vue b/src/components/Feed.vue new file mode 100644 index 000000000..33c5ed6dd --- /dev/null +++ b/src/components/Feed.vue @@ -0,0 +1,85 @@ +<template> + <div class="route-container"> + <div class="header"> + {{ feed ? feed.title : '' }} + <NcCounterBubble v-if="feed" class="counter-bubble"> + {{ feed.unreadCount }} + </NcCounterBubble> + </div> + + <FeedItemDisplayList :items="items" :fetch-key="'feed-'+feedId" @load-more="fetchMore()" /> + </div> +</template> + +<script lang="ts"> +import Vue from 'vue' +import { mapState } from 'vuex' + +import NcCounterBubble from '@nextcloud/vue/dist/Components/NcCounterBubble.js' + +import FeedItemDisplayList from './FeedItemDisplayList.vue' + +import { FeedItem } from '../types/FeedItem' +import { ACTIONS, MUTATIONS } from '../store' +import { Feed } from '../types/Feed' + +export default Vue.extend({ + components: { + NcCounterBubble, + FeedItemDisplayList, + }, + props: { + feedId: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['items', 'feeds']), + feed(): Feed { + return this.$store.getters.feeds.find((feed: Feed) => feed.id === this.id) + }, + items(): FeedItem[] { + return this.$store.state.items.allItems.filter((item: FeedItem) => { + return item.feedId === this.id + }) || [] + }, + id(): number { + return Number(this.feedId) + }, + }, + created() { + this.$store.commit(MUTATIONS.SET_SELECTED_ITEM, { id: undefined }) + this.fetchMore() + this.$watch(() => this.$route.params, this.fetchMore) + + }, + methods: { + async fetchMore() { + if (!this.$store.state.items.fetchingItems['feed-' + this.feedId]) { + this.$store.dispatch(ACTIONS.FETCH_FEED_ITEMS, { feedId: this.id, start: this.items && this.items.length > 0 ? this.items[this.items.length - 1].id : 0 }) + } + }, + }, +}) +</script> + +<style scoped> +.route-container { + height: 100%; +} + +.header { + padding-left: 50px; + position: absolute; + top: 1em; + font-weight: 700; +} + +.counter-bubble { + display: inline-block; + vertical-align: sub; + margin-left: 10px; +} + +</style> diff --git a/src/components/FeedItemDisplay.vue b/src/components/FeedItemDisplay.vue index 5818fad51..1d7d5a843 100644 --- a/src/components/FeedItemDisplay.vue +++ b/src/components/FeedItemDisplay.vue @@ -183,7 +183,7 @@ export default Vue.extend({ </script> -<style> +<style scoped> .feed-item-display { max-height: 100%; overflow-y: hidden; @@ -228,11 +228,10 @@ export default Vue.extend({ .article h1 { font-weight: bold; font-size: 17px; - margin-top: 25px; } .action-bar { - padding: 20px; + padding: 0px 20px 0px 20px; display: flex; justify-content: right @@ -242,4 +241,8 @@ export default Vue.extend({ cursor: pointer; margin: 5px; } + + .action-bar .material-design-icon:hover { + color: var(--color-text-light); + } </style> diff --git a/src/components/FeedItemDisplayList.vue b/src/components/FeedItemDisplayList.vue index e86aa93c1..11be6266a 100644 --- a/src/components/FeedItemDisplayList.vue +++ b/src/components/FeedItemDisplayList.vue @@ -1,17 +1,44 @@ <template> - <div class="feed-item-display-container"> - <VirtualScroll :reached-end="reachedEnd" - :fetch-key="fetchKey" - @load-more="fetchMore()"> - <template v-if="items && items.length > 0"> - <template v-for="item in items"> - <FeedItemRow :key="item.id" :item="item" /> + <div> + <div style="justify-content: right; display: flex"> + <NcActions class="filter-container" :force-menu="true"> + <template #icon> + <FilterIcon /> </template> - </template> - </VirtualScroll> + <NcActionButton v-if="config.unreadFilter" @click="toggleFilter(unreadFilter)"> + <template #default> + {{ t("news", "Unread") }} + </template> + <template #icon> + <EyeIcon v-if="filter !== unreadFilter" /> + <EyeCheckIcon v-if="filter === unreadFilter" /> + </template> + </NcActionButton> + <NcActionButton v-if="config.starFilter" @click="toggleFilter(starFilter)"> + <template #default> + {{ t("news", "Starred") }} + </template> + <template #icon> + <StarIcon v-if="filter !== starFilter" /> + <StarCheckIcon v-if="filter === starFilter" /> + </template> + </NcActionButton> + </NcActions> + </div> + <div class="feed-item-display-container"> + <VirtualScroll :reached-end="reachedEnd" + :fetch-key="fetchKey" + @load-more="fetchMore()"> + <template v-if="items && items.length > 0"> + <template v-for="item in filterSortedItems()"> + <FeedItemRow :key="item.id" :item="item" /> + </template> + </template> + </VirtualScroll> - <div v-if="selected !== undefined" class="feed-item-container"> - <FeedItemDisplay :item="selected" /> + <div v-if="selected !== undefined" class="feed-item-container"> + <FeedItemDisplay :item="selected" /> + </div> </div> </div> </template> @@ -19,6 +46,15 @@ <script lang="ts"> import Vue from 'vue' +import FilterIcon from 'vue-material-design-icons/Filter.vue' +import StarIcon from 'vue-material-design-icons/Star.vue' +import StarCheckIcon from 'vue-material-design-icons/StarCheck.vue' +import EyeIcon from 'vue-material-design-icons/Eye.vue' +import EyeCheckIcon from 'vue-material-design-icons/EyeCheck.vue' + +import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' +import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' + import VirtualScroll from './VirtualScroll.vue' import FeedItemRow from './FeedItemRow.vue' import FeedItemDisplay from './FeedItemDisplay.vue' @@ -30,20 +66,49 @@ export default Vue.extend({ VirtualScroll, FeedItemRow, FeedItemDisplay, + FilterIcon, + StarIcon, + StarCheckIcon, + EyeIcon, + EyeCheckIcon, + NcActions, + NcActionButton, }, props: { items: { - type: Array, + type: Array<FeedItem>, required: true, }, fetchKey: { type: String, required: true, }, + config: { + type: Object, + default: () => { + return { + unreadFilter: true, + starFilter: true, + } + }, + }, }, data() { return { mounted: false, + + // no filter to start + filter: () => { return true as boolean }, + + // Always want to sort by date (most recent first) + sort: (a: FeedItem, b: FeedItem) => { + if (a.pubDate > b.pubDate) { + return -1 + } else { + return 1 + } + }, + cache: [] as FeedItem[] | undefined, } }, computed: { @@ -61,22 +126,70 @@ export default Vue.extend({ fetchMore() { this.$emit('load-more') }, + noFilter(): boolean { + return true + }, + starFilter(item: FeedItem): boolean { + return item.starred + }, + unreadFilter(item: FeedItem): boolean { + return item.unread + }, + toggleFilter(filter: () => boolean) { + if (this.filter === filter) { + this.filter = this.noFilter + if (filter === this.unreadFilter) { + this.cache = undefined + } + } else { + this.filter = filter as () => boolean + } + }, + filterSortedItems(): FeedItem[] { + let response = [...this.items] as FeedItem[] + + // if we're filtering on unread, we want to cache the unread items when the user presses the filter button + // that way when the user opens an item, it won't be removed from the displayed list of items (once it's no longer unread) + if (this.filter === this.unreadFilter) { + if (!this.cache) { + if (this.items.length > 0) { + this.cache = this.items.filter(this.unreadFilter) + } + } else if (this.items.length > (this.cache?.length)) { + for (const item of this.items) { + if (item.unread && this.cache.find((unread: FeedItem) => unread.id === item.id) === undefined) { + this.cache.push(item) + } + } + } + response = [...this.cache as FeedItem[]] + } else { + response = response.filter(this.filter) + } + + return response.sort(this.sort) + }, }, }) </script> <style scoped> + .virtual-scroll { + border-top: 1px solid var(--color-border); + width: 100%; + } + .feed-item-display-container { display: flex; height: 100%; } - .virtual-scroll { - width: 100%; - } - .feed-item-container { max-width: 50%; overflow-y: hidden; } + + .filter-container { + padding: 5px; + } </style> diff --git a/src/components/FeedItemRow.vue b/src/components/FeedItemRow.vue index c7fcfd6ec..6d436ba01 100644 --- a/src/components/FeedItemRow.vue +++ b/src/components/FeedItemRow.vue @@ -210,23 +210,23 @@ export default Vue.extend({ height: 30px; } - .material-design-icon { + .feed-item-row .button-container .material-design-icon { color: var(--color-text-lighter) } - .material-design-icon:hover { + .feed-item-row .button-container .material-design-icon:hover { color: var(--color-text-light); } - .material-design-icon.rss-icon:hover { + .feed-item-row .button-container .material-design-icon.rss-icon:hover { color: #555555; } .material-design-icon.starred { - color: rgb(255, 204, 0); + color: rgb(255, 204, 0) !important; } - .material-design-icon.keep-unread { + .feed-item-row .button-container .material-design-icon.keep-unread { color: var(--color-main-text); } @@ -234,7 +234,7 @@ export default Vue.extend({ color: #555555; } - .eye-check-icon { + .feed-item-row .button-container .eye-check-icon { color: var(--color-primary-light); } </style> diff --git a/src/components/Sidebar.vue b/src/components/Sidebar.vue index c5499c585..9a3f97bfb 100644 --- a/src/components/Sidebar.vue +++ b/src/components/Sidebar.vue @@ -36,17 +36,17 @@ <NcAppNavigationItem v-for="topLevelItem in topLevelNav" :key="topLevelItem.name || topLevelItem.title" :title="topLevelItem.name || topLevelItem.title" - :icon="topLevelItem.name !== undefined ? 'icon-folder': ''" + :icon="isFolder(topLevelItem) ? 'icon-folder': ''" + :to="isFolder(topLevelItem) ? {} : { name: ROUTES.FEED, params: { feedId: topLevelItem.id.toString() } }" :allow-collapse="true"> <template #default> <NcAppNavigationItem v-for="feed in topLevelItem.feeds" :key="feed.name" - :title="feed.title"> + :title="feed.title" + :to="{ name: ROUTES.FEED, props: { feedId: feed.id } }"> <template #icon> - <img v-if="feed.faviconLink" - :src="feed.faviconLink" - alt="feedIcon"> - <div v-if="!feed.faviconLink" class="icon-rss" /> + <RssIcon v-if="!feed.faviconLink" /> + <span v-if="feed.faviconLink" style="width: 24px; background-size: contain;" :style="{ 'backgroundImage': 'url(' + feed.faviconLink + ')' }" /> </template> <template #actions> <NcActionButton icon="icon-checkmark" @@ -100,8 +100,17 @@ </template> </NcAppNavigationItem> </template> - <template v-if="topLevelItem.feedCount > 0" #counter> - <NcCounterBubble>{{ topLevelItem.feedCount }}</NcCounterBubble> + <template #icon> + <RssIcon v-if="topLevelItem.feedCount === undefined && !topLevelItem.faviconLink" /> + <span v-if="topLevelItem.feedCount === undefined && topLevelItem.faviconLink" style="height: 16px; width: 16px; background-size: contain;" :style="{ 'backgroundImage': 'url(' + topLevelItem.faviconLink + ')' }" /> + </template> + <template #counter> + <NcCounterBubble v-if="topLevelItem.feedCount > 0"> + {{ topLevelItem.feedCount }} + </NcCounterBubble> + <NcCounterBubble v-if="topLevelItem.unreadCount > 0"> + {{ topLevelItem.unreadCount }} + </NcCounterBubble> </template> <template #actions> <NcActionButton icon="icon-checkmark" @click="alert('TODO: Mark read')"> @@ -199,6 +208,9 @@ export default Vue.extend({ alert(msg: string) { window.alert(msg) }, + isFolder(item: Feed | Folder) { + return (item as Folder).name !== undefined + }, }, }) diff --git a/src/components/Starred.vue b/src/components/Starred.vue index 29e7d3bf3..3d5845484 100644 --- a/src/components/Starred.vue +++ b/src/components/Starred.vue @@ -7,7 +7,10 @@ </NcCounterBubble> </div> - <FeedItemDisplayList :items="starred" :fetch-key="'starred'" @load-more="fetchMore()" /> + <FeedItemDisplayList :items="starred" + :fetch-key="'starred'" + :config="{ unreadFilter: true }" + @load-more="fetchMore()" /> </div> </template> @@ -47,26 +50,21 @@ export default Vue.extend({ }) </script> -<style> -.route-container { - height: 100%; -} +<style scoped> + .route-container { + height: 100%; + } -.header { - padding-left: 50px; - position: absolute; - top: 1em; - font-weight: 700; -} + .header { + padding-left: 50px; + position: absolute; + top: 1em; + font-weight: 700; + } -.counter-bubble { - display: inline-block; - vertical-align: sub; - margin-left: 10px; -} - -.virtual-scroll { - margin-top: 50px; - border-top: 1px solid var(--color-border); -} + .counter-bubble { + display: inline-block; + vertical-align: sub; + margin-left: 10px; + } </style> diff --git a/src/components/Unread.vue b/src/components/Unread.vue index 9a59f529b..d012ca43b 100644 --- a/src/components/Unread.vue +++ b/src/components/Unread.vue @@ -10,6 +10,7 @@ <FeedItemDisplayList v-if="unread()" :items="unread()" :fetch-key="'unread'" + :config="{ starFilter: true }" @load-more="fetchMore()" /> </div> </template> @@ -74,22 +75,17 @@ export default Vue.extend({ }) </script> -<style> -.header { - padding-left: 50px; - position: absolute; - top: 1em; - font-weight: 700; -} - -.counter-bubble { - display: inline-block; - vertical-align: sub; - margin-left: 10px; -} +<style scoped> + .header { + padding-left: 50px; + position: absolute; + top: 1em; + font-weight: 700; + } -.virtual-scroll { - margin-top: 50px; - border-top: 1px solid var(--color-border); -} + .counter-bubble { + display: inline-block; + vertical-align: sub; + margin-left: 10px; + } </style> diff --git a/src/dataservices/item.service.ts b/src/dataservices/item.service.ts index cddd1e978..6d4aabdb4 100644 --- a/src/dataservices/item.service.ts +++ b/src/dataservices/item.service.ts @@ -6,6 +6,7 @@ import { API_ROUTES } from '../types/ApiRoutes' import { FeedItem } from '../types/FeedItem' export const ITEM_TYPES = { + ALL: 0, STARRED: 2, UNREAD: 6, } @@ -14,6 +15,7 @@ export class ItemService { static debounceFetchStarred = _.debounce(ItemService.fetchStarred, 400, { leading: true }) static debounceFetchUnread = _.debounce(ItemService.fetchUnread, 400, { leading: true }) + static debounceFetchFeedItems = _.debounce(ItemService.fetchFeedItems, 400, { leading: true }) /** * Makes backend call to retrieve starred items @@ -54,6 +56,27 @@ export class ItemService { } /** + * Makes backend call to retrieve items from a specific feed + * + * @param feedId id number of feed to retrieve items for + * @param start (id of last unread item loaded) + * @return {AxiosResponse} response object containing backend request response + */ + static async fetchFeedItems(feedId: number, start: number): Promise<AxiosResponse> { + return await axios.get(API_ROUTES.ITEMS, { + params: { + limit: 40, + oldestFirst: false, + search: '', + showAll: false, + type: ITEM_TYPES.ALL, + offset: start, + id: feedId, + }, + }) + } + + /** * Makes backend call to mark item as read/unread in DB * * @param {FeedItem} item FeedItem (containing id) that wil be marked as read/unread diff --git a/src/routes/index.ts b/src/routes/index.ts index b572742c8..0a67042b7 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -3,11 +3,13 @@ import VueRouter from 'vue-router' import ExplorePanel from '../components/Explore.vue' import StarredPanel from '../components/Starred.vue' import UnreadPanel from '../components/Unread.vue' +import FeedPanel from '../components/Feed.vue' export const ROUTES = { EXPLORE: 'explore', STARRED: 'starred', UNREAD: 'unread', + FEED: 'feeed', } const getInitialRoute = function() { @@ -41,6 +43,12 @@ const routes = [ component: UnreadPanel, props: true, }, + { + name: ROUTES.FEED, + path: '/feed/:feedId', + component: FeedPanel, + props: true, + }, ] export default new VueRouter({ diff --git a/src/store/item.ts b/src/store/item.ts index 982e75979..f409305f1 100644 --- a/src/store/item.ts +++ b/src/store/item.ts @@ -11,6 +11,7 @@ export const FEED_ITEM_ACTION_TYPES = { MARK_UNREAD: 'MARK_UNREAD', STAR_ITEM: 'STAR_ITEM', UNSTAR_ITEM: 'UNSTAR_ITEM', + FETCH_FEED_ITEMS: 'FETCH_FEED_ITEMS', } export type ItemState = { @@ -77,6 +78,16 @@ export const actions = { } commit(FEED_ITEM_MUTATION_TYPES.SET_FETCHING, { key: 'starred', fetching: false }) }, + async [FEED_ITEM_ACTION_TYPES.FETCH_FEED_ITEMS]({ commit }: ActionParams, { feedId, start }: { feedId: number; start: number }) { + commit(FEED_ITEM_MUTATION_TYPES.SET_FETCHING, { key: 'feed-' + feedId, fetching: true }) + const response = await ItemService.debounceFetchFeedItems(feedId, start) + + commit(FEED_ITEM_MUTATION_TYPES.SET_ITEMS, response?.data.items) + if (response?.data.items.length < 40) { + commit(FEED_ITEM_MUTATION_TYPES.SET_ALL_LOADED, { key: 'feed-' + feedId, loaded: true }) + } + commit(FEED_ITEM_MUTATION_TYPES.SET_FETCHING, { key: 'feed-' + feedId, fetching: false }) + }, [FEED_ITEM_ACTION_TYPES.MARK_READ]({ commit }: ActionParams, { item }: { item: FeedItem}) { ItemService.markRead(item, true) diff --git a/src/types/FeedItem.ts b/src/types/FeedItem.ts index dcd8c255a..879a663f4 100644 --- a/src/types/FeedItem.ts +++ b/src/types/FeedItem.ts @@ -5,4 +5,5 @@ export type FeedItem = { starred: boolean; feedId: number; guidHash: string; + pubDate: number; }; |