hamburger menu, article view
This commit is contained in:
@@ -33,6 +33,13 @@
|
|||||||
--color-heading: var(--vt-c-text-light-1);
|
--color-heading: var(--vt-c-text-light-1);
|
||||||
--color-text: 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;
|
--section-gap: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +54,9 @@
|
|||||||
|
|
||||||
--color-heading: var(--vt-c-text-dark-1);
|
--color-heading: var(--vt-c-text-dark-1);
|
||||||
--color-text: var(--vt-c-text-dark-2);
|
--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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-17
@@ -11,7 +11,7 @@
|
|||||||
a,
|
a,
|
||||||
.green {
|
.green {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: hsla(160, 100%, 37%, 1);
|
color: var(--color-accent);
|
||||||
transition: 0.4s;
|
transition: 0.4s;
|
||||||
}
|
}
|
||||||
.feed-actions {
|
.feed-actions {
|
||||||
@@ -38,8 +38,8 @@ a,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
background-color: #3498db;
|
background-color: var(--color-info);
|
||||||
color: white;
|
color: var(--color-info-text);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -52,7 +52,7 @@ a,
|
|||||||
}
|
}
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
a: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;
|
font-weight: 600;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: hsla(160, 100%, 37%, 1);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-title {
|
.feed-title {
|
||||||
@@ -71,7 +71,7 @@ a,
|
|||||||
font-family: 'Courier New';
|
font-family: 'Courier New';
|
||||||
font-size: clamp(1.1rem, 4vw, 1.4rem);
|
font-size: clamp(1.1rem, 4vw, 1.4rem);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: hsla(200, 90%, 45%, 1);
|
color: var(--color-accent-2);
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #ccc;
|
||||||
padding: 0.25em 1em 1em;
|
padding: 0.25em 1em 1em;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
@@ -79,17 +79,7 @@ a,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.feed-title:hover {
|
.feed-title:hover {
|
||||||
color: hsla(200, 90%, 35%, 1);
|
color: var(--color-accent-2-hover);
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.feed-title {
|
|
||||||
color: hsla(200, 90%, 65%, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-title:hover {
|
|
||||||
color: hsla(200, 90%, 75%, 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-content {
|
.feed-content {
|
||||||
|
|||||||
+131
-18
@@ -1,28 +1,88 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
|
import { useFeeds } from '@/composables/useFeeds'
|
||||||
|
|
||||||
const router = useRouter()
|
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() {
|
function logout() {
|
||||||
localStorage.removeItem('user-token')
|
localStorage.removeItem('user-token')
|
||||||
localStorage.removeItem('user-id')
|
localStorage.removeItem('user-id')
|
||||||
|
closeMenu()
|
||||||
router.push({ name: 'login' })
|
router.push({ name: 'login' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSync() {
|
||||||
|
sync()
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddModal() {
|
||||||
|
showModal.value = true
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleViewMode() {
|
||||||
|
toggleViewMode()
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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>
|
<span class="app-nav__title">RSS Reader</span>
|
||||||
<nav class="app-nav__links">
|
<button
|
||||||
<RouterLink to="/feeds">Feeds</RouterLink>
|
class="app-nav__hamburger"
|
||||||
<button class="app-nav__logout" @click="logout">Logout</button>
|
type="button"
|
||||||
</nav>
|
: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>
|
</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>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.app-nav {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.app-nav__wrapper {
|
.app-nav__wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -37,35 +97,88 @@ function logout() {
|
|||||||
font-size: clamp(1.1rem, 4vw, 1.4rem);
|
font-size: clamp(1.1rem, 4vw, 1.4rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__links {
|
.app-nav__hamburger {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-nav__links a {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
min-height: 44px;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-decoration: none;
|
justify-content: center;
|
||||||
color: var(--color-text);
|
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 {
|
.app-nav__hamburger-icon {
|
||||||
font-weight: bold;
|
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;
|
min-height: 44px;
|
||||||
padding: 0.5rem 0.9rem;
|
padding: 0.5rem 0.9rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
cursor: pointer;
|
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) {
|
@media (min-width: 768px) {
|
||||||
.app-nav__wrapper {
|
.app-nav__wrapper {
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
|
|||||||
+122
-157
@@ -1,171 +1,33 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, unref, onMounted, nextTick } from 'vue';
|
import { onMounted } from 'vue';
|
||||||
import axios from 'axios';
|
|
||||||
import { Readability } from '@mozilla/readability';
|
|
||||||
import Modal from './modal/AddUrl.vue';
|
import Modal from './modal/AddUrl.vue';
|
||||||
|
import { useFeeds } from '@/composables/useFeeds';
|
||||||
|
|
||||||
const showMessage = ref(false)
|
const {
|
||||||
const feeds = ref([]);
|
feeds,
|
||||||
const message = ref('')
|
showMessage,
|
||||||
const showModal = ref(false)
|
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 () => {
|
onMounted(async () => {
|
||||||
initialLoad = false
|
setInitialLoad(false)
|
||||||
await fetchData()
|
await fetchData()
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
initialLoad = true
|
setInitialLoad(true)
|
||||||
console.log('set to true')
|
console.log('set to true')
|
||||||
}, 2000);
|
}, 2000);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="feed-actions">
|
|
||||||
<p @click="sync">Sync</p>
|
|
||||||
<p @click="showModal = true">Add RSS</p>
|
|
||||||
</div>
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<modal :show="showModal" @close="showModal = false">
|
<modal :show="showModal" @close="showModal = false">
|
||||||
<template #header>
|
<template #header>
|
||||||
@@ -174,18 +36,121 @@ onMounted(async () => {
|
|||||||
</modal>
|
</modal>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
<div>
|
<div>
|
||||||
<h1>Feeds</h1>
|
|
||||||
<div v-if="showMessage" class="message">{{ message }}</div>
|
<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>
|
<p v-if="feeds.length == 0">No unread articles.</p>
|
||||||
<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>
|
||||||
<h2 @click="getReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
|
<h2 @click="getReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
|
||||||
<h3>{{ feed.timestamp }}</h3>
|
<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 ↗</a>
|
||||||
|
</p>
|
||||||
<p class="feed-content" v-html='feed.content'></p>
|
<p class="feed-content" v-html='feed.content'></p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="article-single">
|
||||||
|
<button type="button" class="article-single__back" @click="viewMode = 'list'">← 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 ↗</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"
|
||||||
|
>↑</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="article-nav__btn"
|
||||||
|
:disabled="feeds.length === 0 || currentIndex === feeds.length - 1"
|
||||||
|
aria-label="Next article"
|
||||||
|
@click="nextArticle"
|
||||||
|
>↓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -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 { mount, flushPromises } from '@vue/test-utils'
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import axios from 'axios'
|
||||||
import AppNav from '../AppNav.vue'
|
import AppNav from '../AppNav.vue'
|
||||||
|
import { useFeeds } from '../../composables/useFeeds'
|
||||||
|
|
||||||
|
vi.mock('axios')
|
||||||
|
|
||||||
describe('AppNav', () => {
|
describe('AppNav', () => {
|
||||||
let router
|
let router
|
||||||
@@ -9,6 +13,13 @@ describe('AppNav', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
localStorage.setItem('user-token', 'abc123')
|
localStorage.setItem('user-token', 'abc123')
|
||||||
localStorage.setItem('user-id', '7')
|
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({
|
router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -21,8 +32,27 @@ describe('AppNav', () => {
|
|||||||
await router.isReady()
|
await router.isReady()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clears stored credentials and redirects to login on logout', async () => {
|
async function mountWithMenuOpen() {
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
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 wrapper.find('.app-nav__logout').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -31,4 +61,33 @@ describe('AppNav', () => {
|
|||||||
expect(localStorage.getItem('user-id')).toBeNull()
|
expect(localStorage.getItem('user-id')).toBeNull()
|
||||||
expect(router.currentRoute.value.name).toBe('login')
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|||||||
import { mount, flushPromises } from '@vue/test-utils'
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import RssFeeds from '../RssFeeds.vue'
|
import RssFeeds from '../RssFeeds.vue'
|
||||||
|
import { useFeeds } from '../../composables/useFeeds'
|
||||||
|
|
||||||
vi.mock('axios')
|
vi.mock('axios')
|
||||||
|
|
||||||
@@ -19,6 +20,14 @@ describe('RssFeeds', () => {
|
|||||||
localStorage.setItem('user-token', 'test-token')
|
localStorage.setItem('user-token', 'test-token')
|
||||||
localStorage.setItem('user-id', '7')
|
localStorage.setItem('user-id', '7')
|
||||||
vi.clearAllMocks()
|
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 () => {
|
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'])
|
expect(titles).toEqual(['Newer article', 'Older article'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('syncs feeds for the current user', async () => {
|
it('shows a link to the original article until the readable version is loaded', async () => {
|
||||||
axios.get.mockResolvedValue({ data: { feeds: [] } })
|
// The API returns each item with a short summary already in `content` —
|
||||||
axios.post.mockResolvedValueOnce({ status: 200 })
|
// 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)
|
const wrapper = mount(RssFeeds)
|
||||||
await flushPromises()
|
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()
|
await flushPromises()
|
||||||
|
|
||||||
expect(axios.post).toHaveBeenCalledWith(
|
expect(wrapper.find('.feed-original-link a').exists()).toBe(false)
|
||||||
'/api/v1/article/sync',
|
})
|
||||||
{ user_id: 7 },
|
|
||||||
expect.anything(),
|
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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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