Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01f8a30505 | ||
|
|
a898e7ac33 | ||
|
|
671ed86b48 | ||
|
|
af5e2b8137 | ||
|
|
2b1d1c57ff |
@@ -2,6 +2,7 @@ services:
|
||||
postgres:
|
||||
container_name: "rss-postgres"
|
||||
image: "postgres:18"
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
@@ -16,6 +17,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
@@ -31,6 +33,7 @@ services:
|
||||
build:
|
||||
context: ./vue
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
# --- builder ---
|
||||
FROM node:20-alpine AS builder
|
||||
# Node 24 (active LTS): Node 20 is past EOL (April 2026), and vue-router 5's
|
||||
# Babel 8 toolchain requires ^22.18.0 || >=24.11.0 (EBADENGINE on node 20).
|
||||
FROM node:24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
Generated
+50
-40
@@ -1275,9 +1275,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
@@ -1427,9 +1427,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -1437,12 +1437,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
||||
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fill-range": "^7.0.1"
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -2205,9 +2205,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
|
||||
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
@@ -2247,9 +2247,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
|
||||
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
@@ -2300,15 +2300,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -2812,10 +2812,20 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
@@ -3224,9 +3234,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
@@ -3299,12 +3309,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
|
||||
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"braces": "^3.0.2",
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3343,9 +3353,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
@@ -3673,9 +3683,9 @@
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -4450,9 +4460,9 @@
|
||||
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
|
||||
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
|
||||
@@ -6,7 +6,7 @@ import Modal from './modal/AddUrl.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, lastProgrammaticScroll } = useFeeds()
|
||||
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, feedUnreadCounts, setFeedFilter, unreadCount, lastProgrammaticScroll } = useFeeds()
|
||||
|
||||
const headerRef = ref(null)
|
||||
|
||||
@@ -155,7 +155,7 @@ function handleToggleLayout() {
|
||||
@change="setFeedFilter($event.target.value || null)"
|
||||
>
|
||||
<option value="">All feeds</option>
|
||||
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }}</option>
|
||||
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }} ({{ feedUnreadCounts[title] ?? 0 }})</option>
|
||||
</select>
|
||||
<button
|
||||
class="app-nav__hamburger"
|
||||
|
||||
@@ -241,8 +241,9 @@ describe('AppNav', () => {
|
||||
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'])
|
||||
// "All feeds" first, then distinct titles sorted alphabetically, each with
|
||||
// its unread count.
|
||||
expect(options).toEqual(['All feeds', 'Feed A (2)', 'Feed B (1)'])
|
||||
})
|
||||
|
||||
it('does not render the feed filter when there are no feeds', async () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ class FakeIntersectionObserver {
|
||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||
|
||||
describe('useFeeds', () => {
|
||||
const { feeds, allItems, feedFilter, feedTitles, unreadCount, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead } = useFeeds()
|
||||
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, setupIntersectionObserver } = useFeeds()
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('user-token', 'test-token')
|
||||
@@ -119,6 +119,39 @@ describe('useFeeds', () => {
|
||||
setInitialLoad(false)
|
||||
})
|
||||
|
||||
it('ignores the initial observer snapshot on connect so a scrolled-down reconnect cannot mass-mark', async () => {
|
||||
feeds.value = [
|
||||
{ id: 201, title: 'First' },
|
||||
{ id: 202, title: 'Second' },
|
||||
]
|
||||
setInitialLoad(true)
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
// A .observe node must exist for setupIntersectionObserver to arm the skip.
|
||||
const node = document.createElement('div')
|
||||
node.className = 'observe'
|
||||
document.body.appendChild(node)
|
||||
setupIntersectionObserver()
|
||||
|
||||
// First callback after connect is the initial snapshot — must be dropped
|
||||
// even though both entries look "scrolled past" (above the topbar).
|
||||
await handleIntersection([
|
||||
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
|
||||
{ isIntersecting: false, boundingClientRect: { y: -5 }, target: { id: '1' } },
|
||||
])
|
||||
expect(axios.put).not.toHaveBeenCalled()
|
||||
expect(feeds.value).toHaveLength(2)
|
||||
|
||||
// A genuine later scroll-past still marks read.
|
||||
await handleIntersection([
|
||||
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
|
||||
])
|
||||
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/201', null, expect.anything())
|
||||
|
||||
document.body.removeChild(node)
|
||||
setInitialLoad(false)
|
||||
})
|
||||
|
||||
describe('feed filter', () => {
|
||||
const twoFeedsResponse = {
|
||||
data: {
|
||||
@@ -219,6 +252,18 @@ describe('useFeeds', () => {
|
||||
await setFeedFilter(null)
|
||||
expect(unreadCount.value).toBe(3)
|
||||
})
|
||||
|
||||
it('exposes per-feed unread counts, excluding items marked read in place', async () => {
|
||||
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||
await fetchData()
|
||||
|
||||
expect(feedUnreadCounts.value).toEqual({ 'Feed A': 2, 'Feed B': 1 })
|
||||
|
||||
// Article view marks items read in place (feed.read = true) without
|
||||
// removing them from the master — the counts must not include those.
|
||||
allItems.value.find(i => i.id === 3).read = true
|
||||
expect(feedUnreadCounts.value).toEqual({ 'Feed A': 1, 'Feed B': 1 })
|
||||
})
|
||||
})
|
||||
|
||||
it('strips leftover embedded-video placeholder headings', async () => {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Reproduces the "reload the tab while in article view" boot sequence:
|
||||
// module-scope init must pick up the persisted viewMode, and RssFeeds'
|
||||
// mount flow (fetchData + sync) must not reset it back to 'list'.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
vi.mock('axios')
|
||||
|
||||
class FakeIntersectionObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||
|
||||
describe('viewMode boot persistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
localStorage.clear()
|
||||
localStorage.setItem('user-token', 'test-token')
|
||||
localStorage.setItem('user-id', '7')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('boots into article view when localStorage says so, surviving the mount fetch/sync', async () => {
|
||||
localStorage.setItem('viewMode', 'article')
|
||||
|
||||
const axios = (await import('axios')).default
|
||||
axios.get.mockResolvedValue({
|
||||
data: {
|
||||
feeds: [
|
||||
{
|
||||
title: 'My Feed',
|
||||
items: [
|
||||
{ id: 1, title: 'Article one', content: '<p>hi</p>', url: 'https://example.test/1', timestamp: '2026-01-01 10:00:00' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
axios.post.mockResolvedValue({ status: 200 })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
// Import AFTER setting storage — module-scope refs initialize at import.
|
||||
const { useFeeds } = await import('../useFeeds')
|
||||
const { viewMode } = useFeeds()
|
||||
expect(viewMode.value).toBe('article')
|
||||
|
||||
const RssFeeds = (await import('../../components/RssFeeds.vue')).default
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
|
||||
expect(viewMode.value).toBe('article')
|
||||
expect(wrapper.find('.article-single').exists()).toBe(true)
|
||||
expect(wrapper.find('#article').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('boots into list view when the key is absent', async () => {
|
||||
const axios = (await import('axios')).default
|
||||
axios.get.mockResolvedValue({ data: { feeds: [] } })
|
||||
axios.post.mockResolvedValue({ status: 200 })
|
||||
|
||||
const { useFeeds } = await import('../useFeeds')
|
||||
const { viewMode } = useFeeds()
|
||||
expect(viewMode.value).toBe('list')
|
||||
})
|
||||
})
|
||||
@@ -22,6 +22,17 @@ const feedTitles = computed(() => {
|
||||
if (feedFilter.value) titles.add(feedFilter.value)
|
||||
return [...titles].sort((a, b) => a.localeCompare(b))
|
||||
})
|
||||
// Unread count per feed title, shown next to each option in the filter
|
||||
// <select>. Derived from the master (allItems) so article view's
|
||||
// read-but-still-shown items are excluded. A title kept in feedTitles with no
|
||||
// unread items left has no key here — render it as 0.
|
||||
const feedUnreadCounts = computed(() => {
|
||||
const counts = {}
|
||||
for (const item of allItems.value) {
|
||||
if (!item.read) counts[item.feedTitle] = (counts[item.feedTitle] ?? 0) + 1
|
||||
}
|
||||
return counts
|
||||
})
|
||||
// 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
|
||||
@@ -34,12 +45,21 @@ const unreadCount = computed(() => {
|
||||
})
|
||||
const message = ref('')
|
||||
const showModal = ref(false)
|
||||
const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu
|
||||
const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
|
||||
const currentIndex = ref(0)
|
||||
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu
|
||||
|
||||
let observer; // Declare observer outside the setup function
|
||||
let initialLoad = false
|
||||
// An IntersectionObserver always delivers an initial snapshot for every element
|
||||
// it starts observing. If the observer (re)connects while the page is scrolled
|
||||
// down — e.g. leaving a scrolled article view, an HMR module reload, or the
|
||||
// post-splice re-setup in handleIntersection — that snapshot reports every
|
||||
// article above the viewport as "not intersecting / scrolled past" and would
|
||||
// mark them all read at once. So each setupIntersectionObserver() sets this and
|
||||
// handleIntersection() drops exactly the first callback after a connect; only
|
||||
// genuine scroll-driven exits mark read after that.
|
||||
let skipNextObservation = false
|
||||
|
||||
// Timestamp (performance.now()) of the most recent programmatic scroll / list
|
||||
// mutation that moves the page without user intent — currently the list-view
|
||||
@@ -282,14 +302,25 @@ function setupIntersectionObserver() {
|
||||
});
|
||||
|
||||
const observedDivs = document.querySelectorAll(".observe");
|
||||
if (observedDivs.length > 0) {
|
||||
observedDivs.forEach(observedDiv => {
|
||||
observer.observe(observedDiv);
|
||||
})
|
||||
}
|
||||
// Arm the skip only when we actually observe nodes: an observe() batch fires
|
||||
// an initial snapshot to drop (a connect while scrolled down would otherwise
|
||||
// mass-mark everything above the viewport), but with nothing observed there's
|
||||
// no snapshot — and assigning unconditionally clears any stale flag from a
|
||||
// prior no-op setup (e.g. one run in article view, which has no .observe nodes).
|
||||
skipNextObservation = observedDivs.length > 0
|
||||
observedDivs.forEach(observedDiv => {
|
||||
observer.observe(observedDiv);
|
||||
})
|
||||
}
|
||||
|
||||
function handleIntersection(entries, topbarHeight = 0) {
|
||||
// Drop the initial snapshot fired on (re)connect — see skipNextObservation.
|
||||
// The old observer is disconnected before this new one observes, so only the
|
||||
// latest observer's initial callback reaches here; one skip is enough.
|
||||
if (skipNextObservation) {
|
||||
skipNextObservation = false
|
||||
return
|
||||
}
|
||||
// Resolve all affected feeds before touching feeds.value — the target.id
|
||||
// indices are render-time positions that shift once we splice the array.
|
||||
const readFeeds = entries
|
||||
@@ -386,6 +417,7 @@ async function leaveArticleView() {
|
||||
allItems.value = allItems.value.filter(feed => !feed.read)
|
||||
currentIndex.value = 0
|
||||
viewMode.value = 'list'
|
||||
localStorage.setItem('viewMode', viewMode.value)
|
||||
// The v-if on the list container tears down and recreates all .observe DOM
|
||||
// nodes when switching views, so the intersection observer must be
|
||||
// re-pointed at the new elements after Vue has finished rendering.
|
||||
@@ -405,6 +437,7 @@ function toggleViewMode() {
|
||||
observer = null
|
||||
}
|
||||
viewMode.value = 'article'
|
||||
localStorage.setItem('viewMode', viewMode.value)
|
||||
currentIndex.value = 0
|
||||
markCurrentArticleRead()
|
||||
}
|
||||
@@ -444,6 +477,7 @@ export function useFeeds() {
|
||||
allItems,
|
||||
feedFilter,
|
||||
feedTitles,
|
||||
feedUnreadCounts,
|
||||
unreadCount,
|
||||
setFeedFilter,
|
||||
showMessage,
|
||||
|
||||
Reference in New Issue
Block a user