hamburger menu, article view

This commit is contained in:
2026-06-08 06:39:47 +02:00
parent 39f08c7218
commit b4fc86302f
8 changed files with 784 additions and 203 deletions
+131 -18
View File
@@ -1,28 +1,88 @@
<script setup>
import { ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useFeeds } from '@/composables/useFeeds'
const router = useRouter()
const { sync, showModal, viewMode, toggleViewMode } = useFeeds()
const menuOpen = ref(false)
function toggleMenu() {
menuOpen.value = !menuOpen.value
}
function closeMenu() {
menuOpen.value = false
}
function logout() {
localStorage.removeItem('user-token')
localStorage.removeItem('user-id')
closeMenu()
router.push({ name: 'login' })
}
function handleSync() {
sync()
closeMenu()
}
function openAddModal() {
showModal.value = true
closeMenu()
}
function handleToggleViewMode() {
toggleViewMode()
closeMenu()
}
</script>
<template>
<header class="app-nav">
<div class="app-nav__wrapper">
<span class="app-nav__title">RSS Reader</span>
<nav class="app-nav__links">
<RouterLink to="/feeds">Feeds</RouterLink>
<button class="app-nav__logout" @click="logout">Logout</button>
</nav>
<button
class="app-nav__hamburger"
type="button"
:aria-expanded="menuOpen"
aria-controls="app-nav-menu"
:aria-label="menuOpen ? 'Close menu' : 'Open menu'"
@click="toggleMenu"
>
<span class="app-nav__hamburger-icon" aria-hidden="true">
<span></span><span></span><span></span>
</span>
</button>
</div>
<Transition name="app-nav-menu">
<nav
v-if="menuOpen"
id="app-nav-menu"
class="app-nav__menu"
@click.self="closeMenu"
>
<div class="app-nav__menu-panel">
<RouterLink to="/feeds" class="app-nav__menu-item" @click="closeMenu">Feeds</RouterLink>
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
{{ viewMode === 'list' ? 'Article view' : 'List view' }}
</button>
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
<button class="app-nav__menu-item" type="button" @click="openAddModal">Add RSS</button>
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
</div>
</nav>
</Transition>
</header>
</template>
<style scoped>
.app-nav {
position: relative;
}
.app-nav__wrapper {
display: flex;
flex-wrap: wrap;
@@ -37,35 +97,88 @@ function logout() {
font-size: clamp(1.1rem, 4vw, 1.4rem);
}
.app-nav__links {
display: flex;
align-items: center;
gap: 0.75rem;
}
.app-nav__links a {
padding: 0.5rem 0.75rem;
min-height: 44px;
.app-nav__hamburger {
display: inline-flex;
align-items: center;
text-decoration: none;
color: var(--color-text);
justify-content: center;
min-height: 44px;
min-width: 44px;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.app-nav__links a.router-link-exact-active {
font-weight: bold;
.app-nav__hamburger-icon {
display: inline-flex;
flex-direction: column;
justify-content: space-between;
width: 22px;
height: 16px;
}
.app-nav__logout {
.app-nav__hamburger-icon span {
display: block;
height: 2px;
border-radius: 1px;
background: var(--color-text);
}
.app-nav__menu {
position: absolute;
inset: 100% 0 auto 0;
z-index: 30;
display: flex;
justify-content: flex-end;
padding: 0 1rem 1rem;
}
.app-nav__menu-panel {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: clamp(220px, 60vw, 280px);
padding: 0.75rem;
background: var(--color-background-soft);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.app-nav__menu-item {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0.5rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
text-decoration: none;
font: inherit;
text-align: left;
cursor: pointer;
}
.app-nav__menu-item:hover {
border-color: var(--color-border-hover);
}
.app-nav__menu-item.router-link-exact-active {
font-weight: bold;
}
.app-nav-menu-enter-active,
.app-nav-menu-leave-active {
transition: opacity 0.2s ease;
}
.app-nav-menu-enter-from,
.app-nav-menu-leave-to {
opacity: 0;
}
@media (min-width: 768px) {
.app-nav__wrapper {
padding: 1rem 2rem;
+122 -157
View File
@@ -1,171 +1,33 @@
<script setup>
import { ref, unref, onMounted, nextTick } from 'vue';
import axios from 'axios';
import { Readability } from '@mozilla/readability';
import { onMounted } from 'vue';
import Modal from './modal/AddUrl.vue';
import { useFeeds } from '@/composables/useFeeds';
const showMessage = ref(false)
const feeds = ref([]);
const message = ref('')
const showModal = ref(false)
const {
feeds,
showMessage,
message,
showModal,
viewMode,
currentIndex,
nextArticle,
prevArticle,
fetchData,
getReadable,
setInitialLoad,
} = useFeeds()
async function getReadable(feed, index) {
try {
const response = await axios.post("/api/v1/article/read", {
url: feed.url
},
{
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
}
})
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 <base> 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);
const article = new Readability(doc).parse();
feeds.value[index].content = article.content;
} 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,
{
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
}
}
)
console.log(response.status)
} catch (error) {
console.log(error)
}
}
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
}
const fetchData = async () => {
const user_id = localStorage.getItem("user-id")
try {
const response = await axios.get("/api/v1/article/get/" + user_id, {
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
}
});
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"))
},
{
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
}
})
if (response.status == 200) {
showMessageForXSeconds('Sync successful.', 5)
}
fetchData();
} catch (error) {
console.error('Error sync', error)
showMessageForXSeconds(error, 5)
}
}
let observer; // Declare observer outside the setup function
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);
}
let initialLoad = false
onMounted(async () => {
initialLoad = false
setInitialLoad(false)
await fetchData()
setTimeout(function () {
initialLoad = true
setInitialLoad(true)
console.log('set to true')
}, 2000);
});
</script>
<template>
<div class="feed-actions">
<p @click="sync">Sync</p>
<p @click="showModal = true">Add RSS</p>
</div>
<Teleport to="body">
<modal :show="showModal" @close="showModal = false">
<template #header>
@@ -174,18 +36,121 @@ onMounted(async () => {
</modal>
</Teleport>
<div>
<h1>Feeds</h1>
<div v-if="showMessage" class="message">{{ message }}</div>
<div id='article' class='article'>
<div v-if="viewMode === 'list'" id='article' class='article'>
<p v-if="feeds.length == 0">No unread articles.</p>
<template v-for="( feed, index ) in feeds ">
<div v-bind:id="index" class="observe">
<p class="feed-source">{{ feed.feedTitle }}</p>
<h2 @click="getReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
<h3>{{ feed.timestamp }}</h3>
<p v-if="!feed.readable" class="feed-original-link">
<a :href="feed.url" target="_blank" rel="noopener noreferrer">Read original article &#8599;</a>
</p>
<p class="feed-content" v-html='feed.content'></p>
</div>
</template>
</div>
<div v-else class="article-single">
<button type="button" class="article-single__back" @click="viewMode = 'list'">&larr; Back to list</button>
<p v-if="feeds.length == 0">No unread articles.</p>
<template v-else>
<p class="feed-source">{{ feeds[currentIndex].feedTitle }}</p>
<h2 @click="getReadable(feeds[currentIndex], currentIndex)" class="feed-title">{{ feeds[currentIndex].title }}</h2>
<h3>{{ feeds[currentIndex].timestamp }}</h3>
<p v-if="!feeds[currentIndex].readable" class="feed-original-link">
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article &#8599;</a>
</p>
<p class="feed-content" v-html="feeds[currentIndex].content"></p>
</template>
<div class="article-nav">
<button
type="button"
class="article-nav__btn"
:disabled="currentIndex === 0"
aria-label="Previous article"
@click="prevArticle"
>&uarr;</button>
<button
type="button"
class="article-nav__btn"
:disabled="feeds.length === 0 || currentIndex === feeds.length - 1"
aria-label="Next article"
@click="nextArticle"
>&darr;</button>
</div>
</div>
</div>
</template>
<style scoped>
.feed-original-link a {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0.25em 1em;
color: var(--color-accent);
text-decoration: none;
}
.feed-original-link a:hover {
text-decoration: underline;
}
.article-single {
position: relative;
padding-bottom: 5rem;
}
.article-single__back {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0.5rem 0.9rem;
margin-bottom: 1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
cursor: pointer;
}
.article-single__back:hover {
border-color: var(--color-border-hover);
}
.article-nav {
position: fixed;
right: 1rem;
bottom: 1.5rem;
z-index: 20;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.article-nav__btn {
min-width: 56px;
min-height: 56px;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--color-background-soft);
color: var(--color-text);
font-size: 1.5rem;
line-height: 1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
cursor: pointer;
}
.article-nav__btn:hover:not(:disabled) {
border-color: var(--color-border-hover);
}
.article-nav__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
+61 -2
View File
@@ -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)
})
})
+100 -9
View File
@@ -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: '<p>short summary</p>',
url: 'https://example.test/1',
timestamp: '2026-01-01',
},
],
},
],
},
})
axios.post.mockResolvedValueOnce({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
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: '<p>one</p>',
url: 'https://example.test/1',
timestamp: '2026-02-01 10:00:00',
},
{
id: 2,
title: 'Article two',
content: '<p>two</p>',
url: 'https://example.test/2',
timestamp: '2026-01-01 10:00:00',
},
],
},
],
},
})
axios.put.mockResolvedValue({ status: 200 })
axios.post.mockResolvedValue({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
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')
})
})