Author SHA1 Message Date
mathias 43269bf1d7 Merge pull request 'Replace the feed select with a multi-feed filter modal' (#4) from feature/feed-filter-modal into master
Reviewed-on: #4
2026-09-27 11:31:27 +02:00
mathias 5ed780baa3 Replace the feed select with a multi-feed filter modal
The header's feed <select> is now a funnel button that opens a modal with
an "All feeds" checkbox and one checkbox per feed with unread items. The
selection is either "All feeds" or at least one feed, is applied once when
the modal closes, and is persisted in localStorage.

- feedFilter holds string[] | null; setFeedFilter is a no-op when the
  selection is unchanged (order-insensitive), sparing callers the
  scroll/mark-read side effects.
- Selected feeds stay listed at 0 unread so they can be unchecked; titles
  whose feed no longer exists are pruned after each fetch.
- The persisted filter is cleared on logout and on 401 (handler extracted
  to httpAuthInterceptor.js so it is unit-testable).
- The modal locks body scroll while open so scrolling behind it can't
  mark articles read.
2026-09-27 11:28:48 +02:00
mathias 8f7ddb77c4 Stop reserving layout space for the idle sync spinner
visibility:hidden still occupied the header's flex row, squeezing the
title on narrow screens and clipping the unread counter. Render the
spinner with v-if instead so it takes no space when not syncing.
2026-09-25 13:55:23 +02:00
mathias e51bce4244 added pwa ap manifest 2026-09-23 17:57:44 +02:00
mathias f31fccdd16 fix break header 2026-09-14 19:59:22 +02:00
mathias a7cfd267c6 Strip Al Jazeera's Recommended Stories section from readable articles
Al Jazeera's article pages embed a <section class="more-on"> block (a
'Recommended Stories' heading plus a list of unrelated article teasers)
that was getting pulled into the parsed content by Readability. Remove
it from the fetched DOM before Readability runs, same as the existing
social-bar/abo/teaser stripping in getReadable().
2026-09-14 18:40:34 +02:00
mathias c584ac4f60 Merge pull request 'Show a sync-in-progress spinner in the header next to the feed filter' (#3) from feature/sync-spinner into master
Reviewed-on: #3
2026-09-14 18:08:44 +02:00
mathias 9c042411fb Show a sync-in-progress spinner in the header next to the feed filter
activeSyncCount/isSyncing() in useFeeds.js already refcounted both boot
reload (fetchData) and manual sync() but wasn't reactive. Make it a ref
+ computed and expose it so AppNav can render a small spinner next to
the feed filter whenever a sync is in flight.

The spinner sits outside the filter's own v-if so it also shows during
a manual sync from pages other than /feeds, and its box is always
reserved (visibility toggle, not v-if) so the one-time --app-nav-height
measurement stays correct.
2026-09-14 18:05:24 +02:00
mathias 4d9e899519 Disable mark-as-read while a sync or reload is in progress
useFeeds.js had no concept of a sync/reload being in flight, so two
passive mark-as-read paths could race a wholesale fetchData()/sync()
list replacement:

- handleIntersection() (list-view scroll marking)
- markCurrentArticleRead() (article-view display/paging marking)

Both are now gated by a refcount (activeSyncCount/beginSync/endSync/
isSyncing), mirroring the existing pendingReadCounts pattern for the
same class of overlapping-async problem. A refcount rather than a
boolean because sync() nests fetchData() inside it, and fetchData()
can also run independently while a sync() elsewhere is still in
flight.

Reads suppressed during a sync/reload aren't dropped: their ids are
queued and flushed (marked read, and swept out of the list) once
every in-flight fetchData()/sync() call has finished. The explicit
"Mark all read" action is left ungated, since it's a deliberate,
already-confirmed action rather than a passive side effect.

87/87 frontend tests passing.
2026-09-13 12:11:14 +02:00
mathias c290513ed1 Fix read-article handling across feed filter switches in article view
Article view marks the displayed article read in place (feed.read =
true) without removing it, so currentIndex stays valid while paging.
leaveArticleView() already dropped those afterward, but switching the
feed filter while still in article view never did — so an
already-read article could resurface (re-selecting its feed
re-projected it from allItems as if still unread). Extracted the drop
into a shared dropReadArticles() and call it from setFeedFilter() too.

Also, switching the filter while in article view immediately displays
the new feed's article in full, which is a genuine view — same as
paging or toggling into article view — but nothing marked it read, so
a single-article feed could never be marked read that way at all. This
was deliberately excluded (see commit 169c294) after a generic watch
proved too broad; setFeedFilter() now marks it explicitly, scoped to
article view only, so list view's scroll-driven read-marking is
unaffected.
2026-09-12 19:42:22 +02:00
mathias 41a17eba6d Move Add Feed button from hamburger menu to admin area
The Add RSS modal is now opened from a button directly above the feed
list in AdminFeeds.vue instead of the hamburger menu, so feed creation
lives alongside the rest of feed management. AppNav.vue no longer
renders it.

AddUrl.vue emits an `added` event on a successful save; AdminFeeds.vue
listens for it and reloads its feed list, so a newly added feed shows
up immediately without leaving the admin page.

Also fixes a bug this move surfaced: the modal's "Close" button had no
`type`, so inside the form it defaulted to type="submit" — clicking
Close on a filled-in form silently created the feed.
2026-09-12 19:42:08 +02:00
mathiasandClaude Sonnet 5 169c294e62 Mark article read on display in article view, including on boot
markCurrentArticleRead() already marked the displayed article read
immediately at each deliberate navigation point (entering article view,
paging next/prev), but reloading the page while viewMode was already
'article' (persisted in localStorage) booted straight into article view
without marking anything read — the article on screen stayed unread
until the user paged forward/back at least once.

Close that gap with a single explicit call in RssFeeds.vue's onMounted:
once the initial fetch resolves, if we're already in article view, mark
the now-displayed article read the same way toggleViewMode()/
nextArticle()/prevArticle() do.

(A first attempt replaced the explicit call sites with a generic watch
on the displayed article's id, but that also fired on unrelated changes
to it — e.g. switching the feed filter while in article view resets
currentIndex and reprojects feeds, so the watch would silently mark an
article the user never viewed as read. Reverted to the explicit,
call-site-scoped approach and added only the one boot-time call needed.)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K2sYVA7rh7N5RYmcCd2KDV
2026-09-12 15:29:32 +02:00
mathias e2df60d112 Merge pull request 'Fix unread counter stuck after reading all articles' (#2) from hotfix/unread-counter-stuck into master
Reviewed-on: #2
2026-09-11 17:22:28 +02:00
mathiasandClaude Sonnet 5 5f816bb0f5 Fix unread counter stuck after reading all articles
markRead() fired its PUT fire-and-forget with no retry and silently
swallowed failures. If one PUT among a burst (paging through article
view fires one per page turn) failed or lost a race with the app's own
background sync()->fetchData() refetch, that article's read-mark was
permanently lost: the header's unreadCount (derived live from
allItems) would show it again mid-session, and a reload reproduced it
identically since the DB genuinely still had it unread.

- markRead() now retries transient failures (no response / 5xx / 429)
  with backoff and a request timeout, skips retrying non-retryable
  4xx, and surfaces a message on final failure instead of only
  console.log.
- A pendingReadCounts refcount (keyed by article id) tracks in-flight/
  retrying marks; fetchData() excludes those ids - snapshotting before
  its GET is dispatched, not just once the response lands - so a
  concurrent refetch can't resurrect an article whose read-confirmation
  is still in flight or unsettled at request time.
- markAllRead() now reports a partial-failure summary instead of
  unconditionally claiming full success when some marks fail.

Extends vue/src/composables/__tests__/useFeeds.spec.js with coverage
for retry/backoff, non-retryable errors, the dispatch-before-response
race, overlapping concurrent marks for the same id, and markAllRead's
partial-failure message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVw1XNQjodp4B7qiZqjtDE
2026-09-11 17:21:16 +02:00
mathiasandClaude Sonnet 5 67b970df9b Merge branch 'fix/concurrent-sync-duplicate-items'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CHcxDSPbHhe7sLJQBRVC9
2026-09-10 16:11:48 +02:00
mathiasandClaude Sonnet 5 6c8b4e9e32 Fix duplicate feed items from concurrent syncs
A sync button press racing a page-reload sync could run two sync
requests concurrently. create_feed_item used a check-then-insert
pattern (query for an existing item, then insert if none found), so
both requests could pass the check before either inserted, creating
duplicate items.

- Add a UNIQUE (feed_id, url) constraint on feed_item, keyed on the
  article's link rather than its title since that's RSS's stable
  article identity (a feed editing a headline after publishing, same
  link, would otherwise dupe under title-based matching).
- The migration first collapses any duplicates already created by the
  race, propagating read=true onto the surviving row when any
  duplicate in its group was already read, so the cleanup can't make
  an already-read article look unread.
- create_feed_item now does a single atomic
  INSERT ... ON CONFLICT (feed_id, url) DO NOTHING instead of
  select-then-insert, closing the race entirely.
- Add a test that races two threads on separate connections inserting
  the same item and asserts only one row survives.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CHcxDSPbHhe7sLJQBRVC9
2026-09-10 16:04:34 +02:00
mathias e2fb32f898 fix cargo audit reports and upgraded cargo dependencies 2026-08-29 14:28:59 +02:00
mathias a2c947f870 Change top bar to only appear whene scrolling up 2026-08-28 07:42:59 +02:00
mathias abb6002078 remove counter of selected feed 2026-08-28 07:37:42 +02:00
mathias f447a9a163 remove slide effect 2026-08-27 21:33:58 +02:00
mathias a26f31a150 bugfix: extend swipe listener to swipe on empty space 2026-08-27 14:25:38 +02:00
mathias c911258578 Revert "Merge branch 'swipe-drag-follow'"
This reverts commit d52fa90b6b, reversing
changes made to 77438d0532.
2026-08-26 22:07:58 +02:00
mathiasandClaude Sonnet 5 d52fa90b6b Merge branch 'swipe-drag-follow'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 22:04:01 +02:00
mathiasandClaude Sonnet 5 bfb926cec6 Replace threshold-based swipe with live drag-follow in article view
The article now visually tracks the finger in real time while swiping
(via a rAF-throttled touchmove handler), instead of only reacting
after the fact on touchend. On release it completes the page-turn if
dragged past ~35% of the article's width, or on a fast-enough flick
even short of that distance; otherwise it springs back to center.
Dragging past the first/last article is damped rather than free, for
tactile feedback that there's nothing further that way. A vertical
drag is detected within the first ~10px of movement and left alone so
it can't fight normal page scrolling.

Also fixes a real bug found while testing the flick path: Date.now()
has ~1ms resolution, so a genuinely fast flick often measures as
elapsed === 0. The velocity calculation was treating that as "zero
velocity" (dividing 0 elapsed into "no flick") instead of "as fast as
it gets" — now a zero-elapsed gesture with real movement counts as a
flick outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 22:03:41 +02:00
mathiasandClaude Sonnet 5 77438d0532 Merge branch 'fix-android-swipe-gesture'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:28:18 +02:00
mathiasandClaude Sonnet 5 49ceec5125 Fix article-view swipe being swallowed by Chrome-on-Android back gesture
On real Android touchscreens, a horizontal drag on content with nothing
left to scroll horizontally gets claimed by Chrome's native
swipe-to-go-back/forward gesture before our own touchend listener
(article-view swipe navigation) ever sees it — silently, with no
touchend firing at all. Synthetic touch testing (mouse-drag emulation
in devtools) doesn't go through that gesture recognizer, so it worked
there while doing nothing on an actual phone.

Fix: overscroll-behavior-x: contain on body stops Chrome from claiming
the gesture for its own navigation, letting our touchend fire normally.

Also drops the overflow-x: hidden added to .article-single for the
slide transition — body already has it (for the same full-bleed-image
reason), so it was redundant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:27:23 +02:00
mathiasandClaude Sonnet 5 15ac8c287b Merge branch 'article-view-swipe-nav'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:16:26 +02:00
mathiasandClaude Sonnet 5 13d9c2c908 Add swipe navigation and slide transition to article view
Swiping left/right in the single-article view now pages to the
next/previous article, same as the up/down nav buttons. Touch handlers
live on .article-single: a swipe needs >=50px horizontal travel and
<=75px vertical travel to count (so scrolling/diagonal drags don't
trigger it), and multi-touch or swipes starting inside a horizontally
scrollable <pre> block are ignored so code blocks keep their own
scroll behavior.

Also adds a direction-aware slide/fade transition (via Vue's
<Transition>, keyed on currentIndex) when paging between articles,
triggered by both the swipe gesture and the nav buttons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:14:52 +02:00
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
mathias a90d10368e New feauter, change font family and font size 2026-06-19 13:42:57 +02:00
mathias 5417176dd4 add some filters for unwanted content 2026-06-16 19:08:31 +02:00
mathias 4d3f5d3285 fix autoscroll 2026-06-16 16:26:33 +02:00
mathias 967803c326 bugfixes 2026-06-16 12:34:08 +02:00
mathias e9c865a254 Improved sticky header 2026-06-16 12:04:42 +02:00
mathias 570db2d948 keep original article link in readable 2026-06-15 19:58:13 +02:00
mathias a37d845875 Fix stuttering list view 2026-06-15 19:53:18 +02:00
mathias 8e57e2f02a Added sync on reload 2026-06-14 17:13:41 +02:00
mathias 3671b90b81 Fix fontend tests, move next button in list view 2026-06-14 17:04:37 +02:00
mathias a399ede401 Fonts, Docker fixes 2026-06-14 09:03:06 +02:00
mathias 82ec6ea902 fix article read after switching to article view 2026-06-13 11:59:33 +02:00
mathias fbf3597984 improve desktop readable for article view 2026-06-13 11:48:45 +02:00
mathias e9580037ef increase token lifetime to one month 2026-06-12 19:34:08 +02:00
42 changed files with 4056 additions and 588 deletions
+2
View File
@@ -3,3 +3,5 @@
.claude
CLAUDE.md
LEARNINGS.md
PLAN.md
/memory
Generated
+74 -244
View File
@@ -251,14 +251,13 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ammonia"
version = "4.1.2"
version = "4.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
dependencies = [
"cssparser 0.35.0",
"html5ever 0.35.0",
"cssparser",
"html5ever",
"maplit",
"tendril 0.4.3",
"url",
]
@@ -323,15 +322,15 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "atom_syndication"
version = "0.12.8"
version = "0.12.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39d8b8ef99e33deb2a51504888222225797f48bace2f0cab01975746a39c05bb"
checksum = "d48d93a31c932d58b9fd68664a904ae5b74bea37f06c5f68d5bec92f0cdd106d"
dependencies = [
"chrono",
"derive_builder",
@@ -386,12 +385,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bcrypt"
version = "0.19.1"
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24ae5479c93d3720e4c1dbd6b945b97457c50cb672781104768190371df1a905"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bcrypt"
version = "0.19.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a"
dependencies = [
"base64 0.22.1",
"base64 0.23.1",
"blowfish",
"getrandom 0.4.2",
"subtle",
@@ -506,9 +511,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -668,42 +673,19 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
dependencies = [
"cssparser-macros 0.6.1",
"dtoa-short",
"itoa",
"phf 0.11.3",
"smallvec",
]
[[package]]
name = "cssparser"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
dependencies = [
"cssparser-macros 0.7.0",
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.13.1",
"phf",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "cssparser-macros"
version = "0.7.0"
@@ -963,10 +945,10 @@ dependencies = [
]
[[package]]
name = "dotenv"
version = "0.15.0"
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast-rs"
@@ -1124,16 +1106,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.32"
@@ -1281,11 +1253,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasip2",
"wasip3",
"wasm-bindgen",
]
[[package]]
@@ -1332,9 +1306,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.14"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
@@ -1396,17 +1370,6 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "html5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
dependencies = [
"log",
"markup5ever 0.35.0",
"match_token",
]
[[package]]
name = "html5ever"
version = "0.39.0"
@@ -1414,7 +1377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
dependencies = [
"log",
"markup5ever 0.39.0",
"markup5ever",
]
[[package]]
@@ -1492,7 +1455,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2 0.4.14",
"h2 0.4.19",
"http 1.4.1",
"http-body",
"httparse",
@@ -1899,29 +1862,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markup5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
dependencies = [
"log",
"tendril 0.4.3",
"web_atoms 0.1.3",
]
[[package]]
name = "markup5ever"
version = "0.39.0"
@@ -1929,19 +1875,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
dependencies = [
"log",
"tendril 0.5.0",
"web_atoms 0.2.4",
]
[[package]]
name = "match_token"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [
"proc-macro2",
"quote",
"syn",
"tendril",
"web_atoms",
]
[[package]]
@@ -2073,55 +2008,25 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros 0.13.1",
"phf_shared 0.13.1",
"phf_macros",
"phf_shared",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.6",
"phf_generator",
"phf_shared",
]
[[package]]
@@ -2131,20 +2036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn",
"phf_shared",
]
[[package]]
@@ -2153,22 +2045,13 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
@@ -2282,9 +2165,9 @@ dependencies = [
[[package]]
name = "quick-xml"
version = "0.39.4"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"encoding_rs",
"memchr",
@@ -2312,15 +2195,16 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.14"
version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"getrandom 0.4.2",
"lru-slab",
"rand 0.9.4",
"rand 0.10.1",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
@@ -2367,15 +2251,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
@@ -2407,12 +2282,6 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rand_core"
version = "0.9.5"
@@ -2428,6 +2297,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
@@ -2493,7 +2371,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2 0.4.14",
"h2 0.4.19",
"http 1.4.1",
"http-body",
"http-body-util",
@@ -2539,9 +2417,9 @@ dependencies = [
[[package]]
name = "rss"
version = "2.0.13"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0e38781082c53bdde56e6081698c06831f69a27eac2593ed6b6cb2511424ad5"
checksum = "dc13570823abc675c60d7837f1fe7daaf18dd181a3cacb3c6ba7a062eef581c0"
dependencies = [
"atom_syndication",
"derive_builder",
@@ -2550,7 +2428,7 @@ dependencies = [
[[package]]
name = "rss-reader"
version = "0.1.0"
version = "0.9.1"
dependencies = [
"actix-cors",
"actix-governor",
@@ -2564,7 +2442,7 @@ dependencies = [
"dateparser",
"diesel",
"diesel_migrations",
"dotenv",
"dotenvy",
"env_logger",
"futures",
"hmac",
@@ -2713,13 +2591,13 @@ version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
dependencies = [
"cssparser 0.37.0",
"cssparser",
"ego-tree",
"getopts",
"html5ever 0.39.0",
"html5ever",
"precomputed-hash",
"selectors",
"tendril 0.5.0",
"tendril",
]
[[package]]
@@ -2752,12 +2630,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
dependencies = [
"bitflags",
"cssparser 0.37.0",
"cssparser",
"derive_more",
"log",
"new_debug_unreachable",
"phf 0.13.1",
"phf_codegen 0.13.1",
"phf",
"phf_codegen",
"precomputed-hash",
"rustc-hash",
"servo_arc",
@@ -2956,19 +2834,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.11.3",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache"
version = "0.9.0"
@@ -2977,30 +2842,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.13.1",
"phf_shared",
"precomputed-hash",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
@@ -3069,17 +2922,6 @@ dependencies = [
"libc",
]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
]
[[package]]
name = "tendril"
version = "0.5.0"
@@ -3573,28 +3415,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
dependencies = [
"phf 0.11.3",
"phf_codegen 0.11.3",
"string_cache 0.8.9",
"string_cache_codegen 0.5.4",
]
[[package]]
name = "web_atoms"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [
"phf 0.13.1",
"phf_codegen 0.13.1",
"string_cache 0.9.0",
"string_cache_codegen 0.6.1",
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "rss-reader"
version = "0.1.0"
version = "0.9.1"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -18,7 +18,7 @@ serde_derive = "1.0.228"
actix-service = "2.0.3"
diesel = { version = "2.3", features = ["postgres", "chrono"] }
diesel_migrations = "2.3"
dotenv = "0.15.0"
dotenvy = "0.15"
bcrypt = "0.19"
uuid = {version = "1.23", features=["serde", "v4"]}
jwt = "0.16.0"
+4 -2
View File
@@ -7,7 +7,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app
COPY . .
RUN cargo build --release
RUN cargo build --release && \
cp target/release/rss-reader /usr/local/bin/rss-reader && \
rm -rf target
# --- runtime ---
FROM debian:bookworm-slim
@@ -16,7 +18,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/rss-reader /usr/local/bin/rss-reader
COPY --from=builder /usr/local/bin/rss-reader /usr/local/bin/rss-reader
EXPOSE 8001
CMD ["rss-reader"]
+6
View File
@@ -169,8 +169,13 @@ docker compose logs -f backend # follow backend logs
docker compose down # stop everything (keeps the postgres_data volume)
docker compose down -v # stop and wipe all data — careful!
docker compose up --build -d # rebuild after pulling code changes
docker builder prune -af && docker image prune -af # reclaim disk used by old build layers/images
```
> Each `docker compose up --build` leaves the previous build's cache layers and images
> behind, which adds up quickly given how much disk `cargo build` needs. Run the prune
> command above after each rebuild (or on a cron job) to reclaim that space.
### Optional: hardened deployment — isolated user + rootless Docker
Anyone who can run `docker` commands effectively has root on the host (container volume mounts can reach the whole filesystem) — being in the `docker` group is root-equivalent. For a production server, it's worth confining this stack to a dedicated, unprivileged system user running its own **rootless Docker** daemon, instead of using a system-wide install or adding the user to the `docker` group.
@@ -292,6 +297,7 @@ Fill in `.env` with strong, unique secrets — `openssl rand -hex 32` is a conve
```sh
docker compose up --build -d
docker builder prune -af && docker image prune -af # reclaim disk used by old build layers/images
```
**6. Firewall** (run as your normal sudo-capable user — not `rss-svc`):
+3
View File
@@ -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:
@@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
ALTER TABLE feed_item
DROP CONSTRAINT feed_item_feed_id_url_unique;
@@ -0,0 +1,36 @@
-- Your SQL goes here
-- Concurrent syncs for the same feed could previously race past the
-- application-level "does this article already exist" check and both
-- insert, producing duplicate feed items. Before enforcing uniqueness,
-- collapse any duplicates that already exist, keyed on the article's link
-- (rather than its title) since that's the stable identity RSS items are
-- built around — some feeds edit a headline after publishing while keeping
-- the same link, which title-based matching would treat as a new article.
-- If any duplicate in a group was already read, propagate that onto the
-- surviving (lowest-id) row first, so this one-time cleanup can't make an
-- already-read article look unread again.
UPDATE feed_item AS keep
SET read = true
FROM feed_item AS dupe
WHERE keep.feed_id = dupe.feed_id
AND keep.url = dupe.url
AND keep.id <> dupe.id
AND keep.id = (
SELECT MIN(candidate.id)
FROM feed_item AS candidate
WHERE candidate.feed_id = dupe.feed_id
AND candidate.url = dupe.url
)
AND dupe.read = true;
-- Keep the oldest (lowest id) row of each duplicate set.
DELETE FROM feed_item a
USING feed_item b
WHERE a.feed_id = b.feed_id
AND a.url = b.url
AND a.id > b.id;
ALTER TABLE feed_item
ADD CONSTRAINT feed_item_feed_id_url_unique UNIQUE (feed_id, url);
+2 -2
View File
@@ -6,14 +6,14 @@ use std::env;
use actix_web::HttpRequest;
use chrono::{Duration, Utc};
use dotenv::dotenv;
use dotenvy::dotenv;
use hmac::{Hmac, Mac};
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
/// How long a freshly issued token remains valid for.
const TOKEN_LIFETIME_HOURS: i64 = 24;
const TOKEN_LIFETIME_HOURS: i64 = 730;
pub struct JwtToken {
pub user_id: i32,
+1 -1
View File
@@ -1,7 +1,7 @@
use diesel::pg::PgConnection;
use diesel::prelude::*;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use dotenv::dotenv;
use dotenvy::dotenv;
use std::env;
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
+1 -2
View File
@@ -1,10 +1,9 @@
extern crate diesel;
extern crate dotenv;
use actix_cors::Cors;
use actix_service::Service;
use actix_web::{App, HttpMessage, HttpResponse, HttpServer};
use dotenv::dotenv;
use dotenvy::dotenv;
use futures::future::{ok, Either};
use std::env;
mod auth;
+70 -13
View File
@@ -4,8 +4,7 @@ use crate::error::AppError;
use crate::json_serialization::user::JsonUser;
use crate::models::feed::rss_feed::Feed;
use crate::models::feed_item::new_feed_item::NewFeedItem;
use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::schema::feed_item::{feed_id, title};
use crate::schema::feed_item::{feed_id, url};
use crate::{
database::establish_connection,
schema::{
@@ -143,12 +142,6 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> a
}
}
let existing_item: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.filter(title.eq(&item_title))
.load(connection)?;
if existing_item.is_empty() {
let new_feed_item = NewFeedItem::new(
feed.id,
content.clone(),
@@ -156,11 +149,22 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> a
item.link.expect("checked above"),
Some(time),
);
let insert_result = diesel::insert_into(feed_item::table)
.values(&new_feed_item)
.execute(connection);
log::info!("Insert Result: {:?}", insert_result);
// `on_conflict` on the (feed_id, url) unique constraint makes this
// insert idempotent, so two syncs for the same feed running concurrently
// (e.g. a sync button press racing a page-reload sync) can't both pass a
// check-then-insert race and create duplicate items. Keying on the
// article's link rather than its title also handles feeds that edit a
// headline after publishing while keeping the same link — the link is
// the stable identity.
let inserted_rows = diesel::insert_into(feed_item::table)
.values(&new_feed_item)
.on_conflict((feed_id, url))
.do_nothing()
.execute(connection)?;
if inserted_rows > 0 {
log::info!("Inserted item: {}", item_title);
} else {
log::info!("Item {} already exists.", item_title);
}
@@ -210,10 +214,11 @@ pub async fn sync(
#[cfg(test)]
mod tests {
use crate::models::feed::new_feed::NewFeed;
use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::models::user::new_user::NewUser;
use crate::models::user::rss_user::User;
use crate::schema::users;
use crate::test_helpers::unique_suffix;
use crate::test_helpers::{delete_feed, delete_user, insert_feed, insert_user, unique_suffix};
use chrono::Duration;
use super::*;
@@ -440,6 +445,58 @@ mod tests {
.ok();
}
#[actix_web::test]
async fn create_feed_item_is_race_safe_under_concurrent_syncs() {
let mut connection = establish_connection();
let suffix = unique_suffix();
let user = insert_user(&mut connection, "secret");
let feed = insert_feed(&mut connection, user.id);
let mut item = Item::default();
item.set_title(Some(format!("Race test article {suffix}")));
item.set_link(Some(format!("https://example.test/race/{suffix}")));
item.set_content(Some("<p>Hello world</p>".to_string()));
// Simulate two concurrent syncs for the same feed (e.g. a sync
// button press racing a page-reload sync) — each on its own
// connection/thread, released together so they genuinely overlap —
// both inserting the same item.
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let handles: Vec<_> = (0..2)
.map(|_| {
let feed = feed.clone();
let item = item.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
let mut connection = establish_connection();
barrier.wait();
create_feed_item(item, &feed, &mut connection)
})
})
.collect();
for handle in handles {
handle.join().unwrap().unwrap();
}
let items: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.load(&mut connection)
.unwrap();
assert_eq!(
1,
items.len(),
"concurrent syncs must not create duplicate feed items"
);
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
.execute(&mut connection)
.ok();
delete_feed(&mut connection, feed.id);
delete_user(&mut connection, user.id);
}
#[actix_web::test]
async fn create_feed_item_strips_onerror_from_feed_image() {
let mut connection = establish_connection();
+3 -1
View File
@@ -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 ./
+6
View File
@@ -5,8 +5,14 @@
<meta charset="UTF-8">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="alternate icon" href="/favicon.ico">
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/icon-192.png">
<meta name="theme-color" content="#1a8f5e">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RSS-Reader</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Lora:ital,wght@0,400;0,700;1,400&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:wght@400;700&family=Raleway:wght@400;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,700;1,8..60,400&display=swap" rel="stylesheet">
</head>
<body>
+50 -40
View File
@@ -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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

+20
View File
@@ -0,0 +1,20 @@
{
"name": "RSS Reader",
"short_name": "RSS Reader",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1a8f5e",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+4
View File
@@ -1,8 +1,12 @@
<script setup>
import { onMounted } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import AppNav from './components/AppNav.vue'
import { useSettings } from './composables/useSettings.js'
const route = useRoute()
const { applySettings } = useSettings()
onMounted(applySettings)
</script>
<template>
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useFeeds } from '../composables/useFeeds'
// router/index.js's route definitions lazily import(...) the view components,
// which importing the real module would still be safe to do (the imports
// themselves aren't eagerly evaluated) — but mocking it keeps this test from
// depending on the app's actual routes and lets currentRoute/push be
// controlled directly.
vi.mock('../router', () => ({
default: {
currentRoute: { value: { name: 'feeds' } },
push: vi.fn(),
},
}))
import router from '../router'
import { handleUnauthorizedResponse } from '../httpAuthInterceptor'
describe('handleUnauthorizedResponse', () => {
const { feedFilter } = useFeeds()
beforeEach(() => {
localStorage.setItem('user-token', 'abc123')
localStorage.setItem('user-id', '7')
localStorage.setItem('feedFilter', JSON.stringify(['Feed A']))
feedFilter.value = ['Feed A']
router.currentRoute.value.name = 'feeds'
router.push.mockClear()
})
it('clears the session and the feed filter, and redirects to login, on a 401 from a non-login route', async () => {
const error = { response: { status: 401 } }
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
expect(localStorage.getItem('user-token')).toBeNull()
expect(localStorage.getItem('user-id')).toBeNull()
expect(localStorage.getItem('feedFilter')).toBeNull()
expect(feedFilter.value).toBeNull()
expect(router.push).toHaveBeenCalledWith({ name: 'login' })
})
it('still clears the session on a 401 but does not push again when already on the login route', async () => {
router.currentRoute.value.name = 'login'
const error = { response: { status: 401 } }
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
expect(localStorage.getItem('user-token')).toBeNull()
expect(router.push).not.toHaveBeenCalled()
})
it('leaves the session and filter untouched, and still rejects, on a non-401 error response', async () => {
const error = { response: { status: 500 } }
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
expect(localStorage.getItem('user-token')).toBe('abc123')
expect(localStorage.getItem('feedFilter')).toBe(JSON.stringify(['Feed A']))
expect(feedFilter.value).toEqual(['Feed A'])
expect(router.push).not.toHaveBeenCalled()
})
it('leaves the session untouched and still rejects on an error with no response (e.g. a network error)', async () => {
const error = new Error('network')
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
expect(localStorage.getItem('user-token')).toBe('abc123')
expect(router.push).not.toHaveBeenCalled()
})
})
+13
View File
@@ -23,6 +23,13 @@
/* semantic color variables for this project */
:root {
--headline-font-family: Glook, 'Courier New';
--content-font-family: Merriweather, Georgia, 'Times New Roman', Times, serif;
--headline-font-size-scale: 1;
--content-font-size-scale: 1;
--content-text-align: left;
--content-padding: 1rem;
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
@@ -74,6 +81,12 @@ body {
wider than the visible content area (scrollbar) and would otherwise
introduce a horizontal scrollbar. */
overflow-x: hidden;
/* Without this, a horizontal touch drag on content with nothing left to
scroll (true here, given overflow-x: hidden above) is claimed by
Chrome-on-Android's native swipe-to-go-back/forward gesture before our
own touchend listener (article-view swipe navigation) ever sees it —
the gesture is silently swallowed with no error, no touchend fires. */
overscroll-behavior-x: contain;
color: var(--color-text);
background: var(--color-background);
transition: color 0.5s, background-color 0.5s;
+9 -6
View File
@@ -4,6 +4,7 @@
max-width: 1280px;
margin: 0 auto;
padding: 0.5rem;
padding-top: var(--app-nav-height, 4.5rem);
font-weight: normal;
}
@@ -68,8 +69,8 @@ a,
.feed-title {
cursor: pointer;
font-family: 'Courier New';
font-size: clamp(1.25rem, 4.5vw, 1.6rem);
font-family: var(--headline-font-family);
font-size: calc(clamp(1.4rem, 5vw, 2rem) * var(--headline-font-size-scale));
font-weight: bold;
color: var(--color-accent-2);
border-bottom: 1px solid #ccc;
@@ -83,9 +84,10 @@ a,
}
.feed-content {
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: clamp(1rem, 3.5vw, 1.25rem);
padding: 0 1em 1em;
font-family: var(--content-font-family);
font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale));
text-align: var(--content-text-align);
padding: 0 var(--content-padding) 1em;
overflow-wrap: break-word;
}
@@ -100,7 +102,7 @@ a,
.feed-content h3 {
padding: 0.5em 0;
font-size: clamp(1rem, 3vw, 1.3rem);
font-size: calc(clamp(1rem, 3vw, 1.3rem) * var(--headline-font-size-scale));
font-weight: bold;
}
@@ -111,5 +113,6 @@ h3 {
@media (min-width: 768px) {
#app {
padding: 0.75rem;
padding-top: var(--app-nav-height, 4.5rem);
}
}
+29
View File
@@ -1,7 +1,9 @@
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
import Modal from './modal/AddUrl.vue'
const showModal = ref(false)
const feeds = ref([])
const error = ref('')
@@ -40,6 +42,7 @@ onMounted(loadFeeds)
<template>
<div class="admin">
<h1 class="admin__heading">Admin</h1>
<button class="admin__add" type="button" @click="showModal = true">Add RSS</button>
<p v-if="error" class="admin__error">{{ error }}</p>
<p v-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p>
<ul v-else class="admin__list">
@@ -51,6 +54,14 @@ onMounted(loadFeeds)
<button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button>
</li>
</ul>
<Teleport to="body">
<Modal :show="showModal" @close="showModal = false" @added="loadFeeds">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</Modal>
</Teleport>
</div>
</template>
@@ -67,6 +78,24 @@ onMounted(loadFeeds)
margin-bottom: 1.25rem;
}
.admin__add {
display: inline-flex;
align-items: center;
min-height: 44px;
margin-bottom: 1.25rem;
padding: 0.5rem 1.1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
font: inherit;
cursor: pointer;
}
.admin__add:hover {
border-color: var(--color-border-hover);
}
.admin__error,
.admin__empty {
opacity: 0.6;
+193
View File
@@ -0,0 +1,193 @@
<script setup>
import { useSettings } from '../composables/useSettings.js'
const {
headlineSizeScale,
contentSizeScale,
headlineFontKey,
contentFontKey,
SIZE_STEPS,
SIZE_LABELS,
HEADLINE_FONT_OPTIONS,
CONTENT_FONT_OPTIONS,
setHeadlineSize,
setContentSize,
setHeadlineFont,
setContentFont,
textAlignKey,
contentPadding,
TEXT_ALIGN_OPTIONS,
PADDING_STEPS,
PADDING_LABELS,
setTextAlign,
setContentPadding,
} = useSettings()
</script>
<template>
<div class="settings">
<h1 class="settings__heading">Typography</h1>
<section class="settings__section">
<h2 class="settings__section-title">Headline Size</h2>
<div class="settings__strip">
<button
v-for="(step, i) in SIZE_STEPS"
:key="step"
class="settings__btn"
:class="{ 'settings__btn--active': headlineSizeScale === step }"
type="button"
@click="setHeadlineSize(step)"
>{{ SIZE_LABELS[i] }}</button>
</div>
</section>
<section class="settings__section">
<h2 class="settings__section-title">Article Text Size</h2>
<div class="settings__strip">
<button
v-for="(step, i) in SIZE_STEPS"
:key="step"
class="settings__btn"
:class="{ 'settings__btn--active': contentSizeScale === step }"
type="button"
@click="setContentSize(step)"
>{{ SIZE_LABELS[i] }}</button>
</div>
</section>
<section class="settings__section">
<h2 class="settings__section-title">Headline Font</h2>
<select
class="settings__select"
:value="headlineFontKey"
@change="setHeadlineFont($event.target.value)"
>
<option
v-for="opt in HEADLINE_FONT_OPTIONS"
:key="opt.key"
:value="opt.key"
>{{ opt.label }}</option>
</select>
</section>
<section class="settings__section">
<h2 class="settings__section-title">Article Text Font</h2>
<select
class="settings__select"
:value="contentFontKey"
@change="setContentFont($event.target.value)"
>
<option
v-for="opt in CONTENT_FONT_OPTIONS"
:key="opt.key"
:value="opt.key"
>{{ opt.label }}</option>
</select>
</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>
</template>
<style scoped>
.settings {
padding: 1.5rem 1rem 0.5rem;
max-width: 720px;
margin: 0 auto;
}
.settings__heading {
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 1.25rem;
}
.settings__section {
margin-bottom: 1.25rem;
padding: 0.75rem 1rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
}
.settings__section-title {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.6;
margin-bottom: 0.6rem;
}
.settings__strip {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.settings__btn {
min-height: 36px;
padding: 0.3rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
font: inherit;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.settings__btn:hover {
border-color: var(--color-border-hover);
}
.settings__btn--active {
border-color: var(--color-accent);
background: var(--color-accent-hover);
color: var(--color-text);
}
.settings__select {
width: 100%;
min-height: 36px;
padding: 0.3rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
font: inherit;
cursor: pointer;
appearance: auto;
}
.settings__select:hover {
border-color: var(--color-border-hover);
}
</style>
+211 -31
View File
@@ -2,29 +2,108 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
import Modal from './modal/AddUrl.vue'
import FeedFilter from './modal/FeedFilter.vue'
const router = useRouter()
const route = useRoute()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds, navTitleVisible } = useFeeds()
const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll, isSyncing } = useFeeds()
const titleRef = ref(null)
let titleObserver
const headerRef = ref(null)
const filterBtnRef = ref(null)
const filterModalOpen = ref(false)
function openFilterModal() {
refreshUnreadDisplay()
filterModalOpen.value = true
}
// setFeedFilter() itself no-ops (order-insensitively) an unchanged selection,
// so there's nothing to check here before applying it.
async function handleFilterClose(selection) {
filterModalOpen.value = false
await setFeedFilter(selection)
filterBtnRef.value?.focus()
}
// Scroll-driven show/hide: the header slides out of view on scroll-down and
// back in only on scroll-up — never merely by proximity to the top. This is
// show/hide via `transform` (not the old resize behaviour) — the header is
// position:fixed, so translating it never reflows content. The app's
// programmatic scrolls resolve to sensible states without any top-of-page
// special case: scrollTo(0, 0) (article paging, layout toggle) is itself a
// large upward jump, so it crosses REVEAL_THRESHOLD like a real scroll-up and
// reveals the header that way; the list-view read-correction scrollBy moves
// only a few px → stays under the threshold and leaves a hidden header hidden.
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)
// Keep the header visible while the menu is open (the dropdown is anchored
// to the header, so hiding it would slide the open menu off-screen).
if (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(() => {
titleObserver = new IntersectionObserver(([entry]) => {
navTitleVisible.value = entry.isIntersecting
})
titleObserver.observe(titleRef.value)
// 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
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)
refreshUnreadDisplay()
})
onUnmounted(() => {
titleObserver?.disconnect()
window.removeEventListener('scroll', onScrollRaf)
document.removeEventListener('click', onDocumentClick)
})
const onFeedsPage = computed(() => route.path === '/feeds')
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
const menuOpen = ref(false)
@@ -36,6 +115,19 @@ function closeMenu() {
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() {
await logoutSession()
closeMenu()
@@ -52,11 +144,6 @@ function handleMarkAllRead() {
closeMenu()
}
function openAddModal() {
showModal.value = true
closeMenu()
}
function handleToggleViewMode() {
toggleViewMode()
closeMenu()
@@ -69,9 +156,39 @@ function handleToggleLayout() {
</script>
<template>
<header class="app-nav">
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
<div class="app-nav__wrapper">
<span ref="titleRef" 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>
<span
v-if="isSyncing"
class="app-nav__sync-spinner"
aria-hidden="true"
></span>
<button
v-if="onFeedsPage && feedTitles.length"
ref="filterBtnRef"
class="app-nav__filter-btn"
:class="{ 'app-nav__filter-btn--active': feedFilter !== null }"
type="button"
aria-haspopup="dialog"
:aria-expanded="filterModalOpen"
:aria-label="feedFilter !== null ? 'Filter feeds (active)' : 'Filter feeds'"
:title="feedFilter !== null ? 'Filter feeds (active)' : 'Filter feeds'"
@click="openFilterModal"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
</svg>
</button>
<Teleport to="body">
<FeedFilter
:show="filterModalOpen"
:titles="feedTitles"
:counts="displayedFeedUnreadCounts"
:selected="feedFilter"
@close="handleFilterClose"
/>
</Teleport>
<button
class="app-nav__hamburger"
type="button"
@@ -105,40 +222,48 @@ function handleToggleLayout() {
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
</template>
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
<button class="app-nav__menu-item" type="button" @click="openAddModal">Add RSS</button>
<RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink>
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
</div>
</nav>
</Transition>
<Teleport to="body">
<Modal :show="showModal" @close="showModal = false">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</Modal>
</Teleport>
</header>
</template>
<style scoped>
.app-nav {
position: relative;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
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 {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.75rem 1rem;
padding: 0.375rem 1rem;
}
.app-nav__title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: bold;
font-size: clamp(1.1rem, 4vw, 1.4rem);
font-size: clamp(0.95rem, 3.5vw, 1.1rem);
}
.app-nav__unread {
@@ -146,10 +271,65 @@ function handleToggleLayout() {
opacity: 0.6;
}
.app-nav__sync-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-top-color: var(--color-text);
border-radius: 50%;
opacity: 0.6;
flex-shrink: 0;
animation: app-nav-spin 0.8s linear infinite;
}
@keyframes app-nav-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.app-nav__sync-spinner {
animation: none;
}
}
.app-nav__filter-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
min-height: 44px;
min-width: 44px;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
cursor: pointer;
}
.app-nav__filter-btn:hover {
border-color: var(--color-border-hover);
}
.app-nav__filter-btn--active::after {
content: '';
position: absolute;
top: 6px;
right: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-accent);
}
.app-nav__hamburger {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-height: 44px;
min-width: 44px;
padding: 0.5rem;
@@ -230,7 +410,7 @@ function handleToggleLayout() {
@media (min-width: 768px) {
.app-nav__wrapper {
padding: 1rem 2rem;
padding: 0.5rem 2rem;
}
}
</style>
+151 -116
View File
@@ -1,5 +1,5 @@
<script setup>
import { onMounted, computed, nextTick, watch } from 'vue';
import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
import { useFeeds } from '@/composables/useFeeds';
const {
@@ -8,15 +8,16 @@ const {
message,
viewMode,
currentIndex,
leaveArticleView,
layout,
nextArticle,
prevArticle,
fetchData,
sync,
getReadable,
disconnectObserver,
setInitialLoad,
showMessageForXSeconds,
navTitleVisible,
markCurrentArticleRead,
} = useFeeds()
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
@@ -34,6 +35,45 @@ function scrollToNextArticle() {
}
}
// Swipe-to-navigate in article view. Transient per-gesture state, not shared
// app state, so it's kept as plain component-local variables rather than in
// useFeeds() — nothing outside this component needs it.
let touchStartX = null
let touchStartY = null
const SWIPE_THRESHOLD_X = 50 // min horizontal travel (px) to count as a swipe
const SWIPE_RESTRAINT_Y = 75 // max vertical travel (px) before it's treated as a scroll, not a swipe
function onArticleTouchStart(event) {
// Ignore multi-touch (pinch-zoom) and swipes starting inside a
// horizontally-scrollable code block (.article-feature__content :deep(pre))
// — those should scroll the block, not page the article.
if (event.touches.length !== 1 || event.target.closest('pre')) {
touchStartX = null
touchStartY = null
return
}
touchStartX = event.touches[0].clientX
touchStartY = event.touches[0].clientY
}
function onArticleTouchEnd(event) {
if (touchStartX === null) return
const touch = event.changedTouches[0]
const deltaX = touch.clientX - touchStartX
const deltaY = touch.clientY - touchStartY
touchStartX = null
touchStartY = null
if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) return
if (deltaX < 0) {
nextArticle()
} else {
prevArticle()
}
}
// Small images (icons, logos, ...) look bad stretched to the full-bleed
// width used for readable article images — leave them at their natural size
// instead. Intrinsic size is only known once the image has loaded, so check
@@ -41,7 +81,7 @@ function scrollToNextArticle() {
const SMALL_IMAGE_THRESHOLD = 200
function markSmallImages() {
document.querySelectorAll('.article-feature__content--readable img').forEach(img => {
document.querySelectorAll('.article-feature__content--readable img, .feed-content--readable img').forEach(img => {
const checkSize = () => {
if (img.naturalWidth && img.naturalWidth <= SMALL_IMAGE_THRESHOLD) {
img.classList.add('article-feature__image--small')
@@ -60,6 +100,12 @@ watch(() => feeds.value[currentIndex.value]?.content, async () => {
markSmallImages()
})
async function loadReadable(feed, index) {
await getReadable(feed, index)
await nextTick()
markSmallImages()
}
async function shareUrl(url) {
if (navigator.share) {
await navigator.share({ url })
@@ -69,9 +115,20 @@ async function shareUrl(url) {
}
}
onBeforeUnmount(() => {
disconnectObserver()
setInitialLoad(false)
})
onMounted(async () => {
setInitialLoad(false)
await fetchData()
// viewMode persists across reloads (see useFeeds.js) — if we're booting
// straight into article view, the article now on screen needs the same
// immediate mark-read that toggleViewMode()/nextArticle()/prevArticle()
// give it when reached by in-session navigation.
if (viewMode.value === 'article') markCurrentArticleRead()
sync(true)
setTimeout(function () {
setInitialLoad(true)
console.log('set to true')
@@ -84,10 +141,6 @@ onMounted(async () => {
<div v-if="showMessage" class="message">{{ message }}</div>
<div v-if="viewMode === 'list'" id='article' class='article' :class="{ 'article--cards': layout === 'cards' }">
<div v-if="feeds.length" class="list-topbar">
<span v-if="!navTitleVisible" class="list-topbar__title">RSS Reader<span v-if="unreadCount" class="list-topbar__unread"> ({{ unreadCount }})</span></span>
<button type="button" class="list-topbar__next" @click="scrollToNextArticle">Skip to next article &darr;</button>
</div>
<div v-if="feeds.length == 0" class="empty-state">
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
@@ -95,29 +148,30 @@ onMounted(async () => {
</svg>
<p class="empty-state__label">All caught up</p>
</div>
<template v-for="( feed, index ) in feeds ">
<template v-for="( feed, index ) in feeds " :key="feed.id">
<div v-bind:id="index" class="observe">
<p class="feed-source">{{ feed.feedTitle }}</p>
<h2 @click="getReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
<h2 @click="loadReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
<h3>{{ feed.timestamp }}</h3>
<p v-if="!feed.readable" class="feed-original-link">
<p class="feed-original-link">
<a :href="feed.url" target="_blank" rel="noopener noreferrer">Read original article &#8599;</a>
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feed.url)" :aria-label="shareLabel">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
</button>
</p>
<p class="feed-content" v-html='feed.content'></p>
<p class="feed-content" :class="{ 'feed-content--readable': feed.readable }" v-html='feed.content'></p>
</div>
</template>
<button
v-if="feeds.length"
type="button"
class="article-nav__btn list-skip-btn"
aria-label="Skip to next article"
@click="scrollToNextArticle"
>&darr;</button>
</div>
<div v-else class="article-single">
<div class="article-single__topbar">
<div class="article-single__topbar-inner">
<button type="button" class="article-single__back" @click="leaveArticleView">&larr; Back to list</button>
<span v-if="feeds.length" class="article-single__progress">{{ currentIndex + 1 }} / {{ feeds.length }}</span>
</div>
</div>
<div v-else class="article-single" @touchstart.passive="onArticleTouchStart" @touchend.passive="onArticleTouchEnd">
<div v-if="feeds.length == 0" class="empty-state">
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
@@ -125,18 +179,22 @@ onMounted(async () => {
</svg>
<p class="empty-state__label">All caught up</p>
</div>
<template v-else>
<article class="article-feature">
<!-- Deliberately no enter/leave transition here: Vue's <Transition>
component forces a synchronous whole-document reflow
(document.body.offsetHeight, in @vue/runtime-dom's forceReflow) on
every enter/leave, regardless of the app's own CSS — confirmed (via
a DevTools performance trace) to be what was corrupting the header's
native <select>/<button> rendering during article paging. -->
<article v-else class="article-feature" :key="currentIndex">
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
<h2 @click="getReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
<p v-if="!feeds[currentIndex].readable" class="feed-original-link">
<p class="feed-original-link">
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article &#8599;</a>
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
</p>
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
</article>
</template>
<div class="article-nav">
<button
@@ -159,48 +217,15 @@ onMounted(async () => {
</template>
<style scoped>
.list-topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0;
margin-bottom: 0.5rem;
background: var(--color-background);
}
.list-topbar__title {
font-weight: bold;
font-size: clamp(1.1rem, 4vw, 1.4rem);
}
.list-topbar__unread {
font-weight: normal;
opacity: 0.6;
}
.list-topbar__next {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0.5rem 0.9rem;
margin-left: auto;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
.list-topbar__next:hover {
border-color: var(--color-border-hover);
.list-skip-btn {
position: fixed;
right: 1rem;
bottom: 1.5rem;
z-index: 20;
}
.observe {
scroll-margin-top: 3.5rem;
scroll-margin-top: var(--app-nav-height, 4.5rem);
}
/* Plain vertical stack of bordered "cards" — deliberately not flex/grid, and
@@ -256,6 +281,38 @@ onMounted(async () => {
width: auto;
}
.feed-content--readable :deep(img),
.feed-content--readable :deep(video) {
display: block;
width: 100vw;
max-width: 100vw;
height: auto;
margin-top: 1.5em;
margin-bottom: 1.5em;
margin-left: 50%;
transform: translateX(-50%);
}
.feed-content--readable :deep(img.article-feature__image--small) {
display: block;
width: auto;
max-width: 100%;
margin: 1.5em auto;
transform: none;
}
@media (min-width: 720px) {
.feed-content--readable :deep(img),
.feed-content--readable :deep(video) {
display: block;
width: auto;
max-width: 100%;
height: auto;
margin: 1.5em auto;
transform: none;
}
}
.feed-original-link {
display: flex;
flex-wrap: wrap;
@@ -300,53 +357,15 @@ onMounted(async () => {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 1em;
padding-bottom: 5rem;
}
.article-single__topbar {
position: sticky;
top: 0;
z-index: 10;
align-self: flex-start;
width: 100vw;
margin-left: 50%;
margin-bottom: 1rem;
transform: translateX(-50%);
background: var(--color-background);
}
.article-single__topbar-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
max-width: 720px;
margin: 0 auto;
padding: 0.5rem 1rem;
}
.article-single__back {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0.5rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
cursor: pointer;
}
.article-single__back:hover {
border-color: var(--color-border-hover);
}
.article-single__progress {
font-size: 0.85rem;
color: var(--color-text);
opacity: 0.6;
font-variant-numeric: tabular-nums;
white-space: nowrap;
/* Fill the viewport below the fixed header even when the article content
is short, so swipe-to-navigate works anywhere on screen instead of only
over the (possibly small) content area. dvh is the accurate unit on
mobile, where this matters most; vh is the fallback for browsers
without dvh support. */
min-height: calc(100vh - var(--app-nav-height, 4.5rem));
min-height: calc(100dvh - var(--app-nav-height, 4.5rem));
}
.article-feature {
@@ -368,8 +387,8 @@ onMounted(async () => {
cursor: pointer;
margin: 0;
padding: 0 1rem;
font-family: 'Courier New';
font-size: clamp(1.75rem, 6vw, 2.75rem);
font-family: var(--headline-font-family);
font-size: calc(clamp(1.4rem, 5vw, 2rem) * var(--headline-font-size-scale));
font-weight: bold;
line-height: 1.15;
color: var(--color-accent-2);
@@ -395,9 +414,10 @@ onMounted(async () => {
}
.article-feature__content {
padding: 0 1rem;
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: clamp(1rem, 3.5vw, 1.25rem);
padding: 0 var(--content-padding);
text-align: var(--content-text-align);
font-family: var(--content-font-family);
font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale));
line-height: 1.75;
overflow-wrap: break-word;
}
@@ -408,7 +428,7 @@ onMounted(async () => {
.article-feature__content :deep(h3) {
padding: 0.5em 0;
font-size: clamp(1rem, 3vw, 1.3rem);
font-size: calc(clamp(1rem, 3vw, 1.3rem) * var(--headline-font-size-scale));
font-weight: bold;
}
@@ -440,6 +460,21 @@ onMounted(async () => {
transform: none;
}
/* On desktop the viewport is much wider than the article column, so the
full-bleed 100vw treatment above would blow images up far beyond their
natural resolution. Keep them at natural size, centered in the text. */
@media (min-width: 720px) {
.article-feature__content--readable :deep(img),
.article-feature__content--readable :deep(video) {
display: block;
width: auto;
max-width: 100%;
height: auto;
margin: 1.5em auto;
transform: none;
}
}
.article-feature__content :deep(a) {
color: var(--color-accent);
text-decoration-color: var(--color-accent-hover);
@@ -450,7 +485,7 @@ onMounted(async () => {
padding: 1em 0;
border-top: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
font-family: Georgia, 'Times New Roman', Times, serif;
font-family: var(--content-font-family);
font-size: 1.25em;
font-style: italic;
text-align: center;
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import axios from 'axios'
import AdminFeeds from '../AdminFeeds.vue'
import AddUrl from '../modal/AddUrl.vue'
vi.mock('axios')
describe('AdminFeeds', () => {
beforeEach(() => {
localStorage.setItem('user-token', 'test-token')
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
})
// Each mount renders the AddUrl modal via <Teleport to="body">, which
// survives outside the wrapper's own element — unmount every mounted
// instance so a leftover doesn't bleed into a later test's document.body.
let mountedWrappers = []
function mountAdmin() {
const wrapper = mount(AdminFeeds)
mountedWrappers.push(wrapper)
return wrapper
}
afterEach(() => {
for (const wrapper of mountedWrappers) {
try {
wrapper.unmount()
} catch {
// already unmounted by the test itself — fine
}
}
mountedWrappers = []
})
it('fetches and renders the feed list on mount', async () => {
axios.get.mockResolvedValueOnce({
data: { feeds: [{ id: 1, title: 'Feed A', url: 'https://example.test/a.xml' }] },
})
const wrapper = mountAdmin()
await flushPromises()
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/feeds/7', expect.anything())
expect(wrapper.text()).toContain('Feed A')
})
it('shows the empty state when there are no feeds', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
const wrapper = mountAdmin()
await flushPromises()
expect(wrapper.text()).toContain('No feeds added yet.')
})
it('shows an error message when the feed list fails to load', async () => {
axios.get.mockRejectedValueOnce(new Error('Network Error'))
const wrapper = mountAdmin()
await flushPromises()
expect(wrapper.text()).toContain('Failed to load feeds.')
})
it('deletes a feed after confirmation', async () => {
axios.get.mockResolvedValueOnce({
data: { feeds: [{ id: 1, title: 'Feed A', url: 'https://example.test/a.xml' }] },
})
axios.delete.mockResolvedValueOnce({ status: 204 })
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
const wrapper = mountAdmin()
await flushPromises()
await wrapper.find('.admin__delete').trigger('click')
await flushPromises()
expect(axios.delete).toHaveBeenCalledWith('/api/v1/article/feed/1', expect.anything())
expect(wrapper.text()).toContain('No feeds added yet.')
confirmSpy.mockRestore()
})
it('opens the add-feed modal from the Add RSS button', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
const wrapper = mountAdmin()
await flushPromises()
await wrapper.find('.admin__add').trigger('click')
expect(wrapper.findComponent(AddUrl).props('show')).toBe(true)
})
it('reloads the feed list when the modal reports a feed was added', async () => {
axios.get
.mockResolvedValueOnce({ data: { feeds: [] } })
.mockResolvedValueOnce({ data: { feeds: [{ id: 2, title: 'Feed B', url: 'https://example.test/b.xml' }] } })
const wrapper = mountAdmin()
await flushPromises()
expect(wrapper.text()).toContain('No feeds added yet.')
await wrapper.findComponent(AddUrl).vm.$emit('added')
await flushPromises()
expect(axios.get).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('Feed B')
})
})
+430 -22
View File
@@ -1,12 +1,23 @@
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 { nextTick } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import axios from 'axios'
import AppNav from '../AppNav.vue'
import FeedFilter from '../modal/FeedFilter.vue'
import { useFeeds } from '../../composables/useFeeds'
vi.mock('axios')
// jsdom does not implement IntersectionObserver, but AppNav sets one up on mount
// to track whether the list view's title is scrolled into view.
class FakeIntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
describe('AppNav', () => {
let router
@@ -15,11 +26,15 @@ describe('AppNav', () => {
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, viewMode, currentIndex, layout } = useFeeds()
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
message.value = ''
showModal.value = false
viewMode.value = 'list'
currentIndex.value = 0
layout.value = 'list'
@@ -35,15 +50,35 @@ describe('AppNav', () => {
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() {
const wrapper = mount(AppNav, { global: { plugins: [router] } })
const wrapper = mountNav()
await wrapper.find('.app-nav__hamburger').trigger('click')
await flushPromises()
return wrapper
}
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)
@@ -54,6 +89,17 @@ describe('AppNav', () => {
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 () => {
const wrapper = await mountWithMenuOpen()
@@ -84,16 +130,6 @@ describe('AppNav', () => {
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
})
it('opens the add-feed modal from the menu', async () => {
const wrapper = await mountWithMenuOpen()
const { showModal } = useFeeds()
const addButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Add RSS')
await addButton.trigger('click')
expect(showModal.value).toBe(true)
})
it('switches the view mode from the menu and closes it', async () => {
const wrapper = await mountWithMenuOpen()
const { viewMode } = useFeeds()
@@ -149,38 +185,409 @@ describe('AppNav', () => {
})
it('shows the unread count in the title when there are articles', async () => {
const { feeds } = useFeeds()
feeds.value = [
// The badge is the global unread total, sourced from the master list
// (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: 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()
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
})
it('excludes already-read articles from the counter while in article view', async () => {
const { feeds } = useFeeds()
feeds.value = [
const { allItems } = useFeeds()
allItems.value = [
{ 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' },
]
const wrapper = mount(AppNav, { global: { plugins: [router] } })
const wrapper = mountNav()
await flushPromises()
expect(wrapper.find('.app-nav__title').text()).toContain('(1)')
})
it('hides the unread count when there are no articles', async () => {
const wrapper = mount(AppNav, { global: { plugins: [router] } })
const wrapper = mountNav()
await flushPromises()
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
})
it('opens a feed filter modal listing "All feeds" plus one entry 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()
await wrapper.find('.app-nav__filter-btn').trigger('click')
const modal = wrapper.findComponent(FeedFilter)
expect(modal.props('show')).toBe(true)
// Distinct titles, sorted alphabetically, with per-feed unread counts.
expect(modal.props('titles')).toEqual(['Feed A', 'Feed B'])
expect(modal.props('counts')).toEqual({ '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-btn').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()
await wrapper.find('.app-nav__filter-btn').trigger('click')
const modal = wrapper.findComponent(FeedFilter)
modal.vm.$emit('close', ['Feed A'])
await flushPromises()
expect(feedFilter.value).toEqual(['Feed A'])
expect(feeds.value.map(f => f.id)).toEqual([1])
await wrapper.find('.app-nav__filter-btn').trigger('click')
modal.vm.$emit('close', null)
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-btn').trigger('click')
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed A'])
await flushPromises()
// Filtered → unread in Feed A only (2).
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
})
it('sums the unread count across every selected feed in the title badge', async () => {
const { allItems, 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' },
{ id: 3, feedTitle: 'Feed C', title: 'c', url: 'https://example.test/3', timestamp: '2026-01-03' },
]
feedFilter.value = ['Feed A', 'Feed B']
const wrapper = mountNav()
await flushPromises()
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
})
it('marks the filter button active and swaps its label/title when a filter is set', async () => {
const { allItems, feedFilter } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
]
const wrapper = mountNav()
await flushPromises()
const button = wrapper.find('.app-nav__filter-btn')
expect(button.classes()).not.toContain('app-nav__filter-btn--active')
expect(button.attributes('aria-label')).toBe('Filter feeds')
expect(button.attributes('title')).toBe('Filter feeds')
feedFilter.value = ['Feed A']
await nextTick()
expect(button.classes()).toContain('app-nav__filter-btn--active')
expect(button.attributes('aria-label')).toBe('Filter feeds (active)')
expect(button.attributes('title')).toBe('Filter feeds (active)')
})
it('tracks the modal\'s open state via aria-expanded on the filter button', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
]
const wrapper = mountNav()
await flushPromises()
const button = wrapper.find('.app-nav__filter-btn')
expect(button.attributes('aria-expanded')).toBe('false')
await button.trigger('click')
expect(button.attributes('aria-expanded')).toBe('true')
wrapper.findComponent(FeedFilter).vm.$emit('close', null)
await flushPromises()
expect(button.attributes('aria-expanded')).toBe('false')
})
it('does not re-apply the filter when the emitted selection is order-insensitively unchanged', async () => {
const { allItems, 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' },
]
feedFilter.value = ['Feed A', 'Feed B']
const wrapper = mountNav()
await flushPromises()
const scrollToSpy = vi.spyOn(window, 'scrollTo')
await wrapper.find('.app-nav__filter-btn').trigger('click')
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed B', 'Feed A'])
await flushPromises()
// setFeedFilter() always scrollTo(0, 0)s — its absence proves the
// no-op path (sameFeedSelection) was taken instead.
expect(scrollToSpy).not.toHaveBeenCalled()
expect(feedFilter.value).toEqual(['Feed A', 'Feed B'])
})
it('does not re-apply the filter when null is emitted while already unfiltered', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
]
const wrapper = mountNav()
await flushPromises()
const scrollToSpy = vi.spyOn(window, 'scrollTo')
await wrapper.find('.app-nav__filter-btn').trigger('click')
wrapper.findComponent(FeedFilter).vm.$emit('close', null)
await flushPromises()
expect(scrollToSpy).not.toHaveBeenCalled()
})
it('returns focus to the filter button once the modal closes', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
]
const wrapper = mountNav({ attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
const button = wrapper.find('.app-nav__filter-btn')
await button.trigger('click')
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed A'])
await flushPromises()
expect(document.activeElement).toBe(button.element)
})
describe('sync spinner', () => {
it('does not render the spinner, and reserves no space for it, when idle', async () => {
const wrapper = mountNav()
await flushPromises()
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(false)
})
it('renders while a sync triggered through the composable is in flight, and disappears once it resolves', async () => {
const wrapper = mountNav()
await flushPromises()
let resolvePost
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
const { sync } = useFeeds()
const syncPromise = sync(true)
await nextTick()
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true)
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
resolvePost({ status: 200 })
await syncPromise
await nextTick()
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(false)
})
it('renders while syncing even on a page where the feed filter itself does not render', async () => {
await router.push('/login')
await router.isReady()
const wrapper = mountNav()
await flushPromises()
let resolvePost
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
const { sync } = useFeeds()
const syncPromise = sync(true)
await nextTick()
expect(wrapper.find('.app-nav__filter-btn').exists()).toBe(false)
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true)
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
resolvePost({ status: 200 })
await syncPromise
})
})
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 })
// getBoundingClientRect() is all-zero in jsdom; give the header a real
// height so onMounted()'s one-time measurement (used for the
// --app-nav-height CSS var — see AppNav.vue) has a realistic value.
// This is a prototype-wide spy (scoped to this describe block only, restored in
// afterEach), so it returns a full DOMRect-shaped object rather than a
// partial one — no test here happens to call getBoundingClientRect() on
// anything but the header, but a bare `{ height: 50 }` would silently
// hand back `undefined` for top/left/width/etc. to any code that did.
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
height: 50, width: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {},
})
})
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('reveals the header on a large upward jump that lands near the top', async () => {
const wrapper = mountNav()
scrollTo(400)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
// A jump this large (e.g. article paging's scrollTo(0, 0)) crosses
// REVEAL_THRESHOLD on its own — no top-of-page special case needed.
scrollTo(10)
await nextTick()
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
})
it('does not reveal the header merely for landing near the top', async () => {
const wrapper = mountNav()
// Hide it with a jump just past the header's own height (50px, mocked
// above) plus the reveal threshold.
scrollTo(62)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
// Move back to right at the header's own height — within the "near the
// top" zone the old code always revealed for — but the upward delta
// (12px) doesn't exceed REVEAL_THRESHOLD, so a hidden header stays
// hidden: proximity to the top is no longer sufficient on its own.
scrollTo(50)
await nextTick()
expect(wrapper.find('header').classes()).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 () => {
const { feeds } = useFeeds()
feeds.value = [
@@ -199,4 +606,5 @@ describe('AppNav', () => {
confirmSpy.mockRestore()
})
})
+217 -15
View File
@@ -15,6 +15,20 @@ class FakeIntersectionObserver {
}
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
// Shared by the swipe-navigation tests below, which all start from the same
// two-article article view.
function twoArticleFeed() {
return [
{
title: 'My Feed',
items: [
{ id: 1, title: 'Article one', content: '<p>one</p>', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' },
{ id: 2, title: 'Article two', content: '<p>two</p>', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' },
],
},
]
}
describe('RssFeeds', () => {
beforeEach(() => {
localStorage.setItem('user-token', 'test-token')
@@ -23,11 +37,10 @@ describe('RssFeeds', () => {
// useFeeds() returns module-level singleton refs shared across the whole
// app (and this spec file) — reset them so state doesn't leak between tests.
const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
const { feeds, showMessage, message, viewMode, currentIndex, layout } = useFeeds()
feeds.value = []
showMessage.value = false
message.value = ''
showModal.value = false
viewMode.value = 'list'
currentIndex.value = 0
layout.value = 'list'
@@ -119,7 +132,14 @@ describe('RssFeeds', () => {
],
},
})
axios.post.mockResolvedValueOnce({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
// axios.post is also hit by the sync triggered on mount, so branch on the
// URL rather than relying on call order via `mockResolvedValueOnce`.
axios.post.mockImplementation((url) => {
if (url === '/api/v1/article/sync') {
return Promise.resolve({ status: 200 })
}
return Promise.resolve({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
})
const { layout } = useFeeds()
layout.value = 'cards'
@@ -177,10 +197,7 @@ describe('RssFeeds', () => {
expect(titles).toEqual(['Newer article', 'Older article'])
})
it('shows a link to the original article until the readable version is loaded', async () => {
// The API returns each item with a short summary already in `content` —
// the link must key off the `readable` flag (set once Readability has
// parsed the full article), not off `content` truthiness.
it('keeps a link to the original article visible after the readable version is loaded', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
@@ -199,7 +216,14 @@ describe('RssFeeds', () => {
],
},
})
axios.post.mockResolvedValueOnce({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
// axios.post is also hit by the sync triggered on mount, so branch on the
// URL rather than relying on call order via `mockResolvedValueOnce`.
axios.post.mockImplementation((url) => {
if (url === '/api/v1/article/sync') {
return Promise.resolve({ status: 200 })
}
return Promise.resolve({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
})
const wrapper = mount(RssFeeds)
await flushPromises()
@@ -212,7 +236,9 @@ describe('RssFeeds', () => {
await wrapper.find('.feed-title').trigger('click')
await flushPromises()
expect(wrapper.find('.feed-original-link a').exists()).toBe(false)
const linkAfter = wrapper.find('.feed-original-link a')
expect(linkAfter.exists()).toBe(true)
expect(linkAfter.attributes('href')).toBe('https://example.test/1')
})
it('switches to article view and navigates between articles', async () => {
@@ -252,30 +278,31 @@ describe('RssFeeds', () => {
useFeeds().toggleViewMode()
await flushPromises()
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article one')
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
// Same as in list view: the readable content is loaded on demand by
// clicking the headline, not fetched automatically on entering the view.
expect(axios.post).not.toHaveBeenCalled()
// (axios.post is also hit by the sync triggered on mount.)
expect(axios.post).not.toHaveBeenCalledWith('/api/v1/article/read', expect.anything(), expect.anything())
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(true)
await wrapper.find('.article-single .feed-title').trigger('click')
await wrapper.find('.article-single .article-feature__title').trigger('click')
await flushPromises()
expect(axios.post).toHaveBeenCalledWith('/api/v1/article/read', { url: 'https://example.test/1' }, expect.anything())
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(false)
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(true)
expect(wrapper.findAll('.article-nav__btn')[0].attributes('disabled')).toBeDefined()
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
await flushPromises()
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article two')
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
expect(wrapper.findAll('.article-nav__btn')[1].attributes('disabled')).toBeDefined()
await wrapper.findAll('.article-nav__btn')[0].trigger('click')
await flushPromises()
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article one')
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
})
it('drops articles read while paging through article view once back in the list', async () => {
@@ -331,4 +358,179 @@ describe('RssFeeds', () => {
const titles = wrapper.findAll('.feed-title').map(el => el.text())
expect(titles).toEqual(['Article three'])
})
it('swipes left to navigate to the next article', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
wrapper.unmount()
})
it('swipes right to navigate to the previous article', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 130, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 200, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a swipe shorter than the horizontal threshold', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 180, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a mostly-vertical drag', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 190, clientY: 250 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a diagonal drag that exceeds the vertical restraint, even with a qualifying horizontal delta', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 200 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('ignores a multi-touch gesture (pinch-zoom)', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', {
touches: [{ clientX: 200, clientY: 100 }, { clientX: 50, clientY: 100 }],
})
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate when a swipe starts inside a code block', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{ id: 1, title: 'Article one', content: '<pre>some code</pre>', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' },
{ id: 2, title: 'Article two', content: '<p>two</p>', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' },
],
},
],
},
})
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.find('pre').trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await wrapper.find('.article-single').trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not throw when swiping with an empty feed list', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.text()).toContain('All caught up')
wrapper.unmount()
})
})
+4 -2
View File
@@ -2,9 +2,10 @@
import '@/assets/modal.css';
import { ref } from 'vue';
import axios from 'axios';
const props = defineProps({
defineProps({
show: Boolean
})
const emit = defineEmits(['close', 'added'])
const submitted = ref(false)
const url = ref('')
const title = ref('')
@@ -30,6 +31,7 @@ async function save() {
console.log(response)
output.value = 'saved successfully'
emit('added')
} catch (error) {
console.error(error.message)
output.value = error.message
@@ -57,7 +59,7 @@ async function save() {
<div class="modal-footer">
<slot name="footer">
<button type="submit">Save</button>
<button class="modal-default-button" @click="$emit('close')">Close</button>
<button class="modal-default-button" type="button" @click="$emit('close')">Close</button>
</slot>
</div>
</form>
+145
View File
@@ -0,0 +1,145 @@
<script setup>
import '@/assets/modal.css';
import { ref, watch, onUnmounted, nextTick } from 'vue'
const props = defineProps({
show: Boolean,
titles: { type: Array, default: () => [] },
counts: { type: Object, default: () => ({}) },
selected: { type: Array, default: null },
})
const emit = defineEmits(['close'])
// Component-local draft: seeded from `selected` each time the modal opens
// (including on initial mount if it's already open — see the `immediate`
// watcher below) and only reported back (via `close`) once it's dismissed —
// see AppNav.vue for how the emitted selection is applied.
const draft = ref(null)
const allFeedsCheckboxRef = ref(null)
function isChecked(title) {
return draft.value !== null && draft.value.includes(title)
}
function onToggleAll(event) {
if (event.target.checked) {
draft.value = null
} else {
// The selection is never allowed to be empty — "All feeds" is the
// fallback state, so it can't be unchecked without checking a feed first.
event.target.checked = true
}
}
function onToggleFeed(title, checked) {
if (checked) {
draft.value = draft.value === null ? [title] : [...draft.value, title]
} else {
const remaining = (draft.value ?? []).filter(t => t !== title)
// Unchecking the last selected feed falls back to "All feeds" — see
// onToggleAll, the selection is never allowed to be empty.
draft.value = remaining.length > 0 ? remaining : null
}
}
function handleClose() {
emit('close', draft.value)
}
function onKeydown(event) {
if (event.key === 'Escape') handleClose()
}
// While the modal is open, scrolling the page behind it would run through
// list view's IntersectionObserver and mark articles read out from under the
// user — lock it, and restore whatever it was before. `''` is a legitimate
// saved overflow value, so `isLocked` (not a null-check on the saved value)
// tracks whether there's actually anything to restore — otherwise unlocking
// on unmount without ever having locked would stomp a value this component
// never touched.
let previousBodyOverflow = ''
let isLocked = false
function lockBodyScroll() {
previousBodyOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
isLocked = true
}
function unlockBodyScroll() {
if (!isLocked) return
document.body.style.overflow = previousBodyOverflow
isLocked = false
}
watch(() => props.show, async (isOpen) => {
if (isOpen) {
draft.value = props.selected ? [...props.selected] : null
lockBodyScroll()
window.addEventListener('keydown', onKeydown)
await nextTick()
allFeedsCheckboxRef.value?.focus()
} else {
unlockBodyScroll()
window.removeEventListener('keydown', onKeydown)
}
}, { immediate: true })
onUnmounted(() => {
unlockBodyScroll()
window.removeEventListener('keydown', onKeydown)
})
</script>
<template>
<Transition name="modal">
<div v-if="show" class="modal-mask" @click.self="handleClose">
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="feed-filter-heading">
<div class="modal-header">
<h3 id="feed-filter-heading">Filter feeds</h3>
</div>
<div class="modal-body">
<label class="feed-filter__option">
<input
ref="allFeedsCheckboxRef"
type="checkbox"
:checked="draft === null"
@change="onToggleAll"
/>
All feeds
</label>
<label v-for="title in titles" :key="title" class="feed-filter__option">
<input
type="checkbox"
:checked="isChecked(title)"
@change="onToggleFeed(title, $event.target.checked)"
/>
{{ title }} ({{ counts[title] ?? 0 }})
</label>
</div>
<div class="modal-footer">
<button type="button" @click="handleClose">Done</button>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.feed-filter__option {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 44px;
cursor: pointer;
}
/* modal.css's global `input` rule (display: block, width: 100%, min-height:
44px, ...) is meant for the text inputs in AddUrl.vue's form — reset it
here so checkboxes don't render full-width and oversized. */
.feed-filter__option input[type='checkbox'] {
display: inline-block;
width: auto;
min-height: 0;
margin: 0;
padding: 0;
}
</style>
@@ -27,6 +27,7 @@ describe('AddUrl', () => {
expect.anything(),
)
expect(wrapper.text()).toContain('saved successfully')
expect(wrapper.emitted('added')).toHaveLength(1)
})
it('surfaces the error message when the request fails', async () => {
@@ -39,5 +40,17 @@ describe('AddUrl', () => {
await flushPromises()
expect(wrapper.text()).toContain('Network Error')
expect(wrapper.emitted('added')).toBeUndefined()
})
it('closes without submitting when Close is clicked, even with fields filled in', async () => {
const wrapper = mount(AddUrl, { props: { show: true } })
await wrapper.find('#url').setValue('https://example.test/feed.xml')
await wrapper.find('#title').setValue('Example feed')
await wrapper.find('.modal-default-button').trigger('click')
expect(axios.post).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toHaveLength(1)
})
})
@@ -0,0 +1,246 @@
import { describe, it, expect, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import FeedFilter from '../FeedFilter.vue'
describe('FeedFilter', () => {
const previousBodyOverflow = document.body.style.overflow
afterEach(() => {
document.body.style.overflow = previousBodyOverflow
})
it('renders nothing when show is false', () => {
const wrapper = mount(FeedFilter, {
props: { show: false, titles: ['Feed A'], counts: { 'Feed A': 2 }, selected: null },
})
expect(wrapper.find('.modal-mask').exists()).toBe(false)
})
it('renders "All feeds" plus one checkbox per title, defaulting a missing count to 0', () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A', 'Feed B'], counts: { 'Feed A': 2 }, selected: null },
})
const options = wrapper.findAll('.feed-filter__option')
expect(options).toHaveLength(3)
expect(options[0].text()).toBe('All feeds')
expect(options[1].text()).toBe('Feed A (2)')
expect(options[2].text()).toBe('Feed B (0)')
})
it('checks "All feeds" and leaves every feed unchecked (but enabled) when nothing is selected', () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: null },
})
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes[0].element.checked).toBe(true)
expect(checkboxes[1].element.checked).toBe(false)
expect(checkboxes[1].element.disabled).toBe(false)
expect(checkboxes[2].element.checked).toBe(false)
})
it('checking a feed unchecks "All feeds"', async () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: null },
})
const checkboxes = wrapper.findAll('input[type="checkbox"]')
await checkboxes[1].setValue(true)
expect(checkboxes[0].element.checked).toBe(false)
expect(checkboxes[1].element.checked).toBe(true)
expect(checkboxes[2].element.checked).toBe(false)
})
it('checking a second feed keeps both checked, with "All feeds" still unchecked', async () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
})
const checkboxes = wrapper.findAll('input[type="checkbox"]')
await checkboxes[2].setValue(true)
expect(checkboxes[0].element.checked).toBe(false)
expect(checkboxes[1].element.checked).toBe(true)
expect(checkboxes[2].element.checked).toBe(true)
})
it('unchecking the last selected feed falls back to "All feeds"', async () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
})
const checkboxes = wrapper.findAll('input[type="checkbox"]')
await checkboxes[1].setValue(false)
expect(checkboxes[0].element.checked).toBe(true)
expect(checkboxes[1].element.checked).toBe(false)
})
it('checking "All feeds" clears every selected feed', async () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A'], counts: {}, selected: ['Feed A'] },
})
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes[0].element.checked).toBe(false)
expect(checkboxes[1].element.checked).toBe(true)
await checkboxes[0].setValue(true)
expect(checkboxes[0].element.checked).toBe(true)
expect(checkboxes[1].element.checked).toBe(false)
})
it('unchecking "All feeds" while it is active leaves it checked — the selection is never empty', async () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A'], counts: {}, selected: null },
})
const allCheckbox = wrapper.findAll('input[type="checkbox"]')[0]
await allCheckbox.setValue(false)
expect(allCheckbox.element.checked).toBe(true)
})
it('emits close with the current draft when Done is clicked', async () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: null },
})
await wrapper.findAll('input[type="checkbox"]')[1].setValue(true)
await wrapper.find('.modal-footer button').trigger('click')
expect(wrapper.emitted('close')).toEqual([[['Feed A']]])
})
it('emits close with the current draft on Escape, but not again once closed', async () => {
const wrapper = mount(FeedFilter, {
props: { show: false, titles: ['Feed A'], counts: {}, selected: null },
})
await wrapper.setProps({ show: true })
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
expect(wrapper.emitted('close')).toEqual([[null]])
await wrapper.setProps({ show: false })
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('emits close on a backdrop click, but not on a click inside the modal container', async () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A'], counts: {}, selected: null },
})
await wrapper.find('.modal-header').trigger('click')
expect(wrapper.emitted('close')).toBeUndefined()
await wrapper.find('.modal-mask').trigger('click')
expect(wrapper.emitted('close')).toEqual([[null]])
})
it('reseeds the draft from `selected` on reopen, discarding an abandoned edit', async () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
})
// Edit the draft without confirming via Done/close.
await wrapper.findAll('input[type="checkbox"]')[2].setValue(true)
expect(wrapper.findAll('input[type="checkbox"]')[2].element.checked).toBe(true)
await wrapper.setProps({ show: false })
await wrapper.setProps({ show: true })
await nextTick()
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes[1].element.checked).toBe(true) // Feed A, from `selected`
expect(checkboxes[2].element.checked).toBe(false) // Feed B, abandoned edit dropped
})
it('locks body scroll while open and restores the prior value on close', async () => {
document.body.style.overflow = 'scroll'
const wrapper = mount(FeedFilter, {
props: { show: false, titles: [], counts: {}, selected: null },
})
await wrapper.setProps({ show: true })
expect(document.body.style.overflow).toBe('hidden')
await wrapper.setProps({ show: false })
expect(document.body.style.overflow).toBe('scroll')
})
it('restores the prior body scroll value on unmount while still open', async () => {
document.body.style.overflow = 'auto'
const wrapper = mount(FeedFilter, {
props: { show: false, titles: [], counts: {}, selected: null },
})
await wrapper.setProps({ show: true })
expect(document.body.style.overflow).toBe('hidden')
wrapper.unmount()
expect(document.body.style.overflow).toBe('auto')
})
it('focuses the "All feeds" checkbox after opening', async () => {
const wrapper = mount(FeedFilter, {
props: { show: false, titles: ['Feed A'], counts: {}, selected: null },
attachTo: document.body,
})
await wrapper.setProps({ show: true })
await nextTick()
await nextTick()
expect(document.activeElement).toBe(wrapper.findAll('input[type="checkbox"]')[0].element)
wrapper.unmount()
})
it('seeds the draft, locks body scroll, and wires Escape when mounted already open', async () => {
document.body.style.overflow = 'auto'
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
})
await nextTick()
expect(document.body.style.overflow).toBe('hidden')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes[1].element.checked).toBe(true) // Feed A, seeded from `selected`
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
expect(wrapper.emitted('close')).toEqual([[['Feed A']]])
wrapper.unmount()
})
it('leaves a pre-set body overflow untouched when mounted closed and then unmounted', () => {
document.body.style.overflow = 'scroll'
const wrapper = mount(FeedFilter, {
props: { show: false, titles: [], counts: {}, selected: null },
})
wrapper.unmount()
expect(document.body.style.overflow).toBe('scroll')
})
it('renders dialog accessibility attributes tied to the heading', () => {
const wrapper = mount(FeedFilter, {
props: { show: true, titles: ['Feed A'], counts: {}, selected: null },
})
const dialog = wrapper.find('.modal-container')
expect(dialog.attributes('role')).toBe('dialog')
expect(dialog.attributes('aria-modal')).toBe('true')
const labelledby = dialog.attributes('aria-labelledby')
expect(wrapper.find(`#${labelledby}`).text()).toBe('Filter feeds')
})
})
@@ -0,0 +1,85 @@
// loadPersistedFeedFilter() (see useFeeds.js) reads localStorage at module
// import time, not lazily — every case here has to vi.resetModules() and
// dynamically re-import so the module-scope ref actually re-initializes
// against the localStorage value set up beforehand. Mirrors the pattern in
// viewModeBoot.spec.js.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('axios')
class FakeIntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
describe('feedFilter boot persistence', () => {
beforeEach(() => {
vi.resetModules()
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
})
it('boots with the persisted array when it is valid', async () => {
localStorage.setItem('feedFilter', JSON.stringify(['Feed A', 'Feed B']))
const { useFeeds } = await import('../useFeeds')
const { feedFilter } = useFeeds()
expect(feedFilter.value).toEqual(['Feed A', 'Feed B'])
})
it('boots into "All feeds" when the key is absent', async () => {
const { useFeeds } = await import('../useFeeds')
const { feedFilter } = useFeeds()
expect(feedFilter.value).toBeNull()
})
it('boots into "All feeds" when the persisted array is empty', async () => {
localStorage.setItem('feedFilter', JSON.stringify([]))
const { useFeeds } = await import('../useFeeds')
const { feedFilter } = useFeeds()
expect(feedFilter.value).toBeNull()
})
it.each([
['a plain object', JSON.stringify({ title: 'Feed A' })],
['a bare string', JSON.stringify('Feed A')],
])('boots into "All feeds" when the persisted value is not an array (%s)', async (_label, raw) => {
localStorage.setItem('feedFilter', raw)
const { useFeeds } = await import('../useFeeds')
const { feedFilter } = useFeeds()
expect(feedFilter.value).toBeNull()
})
it('boots into "All feeds" when the persisted value is not valid JSON', async () => {
localStorage.setItem('feedFilter', 'not json')
const { useFeeds } = await import('../useFeeds')
const { feedFilter } = useFeeds()
expect(feedFilter.value).toBeNull()
})
it.each([
['numbers', [1]],
['null', [null]],
['a mix of strings and non-strings', ['a', 2]],
])('boots into "All feeds" when the array contains non-string elements (%s)', async (_label, value) => {
localStorage.setItem('feedFilter', JSON.stringify(value))
const { useFeeds } = await import('../useFeeds')
const { feedFilter } = useFeeds()
expect(feedFilter.value).toBeNull()
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,76 @@
// 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)
// The article shown right after reload must be marked read immediately —
// not only once the user pages forward/back at least once.
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/1', null, expect.anything())
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')
})
})
+508 -33
View File
@@ -1,20 +1,165 @@
import { ref, nextTick } from 'vue';
import { ref, computed, nextTick } from 'vue';
import axios from 'axios';
import { Readability } from '@mozilla/readability';
// Module-level state — declared outside useFeeds() so every caller shares the
// same singleton refs (a Pinia-free "store" for the feed list and its UI state).
const showMessage = ref(false)
// `allItems` is the full flattened+sorted stream from the last fetch (the
// master); `feeds` is the currently displayed projection of it (all items, or a
// single feed when a filter is active). Every view/observer consumer operates on
// `feeds` — the filter layer only ever swaps what `feeds` points at, so none of
// the index-based observer / currentIndex logic has to know a filter exists.
const allItems = ref([]);
const feeds = ref([]);
// Selected feed titles, or null = all feeds. Persisted in localStorage (see
// setFeedFilter) so the selection survives a reload.
function loadPersistedFeedFilter() {
const raw = localStorage.getItem('feedFilter')
if (!raw) return null
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) && parsed.length > 0 && parsed.every(title => typeof title === 'string') ? parsed : null
} catch {
return null
}
}
const feedFilter = ref(loadPersistedFeedFilter())
// Feed titles are specific to the account that was logged in — call this
// wherever a session ends (explicit logout, or a 401 dropping a stale/expired
// one in main.js) so they can't leak (in storage or in the still-live module
// singleton) into whichever account is active next on this device.
export function clearFeedFilter() {
localStorage.removeItem('feedFilter')
feedFilter.value = null
}
// Distinct feed titles present in the loaded (unread) items — the filter
// checkbox options. Every currently selected title is always kept in the
// list even once its feed runs out of unread items, so a checked feed never
// disappears out from under the user before they get a chance to uncheck it.
const feedTitles = computed(() => {
const titles = new Set(allItems.value.map(i => i.feedTitle))
for (const title of feedFilter.value ?? []) titles.add(title)
return [...titles].sort((a, b) => a.localeCompare(b))
})
// Unread count per feed title, shown next to each checkbox in the filter
// modal. 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(() => {
if (!feedFilter.value) return allItems.value.filter(i => !i.read).length
const titles = new Set(feedFilter.value)
return allItems.value.filter(i => !i.read && titles.has(i.feedTitle)).length
})
// AppNav renders this snapshot (refreshed via refreshUnreadDisplay, below)
// for the filter modal's per-feed checkbox counts, instead of binding
// feedUnreadCounts directly — by design, this stays frozen while paging
// through article view (markCurrentArticleRead marks one article read per
// page turn, which would otherwise recompute it live on every single swipe).
// The header's own unread badge stays bound to the live unreadCount (it
// should update in real time regardless of view). Everywhere else that marks
// articles read — list-view scrolling (handleIntersection), opening/changing
// the filter, syncing, mark-all-read, leaving article view — refreshes this
// snapshot immediately, so it only ever looks stale during article-view
// paging itself.
const displayedFeedUnreadCounts = ref({})
function refreshUnreadDisplay() {
displayedFeedUnreadCounts.value = feedUnreadCounts.value
}
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
const navTitleVisible = ref(true) // whether AppNav's "RSS Reader (N)" title is currently in view
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
// Count of fetchData()/sync() calls currently in flight — a refcount rather
// than a plain boolean for the same reason as pendingReadCounts below: sync()
// awaits its own trailing fetchData(), so one call nests inside the other, and
// fetchData() can also be invoked independently (RssFeeds.vue's boot reload)
// while a sync() elsewhere is still mid network round-trip. A bare boolean
// would have whichever call finishes first clear it out from under the other
// still-running one. Both rebuild allItems/feeds from the server, so the
// passive/paging mark-as-read paths — handleIntersection's scroll-marking and
// markCurrentArticleRead's display-marking — must not fire while any is in
// flight. markAllRead is a deliberate, already-confirmed action and is left
// ungated.
const activeSyncCount = ref(0)
function beginSync() {
activeSyncCount.value += 1
}
// Ids handleIntersection()/markCurrentArticleRead() couldn't mark while
// isSyncing (see below) — flushed once every in-flight fetchData()/sync()
// has finished, rather than left unread forever. Flushing by id (not by
// re-deriving an index) sidesteps fetchData() having replaced
// feeds.value/allItems.value wholesale in the meantime — the id is still
// valid, wherever the item now sits.
const pendingSuppressedReadIds = new Set()
function endSync() {
activeSyncCount.value -= 1
if (activeSyncCount.value === 0) {
flushPendingSuppressedReads()
}
}
const isSyncing = computed(() => activeSyncCount.value > 0)
function flushPendingSuppressedReads() {
if (pendingSuppressedReadIds.size === 0) return
const ids = [...pendingSuppressedReadIds]
pendingSuppressedReadIds.clear()
for (const id of ids) {
// feeds.value is always a filter()/slice() projection of allItems.value
// (see applyFilter), so every item in it is the same object reference as
// one already in allItems.value — one lookup is enough.
const feed = allItems.value.find(f => f.id === id)
if (feed) feed.read = true
markRead(id)
}
// A still-open article view marks in place and relies on dropReadArticles()
// running at the next leaveArticleView()/setFeedFilter() to clean up — same
// invariant markCurrentArticleRead() already keeps while paging, and
// removing one here could shift currentIndex out from under whatever's on
// screen. List view has no such upcoming transition to rely on (the user
// may just keep scrolling), so sweep these out now: this is exactly what a
// scroll-triggered mark would already have done immediately, had a sync not
// been in flight.
if (viewMode.value !== 'article') {
dropReadArticles()
}
refreshUnreadDisplay()
}
// 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() {
return {
@@ -37,6 +182,7 @@ export async function logout() {
}
localStorage.removeItem('user-token')
localStorage.removeItem('user-id')
clearFeedFilter()
}
// Some feeds (e.g. Deutsche Welle) ship <img> tags whose `src` and various
@@ -121,7 +267,40 @@ async function getReadable(feed, index) {
el.remove()
}
})
// Alpine.js widget overlays: x-cloak marks elements that should be hidden
// until Alpine.js initialises (prevents FOUC). These are always widget
// containers (e.g. taz's "taz schneller googeln" promo), never article
// content, so they're safe to remove unconditionally.
doc.querySelectorAll('[x-cloak]').forEach(el => el.remove())
// taz subscription promo blocks: a standalone <section> whose link(s) point
// to an /abo/ subscription page. Only climb to <section>, not <article>,
// to avoid accidentally removing the main article body.
doc.querySelectorAll('a[href*="/abo/"]').forEach(el => {
const container = el.closest('section')
if (container) container.remove()
})
// taz "Mehr zum Thema" related-articles teaser section.
doc.querySelectorAll('#articleTeaser').forEach(el => el.remove())
// taz subsidiary magazine promo blocks (e.g. taz FUTURZWEI): either the
// <article> itself or its direct <a> child carries an aria-label containing "Abo".
doc.querySelectorAll('article[aria-label*="Abo"]').forEach(el => {
const container = el.closest('section') ?? el
container.remove()
})
doc.querySelectorAll('article > a[aria-label*="Abo"]').forEach(el => {
const container = el.closest('section') ?? el.closest('article')
if (container) container.remove()
})
// Al Jazeera embeds a "Recommended Stories" section (a heading followed by
// a list of unrelated article teasers), marked up as <section class="more-on">.
// It's not part of the article, so strip it before Readability pulls it
// into the parsed content.
doc.querySelectorAll('section.more-on').forEach(el => el.remove())
const article = new Readability(doc).parse();
if (!article) {
showMessageForXSeconds('Could not extract readable content.', 5)
return
}
feeds.value[index].content = article.content;
feeds.value[index].readable = true;
} catch (error) {
@@ -130,49 +309,240 @@ async function getReadable(feed, index) {
}
}
// Ids with a mark-read PUT in flight (or being retried) — see markRead() and
// fetchData() below. A refcount rather than a plain Set: nextArticle()/
// prevArticle() can both re-mark the same article (page forward, back,
// forward again), so two overlapping markRead() calls for one id must both
// finish before fetchData() is allowed to trust the server's word on it —
// otherwise the first call to settle would clear the id out from under the
// second, still-in-flight one.
const pendingReadCounts = new Map()
function addPendingRead(id) {
pendingReadCounts.set(id, (pendingReadCounts.get(id) ?? 0) + 1)
}
function removePendingRead(id) {
const count = pendingReadCounts.get(id) ?? 0
if (count <= 1) {
pendingReadCounts.delete(id)
} else {
pendingReadCounts.set(id, count - 1)
}
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
// Short backoff between retries of a failed mark-read PUT — each entry is the
// wait before that retry attempt.
const MARK_READ_RETRY_DELAYS_MS = [300, 1000]
// Bounds how long a hung request (dead connection, captive portal — axios has
// no default timeout) can keep an id "pending": without this, a request that
// never settles would never leave pendingReadCounts, silently withholding
// that article from every fetchData() for the rest of the session.
const MARK_READ_TIMEOUT_MS = 10000
// 4xx responses (expired token, item not found/not owned — see
// src/reader/mark_read.rs) mean the request itself is wrong and won't
// succeed on retry; only a missing response (network error, timeout) or a
// server-side/rate-limit status is worth retrying.
function isRetryableMarkReadError(error) {
const status = error.response?.status
return status === undefined || status >= 500 || status === 429
}
// Resolves to true once the article is confirmed read server-side, or false
// once retries (if any) are exhausted / the error isn't retryable.
async function markRead(id) {
addPendingRead(id)
try {
const response = await axios.put("/api/v1/article/read/" + id, null, authHeaders())
console.log(response.status)
for (let attempt = 0; ; attempt++) {
try {
await axios.put("/api/v1/article/read/" + id, null, { ...authHeaders(), timeout: MARK_READ_TIMEOUT_MS })
return true
} catch (error) {
console.log(error)
console.error('Error marking article read:', error)
if (attempt >= MARK_READ_RETRY_DELAYS_MS.length || !isRetryableMarkReadError(error)) {
// Out of retries, or a retry can't help — this mark never committed
// server-side, so the item genuinely is still unread there. Surface
// it rather than letting it silently vanish from the local list now
// only to mysteriously reappear as unread later.
showMessageForXSeconds('Could not mark an article as read. It may reappear as unread.', 5)
return false
}
await delay(MARK_READ_RETRY_DELAYS_MS[attempt])
}
}
} finally {
removePendingRead(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() {
if (!feedFilter.value) {
feeds.value = allItems.value.slice()
return
}
const titles = new Set(feedFilter.value)
feeds.value = allItems.value.filter(i => titles.has(i.feedTitle))
}
// Article view marks an article read in place (feed.read = true) without
// removing it — see markCurrentArticleRead — so currentIndex stays valid while
// paging within that same list. Anything switches the list out from under
// that index (leaving article view, or re-filtering while still in it) has to
// drop those read-in-place articles from the master here, or they resurface:
// re-selecting their feed would otherwise re-project them from allItems as if
// still unread.
function dropReadArticles() {
// The feeds.value filter here is redundant for the setFeedFilter() caller
// (applyFilter() overwrites feeds.value again right after) but is what
// leaveArticleView() actually needs — kept shared rather than split into two
// near-identical branches.
feeds.value = feeds.value.filter(feed => !feed.read)
allItems.value = allItems.value.filter(feed => !feed.read)
}
// Order-insensitive: callers building the next selection from checkbox
// toggles (see FeedFilter.vue) can produce an array in a different order than
// what's already active — that's not a real change.
function sameFeedSelection(a, b) {
if (a === null || b === null) return a === b
if (a.length !== b.length) return false
const setA = new Set(a)
return b.every(title => setA.has(title))
}
// Shared by setFeedFilter() and pruneFeedFilter() — both assign feedFilter.value
// then need the same localStorage write/removal to keep it in sync.
function persistFeedFilter() {
if (feedFilter.value) {
localStorage.setItem('feedFilter', JSON.stringify(feedFilter.value))
} else {
localStorage.removeItem('feedFilter')
}
}
// 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.
// A no-op (order-insensitively unchanged) selection returns early, before any
// of the scrollTo/dropReadArticles/mark-read side effects below — every
// caller gets this protection for free rather than having to check first.
async function setFeedFilter(titles) {
const next = titles && titles.length > 0 ? titles : null // null for "All feeds"
if (sameFeedSelection(feedFilter.value, next)) return
disconnectObserver()
window.scrollTo(0, 0)
dropReadArticles()
feedFilter.value = next
persistFeedFilter()
currentIndex.value = 0
applyFilter()
// In article view the item at currentIndex is fully displayed, not just
// listed — unlike a plain currentIndex reset from paging, this one really is
// "shown" to the user, so mark it the same way toggleViewMode()/nextArticle()/
// prevArticle() do. List view only shows titles here; its own read-marking
// happens via scroll (handleIntersection), so this must not fire there.
if (viewMode.value === 'article') markCurrentArticleRead()
refreshUnreadDisplay()
await nextTick()
setupIntersectionObserver()
}
// A persisted/selected title can outlive its feed (deleted or renamed from
// Admin) — the modal then has no way to un-list a title the user never got a
// chance to see and uncheck. `allItems` can't tell "the feed still exists"
// from "the feed has 0 unread items right now" (feedTitles deliberately keeps
// the latter listed), so it's not a usable source of truth here; the feed
// list itself is, and AdminFeeds.vue already fetches exactly that from this
// same endpoint. Only requested when a filter is actually active, since
// that's the only case with anything to prune.
async function pruneFeedFilter() {
if (!feedFilter.value) return
try {
const user_id = localStorage.getItem("user-id")
const response = await axios.get("/api/v1/article/feeds/" + user_id, authHeaders())
// The filter may have been cleared (logout / 401) while the request was in flight.
if (!feedFilter.value) return
const existingTitles = new Set(response.data.feeds.map(feed => feed.title))
const pruned = feedFilter.value.filter(title => existingTitles.has(title))
if (pruned.length === feedFilter.value.length) return
feedFilter.value = pruned.length > 0 ? pruned : null
persistFeedFilter()
applyFilter()
} catch (error) {
// Best-effort: a stale title stuck in the modal until the next successful
// prune is harmless, so don't fail the reload over it.
console.error('Error pruning feed filter', error)
}
}
const fetchData = async () => {
const user_id = localStorage.getItem("user-id")
try {
beginSync()
const user_id = localStorage.getItem("user-id")
// Snapshot ids pending *before* the GET goes out, not just when its
// response comes back. A markRead() PUT that commits while this GET is
// in flight is invisible to the check below (its id has already been
// cleared by the time the response arrives) even though the server read
// the DB before that commit and so still reported the item unread — this
// snapshot catches that case too.
const pendingBeforeFetch = new Set(pendingReadCounts.keys())
const response = await axios.get("/api/v1/article/get/" + user_id, authHeaders());
const items = [];
response.data.feeds.forEach(feed => {
feed.items.forEach(item => items.push({ ...item, feedTitle: feed.title }));
});
// An item pending at either end of this request may not have committed
// server-side yet, so the server can still report it unread here. Drop it
// from this snapshot too — otherwise a concurrent background sync's
// refetch (see sync()) would resurrect it as unread out from under the
// user while markRead() is still confirming or retrying it.
const freshItems = items.filter(item => !pendingBeforeFetch.has(item.id) && !pendingReadCounts.has(item.id));
// timestamps are zero-padded "YYYY-MM-DD HH:MM:SS" strings, so a plain
// lexicographic comparison sorts them chronologically.
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
feeds.value = items;
freshItems.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
allItems.value = freshItems;
applyFilter();
await pruneFeedFilter();
refreshUnreadDisplay();
await nextTick();
setupIntersectionObserver();
} catch (error) {
console.error('Error fetching data:', error)
showMessageForXSeconds(error, 5)
} finally {
endSync()
}
};
async function sync() {
async function sync(silent = false) {
beginSync()
try {
const response = await axios.post('/api/v1/article/sync', {
user_id: parseInt(localStorage.getItem("user-id"))
}, authHeaders())
if (response.status == 200) {
if (response.status == 200 && !silent) {
showMessageForXSeconds('Sync successful.', 5)
}
fetchData();
await fetchData();
} catch (error) {
console.error('Error sync', error)
if (!silent) {
showMessageForXSeconds(error, 5)
}
} finally {
endSync()
}
}
function setupIntersectionObserver() {
@@ -183,7 +553,7 @@ function setupIntersectionObserver() {
// The sticky topbar overlays the top of the viewport, so an article fully
// hidden behind it should already count as "scrolled past" — shrink the
// observer's root by that height so it stops intersecting at that point.
const topbarHeight = document.querySelector('.list-topbar')?.getBoundingClientRect().height ?? 0;
const topbarHeight = document.querySelector('.app-nav')?.getBoundingClientRect().height ?? 0;
observer = new IntersectionObserver((entries) => handleIntersection(entries, topbarHeight), {
root: null, // Use the viewport as the root
@@ -192,20 +562,27 @@ function setupIntersectionObserver() {
});
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 => {
observer.observe(observedDiv);
})
}
}
async function handleIntersection(entries, topbarHeight = 0) {
// An article that has scrolled past the (possibly sticky-bar-shrunk) top
// edge of the viewport (not intersecting, bounding box above that edge)
// has been read. Resolve all affected feeds up front, before any removal —
// splicing `feeds` while iterating would shift the array indices that later
// entries' `target.id` refer to, causing the wrong item to be marked read
// and removed.
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
.filter(entry => initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < topbarHeight)
.map(entry => feeds.value[entry.target.id])
@@ -213,13 +590,63 @@ async function handleIntersection(entries, topbarHeight = 0) {
if (readFeeds.length === 0) return
if (isSyncing.value) {
// Don't mutate a list that's mid-reload — queue these ids instead of
// dropping them; flushPendingSuppressedReads() marks (and removes) them
// once every in-flight fetchData()/sync() has finished. Nothing here
// touched the DOM, so there's no observer disconnect/reconnect to do.
for (const feed of readFeeds) {
await markRead(feed.id)
pendingSuppressedReadIds.add(feed.id)
}
return
}
// Disconnect before the DOM mutation. In card layout the cards are short
// enough that the shift caused by removing one can push the next card above
// the header, which the observer would immediately treat as another read —
// cascading until many articles disappear at once.
if (observer) {
observer.disconnect()
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))
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
document.getElementById(0)?.scrollIntoView()
// Mirror into the master so cleared filters don't resurrect read articles.
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
refreshUnreadDisplay()
for (const feed of readFeeds) {
markRead(feed.id)
}
nextTick().then(() => {
// If scroll anchoring didn't compensate for the removed content (common
// with position:fixed headers and overflow-x:hidden on body), the first
// remaining article will have drifted above the header. Correct the scroll
// position so it sits exactly at the header bottom before reconnecting —
// otherwise the initial observation would immediately mark everything above
// the topbar as read and cascade until the list is empty.
const first = document.querySelector('.observe')
if (first) {
const top = first.getBoundingClientRect().top
if (top < topbarHeight) {
markProgrammaticScroll()
window.scrollBy(0, top - topbarHeight)
}
}
setupIntersectionObserver()
})
}
function disconnectObserver() {
if (observer) {
observer.disconnect()
observer = null
}
}
function setInitialLoad(value) {
@@ -231,31 +658,53 @@ async function markAllRead() {
if (!window.confirm('Mark all articles as read?')) return
const ids = feeds.value.map(feed => feed.id)
const readIds = new Set(ids)
feeds.value = []
// markAllRead operates on the visible subset (only the filtered feed, if one
// is active) — drop exactly those from the master too.
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
currentIndex.value = 0
// markRead swallows its own errors, so Promise.all can't reject here.
await Promise.all(ids.map(id => markRead(id)))
refreshUnreadDisplay()
// markRead() resolves to true/false rather than rejecting, so Promise.all
// can't reject here — but a false means that article is still unread
// server-side, which the message below must not contradict.
const results = await Promise.all(ids.map(id => markRead(id)))
const failedCount = results.filter(ok => !ok).length
if (failedCount === 0) {
showMessageForXSeconds('All articles marked as read.', 5)
} else {
// markRead() already surfaced each individual failure — this summarizes
// the batch outcome instead of claiming full success over it.
showMessageForXSeconds(`Marked ${ids.length - failedCount} of ${ids.length} as read; ${failedCount} failed and may reappear.`, 5)
}
}
function markCurrentArticleRead() {
const feed = feeds.value[currentIndex.value]
if (!feed) return
// Don't mark against a list that's mid-reload — see isSyncing/activeSyncCount.
// Queue this article's id instead of dropping it: flushPendingSuppressedReads()
// marks it once every in-flight fetchData()/sync() has finished.
if (isSyncing.value) {
pendingSuppressedReadIds.add(feed.id)
return
}
// Marking read here (rather than via removeFeed, as the scroll-based list
// view does) keeps the array stable so currentIndex stays valid while paging.
// The local `read` flag lets leaveArticleView() drop these once we're done.
if (feed) {
feed.read = true
markRead(feed.id)
}
}
async function leaveArticleView() {
// Articles paged past in article view were marked read but deliberately kept
// in place so currentIndex stayed valid — drop them now so they don't keep
// showing up in the list view.
feeds.value = feeds.value.filter(feed => !feed.read)
dropReadArticles()
currentIndex.value = 0
viewMode.value = 'list'
localStorage.setItem('viewMode', viewMode.value)
refreshUnreadDisplay()
// 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.
@@ -267,15 +716,30 @@ function toggleViewMode() {
if (viewMode.value === 'article') {
leaveArticleView()
} else {
// Disconnect first: the v-if switch is about to unmount all .observe
// elements, which would otherwise fire intersection callbacks reporting
// them as no-longer-intersecting and mark every visible article read.
if (observer) {
observer.disconnect()
observer = null
}
viewMode.value = 'article'
localStorage.setItem('viewMode', viewMode.value)
currentIndex.value = 0
markCurrentArticleRead()
}
}
function toggleLayout() {
async function toggleLayout() {
if (observer) {
observer.disconnect()
observer = null
}
window.scrollTo(0, 0)
layout.value = layout.value === 'list' ? 'cards' : 'list'
localStorage.setItem('layout', layout.value)
await nextTick()
setupIntersectionObserver()
}
function nextArticle() {
@@ -297,16 +761,23 @@ function prevArticle() {
export function useFeeds() {
return {
feeds,
allItems,
feedFilter,
feedTitles,
feedUnreadCounts,
unreadCount,
displayedFeedUnreadCounts,
refreshUnreadDisplay,
setFeedFilter,
showMessage,
message,
showModal,
viewMode,
currentIndex,
toggleViewMode,
leaveArticleView,
markCurrentArticleRead,
layout,
toggleLayout,
navTitleVisible,
nextArticle,
prevArticle,
fetchData,
@@ -316,7 +787,11 @@ export function useFeeds() {
markAllRead,
showMessageForXSeconds,
setupIntersectionObserver,
disconnectObserver,
setInitialLoad,
handleIntersection,
lastProgrammaticScroll,
markProgrammaticScroll,
isSyncing,
}
}
+110
View File
@@ -0,0 +1,110 @@
import { ref } from 'vue'
const HEADLINE_FONT_OPTIONS = [
{ key: 'default', label: 'Default (Glook)', value: "Glook, 'Courier New'" },
{ key: 'playfair', label: 'Playfair Display', value: "'Playfair Display', Georgia, serif" },
{ key: 'lora', label: 'Lora', value: "Lora, Georgia, serif" },
{ key: 'raleway', label: 'Raleway', value: "Raleway, -apple-system, sans-serif" },
{ key: 'inter', label: 'Inter', value: "Inter, -apple-system, sans-serif" },
]
const CONTENT_FONT_OPTIONS = [
{ key: 'default', label: 'Default (Merriweather)', value: "Merriweather, Georgia, 'Times New Roman', Times, serif" },
{ key: 'lora', label: 'Lora', value: "Lora, Georgia, serif" },
{ key: 'source-serif', label: 'Source Serif 4', value: "'Source Serif 4', Georgia, serif" },
{ key: 'inter', label: 'Inter', value: "Inter, -apple-system, sans-serif" },
{ key: 'playfair', label: 'Playfair Display', value: "'Playfair Display', Georgia, serif" },
]
const SIZE_STEPS = [0.85, 1, 1.2, 1.45]
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 contentSizeScale = ref(parseFloat(localStorage.getItem('s-content-size') ?? '1'))
const headlineFontKey = ref(localStorage.getItem('s-headline-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) {
return (options.find(o => o.key === key) ?? options[0]).value
}
function applySettings() {
const s = document.documentElement.style
s.setProperty('--headline-font-size-scale', headlineSizeScale.value)
s.setProperty('--content-font-size-scale', contentSizeScale.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-text-align', textAlignKey.value)
s.setProperty('--content-padding', contentPadding.value + 'rem')
}
function setHeadlineSize(scale) {
headlineSizeScale.value = scale
localStorage.setItem('s-headline-size', scale)
applySettings()
}
function setContentSize(scale) {
contentSizeScale.value = scale
localStorage.setItem('s-content-size', scale)
applySettings()
}
function setHeadlineFont(key) {
headlineFontKey.value = key
localStorage.setItem('s-headline-font', key)
applySettings()
}
function setContentFont(key) {
contentFontKey.value = key
localStorage.setItem('s-content-font', key)
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() {
return {
headlineSizeScale,
contentSizeScale,
headlineFontKey,
contentFontKey,
SIZE_STEPS,
SIZE_LABELS,
HEADLINE_FONT_OPTIONS,
CONTENT_FONT_OPTIONS,
TEXT_ALIGN_OPTIONS,
PADDING_STEPS,
PADDING_LABELS,
applySettings,
setHeadlineSize,
setContentSize,
setHeadlineFont,
setContentFont,
setTextAlign,
setContentPadding,
textAlignKey,
contentPadding,
}
}
+23
View File
@@ -0,0 +1,23 @@
import router from './router'
import { clearFeedFilter } from './composables/useFeeds'
// Extracted out of main.js so it's unit-testable in isolation: importing
// main.js itself boots the whole app (createApp(...).mount('#app')) and
// eagerly touches the router's lazy-loaded route components, neither of
// which a test can do safely.
//
// A 401 means the server has rejected the token (missing, expired, or
// revoked via logout/token_version bump elsewhere). Drop the stale session
// and send the user back to login rather than leaving them on a page where
// every request silently fails.
export function handleUnauthorizedResponse(error) {
if (error.response?.status === 401) {
localStorage.removeItem('user-token')
localStorage.removeItem('user-id')
clearFeedFilter()
if (router.currentRoute.value.name !== 'login') {
router.push({ name: 'login' })
}
}
return Promise.reject(error)
}
+2 -14
View File
@@ -4,23 +4,11 @@ import axios from 'axios'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { handleUnauthorizedResponse } from './httpAuthInterceptor'
// A 401 means the server has rejected the token (missing, expired, or
// revoked via logout/token_version bump elsewhere). Drop the stale session
// and send the user back to login rather than leaving them on a page where
// every request silently fails.
axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('user-token')
localStorage.removeItem('user-id')
if (router.currentRoute.value.name !== 'login') {
router.push({ name: 'login' })
}
}
return Promise.reject(error)
}
handleUnauthorizedResponse
)
const app = createApp(App)
+8 -17
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior: () => ({ top: 0, behavior: 'instant' }),
routes: [
{
path: '/',
@@ -30,23 +31,13 @@ const router = createRouter({
]
})
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth) {
let isAuthenticated = false;
if (localStorage.getItem("user-token") != null){
isAuthenticated = true;
}
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();
router.beforeEach((to) => {
const isAuthenticated = localStorage.getItem("user-token") != null;
// Redirect unauthenticated users hitting a protected route to login;
// returning a value (instead of the deprecated next() callback) is the
// modern vue-router guard API. Returning nothing lets navigation proceed.
if (to.meta.requiresAuth && !isAuthenticated) {
return '/login';
}
});
export default router
+2
View File
@@ -1,9 +1,11 @@
<script setup>
import AdminFeeds from '../components/AdminFeeds.vue'
import AdminSettings from '../components/AdminSettings.vue'
</script>
<template>
<main>
<AdminSettings />
<AdminFeeds />
</main>
</template>