diff --git a/vue/src/assets/base.css b/vue/src/assets/base.css
index de056c3..d79ce9b 100644
--- a/vue/src/assets/base.css
+++ b/vue/src/assets/base.css
@@ -33,6 +33,13 @@
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
+ --color-accent: hsla(160, 100%, 37%, 1);
+ --color-accent-hover: hsla(160, 100%, 37%, 0.2);
+ --color-accent-2: hsla(200, 90%, 45%, 1);
+ --color-accent-2-hover: hsla(200, 90%, 35%, 1);
+ --color-info: #3498db;
+ --color-info-text: white;
+
--section-gap: 160px;
}
@@ -47,6 +54,9 @@
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
+
+ --color-accent-2: hsla(200, 90%, 65%, 1);
+ --color-accent-2-hover: hsla(200, 90%, 75%, 1);
}
}
diff --git a/vue/src/assets/main.css b/vue/src/assets/main.css
index 3f77094..46b8640 100644
--- a/vue/src/assets/main.css
+++ b/vue/src/assets/main.css
@@ -11,7 +11,7 @@
a,
.green {
text-decoration: none;
- color: hsla(160, 100%, 37%, 1);
+ color: var(--color-accent);
transition: 0.4s;
}
.feed-actions {
@@ -38,8 +38,8 @@ a,
}
.message {
- background-color: #3498db;
- color: white;
+ background-color: var(--color-info);
+ color: var(--color-info-text);
padding: 10px;
border-radius: 4px;
position: fixed;
@@ -52,7 +52,7 @@ a,
}
@media (hover: hover) {
a:hover {
- background-color: hsla(160, 100%, 37%, 0.2);
+ background-color: var(--color-accent-hover);
}
}
@@ -63,7 +63,7 @@ a,
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
- color: hsla(160, 100%, 37%, 1);
+ color: var(--color-accent);
}
.feed-title {
@@ -71,7 +71,7 @@ a,
font-family: 'Courier New';
font-size: clamp(1.1rem, 4vw, 1.4rem);
font-weight: bold;
- color: hsla(200, 90%, 45%, 1);
+ color: var(--color-accent-2);
border-bottom: 1px solid #ccc;
padding: 0.25em 1em 1em;
min-height: 44px;
@@ -79,17 +79,7 @@ a,
}
.feed-title:hover {
- color: hsla(200, 90%, 35%, 1);
-}
-
-@media (prefers-color-scheme: dark) {
- .feed-title {
- color: hsla(200, 90%, 65%, 1);
- }
-
- .feed-title:hover {
- color: hsla(200, 90%, 75%, 1);
- }
+ color: var(--color-accent-2-hover);
}
.feed-content {
diff --git a/vue/src/components/AppNav.vue b/vue/src/components/AppNav.vue
index 6a35c29..4a6699b 100644
--- a/vue/src/components/AppNav.vue
+++ b/vue/src/components/AppNav.vue
@@ -1,28 +1,88 @@
diff --git a/vue/src/components/__tests__/AppNav.spec.js b/vue/src/components/__tests__/AppNav.spec.js
index c4f1f8b..4b699a0 100644
--- a/vue/src/components/__tests__/AppNav.spec.js
+++ b/vue/src/components/__tests__/AppNav.spec.js
@@ -1,7 +1,11 @@
-import { describe, it, expect, beforeEach } from 'vitest'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
+import axios from 'axios'
import AppNav from '../AppNav.vue'
+import { useFeeds } from '../../composables/useFeeds'
+
+vi.mock('axios')
describe('AppNav', () => {
let router
@@ -9,6 +13,13 @@ describe('AppNav', () => {
beforeEach(async () => {
localStorage.setItem('user-token', 'abc123')
localStorage.setItem('user-id', '7')
+ vi.clearAllMocks()
+
+ const { feeds, showMessage, message, showModal } = useFeeds()
+ feeds.value = []
+ showMessage.value = false
+ message.value = ''
+ showModal.value = false
router = createRouter({
history: createWebHistory(),
@@ -21,8 +32,27 @@ describe('AppNav', () => {
await router.isReady()
})
- it('clears stored credentials and redirects to login on logout', async () => {
+ async function mountWithMenuOpen() {
const wrapper = mount(AppNav, { global: { plugins: [router] } })
+ await wrapper.find('.app-nav__hamburger').trigger('click')
+ await flushPromises()
+ return wrapper
+ }
+
+ it('toggles the menu open and closed via the hamburger button', async () => {
+ const wrapper = mount(AppNav, { global: { plugins: [router] } })
+
+ expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
+
+ await wrapper.find('.app-nav__hamburger').trigger('click')
+ expect(wrapper.find('.app-nav__menu').exists()).toBe(true)
+
+ await wrapper.find('.app-nav__hamburger').trigger('click')
+ expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
+ })
+
+ it('clears stored credentials and redirects to login on logout', async () => {
+ const wrapper = await mountWithMenuOpen()
await wrapper.find('.app-nav__logout').trigger('click')
await flushPromises()
@@ -31,4 +61,33 @@ describe('AppNav', () => {
expect(localStorage.getItem('user-id')).toBeNull()
expect(router.currentRoute.value.name).toBe('login')
})
+
+ it('triggers a sync from the menu', async () => {
+ axios.get.mockResolvedValue({ data: { feeds: [] } })
+ axios.post.mockResolvedValueOnce({ status: 200 })
+
+ const wrapper = await mountWithMenuOpen()
+
+ const syncButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Sync')
+ await syncButton.trigger('click')
+ await flushPromises()
+
+ expect(axios.post).toHaveBeenCalledWith(
+ '/api/v1/article/sync',
+ { user_id: 7 },
+ expect.anything(),
+ )
+ // Menu auto-closes after an action
+ 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)
+ })
})
diff --git a/vue/src/components/__tests__/RssFeeds.spec.js b/vue/src/components/__tests__/RssFeeds.spec.js
index a2fc369..7575254 100644
--- a/vue/src/components/__tests__/RssFeeds.spec.js
+++ b/vue/src/components/__tests__/RssFeeds.spec.js
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import axios from 'axios'
import RssFeeds from '../RssFeeds.vue'
+import { useFeeds } from '../../composables/useFeeds'
vi.mock('axios')
@@ -19,6 +20,14 @@ describe('RssFeeds', () => {
localStorage.setItem('user-token', 'test-token')
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
+
+ // 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()
+ feeds.value = []
+ showMessage.value = false
+ message.value = ''
+ showModal.value = false
})
it('fetches the current user articles and shows the empty state', async () => {
@@ -98,20 +107,102 @@ describe('RssFeeds', () => {
expect(titles).toEqual(['Newer article', 'Older article'])
})
- it('syncs feeds for the current user', async () => {
- axios.get.mockResolvedValue({ data: { feeds: [] } })
- axios.post.mockResolvedValueOnce({ status: 200 })
+ it('shows a link to the original article until 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({
+ data: {
+ feeds: [
+ {
+ title: 'My Feed',
+ items: [
+ {
+ id: 1,
+ title: 'Article one',
+ content: '
short summary
',
+ url: 'https://example.test/1',
+ timestamp: '2026-01-01',
+ },
+ ],
+ },
+ ],
+ },
+ })
+ axios.post.mockResolvedValueOnce({ data: { content: 'full text
' } })
const wrapper = mount(RssFeeds)
await flushPromises()
- await wrapper.find('.feed-actions p').trigger('click')
+ const link = wrapper.find('.feed-original-link a')
+ expect(link.exists()).toBe(true)
+ expect(link.attributes('href')).toBe('https://example.test/1')
+ expect(link.attributes('target')).toBe('_blank')
+
+ await wrapper.find('.feed-title').trigger('click')
await flushPromises()
- expect(axios.post).toHaveBeenCalledWith(
- '/api/v1/article/sync',
- { user_id: 7 },
- expect.anything(),
- )
+ expect(wrapper.find('.feed-original-link a').exists()).toBe(false)
+ })
+
+ it('switches to article view and navigates between articles', async () => {
+ axios.get.mockResolvedValueOnce({
+ data: {
+ feeds: [
+ {
+ title: 'My Feed',
+ items: [
+ {
+ id: 1,
+ title: 'Article one',
+ content: 'one
',
+ url: 'https://example.test/1',
+ timestamp: '2026-02-01 10:00:00',
+ },
+ {
+ id: 2,
+ title: 'Article two',
+ content: 'two
',
+ url: 'https://example.test/2',
+ timestamp: '2026-01-01 10:00:00',
+ },
+ ],
+ },
+ ],
+ },
+ })
+ axios.put.mockResolvedValue({ status: 200 })
+ axios.post.mockResolvedValue({ data: { content: 'full text
' } })
+
+ const wrapper = mount(RssFeeds)
+ await flushPromises()
+
+ await wrapper.find('.view-toggle__btn').trigger('click')
+ await flushPromises()
+
+ expect(wrapper.find('.article-single .feed-title').text()).toBe('Article one')
+ // Same as in list view: the readable content is loaded on demand by
+ // clicking the headline, not fetched automatically on entering the view.
+ expect(axios.post).not.toHaveBeenCalled()
+ expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(true)
+
+ await wrapper.find('.article-single .feed-title').trigger('click')
+ await flushPromises()
+
+ 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.findAll('.article-nav__btn')[0].attributes('disabled')).toBeDefined()
+
+ await wrapper.findAll('.article-nav__btn')[1].trigger('click')
+ await flushPromises()
+
+ expect(wrapper.find('.article-single .feed-title').text()).toBe('Article two')
+ expect(wrapper.findAll('.article-nav__btn')[1].attributes('disabled')).toBeDefined()
+
+ await wrapper.findAll('.article-nav__btn')[0].trigger('click')
+ await flushPromises()
+
+ expect(wrapper.find('.article-single .feed-title').text()).toBe('Article one')
})
})
diff --git a/vue/src/composables/__tests__/useFeeds.spec.js b/vue/src/composables/__tests__/useFeeds.spec.js
new file mode 100644
index 0000000..34d422f
--- /dev/null
+++ b/vue/src/composables/__tests__/useFeeds.spec.js
@@ -0,0 +1,142 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import axios from 'axios'
+import { useFeeds } from '../useFeeds'
+
+vi.mock('axios')
+
+class FakeIntersectionObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
+
+describe('useFeeds', () => {
+ const { feeds, showMessage, message, showModal, fetchData, sync, getReadable } = useFeeds()
+
+ beforeEach(() => {
+ localStorage.setItem('user-token', 'test-token')
+ localStorage.setItem('user-id', '7')
+ vi.clearAllMocks()
+
+ feeds.value = []
+ showMessage.value = false
+ message.value = ''
+ showModal.value = false
+ })
+
+ it('fetches and flattens articles for the current user', async () => {
+ axios.get.mockResolvedValueOnce({
+ data: {
+ feeds: [
+ {
+ title: 'My Feed',
+ items: [
+ {
+ id: 1,
+ title: 'Article one',
+ content: 'hello
',
+ url: 'https://example.test/1',
+ timestamp: '2026-01-01 10:00:00',
+ },
+ ],
+ },
+ ],
+ },
+ })
+
+ await fetchData()
+
+ expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything())
+ expect(feeds.value).toHaveLength(1)
+ expect(feeds.value[0]).toMatchObject({ title: 'Article one', feedTitle: 'My Feed' })
+ })
+
+ it('sorts articles by timestamp across feeds, newest first', async () => {
+ axios.get.mockResolvedValueOnce({
+ data: {
+ feeds: [
+ {
+ title: 'Old Feed',
+ items: [
+ { id: 1, title: 'Older article', content: '', url: 'https://example.test/1', timestamp: '2026-01-01 10:00:00' },
+ ],
+ },
+ {
+ title: 'New Feed',
+ items: [
+ { id: 2, title: 'Newer article', content: '', url: 'https://example.test/2', timestamp: '2026-02-01 10:00:00' },
+ ],
+ },
+ ],
+ },
+ })
+
+ await fetchData()
+
+ expect(feeds.value.map(f => f.title)).toEqual(['Newer article', 'Older article'])
+ })
+
+ it('syncs feeds for the current user and refetches', async () => {
+ axios.post.mockResolvedValueOnce({ status: 200 })
+ axios.get.mockResolvedValue({ data: { feeds: [] } })
+
+ await sync()
+
+ expect(axios.post).toHaveBeenCalledWith(
+ '/api/v1/article/sync',
+ { user_id: 7 },
+ expect.anything(),
+ )
+ expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything())
+ })
+
+ it('resolves Deutsche-Welle-style templated image URLs from data-format/data-url', async () => {
+ feeds.value = [{
+ id: 1,
+ title: 'Article one',
+ url: 'https://www.dw.com/en/article-one/a-1',
+ content: '',
+ }]
+ axios.post.mockResolvedValueOnce({
+ data: {
+ content: `
+
+ some article text long enough for readability to keep the image and paragraph together in the parsed output, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes for the main article body.
+ `,
+ },
+ })
+
+ await getReadable(feeds.value[0], 0)
+
+ expect(feeds.value[0].content).toContain('src="https://static.dw.com/image/76212061_MASTER_LANDSCAPE.jpg"')
+ // The rendered `src` is what matters — `data-url` retaining the raw
+ // template is harmless since browsers don't load images from data-* attrs.
+ expect(feeds.value[0].content).not.toMatch(/src="[^"]*(\$\{|%7[bB])/)
+ })
+
+ it('drops unresolvable templated images instead of leaving a broken src', async () => {
+ feeds.value = [{
+ id: 1,
+ title: 'Article one',
+ url: 'https://example.test/article-one',
+ content: '',
+ }]
+ axios.post.mockResolvedValueOnce({
+ data: {
+ content: `
+
+ some article text long enough for readability to keep the paragraph as the main content body, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes.
+ `,
+ },
+ })
+
+ await getReadable(feeds.value[0], 0)
+
+ expect(feeds.value[0].content).not.toContain('%7Bsize%7D')
+ expect(feeds.value[0].content).not.toContain('
tags whose `src`/`data-url`
+// contain an unresolved `${formatId}` template that their own frontend fills
+// in from the sibling `data-format` attribute before loading — verbatim they
+// 404. Resolve them the same way here, or drop the
if we can't, so
+// Readability doesn't carry a broken image into the parsed article.
+function resolveTemplatedImage(img) {
+ const placeholder = '${formatId}'
+ const format = img.getAttribute('data-format')
+ const dataUrl = img.getAttribute('data-url')
+ if (format && dataUrl && dataUrl.includes(placeholder)) {
+ img.setAttribute('src', dataUrl.replace(placeholder, format))
+ } else if (/[{]|%7[bB]/.test(img.getAttribute('src') ?? '')) {
+ img.remove()
+ }
+}
+
+function showMessageForXSeconds(text, seconds) {
+ message.value = text;
+ showMessage.value = true;
+
+ // Set a timeout to hide the message after x seconds
+ setTimeout(() => {
+ showMessage.value = false;
+ message.value = '';
+ }, seconds * 1000); // Convert seconds to milliseconds
+}
+
+async function getReadable(feed, index) {
+ try {
+ const response = await axios.post("/api/v1/article/read", {
+ url: feed.url
+ }, authHeaders())
+
+ const doc = new DOMParser().parseFromString(response.data.content, 'text/html');
+ // Scraped articles often contain image/link URLs that are relative to the
+ // source site. A tag makes the browser (and Readability) resolve
+ // them against the article's original URL instead of our own origin.
+ const base = doc.createElement('base');
+ base.setAttribute('href', feed.url);
+ doc.head.prepend(base);
+ doc.querySelectorAll('img').forEach(resolveTemplatedImage);
+ const article = new Readability(doc).parse();
+ feeds.value[index].content = article.content;
+ feeds.value[index].readable = true;
+ } catch (error) {
+ console.error('Error fetching data:', error)
+ showMessageForXSeconds(error, 5)
+ }
+}
+
+async function markRead(id) {
+ try {
+ const response = await axios.put("/api/v1/article/read/" + id, null, authHeaders())
+ console.log(response.status)
+ } catch (error) {
+ console.log(error)
+ }
+}
+
+const fetchData = async () => {
+ const user_id = localStorage.getItem("user-id")
+ try {
+ const response = await axios.get("/api/v1/article/get/" + user_id, authHeaders());
+ const items = [];
+ response.data.feeds.forEach(feed => {
+ feed.items.forEach(item => items.push({ ...item, feedTitle: feed.title }));
+ });
+ // timestamps are zero-padded "YYYY-MM-DD HH:MM:SS" strings, so a plain
+ // lexicographic comparison sorts them chronologically.
+ items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
+ feeds.value = items;
+ await nextTick();
+ setupIntersectionObserver();
+ } catch (error) {
+ console.error('Error fetching data:', error)
+ showMessageForXSeconds(error, 5)
+ }
+};
+
+async function sync() {
+ try {
+ const response = await axios.post('/api/v1/article/sync', {
+ user_id: parseInt(localStorage.getItem("user-id"))
+ }, authHeaders())
+
+ if (response.status == 200) {
+ showMessageForXSeconds('Sync successful.', 5)
+ }
+ fetchData();
+ } catch (error) {
+ console.error('Error sync', error)
+ showMessageForXSeconds(error, 5)
+ }
+}
+
+function setupIntersectionObserver() {
+ if (observer) {
+ observer.disconnect();
+ }
+
+ observer = new IntersectionObserver(handleIntersection, {
+ root: null, // Use the viewport as the root
+ rootMargin: '0px',
+ // threshold: 0.5, // Fire the callback when at least 50% of the element is visible
+ });
+
+ const observedDivs = document.querySelectorAll(".observe");
+ if (observedDivs.length > 0) {
+ observedDivs.forEach(observedDiv => {
+ observer.observe(observedDiv);
+ })
+ }
+}
+
+async function handleIntersection(entries) {
+ // The callback function for when the target element enters or exits the viewport
+ for (const entry of entries) {
+ // An article that has scrolled above the viewport (not intersecting,
+ // bounding box above the top edge) has been read — mark it and remove it.
+ if (initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < 0) {
+ await markRead(feeds.value[entry.target.id].id)
+ removeFeed(entry.target.id)
+ document.getElementById(0)?.scrollIntoView()
+ }
+ }
+}
+
+function removeFeed(index) {
+ const array = unref(feeds);
+ array.splice(index, 1);
+}
+
+function setInitialLoad(value) {
+ initialLoad = value
+}
+
+function markCurrentArticleRead() {
+ const feed = feeds.value[currentIndex.value]
+ // Marking read here (rather than via removeFeed, as the scroll-based list
+ // view does) keeps the array stable so currentIndex stays valid while paging.
+ if (feed) markRead(feed.id)
+}
+
+function toggleViewMode() {
+ viewMode.value = viewMode.value === 'list' ? 'article' : 'list'
+ if (viewMode.value === 'article') {
+ currentIndex.value = 0
+ markCurrentArticleRead()
+ }
+}
+
+function nextArticle() {
+ if (currentIndex.value < feeds.value.length - 1) {
+ currentIndex.value += 1
+ markCurrentArticleRead()
+ }
+}
+
+function prevArticle() {
+ if (currentIndex.value > 0) {
+ currentIndex.value -= 1
+ markCurrentArticleRead()
+ }
+}
+
+export function useFeeds() {
+ return {
+ feeds,
+ showMessage,
+ message,
+ showModal,
+ viewMode,
+ currentIndex,
+ toggleViewMode,
+ nextArticle,
+ prevArticle,
+ fetchData,
+ sync,
+ getReadable,
+ markRead,
+ showMessageForXSeconds,
+ setupIntersectionObserver,
+ removeFeed,
+ setInitialLoad,
+ }
+}