The Add RSS modal is now opened from a button directly above the feed list in AdminFeeds.vue instead of the hamburger menu, so feed creation lives alongside the rest of feed management. AppNav.vue no longer renders it. AddUrl.vue emits an `added` event on a successful save; AdminFeeds.vue listens for it and reloads its feed list, so a newly added feed shows up immediately without leaving the admin page. Also fixes a bug this move surfaced: the modal's "Close" button had no `type`, so inside the form it defaulted to type="submit" — clicking Close on a filled-in form silently created the feed.
359 lines
11 KiB
Vue
359 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 } = 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>
|
|
<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__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>
|