12 Commits

Author SHA1 Message Date
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
10 changed files with 218 additions and 156 deletions
+1
View File
@@ -3,3 +3,4 @@
.claude .claude
CLAUDE.md CLAUDE.md
LEARNINGS.md LEARNINGS.md
PLAN.md
+4 -2
View File
@@ -7,7 +7,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN cargo build --release RUN cargo build --release && \
cp target/release/rss-reader /usr/local/bin/rss-reader && \
rm -rf target
# --- runtime --- # --- runtime ---
FROM debian:bookworm-slim FROM debian:bookworm-slim
@@ -16,7 +18,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 ca-certificates \ libpq5 ca-certificates \
&& rm -rf /var/lib/apt/lists/* && 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 EXPOSE 8001
CMD ["rss-reader"] 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 # stop everything (keeps the postgres_data volume)
docker compose down -v # stop and wipe all data — careful! docker compose down -v # stop and wipe all data — careful!
docker compose up --build -d # rebuild after pulling code changes 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 ### 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. 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 ```sh
docker compose up --build -d 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`): **6. Firewall** (run as your normal sudo-capable user — not `rss-svc`):
+1 -1
View File
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
use sha2::Sha256; use sha2::Sha256;
/// How long a freshly issued token remains valid for. /// 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 struct JwtToken {
pub user_id: i32, pub user_id: i32,
+5 -3
View File
@@ -4,6 +4,7 @@
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
padding: 0.5rem; padding: 0.5rem;
padding-top: var(--app-nav-height, 4.5rem);
font-weight: normal; font-weight: normal;
} }
@@ -68,8 +69,8 @@ a,
.feed-title { .feed-title {
cursor: pointer; cursor: pointer;
font-family: 'Courier New'; font-family: Glook, 'Courier New';
font-size: clamp(1.25rem, 4.5vw, 1.6rem); font-size: clamp(1.4rem, 5vw, 2rem);
font-weight: bold; font-weight: bold;
color: var(--color-accent-2); color: var(--color-accent-2);
border-bottom: 1px solid #ccc; border-bottom: 1px solid #ccc;
@@ -83,7 +84,7 @@ a,
} }
.feed-content { .feed-content {
font-family: Georgia, 'Times New Roman', Times, serif; font-family: Merriweather, Georgia, 'Times New Roman', Times, serif;
font-size: clamp(1rem, 3.5vw, 1.25rem); font-size: clamp(1rem, 3.5vw, 1.25rem);
padding: 0 1em 1em; padding: 0 1em 1em;
overflow-wrap: break-word; overflow-wrap: break-word;
@@ -111,5 +112,6 @@ h3 {
@media (min-width: 768px) { @media (min-width: 768px) {
#app { #app {
padding: 0.75rem; padding: 0.75rem;
padding-top: var(--app-nav-height, 4.5rem);
} }
} }
+13 -15
View File
@@ -1,25 +1,18 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router' import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds' import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
import Modal from './modal/AddUrl.vue' import Modal from './modal/AddUrl.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds, navTitleVisible } = useFeeds() const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds } = useFeeds()
const titleRef = ref(null) const headerRef = ref(null)
let titleObserver
onMounted(() => { onMounted(() => {
titleObserver = new IntersectionObserver(([entry]) => { const h = headerRef.value?.getBoundingClientRect().height ?? 0
navTitleVisible.value = entry.isIntersecting document.documentElement.style.setProperty('--app-nav-height', `${h}px`)
})
titleObserver.observe(titleRef.value)
})
onUnmounted(() => {
titleObserver?.disconnect()
}) })
const onFeedsPage = computed(() => route.path === '/feeds') const onFeedsPage = computed(() => route.path === '/feeds')
@@ -69,9 +62,9 @@ function handleToggleLayout() {
</script> </script>
<template> <template>
<header class="app-nav"> <header ref="headerRef" class="app-nav">
<div class="app-nav__wrapper"> <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>
<button <button
class="app-nav__hamburger" class="app-nav__hamburger"
type="button" type="button"
@@ -124,7 +117,12 @@ function handleToggleLayout() {
<style scoped> <style scoped>
.app-nav { .app-nav {
position: relative; position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: var(--color-background);
} }
.app-nav__wrapper { .app-nav__wrapper {
+76 -105
View File
@@ -8,15 +8,14 @@ const {
message, message,
viewMode, viewMode,
currentIndex, currentIndex,
leaveArticleView,
layout, layout,
nextArticle, nextArticle,
prevArticle, prevArticle,
fetchData, fetchData,
sync,
getReadable, getReadable,
setInitialLoad, setInitialLoad,
showMessageForXSeconds, showMessageForXSeconds,
navTitleVisible,
} = useFeeds() } = useFeeds()
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length) const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
@@ -41,7 +40,7 @@ function scrollToNextArticle() {
const SMALL_IMAGE_THRESHOLD = 200 const SMALL_IMAGE_THRESHOLD = 200
function markSmallImages() { 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 = () => { const checkSize = () => {
if (img.naturalWidth && img.naturalWidth <= SMALL_IMAGE_THRESHOLD) { if (img.naturalWidth && img.naturalWidth <= SMALL_IMAGE_THRESHOLD) {
img.classList.add('article-feature__image--small') img.classList.add('article-feature__image--small')
@@ -60,6 +59,12 @@ watch(() => feeds.value[currentIndex.value]?.content, async () => {
markSmallImages() markSmallImages()
}) })
async function loadReadable(feed, index) {
await getReadable(feed, index)
await nextTick()
markSmallImages()
}
async function shareUrl(url) { async function shareUrl(url) {
if (navigator.share) { if (navigator.share) {
await navigator.share({ url }) await navigator.share({ url })
@@ -72,6 +77,7 @@ async function shareUrl(url) {
onMounted(async () => { onMounted(async () => {
setInitialLoad(false) setInitialLoad(false)
await fetchData() await fetchData()
sync(true)
setTimeout(function () { setTimeout(function () {
setInitialLoad(true) setInitialLoad(true)
console.log('set to true') console.log('set to true')
@@ -84,10 +90,6 @@ onMounted(async () => {
<div v-if="showMessage" class="message">{{ message }}</div> <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="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"> <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"> <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"/> <circle cx="12" cy="12" r="10"/>
@@ -95,29 +97,30 @@ onMounted(async () => {
</svg> </svg>
<p class="empty-state__label">All caught up</p> <p class="empty-state__label">All caught up</p>
</div> </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"> <div v-bind:id="index" class="observe">
<p class="feed-source">{{ feed.feedTitle }}</p> <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> <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> <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"> <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> <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> </button>
</p> </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> </div>
</template> </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>
<div v-else class="article-single"> <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-if="feeds.length == 0" class="empty-state"> <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"> <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"/> <circle cx="12" cy="12" r="10"/>
@@ -128,9 +131,9 @@ onMounted(async () => {
<template v-else> <template v-else>
<article class="article-feature"> <article class="article-feature">
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p> <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> <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> <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> <button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
</p> </p>
@@ -159,48 +162,15 @@ onMounted(async () => {
</template> </template>
<style scoped> <style scoped>
.list-topbar { .list-skip-btn {
position: sticky; position: fixed;
top: 0; right: 1rem;
z-index: 10; bottom: 1.5rem;
display: flex; z-index: 20;
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);
} }
.observe { .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 /* Plain vertical stack of bordered "cards" — deliberately not flex/grid, and
@@ -256,6 +226,38 @@ onMounted(async () => {
width: auto; 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 { .feed-original-link {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -303,52 +305,6 @@ onMounted(async () => {
padding-bottom: 5rem; 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;
}
.article-feature { .article-feature {
width: 100%; width: 100%;
max-width: 720px; max-width: 720px;
@@ -369,7 +325,7 @@ onMounted(async () => {
margin: 0; margin: 0;
padding: 0 1rem; padding: 0 1rem;
font-family: 'Courier New'; font-family: 'Courier New';
font-size: clamp(1.75rem, 6vw, 2.75rem); font-size: clamp(1.4rem, 5vw, 2rem);
font-weight: bold; font-weight: bold;
line-height: 1.15; line-height: 1.15;
color: var(--color-accent-2); color: var(--color-accent-2);
@@ -440,6 +396,21 @@ onMounted(async () => {
transform: none; 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) { .article-feature__content :deep(a) {
color: var(--color-accent); color: var(--color-accent);
text-decoration-color: var(--color-accent-hover); text-decoration-color: var(--color-accent-hover);
@@ -7,6 +7,15 @@ import { useFeeds } from '../../composables/useFeeds'
vi.mock('axios') 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', () => { describe('AppNav', () => {
let router let router
+27 -13
View File
@@ -119,7 +119,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() const { layout } = useFeeds()
layout.value = 'cards' layout.value = 'cards'
@@ -177,10 +184,7 @@ describe('RssFeeds', () => {
expect(titles).toEqual(['Newer article', 'Older article']) expect(titles).toEqual(['Newer article', 'Older article'])
}) })
it('shows a link to the original article until the readable version is loaded', async () => { it('keeps a link to the original article visible after 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.
axios.get.mockResolvedValueOnce({ axios.get.mockResolvedValueOnce({
data: { data: {
feeds: [ feeds: [
@@ -199,7 +203,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) const wrapper = mount(RssFeeds)
await flushPromises() await flushPromises()
@@ -212,7 +223,9 @@ describe('RssFeeds', () => {
await wrapper.find('.feed-title').trigger('click') await wrapper.find('.feed-title').trigger('click')
await flushPromises() 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 () => { it('switches to article view and navigates between articles', async () => {
@@ -252,30 +265,31 @@ describe('RssFeeds', () => {
useFeeds().toggleViewMode() useFeeds().toggleViewMode()
await flushPromises() 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 // Same as in list view: the readable content is loaded on demand by
// clicking the headline, not fetched automatically on entering the view. // 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) 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() await flushPromises()
expect(axios.post).toHaveBeenCalledWith('/api/v1/article/read', { url: 'https://example.test/1' }, expect.anything()) 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() expect(wrapper.findAll('.article-nav__btn')[0].attributes('disabled')).toBeDefined()
await wrapper.findAll('.article-nav__btn')[1].trigger('click') await wrapper.findAll('.article-nav__btn')[1].trigger('click')
await flushPromises() 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() expect(wrapper.findAll('.article-nav__btn')[1].attributes('disabled')).toBeDefined()
await wrapper.findAll('.article-nav__btn')[0].trigger('click') await wrapper.findAll('.article-nav__btn')[0].trigger('click')
await flushPromises() 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 () => { it('drops articles read while paging through article view once back in the list', async () => {
+75 -16
View File
@@ -11,7 +11,6 @@ const showModal = ref(false)
const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu
const currentIndex = ref(0) const currentIndex = ref(0)
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu
const navTitleVisible = ref(true) // whether AppNav's "RSS Reader (N)" title is currently in view
let observer; // Declare observer outside the setup function let observer; // Declare observer outside the setup function
let initialLoad = false let initialLoad = false
@@ -121,7 +120,31 @@ async function getReadable(feed, index) {
el.remove() 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): the promo
// <article> carries an aria-label containing "Abo".
doc.querySelectorAll('article[aria-label*="Abo"]').forEach(el => {
const container = el.closest('section') ?? el
container.remove()
})
const article = new Readability(doc).parse(); 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].content = article.content;
feeds.value[index].readable = true; feeds.value[index].readable = true;
} catch (error) { } catch (error) {
@@ -159,21 +182,23 @@ const fetchData = async () => {
} }
}; };
async function sync() { async function sync(silent = false) {
try { try {
const response = await axios.post('/api/v1/article/sync', { const response = await axios.post('/api/v1/article/sync', {
user_id: parseInt(localStorage.getItem("user-id")) user_id: parseInt(localStorage.getItem("user-id"))
}, authHeaders()) }, authHeaders())
if (response.status == 200) { if (response.status == 200 && !silent) {
showMessageForXSeconds('Sync successful.', 5) showMessageForXSeconds('Sync successful.', 5)
} }
fetchData(); fetchData();
} catch (error) { } catch (error) {
console.error('Error sync', error) console.error('Error sync', error)
if (!silent) {
showMessageForXSeconds(error, 5) showMessageForXSeconds(error, 5)
} }
} }
}
function setupIntersectionObserver() { function setupIntersectionObserver() {
if (observer) { if (observer) {
@@ -183,7 +208,7 @@ function setupIntersectionObserver() {
// The sticky topbar overlays the top of the viewport, so an article fully // The sticky topbar overlays the top of the viewport, so an article fully
// hidden behind it should already count as "scrolled past" — shrink the // hidden behind it should already count as "scrolled past" — shrink the
// observer's root by that height so it stops intersecting at that point. // 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), { observer = new IntersectionObserver((entries) => handleIntersection(entries, topbarHeight), {
root: null, // Use the viewport as the root root: null, // Use the viewport as the root
@@ -199,13 +224,9 @@ function setupIntersectionObserver() {
} }
} }
async function handleIntersection(entries, topbarHeight = 0) { function handleIntersection(entries, topbarHeight = 0) {
// An article that has scrolled past the (possibly sticky-bar-shrunk) top // Resolve all affected feeds before touching feeds.value — the target.id
// edge of the viewport (not intersecting, bounding box above that edge) // indices are render-time positions that shift once we splice the array.
// 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.
const readFeeds = entries const readFeeds = entries
.filter(entry => initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < topbarHeight) .filter(entry => initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < topbarHeight)
.map(entry => feeds.value[entry.target.id]) .map(entry => feeds.value[entry.target.id])
@@ -213,13 +234,38 @@ async function handleIntersection(entries, topbarHeight = 0) {
if (readFeeds.length === 0) return if (readFeeds.length === 0) return
for (const feed of readFeeds) { // Disconnect before the DOM mutation. In card layout the cards are short
await markRead(feed.id) // 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
} }
const readIds = new Set(readFeeds.map(feed => feed.id)) const readIds = new Set(readFeeds.map(feed => feed.id))
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id)) feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
document.getElementById(0)?.scrollIntoView()
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) {
window.scrollBy(0, top - topbarHeight)
}
}
setupIntersectionObserver()
})
} }
function setInitialLoad(value) { function setInitialLoad(value) {
@@ -267,15 +313,29 @@ function toggleViewMode() {
if (viewMode.value === 'article') { if (viewMode.value === 'article') {
leaveArticleView() leaveArticleView()
} else { } 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' viewMode.value = 'article'
currentIndex.value = 0 currentIndex.value = 0
markCurrentArticleRead() markCurrentArticleRead()
} }
} }
function toggleLayout() { async function toggleLayout() {
if (observer) {
observer.disconnect()
observer = null
}
window.scrollTo(0, 0)
layout.value = layout.value === 'list' ? 'cards' : 'list' layout.value = layout.value === 'list' ? 'cards' : 'list'
localStorage.setItem('layout', layout.value) localStorage.setItem('layout', layout.value)
await nextTick()
setupIntersectionObserver()
} }
function nextArticle() { function nextArticle() {
@@ -306,7 +366,6 @@ export function useFeeds() {
leaveArticleView, leaveArticleView,
layout, layout,
toggleLayout, toggleLayout,
navTitleVisible,
nextArticle, nextArticle,
prevArticle, prevArticle,
fetchData, fetchData,