all caught up icon, counter dynamic

This commit is contained in:
2026-06-09 20:20:17 +02:00
parent b851e0257c
commit 039e0b448c
5 changed files with 53 additions and 6 deletions
+4 -2
View File
@@ -1,11 +1,13 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref, computed } from 'vue'
import { RouterLink, useRouter } from 'vue-router' import { RouterLink, useRouter } from 'vue-router'
import { useFeeds } from '@/composables/useFeeds' import { useFeeds } from '@/composables/useFeeds'
const router = useRouter() const router = useRouter()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds } = useFeeds() const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds } = useFeeds()
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
const menuOpen = ref(false) const menuOpen = ref(false)
function toggleMenu() { function toggleMenu() {
@@ -52,7 +54,7 @@ function handleToggleLayout() {
<template> <template>
<header class="app-nav"> <header class="app-nav">
<div class="app-nav__wrapper"> <div class="app-nav__wrapper">
<span class="app-nav__title">RSS Reader<span v-if="feeds.length" class="app-nav__unread"> ({{ feeds.length }})</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"
+33 -2
View File
@@ -53,7 +53,13 @@ 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' }">
<p v-if="feeds.length == 0">No unread articles.</p> <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"/>
<polyline points="7 12.5 10.5 16 17 9"/>
</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 ">
<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>
@@ -72,7 +78,13 @@ onMounted(async () => {
<div v-else class="article-single"> <div v-else class="article-single">
<button type="button" class="article-single__back" @click="leaveArticleView">&larr; Back to list</button> <button type="button" class="article-single__back" @click="leaveArticleView">&larr; Back to list</button>
<p v-if="feeds.length == 0">No unread articles.</p> <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"/>
<polyline points="7 12.5 10.5 16 17 9"/>
</svg>
<p class="empty-state__label">All caught up</p>
</div>
<template v-else> <template v-else>
<p class="feed-source">{{ feeds[currentIndex].feedTitle }}</p> <p class="feed-source">{{ feeds[currentIndex].feedTitle }}</p>
<h2 @click="getReadable(feeds[currentIndex], currentIndex)" class="feed-title">{{ feeds[currentIndex].title }}</h2> <h2 @click="getReadable(feeds[currentIndex], currentIndex)" class="feed-title">{{ feeds[currentIndex].title }}</h2>
@@ -108,6 +120,25 @@ onMounted(async () => {
/* Plain vertical stack of bordered "cards" — deliberately not flex/grid, and /* Plain vertical stack of bordered "cards" — deliberately not flex/grid, and
with no truncation/max-height: normal block flow lets each card grow to fit with no truncation/max-height: normal block flow lets each card grow to fit
its own full content (images included), with no cross-element interaction. */ its own full content (images included), with no cross-element interaction. */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 4rem 1rem;
gap: 1rem;
opacity: 0.4;
}
.empty-state__icon {
width: 64px;
height: 64px;
}
.empty-state__label {
font-size: 1.1rem;
margin: 0;
}
.article--cards .observe { .article--cards .observe {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 8px;
@@ -161,6 +161,19 @@ describe('AppNav', () => {
expect(wrapper.find('.app-nav__title').text()).toContain('(2)') expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
}) })
it('excludes already-read articles from the counter while in article view', async () => {
const { feeds } = useFeeds()
feeds.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] } })
await flushPromises()
expect(wrapper.find('.app-nav__title').text()).toContain('(1)')
})
it('hides the unread count when there are no articles', async () => { it('hides the unread count when there are no articles', async () => {
const wrapper = mount(AppNav, { global: { plugins: [router] } }) const wrapper = mount(AppNav, { global: { plugins: [router] } })
await flushPromises() await flushPromises()
@@ -40,7 +40,7 @@ describe('RssFeeds', () => {
await flushPromises() await flushPromises()
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything()) expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything())
expect(wrapper.text()).toContain('No unread articles.') expect(wrapper.text()).toContain('All caught up')
}) })
it('renders the fetched feed items', async () => { it('renders the fetched feed items', async () => {
@@ -68,7 +68,7 @@ describe('RssFeeds', () => {
expect(wrapper.text()).toContain('Article one') expect(wrapper.text()).toContain('Article one')
expect(wrapper.text()).toContain('My Feed') expect(wrapper.text()).toContain('My Feed')
expect(wrapper.text()).not.toContain('No unread articles.') expect(wrapper.text()).not.toContain('All caught up')
}) })
it('renders the list as cards when the card layout is selected', async () => { it('renders the list as cards when the card layout is selected', async () => {
+1
View File
@@ -80,6 +80,7 @@ async function getReadable(feed, index) {
base.setAttribute('href', feed.url); base.setAttribute('href', feed.url);
doc.head.prepend(base); doc.head.prepend(base);
doc.querySelectorAll('img').forEach(resolveTemplatedImage); doc.querySelectorAll('img').forEach(resolveTemplatedImage);
doc.querySelectorAll('video, audio').forEach(el => el.remove());
const article = new Readability(doc).parse(); const article = new Readability(doc).parse();
feeds.value[index].content = article.content; feeds.value[index].content = article.content;
feeds.value[index].readable = true; feeds.value[index].readable = true;