fix some frontend issues

This commit is contained in:
2026-06-12 10:57:48 +02:00
parent ed1241490d
commit 0820ce6ef7
5 changed files with 383 additions and 23 deletions
+77
View File
@@ -93,6 +93,17 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> a
let frag = Html::parse_fragment(base_content); let frag = Html::parse_fragment(base_content);
let mut content = "".to_string(); let mut content = "".to_string();
// Some feeds (e.g. Stuttgarter Nachrichten) embed a social-sharing widget
// (WhatsApp/Email/Facebook/... links plus a "Link kopiert" tooltip) in the
// article content. It's not part of the article and isn't present in the
// scraped/readable edition either, so skip its text when flattening below.
let selector_social_bar =
Selector::parse("#article-social-bar").expect("\"#article-social-bar\" is a valid CSS selector");
let excluded_node_ids: std::collections::HashSet<_> = frag
.select(&selector_social_bar)
.flat_map(|el| el.descendants().map(|node| node.id()))
.collect();
let selector_img = Selector::parse("img").expect("\"img\" is a valid CSS selector"); let selector_img = Selector::parse("img").expect("\"img\" is a valid CSS selector");
match frag.select(&selector_img).find(image_src_is_resolvable) { match frag.select(&selector_img).find(image_src_is_resolvable) {
Some(image) => { Some(image) => {
@@ -108,6 +119,9 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> a
} }
for node in frag.tree.nodes() { for node in frag.tree.nodes() {
if excluded_node_ids.contains(&node.id()) {
continue;
}
if let scraper::node::Node::Text(text) = node.value() { if let scraper::node::Node::Text(text) = node.value() {
content.push_str(&text.text); content.push_str(&text.text);
} }
@@ -382,4 +396,67 @@ mod tests {
.execute(&mut connection) .execute(&mut connection)
.ok(); .ok();
} }
#[actix_web::test]
async fn create_feed_item_strips_social_sharing_widget() {
let mut connection = establish_connection();
let suffix = unique_suffix();
let new_user = NewUser::new(
format!("social_bar_test_{suffix}"),
format!("social_bar_{suffix}@example.test"),
"secret".to_string(),
)
.unwrap();
let user: User = diesel::insert_into(users::table)
.values(&new_user)
.get_result(&mut connection)
.unwrap();
let new_feed = NewFeed::new(
format!("Social bar 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();
let mut item = Item::default();
item.set_title(Some(format!("Social bar article {suffix}")));
item.set_link(Some(format!("https://example.test/article/{suffix}")));
item.set_content(Some(
r#"<p>Article text</p>
<div id="article-social-bar" data-noprint="true">
<ul>
<li><a id="whatsapp" href="whatsapp://send?text=foo">&nbsp;</a></li>
<li><a id="link_copy" onclick="copyToClipboard()">&nbsp;</a>
<p>Link kopiert</p>
</li>
</ul>
</div>"#
.to_string(),
));
create_feed_item(item, &feed, &mut connection).unwrap();
let items: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.load(&mut connection)
.unwrap();
assert_eq!(1, items.len());
assert!(items[0].content.contains("Article text"));
assert!(!items[0].content.contains("Link kopiert"));
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();
}
} }
+4
View File
@@ -70,6 +70,10 @@
body { body {
min-height: 100vh; min-height: 100vh;
/* Full-bleed article images use a `100vw`-based breakout, which can be
wider than the visible content area (scrollbar) and would otherwise
introduce a horizontal scrollbar. */
overflow-x: hidden;
color: var(--color-text); color: var(--color-text);
background: var(--color-background); background: var(--color-background);
transition: color 0.5s, background-color 0.5s; transition: color 0.5s, background-color 0.5s;
+17 -3
View File
@@ -1,12 +1,26 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router' import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useFeeds } from '@/composables/useFeeds' import { useFeeds } from '@/composables/useFeeds'
import Modal from './modal/AddUrl.vue' import Modal from './modal/AddUrl.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds } = useFeeds() const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds, navTitleVisible } = useFeeds()
const titleRef = ref(null)
let titleObserver
onMounted(() => {
titleObserver = new IntersectionObserver(([entry]) => {
navTitleVisible.value = entry.isIntersecting
})
titleObserver.observe(titleRef.value)
})
onUnmounted(() => {
titleObserver?.disconnect()
})
const onFeedsPage = computed(() => route.path === '/feeds') const onFeedsPage = computed(() => route.path === '/feeds')
@@ -58,7 +72,7 @@ function handleToggleLayout() {
<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 v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span> <span ref="titleRef" class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
<button <button
class="app-nav__hamburger" class="app-nav__hamburger"
type="button" type="button"
+258 -6
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { onMounted } from 'vue'; import { onMounted, computed, nextTick, watch } from 'vue';
import { useFeeds } from '@/composables/useFeeds'; import { useFeeds } from '@/composables/useFeeds';
const { const {
@@ -16,10 +16,50 @@ const {
getReadable, getReadable,
setInitialLoad, setInitialLoad,
showMessageForXSeconds, showMessageForXSeconds,
navTitleVisible,
} = useFeeds() } = useFeeds()
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
const shareLabel = navigator.share ? 'Share' : 'Copy link' const shareLabel = navigator.share ? 'Share' : 'Copy link'
function scrollToNextArticle() {
const articles = document.querySelectorAll('#article .observe')
const threshold = window.scrollY + 1
for (const el of articles) {
if (el.offsetTop > threshold) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
return
}
}
}
// Small images (icons, logos, ...) look bad stretched to the full-bleed
// width used for readable article images — leave them at their natural size
// instead. Intrinsic size is only known once the image has loaded, so check
// on load (or immediately if it's already cached/complete).
const SMALL_IMAGE_THRESHOLD = 200
function markSmallImages() {
document.querySelectorAll('.article-feature__content--readable img').forEach(img => {
const checkSize = () => {
if (img.naturalWidth && img.naturalWidth <= SMALL_IMAGE_THRESHOLD) {
img.classList.add('article-feature__image--small')
}
}
if (img.complete) {
checkSize()
} else {
img.addEventListener('load', checkSize, { once: true })
}
})
}
watch(() => feeds.value[currentIndex.value]?.content, async () => {
await nextTick()
markSmallImages()
})
async function shareUrl(url) { async function shareUrl(url) {
if (navigator.share) { if (navigator.share) {
await navigator.share({ url }) await navigator.share({ url })
@@ -44,6 +84,10 @@ onMounted(async () => {
<div v-if="showMessage" class="message">{{ message }}</div> <div v-if="showMessage" class="message">{{ message }}</div>
<div v-if="viewMode === 'list'" id='article' class='article' :class="{ 'article--cards': layout === 'cards' }"> <div v-if="viewMode === 'list'" id='article' class='article' :class="{ 'article--cards': layout === 'cards' }">
<div v-if="feeds.length" class="list-topbar">
<span v-if="!navTitleVisible" class="list-topbar__title">RSS Reader<span v-if="unreadCount" class="list-topbar__unread"> ({{ unreadCount }})</span></span>
<button type="button" class="list-topbar__next" @click="scrollToNextArticle">Skip to next article &darr;</button>
</div>
<div v-if="feeds.length == 0" class="empty-state"> <div v-if="feeds.length == 0" class="empty-state">
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
@@ -68,7 +112,12 @@ onMounted(async () => {
</div> </div>
<div v-else class="article-single"> <div v-else class="article-single">
<div class="article-single__topbar">
<div class="article-single__topbar-inner">
<button type="button" class="article-single__back" @click="leaveArticleView">&larr; Back to list</button> <button type="button" class="article-single__back" @click="leaveArticleView">&larr; Back to list</button>
<span v-if="feeds.length" class="article-single__progress">{{ currentIndex + 1 }} / {{ feeds.length }}</span>
</div>
</div>
<div v-if="feeds.length == 0" class="empty-state"> <div v-if="feeds.length == 0" class="empty-state">
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
@@ -77,14 +126,16 @@ onMounted(async () => {
<p class="empty-state__label">All caught up</p> <p class="empty-state__label">All caught up</p>
</div> </div>
<template v-else> <template v-else>
<p class="feed-source">{{ feeds[currentIndex].feedTitle }}</p> <article class="article-feature">
<h2 @click="getReadable(feeds[currentIndex], currentIndex)" class="feed-title">{{ feeds[currentIndex].title }}</h2> <p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
<h3>{{ feeds[currentIndex].timestamp }}</h3> <h2 @click="getReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
<p v-if="!feeds[currentIndex].readable" class="feed-original-link"> <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> <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> <button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
</p> </p>
<p class="feed-content" v-html="feeds[currentIndex].content"></p> <p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
</article>
</template> </template>
<div class="article-nav"> <div class="article-nav">
@@ -108,6 +159,50 @@ onMounted(async () => {
</template> </template>
<style scoped> <style scoped>
.list-topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0;
margin-bottom: 0.5rem;
background: var(--color-background);
}
.list-topbar__title {
font-weight: bold;
font-size: clamp(1.1rem, 4vw, 1.4rem);
}
.list-topbar__unread {
font-weight: normal;
opacity: 0.6;
}
.list-topbar__next {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0.5rem 0.9rem;
margin-left: auto;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
.list-topbar__next:hover {
border-color: var(--color-border-hover);
}
.observe {
scroll-margin-top: 3.5rem;
}
/* Plain vertical stack of bordered "cards" — deliberately not flex/grid, and /* Plain vertical stack of bordered "cards" — deliberately not flex/grid, and
with no truncation/max-height: normal block flow lets each card grow to fit with no truncation/max-height: normal block flow lets each card grow to fit
its own full content (images included), with no cross-element interaction. */ its own full content (images included), with no cross-element interaction. */
@@ -202,15 +297,39 @@ onMounted(async () => {
.article-single { .article-single {
position: relative; position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 5rem; padding-bottom: 5rem;
} }
.article-single__topbar {
position: sticky;
top: 0;
z-index: 10;
align-self: flex-start;
width: 100vw;
margin-left: 50%;
margin-bottom: 1rem;
transform: translateX(-50%);
background: var(--color-background);
}
.article-single__topbar-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
max-width: 720px;
margin: 0 auto;
padding: 0.5rem 1rem;
}
.article-single__back { .article-single__back {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-height: 44px; min-height: 44px;
padding: 0.5rem 0.9rem; padding: 0.5rem 0.9rem;
margin-bottom: 1rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 4px; border-radius: 4px;
background: transparent; background: transparent;
@@ -222,6 +341,139 @@ onMounted(async () => {
border-color: var(--color-border-hover); border-color: var(--color-border-hover);
} }
.article-single__progress {
font-size: 0.85rem;
color: var(--color-text);
opacity: 0.6;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.article-feature {
width: 100%;
max-width: 720px;
}
.article-feature__source {
margin: 0 0 0.5em;
padding: 0 1rem;
font-size: clamp(0.75rem, 2vw, 0.85rem);
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--color-accent);
}
.article-feature__title {
cursor: pointer;
margin: 0;
padding: 0 1rem;
font-family: 'Courier New';
font-size: clamp(1.75rem, 6vw, 2.75rem);
font-weight: bold;
line-height: 1.15;
color: var(--color-accent-2);
transition: color 0.2s;
}
.article-feature__title:hover {
color: var(--color-accent-2-hover);
}
.article-feature__meta {
margin: 0.75em 0 1.5em;
padding: 0 1rem 1.5em;
font-size: clamp(0.85rem, 2.5vw, 1rem);
font-weight: normal;
opacity: 0.55;
border-bottom: 1px solid var(--color-border);
}
.article-feature .feed-original-link {
margin-bottom: 1.5em;
padding: 0 1rem;
}
.article-feature__content {
padding: 0 1rem;
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: clamp(1rem, 3.5vw, 1.25rem);
line-height: 1.75;
overflow-wrap: break-word;
}
.article-feature__content :deep(p) {
padding: 0.5em 0;
}
.article-feature__content :deep(h3) {
padding: 0.5em 0;
font-size: clamp(1rem, 3vw, 1.3rem);
font-weight: bold;
}
.article-feature__content :deep(img),
.article-feature__content :deep(video) {
max-width: 100%;
height: auto;
}
.article-feature__content--readable :deep(img),
.article-feature__content--readable :deep(video) {
display: block;
width: 100vw;
max-width: 100vw;
height: auto;
margin-top: 1.5em;
margin-bottom: 1.5em;
margin-left: 50%;
transform: translateX(-50%);
}
/* Small images (icons, logos, ...) keep their natural size instead of being
stretched to the full-bleed width. */
.article-feature__content--readable :deep(img.article-feature__image--small) {
display: block;
width: auto;
max-width: 100%;
margin: 1.5em auto;
transform: none;
}
.article-feature__content :deep(a) {
color: var(--color-accent);
text-decoration-color: var(--color-accent-hover);
}
.article-feature__content :deep(blockquote) {
margin: 1.5em 0;
padding: 1em 0;
border-top: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 1.25em;
font-style: italic;
text-align: center;
}
.article-feature__content :deep(pre) {
overflow-x: auto;
padding: 1em;
background: var(--color-background-soft);
}
.article-feature__content :deep(code) {
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.article-feature__content :deep(figcaption) {
margin-top: 0.5em;
font-size: 0.85em;
text-align: center;
opacity: 0.65;
}
.article-nav { .article-nav {
position: fixed; position: fixed;
right: 1rem; right: 1rem;
+22 -9
View File
@@ -11,6 +11,7 @@ const showModal = ref(false)
const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu
const currentIndex = ref(0) const currentIndex = ref(0)
const layout = ref(localStorage.getItem('layout') || '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
const navTitleVisible = ref(true) // whether AppNav's "RSS Reader (N)" title is currently in view
let observer; // Declare observer outside the setup function let observer; // Declare observer outside the setup function
let initialLoad = false let initialLoad = false
@@ -92,6 +93,11 @@ async function getReadable(feed, index) {
doc.head.prepend(base); doc.head.prepend(base);
doc.querySelectorAll('img').forEach(resolveTemplatedImage); doc.querySelectorAll('img').forEach(resolveTemplatedImage);
doc.querySelectorAll('video, audio').forEach(el => el.remove()); doc.querySelectorAll('video, audio').forEach(el => el.remove());
// Some feeds (e.g. Stuttgarter Nachrichten) embed a social-sharing widget
// (WhatsApp/Email/Facebook/... links plus a "Link kopiert" tooltip) in the
// article body. It's not part of the article, so strip it before Readability
// pulls it into the parsed content.
doc.querySelectorAll('#article-social-bar').forEach(el => el.remove())
// Some feeds (e.g. Deutsche Welle) leave behind a heading + play-icon SVG // Some feeds (e.g. Deutsche Welle) leave behind a heading + play-icon SVG
// for an embedded video/audio player whose actual <video>/<audio>/<iframe> // for an embedded video/audio player whose actual <video>/<audio>/<iframe>
// we already stripped — without it, the heading is just a giant orphaned // we already stripped — without it, the heading is just a giant orphaned
@@ -160,9 +166,14 @@ function setupIntersectionObserver() {
observer.disconnect(); observer.disconnect();
} }
observer = new IntersectionObserver(handleIntersection, { // The sticky topbar overlays the top of the viewport, so an article fully
// hidden behind it should already count as "scrolled past" — shrink the
// observer's root by that height so it stops intersecting at that point.
const topbarHeight = document.querySelector('.list-topbar')?.getBoundingClientRect().height ?? 0;
observer = new IntersectionObserver((entries) => handleIntersection(entries, topbarHeight), {
root: null, // Use the viewport as the root root: null, // Use the viewport as the root
rootMargin: '0px', rootMargin: `-${topbarHeight}px 0px 0px 0px`,
// threshold: 0.5, // Fire the callback when at least 50% of the element is visible // threshold: 0.5, // Fire the callback when at least 50% of the element is visible
}); });
@@ -174,14 +185,15 @@ function setupIntersectionObserver() {
} }
} }
async function handleIntersection(entries) { async function handleIntersection(entries, topbarHeight = 0) {
// An article that has scrolled above the viewport (not intersecting, // An article that has scrolled past the (possibly sticky-bar-shrunk) top
// bounding box above the top edge) has been read. Resolve all affected // edge of the viewport (not intersecting, bounding box above that edge)
// feeds up front, before any removal — splicing `feeds` while iterating // has been read. Resolve all affected feeds up front, before any removal —
// would shift the array indices that later entries' `target.id` refer to, // splicing `feeds` while iterating would shift the array indices that later
// causing the wrong item to be marked read and removed. // entries' `target.id` refer to, causing the wrong item to be marked read
// and removed.
const readFeeds = entries const readFeeds = entries
.filter(entry => initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < 0) .filter(entry => initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < topbarHeight)
.map(entry => feeds.value[entry.target.id]) .map(entry => feeds.value[entry.target.id])
.filter(Boolean) .filter(Boolean)
@@ -280,6 +292,7 @@ export function useFeeds() {
leaveArticleView, leaveArticleView,
layout, layout,
toggleLayout, toggleLayout,
navTitleVisible,
nextArticle, nextArticle,
prevArticle, prevArticle,
fetchData, fetchData,