Feed filter implementation
This commit is contained in:
@@ -6,7 +6,7 @@ import Modal from './modal/AddUrl.vue'
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds, lastProgrammaticScroll } = useFeeds()
|
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, lastProgrammaticScroll } = useFeeds()
|
||||||
|
|
||||||
const headerRef = ref(null)
|
const headerRef = ref(null)
|
||||||
|
|
||||||
@@ -77,15 +77,16 @@ onMounted(() => {
|
|||||||
|
|
||||||
lastY = Math.max(0, window.scrollY)
|
lastY = Math.max(0, window.scrollY)
|
||||||
window.addEventListener('scroll', onScrollRaf, { passive: true })
|
window.addEventListener('scroll', onScrollRaf, { passive: true })
|
||||||
|
document.addEventListener('click', onDocumentClick)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('scroll', onScrollRaf)
|
window.removeEventListener('scroll', onScrollRaf)
|
||||||
|
document.removeEventListener('click', onDocumentClick)
|
||||||
})
|
})
|
||||||
|
|
||||||
const onFeedsPage = computed(() => route.path === '/feeds')
|
const onFeedsPage = computed(() => route.path === '/feeds')
|
||||||
|
|
||||||
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
|
|
||||||
|
|
||||||
const menuOpen = ref(false)
|
const menuOpen = ref(false)
|
||||||
|
|
||||||
@@ -97,6 +98,19 @@ function closeMenu() {
|
|||||||
menuOpen.value = false
|
menuOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The open menu is a thin absolutely-positioned strip under the header, so its
|
||||||
|
// `@click.self` only catches clicks on that strip — a click anywhere else on
|
||||||
|
// the page never reaches it. This document-level listener closes the menu on
|
||||||
|
// any outside click. The hamburger is excluded (it has its own toggle, so an
|
||||||
|
// opening click mustn't immediately re-close), and clicks inside the menu are
|
||||||
|
// left to the menu items' own handlers.
|
||||||
|
function onDocumentClick(event) {
|
||||||
|
if (!menuOpen.value) return
|
||||||
|
const target = event.target
|
||||||
|
if (target.closest?.('.app-nav__hamburger') || target.closest?.('.app-nav__menu')) return
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await logoutSession()
|
await logoutSession()
|
||||||
closeMenu()
|
closeMenu()
|
||||||
@@ -133,6 +147,16 @@ function handleToggleLayout() {
|
|||||||
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
|
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
|
||||||
<div class="app-nav__wrapper">
|
<div class="app-nav__wrapper">
|
||||||
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
|
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
|
||||||
|
<select
|
||||||
|
v-if="onFeedsPage && feedTitles.length"
|
||||||
|
class="app-nav__filter"
|
||||||
|
:value="feedFilter ?? ''"
|
||||||
|
aria-label="Filter by feed"
|
||||||
|
@change="setFeedFilter($event.target.value || null)"
|
||||||
|
>
|
||||||
|
<option value="">All feeds</option>
|
||||||
|
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }}</option>
|
||||||
|
</select>
|
||||||
<button
|
<button
|
||||||
class="app-nav__hamburger"
|
class="app-nav__hamburger"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -210,6 +234,7 @@ function handleToggleLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__title {
|
.app-nav__title {
|
||||||
|
margin-right: auto;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: clamp(0.95rem, 3.5vw, 1.1rem);
|
font-size: clamp(0.95rem, 3.5vw, 1.1rem);
|
||||||
}
|
}
|
||||||
@@ -219,6 +244,23 @@ function handleToggleLayout() {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-nav__filter {
|
||||||
|
min-height: 44px;
|
||||||
|
max-width: clamp(120px, 40vw, 220px);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__filter:hover {
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.app-nav__hamburger {
|
.app-nav__hamburger {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -25,8 +25,13 @@ describe('AppNav', () => {
|
|||||||
localStorage.setItem('user-id', '7')
|
localStorage.setItem('user-id', '7')
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
||||||
const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
|
const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
|
||||||
feeds.value = []
|
feeds.value = []
|
||||||
|
allItems.value = []
|
||||||
|
feedFilter.value = null
|
||||||
|
// Module-singleton state: a prior test may have stamped this via a mocked
|
||||||
|
// performance.now(); reset it so the scroll-driven reveal gate starts clean.
|
||||||
|
lastProgrammaticScroll.value = 0
|
||||||
showMessage.value = false
|
showMessage.value = false
|
||||||
message.value = ''
|
message.value = ''
|
||||||
showModal.value = false
|
showModal.value = false
|
||||||
@@ -84,6 +89,17 @@ describe('AppNav', () => {
|
|||||||
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('closes the menu on a click outside of it', async () => {
|
||||||
|
const wrapper = await mountWithMenuOpen()
|
||||||
|
expect(wrapper.find('.app-nav__menu').exists()).toBe(true)
|
||||||
|
|
||||||
|
// A click anywhere outside the menu strip (e.g. on page content) closes it.
|
||||||
|
document.body.dispatchEvent(new Event('click', { bubbles: true }))
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('clears stored credentials and redirects to login on logout', async () => {
|
it('clears stored credentials and redirects to login on logout', async () => {
|
||||||
const wrapper = await mountWithMenuOpen()
|
const wrapper = await mountWithMenuOpen()
|
||||||
|
|
||||||
@@ -179,8 +195,10 @@ describe('AppNav', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows the unread count in the title when there are articles', async () => {
|
it('shows the unread count in the title when there are articles', async () => {
|
||||||
const { feeds } = useFeeds()
|
// The badge is the global unread total, sourced from the master list
|
||||||
feeds.value = [
|
// (allItems) so it stays correct regardless of any active feed filter.
|
||||||
|
const { allItems } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
{ id: 1, title: 'Article one', content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
{ id: 1, title: 'Article one', content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
{ id: 2, title: 'Article two', content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
{ id: 2, title: 'Article two', content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
]
|
]
|
||||||
@@ -192,8 +210,8 @@ describe('AppNav', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('excludes already-read articles from the counter while in article view', async () => {
|
it('excludes already-read articles from the counter while in article view', async () => {
|
||||||
const { feeds } = useFeeds()
|
const { allItems } = useFeeds()
|
||||||
feeds.value = [
|
allItems.value = [
|
||||||
{ id: 1, title: 'Article one', read: true, content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
{ id: 1, title: 'Article one', read: true, content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
{ id: 2, title: 'Article two', read: false, content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
{ id: 2, title: 'Article two', read: false, content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
]
|
]
|
||||||
@@ -211,6 +229,74 @@ describe('AppNav', () => {
|
|||||||
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
|
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders a feed filter with an "All feeds" option plus one per distinct feed', async () => {
|
||||||
|
const { allItems } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed B', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-02' },
|
||||||
|
{ id: 2, feedTitle: 'Feed A', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-01' },
|
||||||
|
{ id: 3, feedTitle: 'Feed A', title: 'c', url: 'https://example.test/3', timestamp: '2026-01-03' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const options = wrapper.find('.app-nav__filter').findAll('option').map(o => o.text())
|
||||||
|
// "All feeds" first, then distinct titles sorted alphabetically.
|
||||||
|
expect(options).toEqual(['All feeds', 'Feed A', 'Feed B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render the feed filter when there are no feeds', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__filter').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies the selected feed to the displayed list, and "All feeds" restores it', async () => {
|
||||||
|
const { allItems, feeds, feedFilter } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
|
{ id: 2, feedTitle: 'Feed B', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const select = wrapper.find('.app-nav__filter')
|
||||||
|
await select.setValue('Feed A')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBe('Feed A')
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([1])
|
||||||
|
|
||||||
|
await select.setValue('')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the selected feed\'s unread count in the title when a filter is active', async () => {
|
||||||
|
const { allItems } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
|
{ id: 2, feedTitle: 'Feed A', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
|
{ id: 3, feedTitle: 'Feed B', title: 'c', url: 'https://example.test/3', timestamp: '2026-01-03' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// No filter → global total (3).
|
||||||
|
expect(wrapper.find('.app-nav__title').text()).toContain('(3)')
|
||||||
|
|
||||||
|
await wrapper.find('.app-nav__filter').setValue('Feed A')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Filtered → unread in Feed A only (2).
|
||||||
|
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
||||||
|
})
|
||||||
|
|
||||||
describe('scroll-driven show/hide', () => {
|
describe('scroll-driven show/hide', () => {
|
||||||
// The scroll handler is rAF-throttled; run rAF synchronously so a single
|
// The scroll handler is rAF-throttled; run rAF synchronously so a single
|
||||||
// dispatched scroll event resolves before we assert. Per the CLAUDE.md
|
// dispatched scroll event resolves before we assert. Per the CLAUDE.md
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class FakeIntersectionObserver {
|
|||||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||||
|
|
||||||
describe('useFeeds', () => {
|
describe('useFeeds', () => {
|
||||||
const { feeds, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection } = useFeeds()
|
const { feeds, allItems, feedFilter, feedTitles, unreadCount, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead } = useFeeds()
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('user-token', 'test-token')
|
localStorage.setItem('user-token', 'test-token')
|
||||||
@@ -21,6 +21,8 @@ describe('useFeeds', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
||||||
feeds.value = []
|
feeds.value = []
|
||||||
|
allItems.value = []
|
||||||
|
feedFilter.value = null
|
||||||
showMessage.value = false
|
showMessage.value = false
|
||||||
message.value = ''
|
message.value = ''
|
||||||
showModal.value = false
|
showModal.value = false
|
||||||
@@ -117,6 +119,108 @@ describe('useFeeds', () => {
|
|||||||
setInitialLoad(false)
|
setInitialLoad(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('feed filter', () => {
|
||||||
|
const twoFeedsResponse = {
|
||||||
|
data: {
|
||||||
|
feeds: [
|
||||||
|
{
|
||||||
|
title: 'Feed A',
|
||||||
|
items: [
|
||||||
|
{ id: 1, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' },
|
||||||
|
{ id: 3, title: 'A2', content: '', url: 'https://example.test/a2', timestamp: '2026-01-03 10:00:00' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Feed B',
|
||||||
|
items: [
|
||||||
|
{ id: 2, title: 'B1', content: '', url: 'https://example.test/b1', timestamp: '2026-01-02 10:00:00' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
it('narrows the displayed list to a single feed and restores it on "All feeds"', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
// Options are the distinct feed titles, sorted.
|
||||||
|
expect(feedTitles.value).toEqual(['Feed A', 'Feed B'])
|
||||||
|
// Unfiltered: everything, newest first (Jan 3, Jan 2, Jan 1).
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([3, 2, 1])
|
||||||
|
|
||||||
|
await setFeedFilter('Feed A')
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([3, 1])
|
||||||
|
|
||||||
|
await setFeedFilter(null)
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([3, 2, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not resurrect read articles when the filter is cleared', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
await setFeedFilter('Feed A')
|
||||||
|
setInitialLoad(true)
|
||||||
|
|
||||||
|
// The first Feed A article scrolls above the viewport → marked read and
|
||||||
|
// dropped from both the filtered list and the master.
|
||||||
|
await handleIntersection([
|
||||||
|
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
|
||||||
|
])
|
||||||
|
setInitialLoad(false)
|
||||||
|
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([1])
|
||||||
|
|
||||||
|
await setFeedFilter(null)
|
||||||
|
// Article 3 stays gone; the still-unread articles remain, newest first.
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([2, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps an emptied filtered feed selected and selectable, showing "All caught up"', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
await setFeedFilter('Feed B')
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([2])
|
||||||
|
|
||||||
|
// Mark all (visible = just Feed B) read — Feed B has no unread items left.
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
await markAllRead()
|
||||||
|
|
||||||
|
// The list empties, but the filter stays put and the feed remains a
|
||||||
|
// selectable option (so the <select> never dangles) even though it no
|
||||||
|
// longer has unread items.
|
||||||
|
expect(feeds.value).toEqual([])
|
||||||
|
expect(feedFilter.value).toBe('Feed B')
|
||||||
|
expect(feedTitles.value).toEqual(['Feed A', 'Feed B'])
|
||||||
|
|
||||||
|
// Switching away drops the now-empty feed from the options.
|
||||||
|
await setFeedFilter(null)
|
||||||
|
expect(feedTitles.value).toEqual(['Feed A'])
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([3, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts unread items in the selected feed when a filter is active, else the global total', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
// No filter: global unread total across both feeds.
|
||||||
|
expect(unreadCount.value).toBe(3)
|
||||||
|
|
||||||
|
// Filtered: only the selected feed's unread items.
|
||||||
|
await setFeedFilter('Feed B')
|
||||||
|
expect(unreadCount.value).toBe(1)
|
||||||
|
await setFeedFilter('Feed A')
|
||||||
|
expect(unreadCount.value).toBe(2)
|
||||||
|
|
||||||
|
await setFeedFilter(null)
|
||||||
|
expect(unreadCount.value).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('strips leftover embedded-video placeholder headings', async () => {
|
it('strips leftover embedded-video placeholder headings', async () => {
|
||||||
feeds.value = [{
|
feeds.value = [{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
import { ref, nextTick } from 'vue';
|
import { ref, computed, nextTick } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Readability } from '@mozilla/readability';
|
import { Readability } from '@mozilla/readability';
|
||||||
|
|
||||||
// Module-level state — declared outside useFeeds() so every caller shares the
|
// 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).
|
// same singleton refs (a Pinia-free "store" for the feed list and its UI state).
|
||||||
const showMessage = ref(false)
|
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 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 message = ref('')
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu
|
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 fetchData = async () => {
|
||||||
const user_id = localStorage.getItem("user-id")
|
const user_id = localStorage.getItem("user-id")
|
||||||
try {
|
try {
|
||||||
@@ -187,7 +237,8 @@ const fetchData = async () => {
|
|||||||
// timestamps are zero-padded "YYYY-MM-DD HH:MM:SS" strings, so a plain
|
// timestamps are zero-padded "YYYY-MM-DD HH:MM:SS" strings, so a plain
|
||||||
// lexicographic comparison sorts them chronologically.
|
// lexicographic comparison sorts them chronologically.
|
||||||
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||||
feeds.value = items;
|
allItems.value = items;
|
||||||
|
applyFilter();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
setupIntersectionObserver();
|
setupIntersectionObserver();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -262,6 +313,8 @@ function handleIntersection(entries, topbarHeight = 0) {
|
|||||||
markProgrammaticScroll()
|
markProgrammaticScroll()
|
||||||
const readIds = new Set(readFeeds.map(feed => feed.id))
|
const readIds = new Set(readFeeds.map(feed => feed.id))
|
||||||
feeds.value = feeds.value.filter(feed => !readIds.has(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) {
|
for (const feed of readFeeds) {
|
||||||
markRead(feed.id)
|
markRead(feed.id)
|
||||||
@@ -302,7 +355,11 @@ async function markAllRead() {
|
|||||||
if (!window.confirm('Mark all articles as read?')) return
|
if (!window.confirm('Mark all articles as read?')) return
|
||||||
|
|
||||||
const ids = feeds.value.map(feed => feed.id)
|
const ids = feeds.value.map(feed => feed.id)
|
||||||
|
const readIds = new Set(ids)
|
||||||
feeds.value = []
|
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
|
currentIndex.value = 0
|
||||||
// markRead swallows its own errors, so Promise.all can't reject here.
|
// markRead swallows its own errors, so Promise.all can't reject here.
|
||||||
await Promise.all(ids.map(id => markRead(id)))
|
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
|
// in place so currentIndex stayed valid — drop them now so they don't keep
|
||||||
// showing up in the list view.
|
// showing up in the list view.
|
||||||
feeds.value = feeds.value.filter(feed => !feed.read)
|
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
|
currentIndex.value = 0
|
||||||
viewMode.value = 'list'
|
viewMode.value = 'list'
|
||||||
// The v-if on the list container tears down and recreates all .observe DOM
|
// The v-if on the list container tears down and recreates all .observe DOM
|
||||||
@@ -382,6 +441,11 @@ function prevArticle() {
|
|||||||
export function useFeeds() {
|
export function useFeeds() {
|
||||||
return {
|
return {
|
||||||
feeds,
|
feeds,
|
||||||
|
allItems,
|
||||||
|
feedFilter,
|
||||||
|
feedTitles,
|
||||||
|
unreadCount,
|
||||||
|
setFeedFilter,
|
||||||
showMessage,
|
showMessage,
|
||||||
message,
|
message,
|
||||||
showModal,
|
showModal,
|
||||||
|
|||||||
Reference in New Issue
Block a user