diff options
author | Devlin Junker <devlin.junker@gmail.com> | 2023-08-20 20:58:51 -0700 |
---|---|---|
committer | Benjamin Brahmer <info@b-brahmer.de> | 2023-08-26 07:48:18 +0200 |
commit | 9272190bc066fb58ff358fef8623f91ecf52cb57 (patch) | |
tree | 33eeec6dc1d8ecb286c4c642eafc4d16c53a06d8 /src | |
parent | efb1ac236e0e5f2038886fccf4d01337b486732a (diff) |
add unread route and component
- started on unread component and using load-more callback from VirtualScroll component
- realized we need to change to a 3-panel display because VirtualScroll removes the rendered component even when open if you scroll too much
- created FeedItemDisplay component to display a selected feed item details
Signed-off-by: Devlin Junker <devlin.junker@gmail.com>
Diffstat (limited to 'src')
-rw-r--r-- | src/App.vue | 1 | ||||
-rw-r--r-- | src/components/FeedItem.vue | 8 | ||||
-rw-r--r-- | src/components/FeedItemDisplay.vue | 178 | ||||
-rw-r--r-- | src/components/Sidebar.vue | 4 | ||||
-rw-r--r-- | src/components/Starred.vue | 18 | ||||
-rw-r--r-- | src/components/Unread.vue | 111 | ||||
-rw-r--r-- | src/components/VirtualScroll.vue | 6 | ||||
-rw-r--r-- | src/routes/index.ts | 8 | ||||
-rw-r--r-- | src/store/feed.ts | 5 | ||||
-rw-r--r-- | src/store/item.ts | 61 | ||||
-rw-r--r-- | src/types/MutationTypes.ts | 2 |
11 files changed, 389 insertions, 13 deletions
diff --git a/src/App.vue b/src/App.vue index 98c3e393c..dafe1a33c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -25,6 +25,7 @@ export default Vue.extend({ await this.$store.dispatch(ACTIONS.FETCH_FOLDERS) await this.$store.dispatch(ACTIONS.FETCH_FEEDS) await this.$store.dispatch(ACTIONS.FETCH_STARRED) + await this.$store.dispatch(ACTIONS.FETCH_UNREAD) }, }) </script> diff --git a/src/components/FeedItem.vue b/src/components/FeedItem.vue index 3d59b4f92..8650b58dd 100644 --- a/src/components/FeedItem.vue +++ b/src/components/FeedItem.vue @@ -11,7 +11,7 @@ <EarthIcon /> </a> <RssIcon v-if="!getFeed(item.feedId).faviconLink" /> - <span v-if="getFeed(item.feedId).faviconLink" :style="{ 'backgroundImage': 'url(' + Content.getFeed(item.feedId).faviconLink + ')' }" /> + <span v-if="getFeed(item.feedId).faviconLink" style="width: 24px; background-size: contain;" :style="{ 'backgroundImage': 'url(' + getFeed(item.feedId).faviconLink + ')' }" /> </div> <div class="title-container" :class="{ 'unread': item.unread }"> <span :style="{ 'white-space': !isExpanded ? 'nowrap' : 'normal' }" :dir="item.rtl && 'rtl'"> @@ -162,7 +162,8 @@ export default Vue.extend({ }, methods: { expand() { - this.expanded = !this.expanded + this.$store.dispatch(ACTIONS.SET_SELECTED_ITEM, { id: this.item.id }) + // this.expanded = !this.expanded this.markRead(this.item) }, formatDate(epoch: number) { @@ -218,6 +219,9 @@ export default Vue.extend({ toggleStarred(item: FeedItem): void { this.$store.dispatch(item.starred ? ACTIONS.UNSTAR_ITEM : ACTIONS.STAR_ITEM, { item }) }, + isCompactView(): boolean { + return true + }, }, }) diff --git a/src/components/FeedItemDisplay.vue b/src/components/FeedItemDisplay.vue new file mode 100644 index 000000000..8190c8176 --- /dev/null +++ b/src/components/FeedItemDisplay.vue @@ -0,0 +1,178 @@ +<template> + <div class="feed-item-display"> + <div> + <CloseIcon style="position: absolute; right: 30px; top: 20px; cursor: pointer;" @click="clearSelected()" /> + </div> + <div class="article"> + <div class="heading"> + <h1 :dir="item.rtl && 'rtl'"> + <a target="_blank" + rel="noreferrer" + :href="item.url" + :title="item.title"> + {{ item.title }} + </a> + </h1> + <time class="date" :title="formatDate(item.pubDate*1000, 'yyyy-MM-dd HH:mm:ss')" :datetime="formatDate(item.pubDate*1000, 'yyyy-MM-ddTHH:mm:ssZ')"> + {{ formatDate(item.pubDate*1000) }} + </time> + </div> + + <div class="subtitle" :dir="item.rtl && 'rtl'"> + <span v-show="item.author !== undefined && item.author !== null && item.author.trim() !== ''" class="author"> + {{ t('news', 'by') }} {{ item.author }} + </span> + <span v-if="!item.sharedBy" class="source">{{ t('news', 'from') }} + <!-- TODO: Fix link to feed --> + <a :href="`#/items/feeds/${item.feedId}/`"> + {{ getFeed(item.feedId).title }} + <img v-if="getFeed(item.feedId).faviconLink" + :src="getFeed(item.feedId).faviconLink" + alt="favicon" + style="width: 16px"> + </a> + </span> + <span v-if="item.sharedBy"> + <span v-if="item.author">-</span> + {{ t('news', 'shared by') }} + {{ item.sharedByDisplayName }} + </span> + </div> + + <!-- TODO: Test audio/video --> + <div v-if="getMediaType(item.enclosureMime) == 'audio'" class="enclosure"> + <button @click="play(item)"> + {{ t('news', 'Play audio') }} + </button> + <a class="button" + :href="item.enclosureLink" + target="_blank" + rel="noreferrer"> + {{ t('news', 'Download audio') }} + </a> + </div> + <div v-if="getMediaType(item.enclosureMime) == 'video'" class="enclosure"> + <video controls + preload="none" + news-play-one + :src="item.enclosureLink" + :type="item.enclosureMime" /> + <a class="button" + :href="item.enclosureLink" + target="_blank" + rel="noreferrer"> + {{ t('news', 'Download video') }} + </a> + </div> + + <div v-if="item.mediaThumbnail" class="enclosure thumbnail"> + <a :href="item.enclosureLink"><img :src="item.mediaThumbnail" alt=""></a> + </div> + + <div v-if="item.mediaDescription" class="enclosure description" v-html="item.mediaDescription" /> + + <div class="body" :dir="item.rtl && 'rtl'" v-html="item.body" /> + </div> + </div> +</template> + +<script lang="ts"> +import Vue from 'vue' +import { mapState } from 'vuex' + +import CloseIcon from 'vue-material-design-icons/Close.vue' + +import { Feed } from '../types/Feed' +import { FeedItem } from '../types/FeedItem' +import { ACTIONS } from '../store' + +export default Vue.extend({ + name: 'FeedItemDisplay', + components: { + CloseIcon, + }, + props: { + item: { + type: Object, + required: true, + }, + }, + data: () => { + return { + expanded: false, + keepUnread: false, + } + }, + computed: { + isExpanded() { + return this.expanded + }, + ...mapState(['feeds']), + }, + methods: { + clearSelected() { + this.$store.dispatch(ACTIONS.SET_SELECTED_ITEM, { id: undefined }) + }, + formatDate(epoch: number) { + return new Date(epoch).toLocaleString() + }, + formatDatetime(epoch: number) { + return new Date(epoch).toISOString() + }, + getFeed(id: number): Feed { + return this.$store.getters.feeds.find((feed: Feed) => feed.id === id) || {} + }, + getMediaType(mime: any): 'audio' | 'video' | false { + // TODO: figure out how to check media type + return false + }, + play(item: any) { + // TODO: implement play audio/video + }, + toggleStarred(item: FeedItem): void { + this.$store.dispatch(item.starred ? ACTIONS.UNSTAR_ITEM : ACTIONS.STAR_ITEM, { item }) + }, + }, +}) + +</script> + +<style> + .article { + padding: 0 50px 50px 50px; + } + + .article .body { + color: var(--color-main-text); + font-size: 15px; + } + + .article a { + text-decoration: underline; + } + + .article .body a { + color: #3a84e4 + } + + .article .subtitle { + color: var(--color-text-lighter); + font-size: 15px; + padding: 25px 0; + } + + .article .author { + color: var(--color-text-lighter); + font-size: 15px; + } + + .article img { + width: 100%; + } + + .article h1 { + font-weight: bold; + font-size: 17px; + margin-top: 25px; + } +</style> diff --git a/src/components/Sidebar.vue b/src/components/Sidebar.vue index a9c128b15..c5499c585 100644 --- a/src/components/Sidebar.vue +++ b/src/components/Sidebar.vue @@ -10,14 +10,14 @@ icon="icon-add-folder" @new-item="newFolder" /> - <NcAppNavigationItem :title="t('news', 'Unread articles')" icon="icon-rss"> + <NcAppNavigationItem :title="t('news', 'Unread articles')" icon="icon-rss" :to="{ name: ROUTES.UNREAD }"> <template #actions> <NcActionButton icon="icon-checkmark" @click="alert('TODO: Mark Read')"> t('news','Mark read') </NcActionButton> </template> <template #counter> - <NcCounterBubble>5</NcCounterBubble> + <NcCounterBubble>{{ items.unreadCount }}</NcCounterBubble> </template> </NcAppNavigationItem> <NcAppNavigationItem :title="t('news', 'All articles')" icon="icon-rss"> diff --git a/src/components/Starred.vue b/src/components/Starred.vue index 4dbfd9f5f..c07ef66af 100644 --- a/src/components/Starred.vue +++ b/src/components/Starred.vue @@ -1,18 +1,25 @@ <template> - <div> + <div style="display: flex;"> <div class="header"> Starred <NcCounterBubble class="counter-bubble"> {{ items.starredCount }} </NcCounterBubble> </div> - <VirtualScroll :reached-end="reachedEnd" @load-more="fetchMore()"> + <VirtualScroll :reached-end="reachedEnd" + :fetch-key="'starred'" + style="width: 100%;" + @load-more="fetchMore()"> <template v-if="starred && starred.length > 0"> <template v-for="item in starred"> <FeedItemComponent :key="item.id" :item="item" /> </template> </template> </VirtualScroll> + + <div v-if="selected !== undefined" style="max-width: 50%; overflow-y: scroll;"> + <FeedItemDisplay :item="selected" /> + </div> </div> </template> @@ -24,14 +31,17 @@ import NcCounterBubble from '@nextcloud/vue/dist/Components/NcCounterBubble.js' import VirtualScroll from './VirtualScroll.vue' import FeedItemComponent from './FeedItem.vue' +import FeedItemDisplay from './FeedItemDisplay.vue' import { FeedItem } from '../types/FeedItem' +import { ACTIONS } from '../store' export default Vue.extend({ components: { NcCounterBubble, VirtualScroll, FeedItemComponent, + FeedItemDisplay, }, data() { return { @@ -46,9 +56,13 @@ export default Vue.extend({ reachedEnd(): boolean { return this.mounted && this.$store.state.items.starredLoaded }, + selected(): FeedItem | undefined { + return this.$store.getters.selected + }, }, mounted() { this.mounted = true + this.$store.dispatch(ACTIONS.SET_SELECTED_ITEM, { id: undefined }) }, methods: { async fetchMore() { diff --git a/src/components/Unread.vue b/src/components/Unread.vue new file mode 100644 index 000000000..d7a2e9d89 --- /dev/null +++ b/src/components/Unread.vue @@ -0,0 +1,111 @@ +<template> + <div style="height: 100%"> + <div class="header"> + Unread + <NcCounterBubble class="counter-bubble"> + {{ items.unreadCount }} + </NcCounterBubble> + </div> + <div style="display: flex; height: 100%;"> + <VirtualScroll :reached-end="reachedEnd" + :fetch-key="'unread'" + style="width:100%" + @load-more="fetchMore()"> + <template v-if="unread() && unread().length > 0"> + <template v-for="item in unread()"> + <FeedItemComponent :key="item.id" :item="item" /> + </template> + </template> + </VirtualScroll> + + <div v-if="selected !== undefined" style="max-width: 50%; overflow-y: scroll;"> + <FeedItemDisplay :item="selected" /> + </div> + </div> + </div> +</template> + +<script lang="ts"> +import Vue from 'vue' +import { mapState } from 'vuex' + +import NcCounterBubble from '@nextcloud/vue/dist/Components/NcCounterBubble.js' + +import VirtualScroll from './VirtualScroll.vue' +import FeedItemComponent from './FeedItem.vue' +import FeedItemDisplay from './FeedItemDisplay.vue' + +import { FeedItem } from '../types/FeedItem' +import { ACTIONS } from '../store' + +export default Vue.extend({ + components: { + NcCounterBubble, + VirtualScroll, + FeedItemComponent, + FeedItemDisplay, + }, + data() { + return { + mounted: false, + _unread: undefined, + } as any + }, + computed: { + ...mapState(['items']), + reachedEnd(): boolean { + return this.mounted && this.$store.state.items.allItemsLoaded.unread !== undefined && this.$store.state.items.allItemsLoaded.unread + }, + selected(): FeedItem | undefined { + return this.$store.getters.selected + }, + }, + mounted() { + this.mounted = true + this.$store.dispatch(ACTIONS.SET_SELECTED_ITEM, { id: undefined }) + }, + methods: { + unread() { + if (!this._unread) { + if (this.$store.getters.unread.length > 0) { + this._unread = this.$store.getters.unread + } + } else if (this.$store.getters.unread.length > (this._unread?.length)) { + for (const item of this.$store.getters.unread) { + if (this._unread.find((unread: FeedItem) => unread.id === item.id) === undefined) { + this._unread.push(item) + } + } + } + + return this._unread + }, + async fetchMore() { + if (this._unread && !this.$store.state.items.fetchingItems.unread) { + console.log({ start: this._unread[this._unread?.length - 1]?.id }) + this.$store.dispatch(ACTIONS.FETCH_UNREAD, { start: this._unread[this._unread?.length - 1]?.id }) + } + }, + }, +}) +</script> + +<style> +.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); +} +</style> diff --git a/src/components/VirtualScroll.vue b/src/components/VirtualScroll.vue index 775855969..c6bb471bd 100644 --- a/src/components/VirtualScroll.vue +++ b/src/components/VirtualScroll.vue @@ -19,6 +19,10 @@ export default Vue.extend({ type: Boolean, required: true, }, + fetchKey: { + type: String, + required: true, + }, }, data() { return { @@ -31,7 +35,7 @@ export default Vue.extend({ }, computed: { fetching() { - return this.$store.state.items.fetchingItems + return this.$store.state.items.fetchingItems[this.key] }, }, watch: { diff --git a/src/routes/index.ts b/src/routes/index.ts index f54823d60..b572742c8 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -2,10 +2,12 @@ import VueRouter from 'vue-router' import ExplorePanel from '../components/Explore.vue' import StarredPanel from '../components/Starred.vue' +import UnreadPanel from '../components/Unread.vue' export const ROUTES = { EXPLORE: 'explore', STARRED: 'starred', + UNREAD: 'unread', } const getInitialRoute = function() { @@ -33,6 +35,12 @@ const routes = [ component: StarredPanel, props: true, }, + { + name: ROUTES.UNREAD, + path: '/unread', + component: UnreadPanel, + props: true, + }, ] export default new VueRouter({ diff --git a/src/store/feed.ts b/src/store/feed.ts index 9a0da520b..d86604266 100644 --- a/src/store/feed.ts +++ b/src/store/feed.ts @@ -3,7 +3,7 @@ import axios from '@nextcloud/axios' import { ActionParams, AppState } from '../store' import { Feed } from '../types/Feed' import { API_ROUTES } from '../types/ApiRoutes' -import { FOLDER_MUTATION_TYPES, FEED_MUTATION_TYPES } from '../types/MutationTypes' +import { FOLDER_MUTATION_TYPES, FEED_MUTATION_TYPES, FEED_ITEM_MUTATION_TYPES } from '../types/MutationTypes' export const FEED_ACTION_TYPES = { ADD_FEED: 'ADD_FEED', @@ -25,6 +25,9 @@ export const actions = { const feeds = await axios.get(API_ROUTES.FEED) commit(FEED_MUTATION_TYPES.SET_FEEDS, feeds.data.feeds) + commit(FEED_ITEM_MUTATION_TYPES.SET_UNREAD_COUNT, (feeds.data.feeds.reduce((total: number, feed: Feed) => { + return total + feed.unreadCount + }, 0))) }, async [FEED_ACTION_TYPES.ADD_FEED]( { commit }: ActionParams, diff --git a/src/store/item.ts b/src/store/item.ts index dbe343cb5..0fef155f0 100644 --- a/src/store/item.ts +++ b/src/store/item.ts @@ -7,39 +7,79 @@ import { FeedItem } from '../types/FeedItem' export const FEED_ITEM_ACTION_TYPES = { FETCH_STARRED: 'FETCH_STARRED', + FETCH_UNREAD: 'FETCH_UNREAD', MARK_READ: 'MARK_READ', MARK_UNREAD: 'MARK_UNREAD', STAR_ITEM: 'STAR_ITEM', UNSTAR_ITEM: 'UNSTAR_ITEM', + SET_SELECTED_ITEM: 'SET_SELECTED_ITEM', } export type ItemState = { - fetchingItems: boolean; + fetchingItems: { [key: string]: boolean }; + allItemsLoaded: { [key: string]: boolean }; starredLoaded: boolean; starredCount: number; + unreadCount: number; allItems: FeedItem[]; + + selectedId?: string; } const state: ItemState = { - fetchingItems: false, + fetchingItems: {}, + allItemsLoaded: {}, starredLoaded: false, starredCount: 0, + unreadCount: 0, allItems: [], + selectedId: undefined, } const getters = { starred(state: ItemState) { return state.allItems.filter((item) => item.starred) }, + unread(state: ItemState) { + return state.allItems.filter((item) => item.unread) + }, + selected(state: ItemState) { + return state.allItems.find((item: FeedItem) => item.id === state.selectedId) + }, } export const actions = { + async [FEED_ITEM_ACTION_TYPES.SET_SELECTED_ITEM]({ commit }: ActionParams, { id }: { id: string }) { + state.selectedId = id + }, + async [FEED_ITEM_ACTION_TYPES.FETCH_UNREAD]({ commit }: ActionParams, { start }: { start: number } = { start: 0 }) { + if (state.allItems.filter((item) => item.unread).length === 0) { + state.fetchingItems.unread = true + } + const response = await axios.get(API_ROUTES.ITEMS, { + params: { + limit: 40, + oldestFirst: false, + search: '', + showAll: false, + type: 6, + offset: start, + }, + }) + + commit(FEED_ITEM_MUTATION_TYPES.SET_ITEMS, response.data.items) + + if (response.data.items.length < 40) { + state.allItemsLoaded.unread = true + } + state.fetchingItems.unread = false + }, async [FEED_ITEM_ACTION_TYPES.FETCH_STARRED]({ commit }: ActionParams) { - state.fetchingItems = true + state.fetchingItems.starred = true const response = await axios.get(API_ROUTES.ITEMS, { params: { limit: 40, @@ -57,12 +97,15 @@ export const actions = { if (response.data.items.length < 40) { state.starredLoaded = true } - state.fetchingItems = false + state.fetchingItems.starred = false }, [FEED_ITEM_ACTION_TYPES.MARK_READ]({ commit }: ActionParams, { item }: { item: FeedItem}) { axios.post(API_ROUTES.ITEMS + `/${item.id}/read`, { isRead: true, }) + if (item.unread) { + commit(FEED_ITEM_MUTATION_TYPES.SET_UNREAD_COUNT, state.unreadCount - 1) + } item.unread = false commit(FEED_ITEM_MUTATION_TYPES.UPDATE_ITEM, { item }) }, @@ -70,6 +113,9 @@ export const actions = { axios.post(API_ROUTES.ITEMS + `/${item.id}/read`, { isRead: false, }) + if (!item.unread) { + commit(FEED_ITEM_MUTATION_TYPES.SET_UNREAD_COUNT, state.unreadCount + 1) + } item.unread = true commit(FEED_ITEM_MUTATION_TYPES.UPDATE_ITEM, { item }) }, @@ -94,12 +140,17 @@ export const actions = { export const mutations = { [FEED_ITEM_MUTATION_TYPES.SET_ITEMS](state: ItemState, items: FeedItem[]) { items.forEach(it => { - state.allItems.push(it) + if (state.allItems.find((existing: FeedItem) => existing.id === it.id) === undefined) { + state.allItems.push(it) + } }) }, [FEED_ITEM_MUTATION_TYPES.SET_STARRED_COUNT](state: ItemState, count: number) { state.starredCount = count }, + [FEED_ITEM_MUTATION_TYPES.SET_UNREAD_COUNT](state: ItemState, count: number) { + state.unreadCount = count + }, [FEED_ITEM_MUTATION_TYPES.UPDATE_ITEM](state: ItemState, { item }: { item: FeedItem }) { const idx = state.allItems.findIndex((it) => it.id === item.id) state.allItems.splice(idx, 1, item) diff --git a/src/types/MutationTypes.ts b/src/types/MutationTypes.ts index 8c00e714b..7176366e5 100644 --- a/src/types/MutationTypes.ts +++ b/src/types/MutationTypes.ts @@ -11,5 +11,7 @@ export const FOLDER_MUTATION_TYPES = { export const FEED_ITEM_MUTATION_TYPES = { SET_ITEMS: 'SET_ITEMS', SET_STARRED_COUNT: 'SET_STARRED_COUNT', + SET_UNREAD_COUNT: 'SET_UNREAD_COUNT', UPDATE_ITEM: 'UPDATE_ITEM', + SET_FETCHING: 'SET_FETCHING' } |