Feed filter implementation

This commit is contained in:
2026-07-12 12:28:32 +02:00
parent 0e3142bac9
commit 31991ea1f8
4 changed files with 306 additions and 10 deletions
+66 -2
View File
@@ -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,