hamburger menu, article view
This commit is contained in:
+122
-157
@@ -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 ↗</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'">← 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>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user