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.
392 lines
11 KiB
Vue
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>
|