diff --git a/vue/src/components/AppNav.vue b/vue/src/components/AppNav.vue
index 4a6699b..eeb7f61 100644
--- a/vue/src/components/AppNav.vue
+++ b/vue/src/components/AppNav.vue
@@ -4,7 +4,7 @@ import { RouterLink, useRouter } from 'vue-router'
import { useFeeds } from '@/composables/useFeeds'
const router = useRouter()
-const { sync, showModal, viewMode, toggleViewMode } = useFeeds()
+const { sync, showModal, viewMode, toggleViewMode, markAllRead } = useFeeds()
const menuOpen = ref(false)
@@ -28,6 +28,11 @@ function handleSync() {
closeMenu()
}
+function handleMarkAllRead() {
+ markAllRead()
+ closeMenu()
+}
+
function openAddModal() {
showModal.value = true
closeMenu()
@@ -70,6 +75,7 @@ function handleToggleViewMode() {
{{ viewMode === 'list' ? 'Article view' : 'List view' }}
+
diff --git a/vue/src/components/__tests__/AppNav.spec.js b/vue/src/components/__tests__/AppNav.spec.js
index 4b699a0..604101d 100644
--- a/vue/src/components/__tests__/AppNav.spec.js
+++ b/vue/src/components/__tests__/AppNav.spec.js
@@ -15,11 +15,13 @@ describe('AppNav', () => {
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
- const { feeds, showMessage, message, showModal } = useFeeds()
+ const { feeds, showMessage, message, showModal, viewMode, currentIndex } = useFeeds()
feeds.value = []
showMessage.value = false
message.value = ''
showModal.value = false
+ viewMode.value = 'list'
+ currentIndex.value = 0
router = createRouter({
history: createWebHistory(),
@@ -90,4 +92,57 @@ describe('AppNav', () => {
expect(showModal.value).toBe(true)
})
+
+ it('switches the view mode from the menu and closes it', async () => {
+ const wrapper = await mountWithMenuOpen()
+ const { viewMode } = useFeeds()
+
+ const viewButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Article view')
+ await viewButton.trigger('click')
+
+ expect(viewMode.value).toBe('article')
+ expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
+ })
+
+ it('marks all articles as read from the menu after confirmation', async () => {
+ const { feeds } = useFeeds()
+ feeds.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' },
+ ]
+ axios.put.mockResolvedValue({ status: 200 })
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
+
+ const wrapper = await mountWithMenuOpen()
+ const markAllButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Mark all as read')
+ await markAllButton.trigger('click')
+ await flushPromises()
+
+ expect(confirmSpy).toHaveBeenCalled()
+ expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/1', null, expect.anything())
+ expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/2', null, expect.anything())
+ expect(feeds.value).toHaveLength(0)
+ expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
+
+ confirmSpy.mockRestore()
+ })
+
+ it('does not mark articles as read when the confirmation is dismissed', async () => {
+ const { feeds } = useFeeds()
+ feeds.value = [
+ { id: 1, title: 'Article one', content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
+ ]
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
+
+ const wrapper = await mountWithMenuOpen()
+ const markAllButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Mark all as read')
+ await markAllButton.trigger('click')
+ await flushPromises()
+
+ expect(confirmSpy).toHaveBeenCalled()
+ expect(axios.put).not.toHaveBeenCalled()
+ expect(feeds.value).toHaveLength(1)
+
+ confirmSpy.mockRestore()
+ })
})
diff --git a/vue/src/components/__tests__/RssFeeds.spec.js b/vue/src/components/__tests__/RssFeeds.spec.js
index 7575254..637c006 100644
--- a/vue/src/components/__tests__/RssFeeds.spec.js
+++ b/vue/src/components/__tests__/RssFeeds.spec.js
@@ -23,11 +23,13 @@ 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 } = useFeeds()
+ const { feeds, showMessage, message, showModal, viewMode, currentIndex } = useFeeds()
feeds.value = []
showMessage.value = false
message.value = ''
showModal.value = false
+ viewMode.value = 'list'
+ currentIndex.value = 0
})
it('fetches the current user articles and shows the empty state', async () => {
@@ -177,7 +179,9 @@ describe('RssFeeds', () => {
const wrapper = mount(RssFeeds)
await flushPromises()
- await wrapper.find('.view-toggle__btn').trigger('click')
+ // The view-toggle button now lives in AppNav's hamburger menu, not here —
+ // switch modes directly through the shared composable, as AppNav would.
+ useFeeds().toggleViewMode()
await flushPromises()
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article one')
diff --git a/vue/src/composables/useFeeds.js b/vue/src/composables/useFeeds.js
index 895df9a..9470dd6 100644
--- a/vue/src/composables/useFeeds.js
+++ b/vue/src/composables/useFeeds.js
@@ -159,6 +159,18 @@ function setInitialLoad(value) {
initialLoad = value
}
+async function markAllRead() {
+ if (feeds.value.length === 0) return
+ if (!window.confirm('Mark all articles as read?')) return
+
+ const ids = feeds.value.map(feed => feed.id)
+ feeds.value = []
+ currentIndex.value = 0
+ // markRead swallows its own errors, so Promise.all can't reject here.
+ await Promise.all(ids.map(id => markRead(id)))
+ showMessageForXSeconds('All articles marked as read.', 5)
+}
+
function markCurrentArticleRead() {
const feed = feeds.value[currentIndex.value]
// Marking read here (rather than via removeFeed, as the scroll-based list
@@ -203,6 +215,7 @@ export function useFeeds() {
sync,
getReadable,
markRead,
+ markAllRead,
showMessageForXSeconds,
setupIntersectionObserver,
removeFeed,