15 Commits
Author SHA1 Message Date
mathias 01f8a30505 autorestrat images 2026-08-03 14:58:45 +02:00
mathias a898e7ac33 counter on feed select 2026-07-13 17:46:49 +02:00
mathias 671ed86b48 added test 2026-07-13 09:47:10 +02:00
mathias af5e2b8137 upgrade packages 2026-07-12 17:27:16 +02:00
mathias 2b1d1c57ff keep state of aritcle/list view 2026-07-12 17:22:30 +02:00
mathias 31991ea1f8 Feed filter implementation 2026-07-12 12:28:32 +02:00
mathias 0e3142bac9 remove deprecations, remove header when scrolling 2026-07-04 17:06:45 +02:00
mathias a73a1b57de padding for article view 2026-07-03 19:38:53 +02:00
mathias 3642635b20 remove variable header size 2026-07-03 11:10:14 +02:00
mathias b3cf5e4787 variable header size 2026-07-02 22:24:01 +02:00
mathias fe0adcf68e added shadow to header 2026-07-02 21:51:58 +02:00
mathias 3f9de099aa new textadjustment options 2026-06-23 18:03:11 +02:00
mathias b9c0951f2b Filter comercials in readable 2026-06-22 20:30:00 +02:00
mathias 7a24980101 Fix article get read when navigation to admin area. 2026-06-19 16:41:16 +02:00
mathias dfc2e29e36 Change version number 2026-06-19 13:44:05 +02:00
16 changed files with 851 additions and 92 deletions
Generated
+1 -1
View File
@@ -2550,7 +2550,7 @@ dependencies = [
[[package]] [[package]]
name = "rss-reader" name = "rss-reader"
version = "0.1.0" version = "0.9.1"
dependencies = [ dependencies = [
"actix-cors", "actix-cors",
"actix-governor", "actix-governor",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rss-reader" name = "rss-reader"
version = "0.1.0" version = "0.9.1"
edition = "2024" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+3
View File
@@ -2,6 +2,7 @@ services:
postgres: postgres:
container_name: "rss-postgres" container_name: "rss-postgres"
image: "postgres:18" image: "postgres:18"
restart: unless-stopped
ports: ports:
- "5432:5432" - "5432:5432"
environment: environment:
@@ -16,6 +17,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped
depends_on: depends_on:
- postgres - postgres
environment: environment:
@@ -31,6 +33,7 @@ services:
build: build:
context: ./vue context: ./vue
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped
depends_on: depends_on:
- backend - backend
ports: ports:
+3 -1
View File
@@ -1,5 +1,7 @@
# --- builder --- # --- 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 WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
+50 -40
View File
@@ -1275,9 +1275,9 @@
} }
}, },
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
@@ -1427,9 +1427,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
@@ -1437,12 +1437,12 @@
} }
}, },
"node_modules/braces": { "node_modules/braces": {
"version": "3.0.2", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"fill-range": "^7.0.1" "fill-range": "^7.1.1"
}, },
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -2205,9 +2205,9 @@
} }
}, },
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.0.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
@@ -2247,9 +2247,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.2.7", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true "dev": true
}, },
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
@@ -2300,15 +2300,15 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -2812,10 +2812,20 @@
"dev": true "dev": true
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true, "dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
}, },
@@ -3224,9 +3234,9 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true "dev": true
}, },
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
@@ -3299,12 +3309,12 @@
} }
}, },
"node_modules/micromatch": { "node_modules/micromatch": {
"version": "4.0.5", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"braces": "^3.0.2", "braces": "^3.0.3",
"picomatch": "^2.3.1" "picomatch": "^2.3.1"
}, },
"engines": { "engines": {
@@ -3343,9 +3353,9 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
@@ -3673,9 +3683,9 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@@ -4450,9 +4460,9 @@
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==" "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "7.27.2", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=20.18.1" "node": ">=20.18.1"
+2
View File
@@ -27,6 +27,8 @@
--content-font-family: Merriweather, Georgia, 'Times New Roman', Times, serif; --content-font-family: Merriweather, Georgia, 'Times New Roman', Times, serif;
--headline-font-size-scale: 1; --headline-font-size-scale: 1;
--content-font-size-scale: 1; --content-font-size-scale: 1;
--content-text-align: left;
--content-padding: 1rem;
--color-background: var(--vt-c-white); --color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft); --color-background-soft: var(--vt-c-white-soft);
+2 -1
View File
@@ -86,7 +86,8 @@ a,
.feed-content { .feed-content {
font-family: var(--content-font-family); font-family: var(--content-font-family);
font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale)); font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale));
padding: 0 1em 1em; text-align: var(--content-text-align);
padding: 0 var(--content-padding) 1em;
overflow-wrap: break-word; overflow-wrap: break-word;
} }
+35
View File
@@ -14,6 +14,13 @@ const {
setContentSize, setContentSize,
setHeadlineFont, setHeadlineFont,
setContentFont, setContentFont,
textAlignKey,
contentPadding,
TEXT_ALIGN_OPTIONS,
PADDING_STEPS,
PADDING_LABELS,
setTextAlign,
setContentPadding,
} = useSettings() } = useSettings()
</script> </script>
@@ -78,6 +85,34 @@ const {
>{{ opt.label }}</option> >{{ opt.label }}</option>
</select> </select>
</section> </section>
<section class="settings__section">
<h2 class="settings__section-title">Text Alignment</h2>
<div class="settings__strip">
<button
v-for="opt in TEXT_ALIGN_OPTIONS"
:key="opt.key"
class="settings__btn"
:class="{ 'settings__btn--active': textAlignKey === opt.key }"
type="button"
@click="setTextAlign(opt.key)"
>{{ opt.label }}</button>
</div>
</section>
<section class="settings__section">
<h2 class="settings__section-title">Content Padding</h2>
<div class="settings__strip">
<button
v-for="(step, i) in PADDING_STEPS"
:key="step"
class="settings__btn"
:class="{ 'settings__btn--active': contentPadding === step }"
type="button"
@click="setContentPadding(step)"
>{{ PADDING_LABELS[i] }}</button>
</div>
</section>
</div> </div>
</template> </template>
+124 -7
View File
@@ -1,23 +1,92 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router' import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds' import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
import Modal from './modal/AddUrl.vue' 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 } = useFeeds() const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, feedUnreadCounts, setFeedFilter, unreadCount, lastProgrammaticScroll } = useFeeds()
const headerRef = ref(null) const headerRef = ref(null)
// Scroll-driven show/hide: the header slides out of view on scroll-down and
// back in on scroll-up. This is show/hide via `transform` (not the old
// resize behaviour) — the header is position:fixed, so translating it never
// reflows content, and the app's programmatic scrolls resolve to sensible
// states: scrollTo(0, 0) lands near the top → shown; the list-view
// read-correction scrollBy moves only a few px → stays under the threshold.
const hidden = ref(false)
const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling
// When the feed list mutates itself (read-correction scrollBy + array-splice
// scroll anchoring) the page jumps *upward* without user intent. For this long
// after such a jump we gate only the reveal direction, so the jump can't pop
// the header back into view mid-read. Hiding stays allowed the whole time
// (the jump never scrolls down), so scrolling down still hides normally even
// while articles are being marked read. See lastProgrammaticScroll in useFeeds.
const PROGRAMMATIC_SUPPRESS_MS = 300
let lastY = 0
let accumulated = 0
function onScroll() {
const y = Math.max(0, window.scrollY)
const headerH = headerRef.value?.offsetHeight ?? 0
// Always reveal near the very top, and keep it visible while the menu is
// open (the dropdown is anchored to the header, so hiding it would slide the
// open menu off-screen).
if (y <= headerH || menuOpen.value) {
hidden.value = false
accumulated = 0
lastY = y
return
}
const delta = y - lastY
lastY = y
// Reset the accumulator whenever direction flips, so the threshold is
// measured from the last turning point (not from page load).
if ((delta > 0) !== (accumulated > 0)) accumulated = 0
accumulated += delta
// Hiding (scroll-down) is always allowed. Revealing (scroll-up) is gated for
// a short window after a programmatic list update, whose induced jump is
// upward and would otherwise pop the header back into view mid-read. A
// genuine scroll-up reveals once the window has elapsed.
const afterProgrammatic = performance.now() - lastProgrammaticScroll.value < PROGRAMMATIC_SUPPRESS_MS
if (accumulated > REVEAL_THRESHOLD) hidden.value = true // scrolling down
else if (accumulated < -REVEAL_THRESHOLD && !afterProgrammatic) hidden.value = false // scrolling up
}
let ticking = false
function onScrollRaf() {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
onScroll()
ticking = false
})
}
onMounted(() => { onMounted(() => {
// Drives #app's padding-top / RssFeeds' scroll-margin-top so content below
// the fixed header isn't hidden behind it at scroll position 0. The header is
// a fixed size, so this is measured once on mount and never changes.
const h = headerRef.value?.getBoundingClientRect().height ?? 0 const h = headerRef.value?.getBoundingClientRect().height ?? 0
document.documentElement.style.setProperty('--app-nav-height', `${h}px`) document.documentElement.style.setProperty('--app-nav-height', `${h}px`)
lastY = Math.max(0, window.scrollY)
window.addEventListener('scroll', onScrollRaf, { passive: true })
document.addEventListener('click', onDocumentClick)
})
onUnmounted(() => {
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)
@@ -29,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()
@@ -62,9 +144,19 @@ function handleToggleLayout() {
</script> </script>
<template> <template>
<header ref="headerRef" class="app-nav"> <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 }} ({{ feedUnreadCounts[title] ?? 0 }})</option>
</select>
<button <button
class="app-nav__hamburger" class="app-nav__hamburger"
type="button" type="button"
@@ -123,6 +215,13 @@ function handleToggleLayout() {
right: 0; right: 0;
z-index: 20; z-index: 20;
background: var(--color-background); background: var(--color-background);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
transition: transform 0.25s ease;
will-change: transform;
}
.app-nav--hidden {
transform: translateY(-100%);
} }
.app-nav__wrapper { .app-nav__wrapper {
@@ -131,12 +230,13 @@ function handleToggleLayout() {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.5rem; gap: 0.5rem;
padding: 0.75rem 1rem; padding: 0.375rem 1rem;
} }
.app-nav__title { .app-nav__title {
margin-right: auto;
font-weight: bold; font-weight: bold;
font-size: clamp(1.1rem, 4vw, 1.4rem); font-size: clamp(0.95rem, 3.5vw, 1.1rem);
} }
.app-nav__unread { .app-nav__unread {
@@ -144,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;
@@ -228,7 +345,7 @@ function handleToggleLayout() {
@media (min-width: 768px) { @media (min-width: 768px) {
.app-nav__wrapper { .app-nav__wrapper {
padding: 1rem 2rem; padding: 0.5rem 2rem;
} }
} }
</style> </style>
+10 -2
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { onMounted, computed, nextTick, watch } from 'vue'; import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
import { useFeeds } from '@/composables/useFeeds'; import { useFeeds } from '@/composables/useFeeds';
const { const {
@@ -14,6 +14,7 @@ const {
fetchData, fetchData,
sync, sync,
getReadable, getReadable,
disconnectObserver,
setInitialLoad, setInitialLoad,
showMessageForXSeconds, showMessageForXSeconds,
} = useFeeds() } = useFeeds()
@@ -74,6 +75,11 @@ async function shareUrl(url) {
} }
} }
onBeforeUnmount(() => {
disconnectObserver()
setInitialLoad(false)
})
onMounted(async () => { onMounted(async () => {
setInitialLoad(false) setInitialLoad(false)
await fetchData() await fetchData()
@@ -302,6 +308,7 @@ onMounted(async () => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding-top: 1em;
padding-bottom: 5rem; padding-bottom: 5rem;
} }
@@ -351,7 +358,8 @@ onMounted(async () => {
} }
.article-feature__content { .article-feature__content {
padding: 0 1rem; padding: 0 var(--content-padding);
text-align: var(--content-text-align);
font-family: var(--content-font-family); font-family: var(--content-font-family);
font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale)); font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale));
line-height: 1.75; line-height: 1.75;
+223 -11
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import axios from 'axios' import axios from 'axios'
import AppNav from '../AppNav.vue' import AppNav from '../AppNav.vue'
@@ -24,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
@@ -44,15 +50,35 @@ describe('AppNav', () => {
await router.isReady() await router.isReady()
}) })
// Unmount every AppNav mounted via mountNav() after each test so mounted
// instances (and their router/menu listeners) don't pile up across the file.
let mountedWrappers = []
function mountNav(options = { global: { plugins: [router] } }) {
const wrapper = mount(AppNav, options)
mountedWrappers.push(wrapper)
return wrapper
}
afterEach(() => {
for (const wrapper of mountedWrappers) {
try {
wrapper.unmount()
} catch {
// already unmounted by the test itself — fine
}
}
mountedWrappers = []
})
async function mountWithMenuOpen() { async function mountWithMenuOpen() {
const wrapper = mount(AppNav, { global: { plugins: [router] } }) const wrapper = mountNav()
await wrapper.find('.app-nav__hamburger').trigger('click') await wrapper.find('.app-nav__hamburger').trigger('click')
await flushPromises() await flushPromises()
return wrapper return wrapper
} }
it('toggles the menu open and closed via the hamburger button', async () => { it('toggles the menu open and closed via the hamburger button', async () => {
const wrapper = mount(AppNav, { global: { plugins: [router] } }) const wrapper = mountNav()
expect(wrapper.find('.app-nav__menu').exists()).toBe(false) expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
@@ -63,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()
@@ -158,38 +195,212 @@ 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' },
] ]
const wrapper = mount(AppNav, { global: { plugins: [router] } }) const wrapper = mountNav()
await flushPromises() await flushPromises()
expect(wrapper.find('.app-nav__title').text()).toContain('(2)') expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
}) })
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' },
] ]
const wrapper = mount(AppNav, { global: { plugins: [router] } }) const wrapper = mountNav()
await flushPromises() await flushPromises()
expect(wrapper.find('.app-nav__title').text()).toContain('(1)') expect(wrapper.find('.app-nav__title').text()).toContain('(1)')
}) })
it('hides the unread count when there are no articles', async () => { it('hides the unread count when there are no articles', async () => {
const wrapper = mount(AppNav, { global: { plugins: [router] } }) const wrapper = mountNav()
await flushPromises() await flushPromises()
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, 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 () => {
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', () => {
// The scroll handler is rAF-throttled; run rAF synchronously so a single
// dispatched scroll event resolves before we assert. Per the CLAUDE.md
// Vitest gotcha, avoid bare fake timers here — they'd clobber this stub.
beforeEach(() => {
// Reset scroll position so each mount's lastY baseline starts at 0.
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true })
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
// offsetHeight is 0 in jsdom; give the header a real height so the
// "near the top" guard (scrollY <= headerH) has something to compare to.
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(50)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
function scrollTo(y) {
Object.defineProperty(window, 'scrollY', { value: y, configurable: true, writable: true })
window.dispatchEvent(new Event('scroll'))
}
it('hides the header when scrolling down past the threshold', async () => {
const wrapper = mountNav()
scrollTo(200)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
})
it('reveals the header again when scrolling back up past the threshold', async () => {
const wrapper = mountNav()
scrollTo(200)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
scrollTo(150)
await nextTick()
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
})
it('always shows the header near the top of the page', async () => {
const wrapper = mountNav()
scrollTo(400)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
// Back within the header's own height of the top → always revealed.
scrollTo(10)
await nextTick()
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
})
it('does not let a programmatic upward jump reveal the header mid-read', async () => {
const { markProgrammaticScroll } = useFeeds()
const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(1000)
const wrapper = mountNav()
// Hide it first via a normal scroll-down (no programmatic flag active).
scrollTo(400)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
// A read-correction flags a programmatic scroll, then the page jumps
// upward. Within the window that upward jump must NOT reveal the header.
markProgrammaticScroll() // records lastProgrammaticScroll = 1000
nowSpy.mockReturnValue(1100) // 100ms later — inside the 300ms window
scrollTo(200)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
// Still allows hiding on scroll-down even while the flag is active.
scrollTo(500)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
// Once the window elapses, a genuine scroll-up reveals it again.
nowSpy.mockReturnValue(1500) // 500ms after the flag — outside the window
scrollTo(450)
await nextTick()
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
})
it('does not toggle on sub-threshold jitter', async () => {
const wrapper = mountNav()
// Start well below the top so the "near the top" guard doesn't apply.
scrollTo(300)
await nextTick()
// Reveal first so we're testing that small moves don't hide it.
scrollTo(260)
await nextTick()
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
scrollTo(268) // +8px, under the 12px threshold
await nextTick()
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
})
})
it('does not mark articles as read when the confirmation is dismissed', async () => { it('does not mark articles as read when the confirmation is dismissed', async () => {
const { feeds } = useFeeds() const { feeds } = useFeeds()
feeds.value = [ feeds.value = [
@@ -208,4 +419,5 @@ describe('AppNav', () => {
confirmSpy.mockRestore() confirmSpy.mockRestore()
}) })
}) })
+151 -1
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import axios from 'axios' import axios from 'axios'
import { useFeeds } from '../useFeeds' import { useFeeds } from '../useFeeds'
@@ -12,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, feedUnreadCounts, unreadCount, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, setupIntersectionObserver } = useFeeds()
beforeEach(() => { beforeEach(() => {
localStorage.setItem('user-token', 'test-token') localStorage.setItem('user-token', 'test-token')
@@ -20,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
@@ -116,6 +119,153 @@ describe('useFeeds', () => {
setInitialLoad(false) 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: {
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('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 () => { it('strips leftover embedded-video placeholder headings', async () => {
feeds.value = [{ feeds.value = [{
id: 1, id: 1,
@@ -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')
})
})
+133 -7
View File
@@ -1,19 +1,75 @@
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))
})
// 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
// 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(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
const currentIndex = ref(0) const currentIndex = ref(0)
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu 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 observer; // Declare observer outside the setup function
let initialLoad = false 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
// read-correction below. AppNav's auto-hide handler resyncs its scroll baseline
// (instead of treating the induced jump as a user scroll) for a short window
// after this, so removing read articles can't pop the header in/out mid-read.
const lastProgrammaticScroll = ref(0)
function markProgrammaticScroll() {
lastProgrammaticScroll.value = performance.now()
}
export function authHeaders() { export function authHeaders() {
return { return {
@@ -134,12 +190,16 @@ async function getReadable(feed, index) {
}) })
// taz "Mehr zum Thema" related-articles teaser section. // taz "Mehr zum Thema" related-articles teaser section.
doc.querySelectorAll('#articleTeaser').forEach(el => el.remove()) doc.querySelectorAll('#articleTeaser').forEach(el => el.remove())
// taz subsidiary magazine promo blocks (e.g. taz FUTURZWEI): the promo // taz subsidiary magazine promo blocks (e.g. taz FUTURZWEI): either the
// <article> carries an aria-label containing "Abo". // <article> itself or its direct <a> child carries an aria-label containing "Abo".
doc.querySelectorAll('article[aria-label*="Abo"]').forEach(el => { doc.querySelectorAll('article[aria-label*="Abo"]').forEach(el => {
const container = el.closest('section') ?? el const container = el.closest('section') ?? el
container.remove() container.remove()
}) })
doc.querySelectorAll('article > a[aria-label*="Abo"]').forEach(el => {
const container = el.closest('section') ?? el.closest('article')
if (container) container.remove()
})
const article = new Readability(doc).parse(); const article = new Readability(doc).parse();
if (!article) { if (!article) {
showMessageForXSeconds('Could not extract readable content.', 5) showMessageForXSeconds('Could not extract readable content.', 5)
@@ -162,6 +222,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 {
@@ -173,7 +257,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) {
@@ -217,14 +302,25 @@ function setupIntersectionObserver() {
}); });
const observedDivs = document.querySelectorAll(".observe"); const observedDivs = document.querySelectorAll(".observe");
if (observedDivs.length > 0) { // 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 => { observedDivs.forEach(observedDiv => {
observer.observe(observedDiv); observer.observe(observedDiv);
}) })
}
} }
function handleIntersection(entries, topbarHeight = 0) { 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 // Resolve all affected feeds before touching feeds.value — the target.id
// indices are render-time positions that shift once we splice the array. // indices are render-time positions that shift once we splice the array.
const readFeeds = entries const readFeeds = entries
@@ -243,8 +339,13 @@ function handleIntersection(entries, topbarHeight = 0) {
observer = null observer = null
} }
// Both the array splice (via scroll anchoring) and the scrollBy correction
// below move the page — flag it so AppNav's header auto-hide ignores the jump.
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)
@@ -261,6 +362,7 @@ function handleIntersection(entries, topbarHeight = 0) {
if (first) { if (first) {
const top = first.getBoundingClientRect().top const top = first.getBoundingClientRect().top
if (top < topbarHeight) { if (top < topbarHeight) {
markProgrammaticScroll()
window.scrollBy(0, top - topbarHeight) window.scrollBy(0, top - topbarHeight)
} }
} }
@@ -268,6 +370,13 @@ function handleIntersection(entries, topbarHeight = 0) {
}) })
} }
function disconnectObserver() {
if (observer) {
observer.disconnect()
observer = null
}
}
function setInitialLoad(value) { function setInitialLoad(value) {
initialLoad = value initialLoad = value
} }
@@ -277,7 +386,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)))
@@ -300,8 +413,11 @@ 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'
localStorage.setItem('viewMode', viewMode.value)
// 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
// nodes when switching views, so the intersection observer must be // nodes when switching views, so the intersection observer must be
// re-pointed at the new elements after Vue has finished rendering. // re-pointed at the new elements after Vue has finished rendering.
@@ -321,6 +437,7 @@ function toggleViewMode() {
observer = null observer = null
} }
viewMode.value = 'article' viewMode.value = 'article'
localStorage.setItem('viewMode', viewMode.value)
currentIndex.value = 0 currentIndex.value = 0
markCurrentArticleRead() markCurrentArticleRead()
} }
@@ -357,6 +474,12 @@ function prevArticle() {
export function useFeeds() { export function useFeeds() {
return { return {
feeds, feeds,
allItems,
feedFilter,
feedTitles,
feedUnreadCounts,
unreadCount,
setFeedFilter,
showMessage, showMessage,
message, message,
showModal, showModal,
@@ -375,7 +498,10 @@ export function useFeeds() {
markAllRead, markAllRead,
showMessageForXSeconds, showMessageForXSeconds,
setupIntersectionObserver, setupIntersectionObserver,
disconnectObserver,
setInitialLoad, setInitialLoad,
handleIntersection, handleIntersection,
lastProgrammaticScroll,
markProgrammaticScroll,
} }
} }
+31
View File
@@ -19,10 +19,20 @@ const CONTENT_FONT_OPTIONS = [
const SIZE_STEPS = [0.85, 1, 1.2, 1.45] const SIZE_STEPS = [0.85, 1, 1.2, 1.45]
const SIZE_LABELS = ['S', 'M', 'L', 'XL'] const SIZE_LABELS = ['S', 'M', 'L', 'XL']
const TEXT_ALIGN_OPTIONS = [
{ key: 'left', label: 'Left' },
{ key: 'justify', label: 'Justified' },
]
const PADDING_STEPS = [1, 0.5, 0.15]
const PADDING_LABELS = ['Default', 'Compact', 'Minimal']
const headlineSizeScale = ref(parseFloat(localStorage.getItem('s-headline-size') ?? '1')) const headlineSizeScale = ref(parseFloat(localStorage.getItem('s-headline-size') ?? '1'))
const contentSizeScale = ref(parseFloat(localStorage.getItem('s-content-size') ?? '1')) const contentSizeScale = ref(parseFloat(localStorage.getItem('s-content-size') ?? '1'))
const headlineFontKey = ref(localStorage.getItem('s-headline-font') ?? 'default') const headlineFontKey = ref(localStorage.getItem('s-headline-font') ?? 'default')
const contentFontKey = ref(localStorage.getItem('s-content-font') ?? 'default') const contentFontKey = ref(localStorage.getItem('s-content-font') ?? 'default')
const textAlignKey = ref(localStorage.getItem('s-text-align') ?? 'left')
const contentPadding = ref(parseFloat(localStorage.getItem('s-content-padding') ?? '1'))
function fontValue(options, key) { function fontValue(options, key) {
return (options.find(o => o.key === key) ?? options[0]).value return (options.find(o => o.key === key) ?? options[0]).value
@@ -34,6 +44,8 @@ function applySettings() {
s.setProperty('--content-font-size-scale', contentSizeScale.value) s.setProperty('--content-font-size-scale', contentSizeScale.value)
s.setProperty('--headline-font-family', fontValue(HEADLINE_FONT_OPTIONS, headlineFontKey.value)) s.setProperty('--headline-font-family', fontValue(HEADLINE_FONT_OPTIONS, headlineFontKey.value))
s.setProperty('--content-font-family', fontValue(CONTENT_FONT_OPTIONS, contentFontKey.value)) s.setProperty('--content-font-family', fontValue(CONTENT_FONT_OPTIONS, contentFontKey.value))
s.setProperty('--content-text-align', textAlignKey.value)
s.setProperty('--content-padding', contentPadding.value + 'rem')
} }
function setHeadlineSize(scale) { function setHeadlineSize(scale) {
@@ -60,6 +72,18 @@ function setContentFont(key) {
applySettings() applySettings()
} }
function setTextAlign(key) {
textAlignKey.value = key
localStorage.setItem('s-text-align', key)
applySettings()
}
function setContentPadding(step) {
contentPadding.value = step
localStorage.setItem('s-content-padding', step)
applySettings()
}
export function useSettings() { export function useSettings() {
return { return {
headlineSizeScale, headlineSizeScale,
@@ -70,10 +94,17 @@ export function useSettings() {
SIZE_LABELS, SIZE_LABELS,
HEADLINE_FONT_OPTIONS, HEADLINE_FONT_OPTIONS,
CONTENT_FONT_OPTIONS, CONTENT_FONT_OPTIONS,
TEXT_ALIGN_OPTIONS,
PADDING_STEPS,
PADDING_LABELS,
applySettings, applySettings,
setHeadlineSize, setHeadlineSize,
setContentSize, setContentSize,
setHeadlineFont, setHeadlineFont,
setContentFont, setContentFont,
setTextAlign,
setContentPadding,
textAlignKey,
contentPadding,
} }
} }
+7 -17
View File
@@ -31,23 +31,13 @@ const router = createRouter({
] ]
}) })
router.beforeEach((to, from, next) => { router.beforeEach((to) => {
if (to.meta.requiresAuth) { const isAuthenticated = localStorage.getItem("user-token") != null;
let isAuthenticated = false; // Redirect unauthenticated users hitting a protected route to login;
if (localStorage.getItem("user-token") != null){ // returning a value (instead of the deprecated next() callback) is the
isAuthenticated = true; // modern vue-router guard API. Returning nothing lets navigation proceed.
} if (to.meta.requiresAuth && !isAuthenticated) {
return '/login';
if (!isAuthenticated) {
// Redirect to the login page
next('/login');
} else {
// Proceed to the protected route
next();
}
} else {
// For routes that don't require authentication, proceed without checking
next();
} }
}); });
export default router export default router