Compare commits
10 Commits
5417176dd4
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e3142bac9 | |||
| a73a1b57de | |||
| 3642635b20 | |||
| b3cf5e4787 | |||
| fe0adcf68e | |||
| 3f9de099aa | |||
| b9c0951f2b | |||
| 7a24980101 | |||
| dfc2e29e36 | |||
| a90d10368e |
@@ -4,3 +4,4 @@
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
LEARNINGS.md
|
LEARNINGS.md
|
||||||
PLAN.md
|
PLAN.md
|
||||||
|
/memory
|
||||||
|
|||||||
Generated
+1
-1
@@ -2550,7 +2550,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rss-reader"
|
name = "rss-reader"
|
||||||
version = "0.1.0"
|
version = "0.9.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"actix-cors",
|
"actix-cors",
|
||||||
"actix-governor",
|
"actix-governor",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rss-reader"
|
name = "rss-reader"
|
||||||
version = "0.1.0"
|
version = "0.9.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
<link rel="alternate icon" href="/favicon.ico">
|
<link rel="alternate icon" href="/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>RSS-Reader</title>
|
<title>RSS-Reader</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Lora:ital,wght@0,400;0,700;1,400&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:wght@400;700&family=Raleway:wght@400;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,700;1,8..60,400&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
import { RouterView, useRoute } from 'vue-router'
|
import { RouterView, useRoute } from 'vue-router'
|
||||||
import AppNav from './components/AppNav.vue'
|
import AppNav from './components/AppNav.vue'
|
||||||
|
import { useSettings } from './composables/useSettings.js'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { applySettings } = useSettings()
|
||||||
|
onMounted(applySettings)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -23,6 +23,13 @@
|
|||||||
|
|
||||||
/* semantic color variables for this project */
|
/* semantic color variables for this project */
|
||||||
:root {
|
:root {
|
||||||
|
--headline-font-family: Glook, 'Courier New';
|
||||||
|
--content-font-family: Merriweather, Georgia, 'Times New Roman', Times, serif;
|
||||||
|
--headline-font-size-scale: 1;
|
||||||
|
--content-font-size-scale: 1;
|
||||||
|
--content-text-align: left;
|
||||||
|
--content-padding: 1rem;
|
||||||
|
|
||||||
--color-background: var(--vt-c-white);
|
--color-background: var(--vt-c-white);
|
||||||
--color-background-soft: var(--vt-c-white-soft);
|
--color-background-soft: var(--vt-c-white-soft);
|
||||||
--color-background-mute: var(--vt-c-white-mute);
|
--color-background-mute: var(--vt-c-white-mute);
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ a,
|
|||||||
|
|
||||||
.feed-title {
|
.feed-title {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: Glook, 'Courier New';
|
font-family: var(--headline-font-family);
|
||||||
font-size: clamp(1.4rem, 5vw, 2rem);
|
font-size: calc(clamp(1.4rem, 5vw, 2rem) * var(--headline-font-size-scale));
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--color-accent-2);
|
color: var(--color-accent-2);
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #ccc;
|
||||||
@@ -84,9 +84,10 @@ a,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.feed-content {
|
.feed-content {
|
||||||
font-family: Merriweather, Georgia, 'Times New Roman', Times, serif;
|
font-family: var(--content-font-family);
|
||||||
font-size: clamp(1rem, 3.5vw, 1.25rem);
|
font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale));
|
||||||
padding: 0 1em 1em;
|
text-align: var(--content-text-align);
|
||||||
|
padding: 0 var(--content-padding) 1em;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +102,7 @@ a,
|
|||||||
|
|
||||||
.feed-content h3 {
|
.feed-content h3 {
|
||||||
padding: 0.5em 0;
|
padding: 0.5em 0;
|
||||||
font-size: clamp(1rem, 3vw, 1.3rem);
|
font-size: calc(clamp(1rem, 3vw, 1.3rem) * var(--headline-font-size-scale));
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useSettings } from '../composables/useSettings.js'
|
||||||
|
|
||||||
|
const {
|
||||||
|
headlineSizeScale,
|
||||||
|
contentSizeScale,
|
||||||
|
headlineFontKey,
|
||||||
|
contentFontKey,
|
||||||
|
SIZE_STEPS,
|
||||||
|
SIZE_LABELS,
|
||||||
|
HEADLINE_FONT_OPTIONS,
|
||||||
|
CONTENT_FONT_OPTIONS,
|
||||||
|
setHeadlineSize,
|
||||||
|
setContentSize,
|
||||||
|
setHeadlineFont,
|
||||||
|
setContentFont,
|
||||||
|
textAlignKey,
|
||||||
|
contentPadding,
|
||||||
|
TEXT_ALIGN_OPTIONS,
|
||||||
|
PADDING_STEPS,
|
||||||
|
PADDING_LABELS,
|
||||||
|
setTextAlign,
|
||||||
|
setContentPadding,
|
||||||
|
} = useSettings()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="settings">
|
||||||
|
<h1 class="settings__heading">Typography</h1>
|
||||||
|
|
||||||
|
<section class="settings__section">
|
||||||
|
<h2 class="settings__section-title">Headline Size</h2>
|
||||||
|
<div class="settings__strip">
|
||||||
|
<button
|
||||||
|
v-for="(step, i) in SIZE_STEPS"
|
||||||
|
:key="step"
|
||||||
|
class="settings__btn"
|
||||||
|
:class="{ 'settings__btn--active': headlineSizeScale === step }"
|
||||||
|
type="button"
|
||||||
|
@click="setHeadlineSize(step)"
|
||||||
|
>{{ SIZE_LABELS[i] }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings__section">
|
||||||
|
<h2 class="settings__section-title">Article Text Size</h2>
|
||||||
|
<div class="settings__strip">
|
||||||
|
<button
|
||||||
|
v-for="(step, i) in SIZE_STEPS"
|
||||||
|
:key="step"
|
||||||
|
class="settings__btn"
|
||||||
|
:class="{ 'settings__btn--active': contentSizeScale === step }"
|
||||||
|
type="button"
|
||||||
|
@click="setContentSize(step)"
|
||||||
|
>{{ SIZE_LABELS[i] }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings__section">
|
||||||
|
<h2 class="settings__section-title">Headline Font</h2>
|
||||||
|
<select
|
||||||
|
class="settings__select"
|
||||||
|
:value="headlineFontKey"
|
||||||
|
@change="setHeadlineFont($event.target.value)"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="opt in HEADLINE_FONT_OPTIONS"
|
||||||
|
:key="opt.key"
|
||||||
|
:value="opt.key"
|
||||||
|
>{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings__section">
|
||||||
|
<h2 class="settings__section-title">Article Text Font</h2>
|
||||||
|
<select
|
||||||
|
class="settings__select"
|
||||||
|
:value="contentFontKey"
|
||||||
|
@change="setContentFont($event.target.value)"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="opt in CONTENT_FONT_OPTIONS"
|
||||||
|
:key="opt.key"
|
||||||
|
:value="opt.key"
|
||||||
|
>{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings__section">
|
||||||
|
<h2 class="settings__section-title">Text Alignment</h2>
|
||||||
|
<div class="settings__strip">
|
||||||
|
<button
|
||||||
|
v-for="opt in TEXT_ALIGN_OPTIONS"
|
||||||
|
:key="opt.key"
|
||||||
|
class="settings__btn"
|
||||||
|
:class="{ 'settings__btn--active': textAlignKey === opt.key }"
|
||||||
|
type="button"
|
||||||
|
@click="setTextAlign(opt.key)"
|
||||||
|
>{{ opt.label }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings__section">
|
||||||
|
<h2 class="settings__section-title">Content Padding</h2>
|
||||||
|
<div class="settings__strip">
|
||||||
|
<button
|
||||||
|
v-for="(step, i) in PADDING_STEPS"
|
||||||
|
:key="step"
|
||||||
|
class="settings__btn"
|
||||||
|
:class="{ 'settings__btn--active': contentPadding === step }"
|
||||||
|
type="button"
|
||||||
|
@click="setContentPadding(step)"
|
||||||
|
>{{ PADDING_LABELS[i] }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings {
|
||||||
|
padding: 1.5rem 1rem 0.5rem;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__heading {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__section {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__section-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__btn {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0.3rem 0.9rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__btn:hover {
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__btn--active {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__select:hover {
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,18 +1,86 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } 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, logout as logoutSession } from '@/composables/useFeeds'
|
import { useFeeds, logout as logoutSession } 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, lastProgrammaticScroll } = useFeeds()
|
||||||
|
|
||||||
const headerRef = ref(null)
|
const headerRef = ref(null)
|
||||||
|
|
||||||
|
// Scroll-driven show/hide: the header slides out of view on scroll-down and
|
||||||
|
// back in on scroll-up. This is show/hide via `transform` (not the old
|
||||||
|
// resize behaviour) — the header is position:fixed, so translating it never
|
||||||
|
// reflows content, and the app's programmatic scrolls resolve to sensible
|
||||||
|
// states: scrollTo(0, 0) lands near the top → shown; the list-view
|
||||||
|
// read-correction scrollBy moves only a few px → stays under the threshold.
|
||||||
|
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)
|
||||||
|
const headerH = headerRef.value?.offsetHeight ?? 0
|
||||||
|
|
||||||
|
// Always reveal near the very top, and keep it visible while the menu is
|
||||||
|
// open (the dropdown is anchored to the header, so hiding it would slide the
|
||||||
|
// open menu off-screen).
|
||||||
|
if (y <= headerH || 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(() => {
|
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
|
const h = headerRef.value?.getBoundingClientRect().height ?? 0
|
||||||
document.documentElement.style.setProperty('--app-nav-height', `${h}px`)
|
document.documentElement.style.setProperty('--app-nav-height', `${h}px`)
|
||||||
|
|
||||||
|
lastY = Math.max(0, window.scrollY)
|
||||||
|
window.addEventListener('scroll', onScrollRaf, { passive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('scroll', onScrollRaf)
|
||||||
})
|
})
|
||||||
|
|
||||||
const onFeedsPage = computed(() => route.path === '/feeds')
|
const onFeedsPage = computed(() => route.path === '/feeds')
|
||||||
@@ -62,7 +130,7 @@ function handleToggleLayout() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header ref="headerRef" class="app-nav">
|
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
|
||||||
<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 class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
|
||||||
<button
|
<button
|
||||||
@@ -123,6 +191,13 @@ function handleToggleLayout() {
|
|||||||
right: 0;
|
right: 0;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
background: var(--color-background);
|
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 {
|
.app-nav__wrapper {
|
||||||
@@ -131,12 +206,12 @@ function handleToggleLayout() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.375rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__title {
|
.app-nav__title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: clamp(1.1rem, 4vw, 1.4rem);
|
font-size: clamp(0.95rem, 3.5vw, 1.1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__unread {
|
.app-nav__unread {
|
||||||
@@ -228,7 +303,7 @@ function handleToggleLayout() {
|
|||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.app-nav__wrapper {
|
.app-nav__wrapper {
|
||||||
padding: 1rem 2rem;
|
padding: 0.5rem 2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, computed, nextTick, watch } from 'vue';
|
import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
|
||||||
import { useFeeds } from '@/composables/useFeeds';
|
import { useFeeds } from '@/composables/useFeeds';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -14,6 +14,7 @@ const {
|
|||||||
fetchData,
|
fetchData,
|
||||||
sync,
|
sync,
|
||||||
getReadable,
|
getReadable,
|
||||||
|
disconnectObserver,
|
||||||
setInitialLoad,
|
setInitialLoad,
|
||||||
showMessageForXSeconds,
|
showMessageForXSeconds,
|
||||||
} = useFeeds()
|
} = useFeeds()
|
||||||
@@ -74,6 +75,11 @@ async function shareUrl(url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disconnectObserver()
|
||||||
|
setInitialLoad(false)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
setInitialLoad(false)
|
setInitialLoad(false)
|
||||||
await fetchData()
|
await fetchData()
|
||||||
@@ -302,6 +308,7 @@ onMounted(async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding-top: 1em;
|
||||||
padding-bottom: 5rem;
|
padding-bottom: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,8 +331,8 @@ onMounted(async () => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
font-family: 'Courier New';
|
font-family: var(--headline-font-family);
|
||||||
font-size: clamp(1.4rem, 5vw, 2rem);
|
font-size: calc(clamp(1.4rem, 5vw, 2rem) * var(--headline-font-size-scale));
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
color: var(--color-accent-2);
|
color: var(--color-accent-2);
|
||||||
@@ -351,9 +358,10 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-feature__content {
|
.article-feature__content {
|
||||||
padding: 0 1rem;
|
padding: 0 var(--content-padding);
|
||||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
text-align: var(--content-text-align);
|
||||||
font-size: clamp(1rem, 3.5vw, 1.25rem);
|
font-family: var(--content-font-family);
|
||||||
|
font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale));
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
@@ -364,7 +372,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.article-feature__content :deep(h3) {
|
.article-feature__content :deep(h3) {
|
||||||
padding: 0.5em 0;
|
padding: 0.5em 0;
|
||||||
font-size: clamp(1rem, 3vw, 1.3rem);
|
font-size: calc(clamp(1rem, 3vw, 1.3rem) * var(--headline-font-size-scale));
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,7 +429,7 @@ onMounted(async () => {
|
|||||||
padding: 1em 0;
|
padding: 1em 0;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
font-family: var(--content-font-family);
|
||||||
font-size: 1.25em;
|
font-size: 1.25em;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { mount, flushPromises } from '@vue/test-utils'
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import AppNav from '../AppNav.vue'
|
import AppNav from '../AppNav.vue'
|
||||||
@@ -44,15 +45,35 @@ describe('AppNav', () => {
|
|||||||
await router.isReady()
|
await router.isReady()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Unmount every AppNav mounted via mountNav() after each test so mounted
|
||||||
|
// instances (and their router/menu listeners) don't pile up across the file.
|
||||||
|
let mountedWrappers = []
|
||||||
|
function mountNav(options = { global: { plugins: [router] } }) {
|
||||||
|
const wrapper = mount(AppNav, options)
|
||||||
|
mountedWrappers.push(wrapper)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const wrapper of mountedWrappers) {
|
||||||
|
try {
|
||||||
|
wrapper.unmount()
|
||||||
|
} catch {
|
||||||
|
// already unmounted by the test itself — fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mountedWrappers = []
|
||||||
|
})
|
||||||
|
|
||||||
async function mountWithMenuOpen() {
|
async function mountWithMenuOpen() {
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
await wrapper.find('.app-nav__hamburger').trigger('click')
|
await wrapper.find('.app-nav__hamburger').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
return wrapper
|
return wrapper
|
||||||
}
|
}
|
||||||
|
|
||||||
it('toggles the menu open and closed via the hamburger button', async () => {
|
it('toggles the menu open and closed via the hamburger button', async () => {
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
||||||
|
|
||||||
@@ -164,7 +185,7 @@ describe('AppNav', () => {
|
|||||||
{ id: 2, title: 'Article two', content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
{ id: 2, title: 'Article two', content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
||||||
@@ -177,19 +198,122 @@ describe('AppNav', () => {
|
|||||||
{ id: 2, title: 'Article two', read: false, content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
{ id: 2, title: 'Article two', read: false, content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__title').text()).toContain('(1)')
|
expect(wrapper.find('.app-nav__title').text()).toContain('(1)')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides the unread count when there are no articles', async () => {
|
it('hides the unread count when there are no articles', async () => {
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
|
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('scroll-driven show/hide', () => {
|
||||||
|
// The scroll handler is rAF-throttled; run rAF synchronously so a single
|
||||||
|
// dispatched scroll event resolves before we assert. Per the CLAUDE.md
|
||||||
|
// Vitest gotcha, avoid bare fake timers here — they'd clobber this stub.
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset scroll position so each mount's lastY baseline starts at 0.
|
||||||
|
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true })
|
||||||
|
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
|
||||||
|
// offsetHeight is 0 in jsdom; give the header a real height so the
|
||||||
|
// "near the top" guard (scrollY <= headerH) has something to compare to.
|
||||||
|
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
function scrollTo(y) {
|
||||||
|
Object.defineProperty(window, 'scrollY', { value: y, configurable: true, writable: true })
|
||||||
|
window.dispatchEvent(new Event('scroll'))
|
||||||
|
}
|
||||||
|
|
||||||
|
it('hides the header when scrolling down past the threshold', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
scrollTo(200)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reveals the header again when scrolling back up past the threshold', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
scrollTo(200)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
scrollTo(150)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('always shows the header near the top of the page', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
scrollTo(400)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// Back within the header's own height of the top → always revealed.
|
||||||
|
scrollTo(10)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let a programmatic upward jump reveal the header mid-read', async () => {
|
||||||
|
const { markProgrammaticScroll } = useFeeds()
|
||||||
|
const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(1000)
|
||||||
|
const wrapper = mountNav()
|
||||||
|
|
||||||
|
// Hide it first via a normal scroll-down (no programmatic flag active).
|
||||||
|
scrollTo(400)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// A read-correction flags a programmatic scroll, then the page jumps
|
||||||
|
// upward. Within the window that upward jump must NOT reveal the header.
|
||||||
|
markProgrammaticScroll() // records lastProgrammaticScroll = 1000
|
||||||
|
nowSpy.mockReturnValue(1100) // 100ms later — inside the 300ms window
|
||||||
|
scrollTo(200)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// Still allows hiding on scroll-down even while the flag is active.
|
||||||
|
scrollTo(500)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// Once the window elapses, a genuine scroll-up reveals it again.
|
||||||
|
nowSpy.mockReturnValue(1500) // 500ms after the flag — outside the window
|
||||||
|
scrollTo(450)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not toggle on sub-threshold jitter', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
// Start well below the top so the "near the top" guard doesn't apply.
|
||||||
|
scrollTo(300)
|
||||||
|
await nextTick()
|
||||||
|
// Reveal first so we're testing that small moves don't hide it.
|
||||||
|
scrollTo(260)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
scrollTo(268) // +8px, under the 12px threshold
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('does not mark articles as read when the confirmation is dismissed', async () => {
|
it('does not mark articles as read when the confirmation is dismissed', async () => {
|
||||||
const { feeds } = useFeeds()
|
const { feeds } = useFeeds()
|
||||||
feeds.value = [
|
feeds.value = [
|
||||||
@@ -208,4 +332,5 @@ describe('AppNav', () => {
|
|||||||
|
|
||||||
confirmSpy.mockRestore()
|
confirmSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useFeeds } from '../useFeeds'
|
import { useFeeds } from '../useFeeds'
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards'
|
|||||||
let observer; // Declare observer outside the setup function
|
let observer; // Declare observer outside the setup function
|
||||||
let initialLoad = false
|
let initialLoad = false
|
||||||
|
|
||||||
|
// Timestamp (performance.now()) of the most recent programmatic scroll / list
|
||||||
|
// mutation that moves the page without user intent — currently the list-view
|
||||||
|
// read-correction below. AppNav's auto-hide handler resyncs its scroll baseline
|
||||||
|
// (instead of treating the induced jump as a user scroll) for a short window
|
||||||
|
// after this, so removing read articles can't pop the header in/out mid-read.
|
||||||
|
const lastProgrammaticScroll = ref(0)
|
||||||
|
function markProgrammaticScroll() {
|
||||||
|
lastProgrammaticScroll.value = performance.now()
|
||||||
|
}
|
||||||
|
|
||||||
export function authHeaders() {
|
export function authHeaders() {
|
||||||
return {
|
return {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -134,12 +144,16 @@ async function getReadable(feed, index) {
|
|||||||
})
|
})
|
||||||
// taz "Mehr zum Thema" related-articles teaser section.
|
// taz "Mehr zum Thema" related-articles teaser section.
|
||||||
doc.querySelectorAll('#articleTeaser').forEach(el => el.remove())
|
doc.querySelectorAll('#articleTeaser').forEach(el => el.remove())
|
||||||
// taz subsidiary magazine promo blocks (e.g. taz FUTURZWEI): the promo
|
// taz subsidiary magazine promo blocks (e.g. taz FUTURZWEI): either the
|
||||||
// <article> carries an aria-label containing "Abo".
|
// <article> itself or its direct <a> child carries an aria-label containing "Abo".
|
||||||
doc.querySelectorAll('article[aria-label*="Abo"]').forEach(el => {
|
doc.querySelectorAll('article[aria-label*="Abo"]').forEach(el => {
|
||||||
const container = el.closest('section') ?? el
|
const container = el.closest('section') ?? el
|
||||||
container.remove()
|
container.remove()
|
||||||
})
|
})
|
||||||
|
doc.querySelectorAll('article > a[aria-label*="Abo"]').forEach(el => {
|
||||||
|
const container = el.closest('section') ?? el.closest('article')
|
||||||
|
if (container) container.remove()
|
||||||
|
})
|
||||||
const article = new Readability(doc).parse();
|
const article = new Readability(doc).parse();
|
||||||
if (!article) {
|
if (!article) {
|
||||||
showMessageForXSeconds('Could not extract readable content.', 5)
|
showMessageForXSeconds('Could not extract readable content.', 5)
|
||||||
@@ -243,6 +257,9 @@ function handleIntersection(entries, topbarHeight = 0) {
|
|||||||
observer = null
|
observer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both the array splice (via scroll anchoring) and the scrollBy correction
|
||||||
|
// below move the page — flag it so AppNav's header auto-hide ignores the jump.
|
||||||
|
markProgrammaticScroll()
|
||||||
const readIds = new Set(readFeeds.map(feed => feed.id))
|
const readIds = new Set(readFeeds.map(feed => feed.id))
|
||||||
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
|
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
|
||||||
|
|
||||||
@@ -261,6 +278,7 @@ function handleIntersection(entries, topbarHeight = 0) {
|
|||||||
if (first) {
|
if (first) {
|
||||||
const top = first.getBoundingClientRect().top
|
const top = first.getBoundingClientRect().top
|
||||||
if (top < topbarHeight) {
|
if (top < topbarHeight) {
|
||||||
|
markProgrammaticScroll()
|
||||||
window.scrollBy(0, top - topbarHeight)
|
window.scrollBy(0, top - topbarHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,6 +286,13 @@ function handleIntersection(entries, topbarHeight = 0) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function disconnectObserver() {
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect()
|
||||||
|
observer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setInitialLoad(value) {
|
function setInitialLoad(value) {
|
||||||
initialLoad = value
|
initialLoad = value
|
||||||
}
|
}
|
||||||
@@ -375,7 +400,10 @@ export function useFeeds() {
|
|||||||
markAllRead,
|
markAllRead,
|
||||||
showMessageForXSeconds,
|
showMessageForXSeconds,
|
||||||
setupIntersectionObserver,
|
setupIntersectionObserver,
|
||||||
|
disconnectObserver,
|
||||||
setInitialLoad,
|
setInitialLoad,
|
||||||
handleIntersection,
|
handleIntersection,
|
||||||
|
lastProgrammaticScroll,
|
||||||
|
markProgrammaticScroll,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const HEADLINE_FONT_OPTIONS = [
|
||||||
|
{ key: 'default', label: 'Default (Glook)', value: "Glook, 'Courier New'" },
|
||||||
|
{ key: 'playfair', label: 'Playfair Display', value: "'Playfair Display', Georgia, serif" },
|
||||||
|
{ key: 'lora', label: 'Lora', value: "Lora, Georgia, serif" },
|
||||||
|
{ key: 'raleway', label: 'Raleway', value: "Raleway, -apple-system, sans-serif" },
|
||||||
|
{ key: 'inter', label: 'Inter', value: "Inter, -apple-system, sans-serif" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CONTENT_FONT_OPTIONS = [
|
||||||
|
{ key: 'default', label: 'Default (Merriweather)', value: "Merriweather, Georgia, 'Times New Roman', Times, serif" },
|
||||||
|
{ key: 'lora', label: 'Lora', value: "Lora, Georgia, serif" },
|
||||||
|
{ key: 'source-serif', label: 'Source Serif 4', value: "'Source Serif 4', Georgia, serif" },
|
||||||
|
{ key: 'inter', label: 'Inter', value: "Inter, -apple-system, sans-serif" },
|
||||||
|
{ key: 'playfair', label: 'Playfair Display', value: "'Playfair Display', Georgia, serif" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SIZE_STEPS = [0.85, 1, 1.2, 1.45]
|
||||||
|
const SIZE_LABELS = ['S', 'M', 'L', 'XL']
|
||||||
|
|
||||||
|
const TEXT_ALIGN_OPTIONS = [
|
||||||
|
{ key: 'left', label: 'Left' },
|
||||||
|
{ key: 'justify', label: 'Justified' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PADDING_STEPS = [1, 0.5, 0.15]
|
||||||
|
const PADDING_LABELS = ['Default', 'Compact', 'Minimal']
|
||||||
|
|
||||||
|
const headlineSizeScale = ref(parseFloat(localStorage.getItem('s-headline-size') ?? '1'))
|
||||||
|
const contentSizeScale = ref(parseFloat(localStorage.getItem('s-content-size') ?? '1'))
|
||||||
|
const headlineFontKey = ref(localStorage.getItem('s-headline-font') ?? 'default')
|
||||||
|
const contentFontKey = ref(localStorage.getItem('s-content-font') ?? 'default')
|
||||||
|
const textAlignKey = ref(localStorage.getItem('s-text-align') ?? 'left')
|
||||||
|
const contentPadding = ref(parseFloat(localStorage.getItem('s-content-padding') ?? '1'))
|
||||||
|
|
||||||
|
function fontValue(options, key) {
|
||||||
|
return (options.find(o => o.key === key) ?? options[0]).value
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettings() {
|
||||||
|
const s = document.documentElement.style
|
||||||
|
s.setProperty('--headline-font-size-scale', headlineSizeScale.value)
|
||||||
|
s.setProperty('--content-font-size-scale', contentSizeScale.value)
|
||||||
|
s.setProperty('--headline-font-family', fontValue(HEADLINE_FONT_OPTIONS, headlineFontKey.value))
|
||||||
|
s.setProperty('--content-font-family', fontValue(CONTENT_FONT_OPTIONS, contentFontKey.value))
|
||||||
|
s.setProperty('--content-text-align', textAlignKey.value)
|
||||||
|
s.setProperty('--content-padding', contentPadding.value + 'rem')
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeadlineSize(scale) {
|
||||||
|
headlineSizeScale.value = scale
|
||||||
|
localStorage.setItem('s-headline-size', scale)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContentSize(scale) {
|
||||||
|
contentSizeScale.value = scale
|
||||||
|
localStorage.setItem('s-content-size', scale)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeadlineFont(key) {
|
||||||
|
headlineFontKey.value = key
|
||||||
|
localStorage.setItem('s-headline-font', key)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContentFont(key) {
|
||||||
|
contentFontKey.value = key
|
||||||
|
localStorage.setItem('s-content-font', key)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTextAlign(key) {
|
||||||
|
textAlignKey.value = key
|
||||||
|
localStorage.setItem('s-text-align', key)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContentPadding(step) {
|
||||||
|
contentPadding.value = step
|
||||||
|
localStorage.setItem('s-content-padding', step)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
return {
|
||||||
|
headlineSizeScale,
|
||||||
|
contentSizeScale,
|
||||||
|
headlineFontKey,
|
||||||
|
contentFontKey,
|
||||||
|
SIZE_STEPS,
|
||||||
|
SIZE_LABELS,
|
||||||
|
HEADLINE_FONT_OPTIONS,
|
||||||
|
CONTENT_FONT_OPTIONS,
|
||||||
|
TEXT_ALIGN_OPTIONS,
|
||||||
|
PADDING_STEPS,
|
||||||
|
PADDING_LABELS,
|
||||||
|
applySettings,
|
||||||
|
setHeadlineSize,
|
||||||
|
setContentSize,
|
||||||
|
setHeadlineFont,
|
||||||
|
setContentFont,
|
||||||
|
setTextAlign,
|
||||||
|
setContentPadding,
|
||||||
|
textAlignKey,
|
||||||
|
contentPadding,
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-17
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
scrollBehavior: () => ({ top: 0, behavior: 'instant' }),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -30,23 +31,13 @@ const router = createRouter({
|
|||||||
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to) => {
|
||||||
if (to.meta.requiresAuth) {
|
const isAuthenticated = localStorage.getItem("user-token") != null;
|
||||||
let isAuthenticated = false;
|
// Redirect unauthenticated users hitting a protected route to login;
|
||||||
if (localStorage.getItem("user-token") != null){
|
// returning a value (instead of the deprecated next() callback) is the
|
||||||
isAuthenticated = true;
|
// modern vue-router guard API. Returning nothing lets navigation proceed.
|
||||||
}
|
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||||
|
return '/login';
|
||||||
if (!isAuthenticated) {
|
|
||||||
// Redirect to the login page
|
|
||||||
next('/login');
|
|
||||||
} else {
|
|
||||||
// Proceed to the protected route
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For routes that don't require authentication, proceed without checking
|
|
||||||
next();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import AdminFeeds from '../components/AdminFeeds.vue'
|
import AdminFeeds from '../components/AdminFeeds.vue'
|
||||||
|
import AdminSettings from '../components/AdminSettings.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
|
<AdminSettings />
|
||||||
<AdminFeeds />
|
<AdminFeeds />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user