From b4fc86302f2f78750dc3ba369d5d61c7dd925f95 Mon Sep 17 00:00:00 2001 From: mace Date: Mon, 8 Jun 2026 06:39:47 +0200 Subject: [PATCH] hamburger menu, article view --- vue/src/assets/base.css | 10 + vue/src/assets/main.css | 24 +- vue/src/components/AppNav.vue | 149 ++++++++-- vue/src/components/RssFeeds.vue | 279 ++++++++---------- vue/src/components/__tests__/AppNav.spec.js | 63 +++- vue/src/components/__tests__/RssFeeds.spec.js | 109 ++++++- .../composables/__tests__/useFeeds.spec.js | 142 +++++++++ vue/src/composables/useFeeds.js | 211 +++++++++++++ 8 files changed, 784 insertions(+), 203 deletions(-) create mode 100644 vue/src/composables/__tests__/useFeeds.spec.js create mode 100644 vue/src/composables/useFeeds.js 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: `
+ Merz and Trump +

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, + } +}