2 Commits
Author SHA1 Message Date
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
9 changed files with 241 additions and 39 deletions
+29
View File
@@ -1,7 +1,9 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import axios from 'axios' import axios from 'axios'
import Modal from './modal/AddUrl.vue'
const showModal = ref(false)
const feeds = ref([]) const feeds = ref([])
const error = ref('') const error = ref('')
@@ -40,6 +42,7 @@ onMounted(loadFeeds)
<template> <template>
<div class="admin"> <div class="admin">
<h1 class="admin__heading">Admin</h1> <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-if="error" class="admin__error">{{ error }}</p>
<p v-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p> <p v-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p>
<ul v-else class="admin__list"> <ul v-else class="admin__list">
@@ -51,6 +54,14 @@ onMounted(loadFeeds)
<button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button> <button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button>
</li> </li>
</ul> </ul>
<Teleport to="body">
<Modal :show="showModal" @close="showModal = false" @added="loadFeeds">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</Modal>
</Teleport>
</div> </div>
</template> </template>
@@ -67,6 +78,24 @@ onMounted(loadFeeds)
margin-bottom: 1.25rem; 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__error,
.admin__empty { .admin__empty {
opacity: 0.6; opacity: 0.6;
+1 -16
View File
@@ -2,11 +2,10 @@
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router' import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds' import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
import Modal from './modal/AddUrl.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll } = useFeeds() const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll } = useFeeds()
const headerRef = ref(null) const headerRef = ref(null)
@@ -147,11 +146,6 @@ function handleMarkAllRead() {
closeMenu() closeMenu()
} }
function openAddModal() {
showModal.value = true
closeMenu()
}
function handleToggleViewMode() { function handleToggleViewMode() {
toggleViewMode() toggleViewMode()
closeMenu() closeMenu()
@@ -212,20 +206,11 @@ function handleToggleLayout() {
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button> <button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
</template> </template>
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button> <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> <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> <button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
</div> </div>
</nav> </nav>
</Transition> </Transition>
<Teleport to="body">
<Modal :show="showModal" @close="showModal = false">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</Modal>
</Teleport>
</header> </header>
</template> </template>
@@ -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')
})
})
+1 -12
View File
@@ -25,7 +25,7 @@ describe('AppNav', () => {
localStorage.setItem('user-id', '7') localStorage.setItem('user-id', '7')
vi.clearAllMocks() vi.clearAllMocks()
const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds() const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, viewMode, currentIndex, layout } = useFeeds()
feeds.value = [] feeds.value = []
allItems.value = [] allItems.value = []
feedFilter.value = null feedFilter.value = null
@@ -34,7 +34,6 @@ describe('AppNav', () => {
lastProgrammaticScroll.value = 0 lastProgrammaticScroll.value = 0
showMessage.value = false showMessage.value = false
message.value = '' message.value = ''
showModal.value = false
viewMode.value = 'list' viewMode.value = 'list'
currentIndex.value = 0 currentIndex.value = 0
layout.value = 'list' layout.value = 'list'
@@ -130,16 +129,6 @@ describe('AppNav', () => {
expect(wrapper.find('.app-nav__menu').exists()).toBe(false) 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 () => { it('switches the view mode from the menu and closes it', async () => {
const wrapper = await mountWithMenuOpen() const wrapper = await mountWithMenuOpen()
const { viewMode } = useFeeds() const { viewMode } = useFeeds()
@@ -37,11 +37,10 @@ describe('RssFeeds', () => {
// useFeeds() returns module-level singleton refs shared across the whole // useFeeds() returns module-level singleton refs shared across the whole
// app (and this spec file) — reset them so state doesn't leak between tests. // 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 = [] feeds.value = []
showMessage.value = false showMessage.value = false
message.value = '' message.value = ''
showModal.value = false
viewMode.value = 'list' viewMode.value = 'list'
currentIndex.value = 0 currentIndex.value = 0
layout.value = 'list' layout.value = 'list'
+4 -2
View File
@@ -2,9 +2,10 @@
import '@/assets/modal.css'; import '@/assets/modal.css';
import { ref } from 'vue'; import { ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
const props = defineProps({ defineProps({
show: Boolean show: Boolean
}) })
const emit = defineEmits(['close', 'added'])
const submitted = ref(false) const submitted = ref(false)
const url = ref('') const url = ref('')
const title = ref('') const title = ref('')
@@ -30,6 +31,7 @@ async function save() {
console.log(response) console.log(response)
output.value = 'saved successfully' output.value = 'saved successfully'
emit('added')
} catch (error) { } catch (error) {
console.error(error.message) console.error(error.message)
output.value = error.message output.value = error.message
@@ -57,7 +59,7 @@ async function save() {
<div class="modal-footer"> <div class="modal-footer">
<slot name="footer"> <slot name="footer">
<button type="submit">Save</button> <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> </slot>
</div> </div>
</form> </form>
@@ -27,6 +27,7 @@ describe('AddUrl', () => {
expect.anything(), expect.anything(),
) )
expect(wrapper.text()).toContain('saved successfully') expect(wrapper.text()).toContain('saved successfully')
expect(wrapper.emitted('added')).toHaveLength(1)
}) })
it('surfaces the error message when the request fails', async () => { it('surfaces the error message when the request fails', async () => {
@@ -39,5 +40,17 @@ describe('AddUrl', () => {
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('Network Error') 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)
}) })
}) })
+56 -2
View File
@@ -12,7 +12,7 @@ class FakeIntersectionObserver {
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver) vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
describe('useFeeds', () => { describe('useFeeds', () => {
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, markRead, setupIntersectionObserver } = useFeeds() const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, viewMode, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, markRead, markCurrentArticleRead, setupIntersectionObserver } = useFeeds()
beforeEach(() => { beforeEach(() => {
localStorage.setItem('user-token', 'test-token') localStorage.setItem('user-token', 'test-token')
@@ -24,7 +24,7 @@ describe('useFeeds', () => {
feedFilter.value = null feedFilter.value = null
showMessage.value = false showMessage.value = false
message.value = '' message.value = ''
showModal.value = false viewMode.value = 'list'
}) })
it('fetches and flattens articles for the current user', async () => { it('fetches and flattens articles for the current user', async () => {
@@ -423,6 +423,60 @@ describe('useFeeds', () => {
expect(feeds.value.map(f => f.id)).toEqual([2, 1]) expect(feeds.value.map(f => f.id)).toEqual([2, 1])
}) })
it('does not resurrect an article-view read-in-place article when re-selecting its feed', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse)
axios.put.mockResolvedValue({ status: 200 })
await fetchData()
await setFeedFilter('Feed B')
expect(feeds.value.map(f => f.id)).toEqual([2])
// Article view marks the displayed article read in place (feed.read =
// true) without removing it, so currentIndex stays valid while paging —
// see markCurrentArticleRead. It's still in the list at this point.
markCurrentArticleRead()
expect(feeds.value.map(f => f.id)).toEqual([2])
// Switching to another feed and back must not resurrect it as if it
// were still unread.
await setFeedFilter('Feed A')
await setFeedFilter('Feed B')
expect(feeds.value).toEqual([])
})
it('marks the newly displayed article read when the filter is switched while in article view', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse)
axios.put.mockResolvedValue({ status: 200 })
await fetchData()
viewMode.value = 'article'
// Switching into Feed B while in article view immediately displays its
// one article's full content — that counts as "viewed", so it's marked
// read the same way toggleViewMode()/nextArticle()/prevArticle() do.
await setFeedFilter('Feed B')
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/2', null, expect.anything())
expect(feeds.value[0].read).toBe(true)
// It doesn't resurface either, once dropped on the next switch.
await setFeedFilter('Feed A')
await setFeedFilter('Feed B')
expect(feeds.value).toEqual([])
})
it('does not mark the newly displayed article read when the filter is switched in list view', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse)
await fetchData()
// List view only shows titles — switching filters there must not mark
// anything read; that's still driven solely by scroll (handleIntersection).
await setFeedFilter('Feed B')
expect(axios.put).not.toHaveBeenCalled()
expect(feeds.value[0].read).toBeUndefined()
})
it('keeps an emptied filtered feed selected and selectable, showing "All caught up"', async () => { it('keeps an emptied filtered feed selected and selectable, showing "All caught up"', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse) axios.get.mockResolvedValueOnce(twoFeedsResponse)
axios.put.mockResolvedValue({ status: 200 }) axios.put.mockResolvedValue({ status: 200 })
+24 -5
View File
@@ -59,7 +59,6 @@ function refreshUnreadDisplay() {
displayedFeedUnreadCounts.value = feedUnreadCounts.value displayedFeedUnreadCounts.value = feedUnreadCounts.value
} }
const message = ref('') const message = ref('')
const showModal = ref(false)
const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
const currentIndex = ref(0) const currentIndex = ref(0)
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu
@@ -308,6 +307,22 @@ function applyFilter() {
: allItems.value.slice() : allItems.value.slice()
} }
// 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)
}
// Changes the active feed filter and re-projects. Mirrors toggleLayout's // Changes the active feed filter and re-projects. Mirrors toggleLayout's
// observer/scroll-safe pattern: disconnect first, scroll to top, then re-point // 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 // the observer at the new .observe nodes after Vue has re-rendered. Works in
@@ -316,9 +331,16 @@ function applyFilter() {
async function setFeedFilter(title) { async function setFeedFilter(title) {
disconnectObserver() disconnectObserver()
window.scrollTo(0, 0) window.scrollTo(0, 0)
dropReadArticles()
feedFilter.value = title // null for "All feeds" feedFilter.value = title // null for "All feeds"
currentIndex.value = 0 currentIndex.value = 0
applyFilter() 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() refreshUnreadDisplay()
await nextTick() await nextTick()
setupIntersectionObserver() setupIntersectionObserver()
@@ -515,9 +537,7 @@ async function leaveArticleView() {
// Articles paged past in article view were marked read but deliberately kept // 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 // in place so currentIndex stayed valid — drop them now so they don't keep
// showing up in the list view. // showing up in the list view.
feeds.value = feeds.value.filter(feed => !feed.read) dropReadArticles()
// Shared references — the paged-past objects carry .read on the master too.
allItems.value = allItems.value.filter(feed => !feed.read)
currentIndex.value = 0 currentIndex.value = 0
viewMode.value = 'list' viewMode.value = 'list'
localStorage.setItem('viewMode', viewMode.value) localStorage.setItem('viewMode', viewMode.value)
@@ -588,7 +608,6 @@ export function useFeeds() {
setFeedFilter, setFeedFilter,
showMessage, showMessage,
message, message,
showModal,
viewMode, viewMode,
currentIndex, currentIndex,
toggleViewMode, toggleViewMode,