summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDevlin Junker <devlin.junker@gmail.com>2023-08-20 20:58:51 -0700
committerBenjamin Brahmer <info@b-brahmer.de>2023-08-26 07:48:18 +0200
commit9272190bc066fb58ff358fef8623f91ecf52cb57 (patch)
tree33eeec6dc1d8ecb286c4c642eafc4d16c53a06d8 /src
parentefb1ac236e0e5f2038886fccf4d01337b486732a (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.vue1
-rw-r--r--src/components/FeedItem.vue8
-rw-r--r--src/components/FeedItemDisplay.vue178
-rw-r--r--src/components/Sidebar.vue4
-rw-r--r--src/components/Starred.vue18
-rw-r--r--src/components/Unread.vue111
-rw-r--r--src/components/VirtualScroll.vue6
-rw-r--r--src/routes/index.ts8
-rw-r--r--src/store/feed.ts5
-rw-r--r--src/store/item.ts61
-rw-r--r--src/types/MutationTypes.ts2
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'
}