Feed filter implementation
This commit is contained in:
@@ -1,11 +1,37 @@
|
||||
import { ref, nextTick } from 'vue';
|
||||
import { ref, computed, nextTick } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { Readability } from '@mozilla/readability';
|
||||
|
||||
// Module-level state — declared outside useFeeds() so every caller shares the
|
||||
// same singleton refs (a Pinia-free "store" for the feed list and its UI state).
|
||||
const showMessage = ref(false)
|
||||
// `allItems` is the full flattened+sorted stream from the last fetch (the
|
||||
// master); `feeds` is the currently displayed projection of it (all items, or a
|
||||
// single feed when a filter is active). Every view/observer consumer operates on
|
||||
// `feeds` — the filter layer only ever swaps what `feeds` points at, so none of
|
||||
// the index-based observer / currentIndex logic has to know a filter exists.
|
||||
const allItems = ref([]);
|
||||
const feeds = ref([]);
|
||||
const feedFilter = ref(null) // selected feedTitle, or null = all feeds (not persisted)
|
||||
// Distinct feed titles present in the loaded (unread) items — the filter options.
|
||||
// The active filter is always kept in the list even once its feed runs out of
|
||||
// unread items, so the <select> never binds to a value that isn't an option
|
||||
// (it just shows "All caught up") and the feed stays re-selectable.
|
||||
const feedTitles = computed(() => {
|
||||
const titles = new Set(allItems.value.map(i => i.feedTitle))
|
||||
if (feedFilter.value) titles.add(feedFilter.value)
|
||||
return [...titles].sort((a, b) => a.localeCompare(b))
|
||||
})
|
||||
// The header badge count: unread items in the selected feed when a filter is
|
||||
// active, otherwise the global unread total. Derived from the master (allItems)
|
||||
// rather than the displayed `feeds` so article view's read-but-still-shown
|
||||
// items are excluded and the count matches the filter regardless of view.
|
||||
const unreadCount = computed(() => {
|
||||
const items = feedFilter.value
|
||||
? allItems.value.filter(i => i.feedTitle === feedFilter.value)
|
||||
: allItems.value
|
||||
return items.filter(i => !i.read).length
|
||||
})
|
||||
const message = ref('')
|
||||
const showModal = ref(false)
|
||||
const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu
|
||||
@@ -176,6 +202,30 @@ async function markRead(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// Projects the master list onto `feeds` through the active filter. A filtered
|
||||
// feed with no remaining unread items projects to an empty list ("All caught
|
||||
// up") while staying selected — see feedTitles.
|
||||
function applyFilter() {
|
||||
feeds.value = feedFilter.value
|
||||
? allItems.value.filter(i => i.feedTitle === feedFilter.value)
|
||||
: allItems.value.slice()
|
||||
}
|
||||
|
||||
// Changes the active feed filter and re-projects. Mirrors toggleLayout's
|
||||
// observer/scroll-safe pattern: disconnect first, scroll to top, then re-point
|
||||
// the observer at the new .observe nodes after Vue has re-rendered. Works in
|
||||
// article view too — there are no .observe nodes there so the setup is a no-op,
|
||||
// and resetting currentIndex keeps paging valid against the new list.
|
||||
async function setFeedFilter(title) {
|
||||
disconnectObserver()
|
||||
window.scrollTo(0, 0)
|
||||
feedFilter.value = title // null for "All feeds"
|
||||
currentIndex.value = 0
|
||||
applyFilter()
|
||||
await nextTick()
|
||||
setupIntersectionObserver()
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
const user_id = localStorage.getItem("user-id")
|
||||
try {
|
||||
@@ -187,7 +237,8 @@ const fetchData = async () => {
|
||||
// timestamps are zero-padded "YYYY-MM-DD HH:MM:SS" strings, so a plain
|
||||
// lexicographic comparison sorts them chronologically.
|
||||
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
feeds.value = items;
|
||||
allItems.value = items;
|
||||
applyFilter();
|
||||
await nextTick();
|
||||
setupIntersectionObserver();
|
||||
} catch (error) {
|
||||
@@ -262,6 +313,8 @@ function handleIntersection(entries, topbarHeight = 0) {
|
||||
markProgrammaticScroll()
|
||||
const readIds = new Set(readFeeds.map(feed => feed.id))
|
||||
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
|
||||
// Mirror into the master so cleared filters don't resurrect read articles.
|
||||
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
|
||||
|
||||
for (const feed of readFeeds) {
|
||||
markRead(feed.id)
|
||||
@@ -302,7 +355,11 @@ async function markAllRead() {
|
||||
if (!window.confirm('Mark all articles as read?')) return
|
||||
|
||||
const ids = feeds.value.map(feed => feed.id)
|
||||
const readIds = new Set(ids)
|
||||
feeds.value = []
|
||||
// markAllRead operates on the visible subset (only the filtered feed, if one
|
||||
// is active) — drop exactly those from the master too.
|
||||
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
|
||||
currentIndex.value = 0
|
||||
// markRead swallows its own errors, so Promise.all can't reject here.
|
||||
await Promise.all(ids.map(id => markRead(id)))
|
||||
@@ -325,6 +382,8 @@ async function leaveArticleView() {
|
||||
// in place so currentIndex stayed valid — drop them now so they don't keep
|
||||
// showing up in the list view.
|
||||
feeds.value = feeds.value.filter(feed => !feed.read)
|
||||
// Shared references — the paged-past objects carry .read on the master too.
|
||||
allItems.value = allItems.value.filter(feed => !feed.read)
|
||||
currentIndex.value = 0
|
||||
viewMode.value = 'list'
|
||||
// The v-if on the list container tears down and recreates all .observe DOM
|
||||
@@ -382,6 +441,11 @@ function prevArticle() {
|
||||
export function useFeeds() {
|
||||
return {
|
||||
feeds,
|
||||
allItems,
|
||||
feedFilter,
|
||||
feedTitles,
|
||||
unreadCount,
|
||||
setFeedFilter,
|
||||
showMessage,
|
||||
message,
|
||||
showModal,
|
||||
|
||||
Reference in New Issue
Block a user