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
+4
View File
@@ -70,6 +70,10 @@
body {
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);
background: var(--color-background);
transition: color 0.5s, background-color 0.5s;
+17 -3
View File
@@ -1,12 +1,26 @@
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useFeeds } from '@/composables/useFeeds'
import Modal from './modal/AddUrl.vue'
const router = useRouter()
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')
@@ -58,7 +72,7 @@ function handleToggleLayout() {
<template>
<header class="app-nav">
<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
class="app-nav__hamburger"
type="button"
+263 -11
View File
@@ -1,5 +1,5 @@
<script setup>
import { onMounted } from 'vue';
import { onMounted, computed, nextTick, watch } from 'vue';
import { useFeeds } from '@/composables/useFeeds';
const {
@@ -16,10 +16,50 @@ const {
getReadable,
setInitialLoad,
showMessageForXSeconds,
navTitleVisible,
} = useFeeds()
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
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) {
if (navigator.share) {
await navigator.share({ url })
@@ -44,6 +84,10 @@ onMounted(async () => {
<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="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">
<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"/>
@@ -68,7 +112,12 @@ onMounted(async () => {
</div>
<div v-else class="article-single">
<button type="button" class="article-single__back" @click="leaveArticleView">&larr; Back to list</button>
<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>
<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">
<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"/>
@@ -77,14 +126,16 @@ onMounted(async () => {
<p class="empty-state__label">All caught up</p>
</div>
<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 &#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>
<article class="article-feature">
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
<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">
<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="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
</article>
</template>
<div class="article-nav">
@@ -108,6 +159,50 @@ onMounted(async () => {
</template>
<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
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. */
@@ -202,15 +297,39 @@ onMounted(async () => {
.article-single {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
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 {
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;
@@ -222,6 +341,139 @@ onMounted(async () => {
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 {
position: fixed;
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 currentIndex = ref(0)
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 initialLoad = false
@@ -92,6 +93,11 @@ async function getReadable(feed, index) {
doc.head.prepend(base);
doc.querySelectorAll('img').forEach(resolveTemplatedImage);
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
// for an embedded video/audio player whose actual <video>/<audio>/<iframe>
// we already stripped — without it, the heading is just a giant orphaned
@@ -160,9 +166,14 @@ function setupIntersectionObserver() {
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
rootMargin: '0px',
rootMargin: `-${topbarHeight}px 0px 0px 0px`,
// 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) {
// An article that has scrolled above the viewport (not intersecting,
// bounding box above the top edge) has been read. Resolve all affected
// feeds up front, before any removal — splicing `feeds` while iterating
// would shift the array indices that later entries' `target.id` refer to,
// causing the wrong item to be marked read and removed.
async function handleIntersection(entries, topbarHeight = 0) {
// An article that has scrolled past the (possibly sticky-bar-shrunk) top
// edge of the viewport (not intersecting, bounding box above that edge)
// has been read. Resolve all affected feeds up front, before any removal —
// splicing `feeds` while iterating would shift the array indices that later
// entries' `target.id` refer to, causing the wrong item to be marked read
// and removed.
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])
.filter(Boolean)
@@ -280,6 +292,7 @@ export function useFeeds() {
leaveArticleView,
layout,
toggleLayout,
navTitleVisible,
nextArticle,
prevArticle,
fetchData,