all caught up icon, counter dynamic
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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">← Back to list</button>
|
<button type="button" class="article-single__back" @click="leaveArticleView">← 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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user