fix sync issue, frontend improvement

This commit is contained in:
2026-06-09 19:50:47 +02:00
parent d826a8f3dc
commit b851e0257c
6 changed files with 166 additions and 18 deletions
+87 -11
View File
@@ -64,6 +64,27 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
let item_title = item.title.clone().unwrap();
log::info!("Create feed item: {}", item_title);
// Resolve the publication date before any HTML parsing or DB work so we can
// bail out early for old articles. Items without a pub_date are treated as
// current (inserted unconditionally) — feeds that don't publish dates are
// typically small/curated enough that this is fine.
let mut time: NaiveDateTime = Local::now().naive_local();
if let Some(pub_date) = item.pub_date() {
time = match get_date(pub_date) {
Ok(date) => date,
Err(err) => {
log::error!("could not parse pub date: {}", err);
time
}
};
}
let cutoff = Local::now().naive_local() - Duration::days(14);
if time < cutoff {
log::info!("Skipping item {} (older than 2 weeks).", item_title);
return;
}
let base_content: &str = item.content().or(item.description()).unwrap_or_default();
let frag = Html::parse_fragment(base_content);
@@ -96,17 +117,6 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
.unwrap();
if existing_item.is_empty() {
log::info!("{:?}", item.pub_date());
let mut time: NaiveDateTime = Local::now().naive_local();
if item.pub_date().is_some() {
time = match get_date(item.pub_date().unwrap()) {
Ok(date) => date,
Err(err) => {
log::error!("could not unwrap pub date: {}", err);
time
}
};
}
let new_feed_item = NewFeedItem::new(
feed.id,
content.clone(),
@@ -331,6 +341,72 @@ mod tests {
.ok();
}
#[actix_web::test]
async fn create_feed_item_skips_articles_older_than_two_weeks() {
let mut connection = establish_connection();
let suffix = unique_suffix();
let new_user = NewUser::new(
format!("age_skip_test_{suffix}"),
format!("age_skip_{suffix}@example.test"),
"secret".to_string(),
);
let user: User = diesel::insert_into(users::table)
.values(&new_user)
.get_result(&mut connection)
.unwrap();
let new_feed = NewFeed::new(
format!("Age skip test feed {suffix}"),
format!("https://example.test/feed/{suffix}"),
user.id,
);
let feed: Feed = diesel::insert_into(feed::table)
.values(&new_feed)
.get_result(&mut connection)
.unwrap();
// Item with a pub_date 20 days ago — should be ignored by create_feed_item.
let old_date = (Local::now() - Duration::days(20))
.format("%a, %d %b %Y %H:%M:%S %z")
.to_string();
let mut old_item = Item::default();
old_item.set_title(Some(format!("Old article {suffix}")));
old_item.set_link(Some(format!("https://example.test/old/{suffix}")));
old_item.set_pub_date(Some(old_date));
old_item.set_content(Some("<p>old</p>".to_string()));
// Item without a pub_date — treated as current, should be inserted.
let mut fresh_item = Item::default();
fresh_item.set_title(Some(format!("Fresh article {suffix}")));
fresh_item.set_link(Some(format!("https://example.test/fresh/{suffix}")));
fresh_item.set_content(Some("<p>fresh</p>".to_string()));
create_feed_item(old_item, &feed, &mut connection);
create_feed_item(fresh_item, &feed, &mut connection);
let items: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.load(&mut connection)
.unwrap();
assert_eq!(1, items.len(), "old item should have been skipped");
assert!(
items[0].title.contains("Fresh article"),
"only the fresh item should be present"
);
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
.execute(&mut connection)
.ok();
diesel::delete(feed::table.filter(feed::id.eq(feed.id)))
.execute(&mut connection)
.ok();
diesel::delete(users::table.filter(users::id.eq(user.id)))
.execute(&mut connection)
.ok();
}
#[actix_web::test]
async fn create_feed_item_does_not_duplicate_existing_items() {
let mut connection = establish_connection();
+3 -3
View File
@@ -3,7 +3,7 @@
#app {
max-width: 1280px;
margin: 0 auto;
padding: 1rem;
padding: 0.5rem;
font-weight: normal;
}
@@ -69,7 +69,7 @@ a,
.feed-title {
cursor: pointer;
font-family: 'Courier New';
font-size: clamp(1.1rem, 4vw, 1.4rem);
font-size: clamp(1.25rem, 4.5vw, 1.6rem);
font-weight: bold;
color: var(--color-accent-2);
border-bottom: 1px solid #ccc;
@@ -110,6 +110,6 @@ h3 {
@media (min-width: 768px) {
#app {
padding: 2rem;
padding: 0.75rem;
}
}
+7 -2
View File
@@ -4,7 +4,7 @@ import { RouterLink, useRouter } from 'vue-router'
import { useFeeds } from '@/composables/useFeeds'
const router = useRouter()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead } = useFeeds()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds } = useFeeds()
const menuOpen = ref(false)
@@ -52,7 +52,7 @@ function handleToggleLayout() {
<template>
<header class="app-nav">
<div class="app-nav__wrapper">
<span class="app-nav__title">RSS Reader</span>
<span class="app-nav__title">RSS Reader<span v-if="feeds.length" class="app-nav__unread"> ({{ feeds.length }})</span></span>
<button
class="app-nav__hamburger"
type="button"
@@ -111,6 +111,11 @@ function handleToggleLayout() {
font-size: clamp(1.1rem, 4vw, 1.4rem);
}
.app-nav__unread {
font-weight: normal;
opacity: 0.6;
}
.app-nav__hamburger {
display: inline-flex;
align-items: center;
+47 -1
View File
@@ -17,8 +17,20 @@ const {
fetchData,
getReadable,
setInitialLoad,
showMessageForXSeconds,
} = useFeeds()
const shareLabel = navigator.share ? 'Share' : 'Copy link'
async function shareUrl(url) {
if (navigator.share) {
await navigator.share({ url })
} else {
await navigator.clipboard.writeText(url)
showMessageForXSeconds('Link copied.', 2)
}
}
onMounted(async () => {
setInitialLoad(false)
await fetchData()
@@ -49,6 +61,9 @@ onMounted(async () => {
<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>
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feed.url)" :aria-label="shareLabel">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
</button>
</p>
<p class="feed-content" v-html='feed.content'></p>
</div>
@@ -64,6 +79,7 @@ onMounted(async () => {
<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>
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
</p>
<p class="feed-content" v-html="feeds[currentIndex].content"></p>
</template>
@@ -123,6 +139,13 @@ onMounted(async () => {
width: auto;
}
.feed-original-link {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.feed-original-link a {
display: inline-flex;
align-items: center;
@@ -136,6 +159,25 @@ onMounted(async () => {
text-decoration: underline;
}
.feed-share-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 0;
border: none;
background: transparent;
color: var(--color-text);
opacity: 0.45;
cursor: pointer;
transition: opacity 0.15s;
}
.feed-share-btn:hover {
opacity: 1;
}
.article-single {
position: relative;
padding-bottom: 5rem;
@@ -179,10 +221,14 @@ onMounted(async () => {
line-height: 1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
cursor: pointer;
opacity: 0.55;
transition: opacity 0.15s, border-color 0.15s;
}
.article-nav__btn:hover:not(:disabled) {
.article-nav__btn:hover:not(:disabled),
.article-nav__btn:focus-visible {
border-color: var(--color-border-hover);
opacity: 1;
}
.article-nav__btn:disabled {
@@ -148,6 +148,26 @@ describe('AppNav', () => {
confirmSpy.mockRestore()
})
it('shows the unread count in the title when there are articles', async () => {
const { feeds } = useFeeds()
feeds.value = [
{ id: 1, title: 'Article one', content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, title: 'Article two', content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
]
const wrapper = mount(AppNav, { global: { plugins: [router] } })
await flushPromises()
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
})
it('hides the unread count when there are no articles', async () => {
const wrapper = mount(AppNav, { global: { plugins: [router] } })
await flushPromises()
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
})
it('does not mark articles as read when the confirmation is dismissed', async () => {
const { feeds } = useFeeds()
feeds.value = [
+2 -1
View File
@@ -10,7 +10,7 @@ const message = ref('')
const showModal = ref(false)
const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu
const currentIndex = ref(0)
const layout = ref('list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu
let observer; // Declare observer outside the setup function
let initialLoad = false
@@ -219,6 +219,7 @@ function toggleViewMode() {
function toggleLayout() {
layout.value = layout.value === 'list' ? 'cards' : 'list'
localStorage.setItem('layout', layout.value)
}
function nextArticle() {