Files
rss-reader/vue/src/components/AppNav.vue
T
mathias 9c042411fb Show a sync-in-progress spinner in the header next to the feed filter
activeSyncCount/isSyncing() in useFeeds.js already refcounted both boot
reload (fetchData) and manual sync() but wasn't reactive. Make it a ref
+ computed and expose it so AppNav can render a small spinner next to
the feed filter whenever a sync is in flight.

The spinner sits outside the filter's own v-if so it also shows during
a manual sync from pages other than /feeds, and its box is always
reserved (visibility toggle, not v-if) so the one-time --app-nav-height
measurement stays correct.
2026-09-14 18:05:24 +02:00

392 lines
11 KiB
Vue

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
const router = useRouter()
const route = useRoute()
const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll, isSyncing } = useFeeds()
const headerRef = ref(null)
// Native <select>/<option> has no "is open" state — the closed box just shows
// the selected option's text verbatim, so per-option unread counts can only be
// toggled based on focus, since the popup is only ever shown while focused.
// @focus ~ opening (shows counts); @change (picking an option closes the
// popup but keeps focus, so no blur fires) and @blur (focus left without
// picking) ~ closed (counts hidden, so the closed box just shows the name).
const filterOpen = ref(false)
function onFilterFocus() {
filterOpen.value = true
refreshUnreadDisplay()
}
function onFilterChange(event) {
setFeedFilter(event.target.value || null)
filterOpen.value = false
}
// Scroll-driven show/hide: the header slides out of view on scroll-down and
// back in only on scroll-up — never merely by proximity to the top. This is
// show/hide via `transform` (not the old resize behaviour) — the header is
// position:fixed, so translating it never reflows content. The app's
// programmatic scrolls resolve to sensible states without any top-of-page
// special case: scrollTo(0, 0) (article paging, layout toggle) is itself a
// large upward jump, so it crosses REVEAL_THRESHOLD like a real scroll-up and
// reveals the header that way; the list-view read-correction scrollBy moves
// only a few px → stays under the threshold and leaves a hidden header hidden.
const hidden = ref(false)
const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling
// When the feed list mutates itself (read-correction scrollBy + array-splice
// scroll anchoring) the page jumps *upward* without user intent. For this long
// after such a jump we gate only the reveal direction, so the jump can't pop
// the header back into view mid-read. Hiding stays allowed the whole time
// (the jump never scrolls down), so scrolling down still hides normally even
// while articles are being marked read. See lastProgrammaticScroll in useFeeds.
const PROGRAMMATIC_SUPPRESS_MS = 300
let lastY = 0
let accumulated = 0
function onScroll() {
const y = Math.max(0, window.scrollY)
// Keep the header visible while the menu is open (the dropdown is anchored
// to the header, so hiding it would slide the open menu off-screen).
if (menuOpen.value) {
hidden.value = false
accumulated = 0
lastY = y
return
}
const delta = y - lastY
lastY = y
// Reset the accumulator whenever direction flips, so the threshold is
// measured from the last turning point (not from page load).
if ((delta > 0) !== (accumulated > 0)) accumulated = 0
accumulated += delta
// Hiding (scroll-down) is always allowed. Revealing (scroll-up) is gated for
// a short window after a programmatic list update, whose induced jump is
// upward and would otherwise pop the header back into view mid-read. A
// genuine scroll-up reveals once the window has elapsed.
const afterProgrammatic = performance.now() - lastProgrammaticScroll.value < PROGRAMMATIC_SUPPRESS_MS
if (accumulated > REVEAL_THRESHOLD) hidden.value = true // scrolling down
else if (accumulated < -REVEAL_THRESHOLD && !afterProgrammatic) hidden.value = false // scrolling up
}
let ticking = false
function onScrollRaf() {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
onScroll()
ticking = false
})
}
onMounted(() => {
// Drives #app's padding-top / RssFeeds' scroll-margin-top so content below
// the fixed header isn't hidden behind it at scroll position 0. The header is
// a fixed size, so this is measured once on mount and never changes.
const h = headerRef.value?.getBoundingClientRect().height ?? 0
document.documentElement.style.setProperty('--app-nav-height', `${h}px`)
lastY = Math.max(0, window.scrollY)
window.addEventListener('scroll', onScrollRaf, { passive: true })
document.addEventListener('click', onDocumentClick)
refreshUnreadDisplay()
})
onUnmounted(() => {
window.removeEventListener('scroll', onScrollRaf)
document.removeEventListener('click', onDocumentClick)
})
const onFeedsPage = computed(() => route.path === '/feeds')
const menuOpen = ref(false)
function toggleMenu() {
menuOpen.value = !menuOpen.value
}
function closeMenu() {
menuOpen.value = false
}
// The open menu is a thin absolutely-positioned strip under the header, so its
// `@click.self` only catches clicks on that strip — a click anywhere else on
// the page never reaches it. This document-level listener closes the menu on
// any outside click. The hamburger is excluded (it has its own toggle, so an
// opening click mustn't immediately re-close), and clicks inside the menu are
// left to the menu items' own handlers.
function onDocumentClick(event) {
if (!menuOpen.value) return
const target = event.target
if (target.closest?.('.app-nav__hamburger') || target.closest?.('.app-nav__menu')) return
closeMenu()
}
async function logout() {
await logoutSession()
closeMenu()
router.push({ name: 'login' })
}
function handleSync() {
sync()
closeMenu()
}
function handleMarkAllRead() {
markAllRead()
closeMenu()
}
function handleToggleViewMode() {
toggleViewMode()
closeMenu()
}
function handleToggleLayout() {
toggleLayout()
closeMenu()
}
</script>
<template>
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
<div class="app-nav__wrapper">
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
<span
class="app-nav__sync-spinner"
:class="{ 'app-nav__sync-spinner--active': isSyncing }"
aria-hidden="true"
></span>
<select
v-if="onFeedsPage && feedTitles.length"
class="app-nav__filter"
:value="feedFilter ?? ''"
aria-label="Filter by feed"
@focus="onFilterFocus"
@change="onFilterChange"
@blur="filterOpen = false"
>
<option value="">All feeds</option>
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }}<template v-if="filterOpen"> ({{ displayedFeedUnreadCounts[title] ?? 0 }})</template></option>
</select>
<button
class="app-nav__hamburger"
type="button"
: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>
<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>
<template v-if="onFeedsPage">
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
{{ viewMode === 'list' ? 'Article view' : 'List view' }}
</button>
<button v-if="viewMode === 'list'" class="app-nav__menu-item" type="button" @click="handleToggleLayout">
{{ layout === 'list' ? 'Card layout' : 'List layout' }}
</button>
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
</template>
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
<RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink>
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
</div>
</nav>
</Transition>
</header>
</template>
<style scoped>
.app-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: var(--color-background);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
transition: transform 0.25s ease;
will-change: transform;
}
.app-nav--hidden {
transform: translateY(-100%);
}
.app-nav__wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.375rem 1rem;
}
.app-nav__title {
margin-right: auto;
font-weight: bold;
font-size: clamp(0.95rem, 3.5vw, 1.1rem);
}
.app-nav__unread {
font-weight: normal;
opacity: 0.6;
}
.app-nav__sync-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-top-color: var(--color-text);
border-radius: 50%;
opacity: 0.6;
flex-shrink: 0;
visibility: hidden;
}
.app-nav__sync-spinner--active {
visibility: visible;
animation: app-nav-spin 0.8s linear infinite;
}
@keyframes app-nav-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.app-nav__sync-spinner--active {
animation: none;
}
}
.app-nav__filter {
min-height: 44px;
max-width: clamp(120px, 40vw, 220px);
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
font: inherit;
cursor: pointer;
text-overflow: ellipsis;
}
.app-nav__filter:hover {
border-color: var(--color-border-hover);
}
.app-nav__hamburger {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
min-width: 44px;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.app-nav__hamburger-icon {
display: inline-flex;
flex-direction: column;
justify-content: space-between;
width: 22px;
height: 16px;
}
.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;
padding: 0.5rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
text-decoration: none;
font: inherit;
text-align: left;
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) {
.app-nav__wrapper {
padding: 0.5rem 2rem;
}
}
</style>