hamburger menu, article view
This commit is contained in:
@@ -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: '<p>hello</p>',
|
||||
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: `<html><body><article>
|
||||
<img data-format="MASTER_LANDSCAPE" data-id="76212061"
|
||||
data-url="https://static.dw.com/image/76212061_\${formatId}.jpg"
|
||||
data-aspect-ratio="16/9" alt="Merz and Trump"
|
||||
src="https://static.dw.com/image/76212061_$%7BformatId%7D.jpg">
|
||||
<p>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.</p>
|
||||
</article></body></html>`,
|
||||
},
|
||||
})
|
||||
|
||||
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: `<html><body><article>
|
||||
<img data-url="https://example.test/img_\${size}.jpg" src="https://example.test/img_%7Bsize%7D.jpg">
|
||||
<p>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.</p>
|
||||
</article></body></html>`,
|
||||
},
|
||||
})
|
||||
|
||||
await getReadable(feeds.value[0], 0)
|
||||
|
||||
expect(feeds.value[0].content).not.toContain('%7Bsize%7D')
|
||||
expect(feeds.value[0].content).not.toContain('<img')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import { ref, unref, nextTick } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { Readability } from '@mozilla/readability';
|
||||
|
||||
// Module-level state — declared outside useFeeds() so every caller shares the
|
||||
// same singleton refs (a Pinia-free "store" for the feed list and its UI state).
|
||||
const showMessage = ref(false)
|
||||
const feeds = ref([]);
|
||||
const message = ref('')
|
||||
const showModal = ref(false)
|
||||
const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu
|
||||
const currentIndex = ref(0)
|
||||
|
||||
let observer; // Declare observer outside the setup function
|
||||
let initialLoad = false
|
||||
|
||||
function authHeaders() {
|
||||
return {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'user-token': localStorage.getItem("user-token")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Some feeds (e.g. Deutsche Welle) ship <img> 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 <img> 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 <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);
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user