Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfb926cec6 | ||
|
|
77438d0532 | ||
|
|
49ceec5125 | ||
|
|
15ac8c287b | ||
|
|
13d9c2c908 | ||
|
|
01f8a30505 | ||
|
|
a898e7ac33 | ||
|
|
671ed86b48 | ||
|
|
af5e2b8137 | ||
|
|
2b1d1c57ff | ||
|
|
31991ea1f8 | ||
|
|
0e3142bac9 | ||
|
|
a73a1b57de | ||
|
|
3642635b20 | ||
|
|
b3cf5e4787 | ||
|
|
fe0adcf68e | ||
|
|
3f9de099aa | ||
|
|
b9c0951f2b | ||
|
|
7a24980101 | ||
|
|
dfc2e29e36 | ||
|
|
a90d10368e | ||
|
|
5417176dd4 | ||
|
|
4d3f5d3285 | ||
|
|
967803c326 | ||
|
|
e9c865a254 | ||
|
|
570db2d948 | ||
|
|
a37d845875 | ||
|
|
8e57e2f02a | ||
|
|
3671b90b81 | ||
|
|
a399ede401 | ||
|
|
82ec6ea902 | ||
|
|
fbf3597984 | ||
|
|
e9580037ef |
@@ -3,3 +3,5 @@
|
||||
.claude
|
||||
CLAUDE.md
|
||||
LEARNINGS.md
|
||||
PLAN.md
|
||||
/memory
|
||||
|
||||
Generated
+1
-1
@@ -2550,7 +2550,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rss-reader"
|
||||
version = "0.1.0"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"actix-cors",
|
||||
"actix-governor",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rss-reader"
|
||||
version = "0.1.0"
|
||||
version = "0.9.1"
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
+4
-2
@@ -7,7 +7,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN cargo build --release
|
||||
RUN cargo build --release && \
|
||||
cp target/release/rss-reader /usr/local/bin/rss-reader && \
|
||||
rm -rf target
|
||||
|
||||
# --- runtime ---
|
||||
FROM debian:bookworm-slim
|
||||
@@ -16,7 +18,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/target/release/rss-reader /usr/local/bin/rss-reader
|
||||
COPY --from=builder /usr/local/bin/rss-reader /usr/local/bin/rss-reader
|
||||
|
||||
EXPOSE 8001
|
||||
CMD ["rss-reader"]
|
||||
|
||||
@@ -169,8 +169,13 @@ docker compose logs -f backend # follow backend logs
|
||||
docker compose down # stop everything (keeps the postgres_data volume)
|
||||
docker compose down -v # stop and wipe all data — careful!
|
||||
docker compose up --build -d # rebuild after pulling code changes
|
||||
docker builder prune -af && docker image prune -af # reclaim disk used by old build layers/images
|
||||
```
|
||||
|
||||
> Each `docker compose up --build` leaves the previous build's cache layers and images
|
||||
> behind, which adds up quickly given how much disk `cargo build` needs. Run the prune
|
||||
> command above after each rebuild (or on a cron job) to reclaim that space.
|
||||
|
||||
### Optional: hardened deployment — isolated user + rootless Docker
|
||||
|
||||
Anyone who can run `docker` commands effectively has root on the host (container volume mounts can reach the whole filesystem) — being in the `docker` group is root-equivalent. For a production server, it's worth confining this stack to a dedicated, unprivileged system user running its own **rootless Docker** daemon, instead of using a system-wide install or adding the user to the `docker` group.
|
||||
@@ -292,6 +297,7 @@ Fill in `.env` with strong, unique secrets — `openssl rand -hex 32` is a conve
|
||||
|
||||
```sh
|
||||
docker compose up --build -d
|
||||
docker builder prune -af && docker image prune -af # reclaim disk used by old build layers/images
|
||||
```
|
||||
|
||||
**6. Firewall** (run as your normal sudo-capable user — not `rss-svc`):
|
||||
|
||||
@@ -2,6 +2,7 @@ services:
|
||||
postgres:
|
||||
container_name: "rss-postgres"
|
||||
image: "postgres:18"
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
@@ -16,6 +17,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
@@ -31,6 +33,7 @@ services:
|
||||
build:
|
||||
context: ./vue
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
|
||||
/// How long a freshly issued token remains valid for.
|
||||
const TOKEN_LIFETIME_HOURS: i64 = 24;
|
||||
const TOKEN_LIFETIME_HOURS: i64 = 730;
|
||||
|
||||
pub struct JwtToken {
|
||||
pub user_id: i32,
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
# --- builder ---
|
||||
FROM node:20-alpine AS builder
|
||||
# Node 24 (active LTS): Node 20 is past EOL (April 2026), and vue-router 5's
|
||||
# Babel 8 toolchain requires ^22.18.0 || >=24.11.0 (EBADENGINE on node 20).
|
||||
FROM node:24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
<link rel="alternate icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
|
||||
<body>
|
||||
|
||||
Generated
+50
-40
@@ -1275,9 +1275,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
@@ -1427,9 +1427,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -1437,12 +1437,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
||||
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fill-range": "^7.0.1"
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -2205,9 +2205,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
|
||||
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
@@ -2247,9 +2247,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
|
||||
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
@@ -2300,15 +2300,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -2812,10 +2812,20 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
@@ -3224,9 +3234,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
@@ -3299,12 +3309,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
|
||||
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"braces": "^3.0.2",
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3343,9 +3353,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
@@ -3673,9 +3683,9 @@
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -4450,9 +4460,9 @@
|
||||
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
|
||||
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import AppNav from './components/AppNav.vue'
|
||||
import { useSettings } from './composables/useSettings.js'
|
||||
|
||||
const route = useRoute()
|
||||
const { applySettings } = useSettings()
|
||||
onMounted(applySettings)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -23,6 +23,13 @@
|
||||
|
||||
/* semantic color variables for this project */
|
||||
: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-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
@@ -74,6 +81,12 @@ body {
|
||||
wider than the visible content area (scrollbar) and would otherwise
|
||||
introduce a horizontal scrollbar. */
|
||||
overflow-x: hidden;
|
||||
/* Without this, a horizontal touch drag on content with nothing left to
|
||||
scroll (true here, given overflow-x: hidden above) is claimed by
|
||||
Chrome-on-Android's native swipe-to-go-back/forward gesture before our
|
||||
own touchend listener (article-view swipe navigation) ever sees it —
|
||||
the gesture is silently swallowed with no error, no touchend fires. */
|
||||
overscroll-behavior-x: contain;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition: color 0.5s, background-color 0.5s;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0.5rem;
|
||||
padding-top: var(--app-nav-height, 4.5rem);
|
||||
|
||||
font-weight: normal;
|
||||
}
|
||||
@@ -68,8 +69,8 @@ a,
|
||||
|
||||
.feed-title {
|
||||
cursor: pointer;
|
||||
font-family: 'Courier New';
|
||||
font-size: clamp(1.25rem, 4.5vw, 1.6rem);
|
||||
font-family: var(--headline-font-family);
|
||||
font-size: calc(clamp(1.4rem, 5vw, 2rem) * var(--headline-font-size-scale));
|
||||
font-weight: bold;
|
||||
color: var(--color-accent-2);
|
||||
border-bottom: 1px solid #ccc;
|
||||
@@ -83,9 +84,10 @@ a,
|
||||
}
|
||||
|
||||
.feed-content {
|
||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||
font-size: clamp(1rem, 3.5vw, 1.25rem);
|
||||
padding: 0 1em 1em;
|
||||
font-family: var(--content-font-family);
|
||||
font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale));
|
||||
text-align: var(--content-text-align);
|
||||
padding: 0 var(--content-padding) 1em;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
@@ -100,7 +102,7 @@ a,
|
||||
|
||||
.feed-content h3 {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -111,5 +113,6 @@ h3 {
|
||||
@media (min-width: 768px) {
|
||||
#app {
|
||||
padding: 0.75rem;
|
||||
padding-top: var(--app-nav-height, 4.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
+130
-15
@@ -6,25 +6,87 @@ import Modal from './modal/AddUrl.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds, navTitleVisible } = useFeeds()
|
||||
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, feedUnreadCounts, setFeedFilter, unreadCount, lastProgrammaticScroll } = useFeeds()
|
||||
|
||||
const titleRef = ref(null)
|
||||
let titleObserver
|
||||
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(() => {
|
||||
titleObserver = new IntersectionObserver(([entry]) => {
|
||||
navTitleVisible.value = entry.isIntersecting
|
||||
})
|
||||
titleObserver.observe(titleRef.value)
|
||||
// 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)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
titleObserver?.disconnect()
|
||||
window.removeEventListener('scroll', onScrollRaf)
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
})
|
||||
|
||||
const onFeedsPage = computed(() => route.path === '/feeds')
|
||||
|
||||
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
|
||||
|
||||
const menuOpen = ref(false)
|
||||
|
||||
@@ -36,6 +98,19 @@ 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()
|
||||
@@ -69,9 +144,19 @@ function handleToggleLayout() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-nav">
|
||||
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
|
||||
<div class="app-nav__wrapper">
|
||||
<span ref="titleRef" 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>
|
||||
<select
|
||||
v-if="onFeedsPage && feedTitles.length"
|
||||
class="app-nav__filter"
|
||||
:value="feedFilter ?? ''"
|
||||
aria-label="Filter by feed"
|
||||
@change="setFeedFilter($event.target.value || null)"
|
||||
>
|
||||
<option value="">All feeds</option>
|
||||
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }} ({{ feedUnreadCounts[title] ?? 0 }})</option>
|
||||
</select>
|
||||
<button
|
||||
class="app-nav__hamburger"
|
||||
type="button"
|
||||
@@ -124,7 +209,19 @@ function handleToggleLayout() {
|
||||
|
||||
<style scoped>
|
||||
.app-nav {
|
||||
position: relative;
|
||||
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 {
|
||||
@@ -133,12 +230,13 @@ function handleToggleLayout() {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 0.375rem 1rem;
|
||||
}
|
||||
|
||||
.app-nav__title {
|
||||
margin-right: auto;
|
||||
font-weight: bold;
|
||||
font-size: clamp(1.1rem, 4vw, 1.4rem);
|
||||
font-size: clamp(0.95rem, 3.5vw, 1.1rem);
|
||||
}
|
||||
|
||||
.app-nav__unread {
|
||||
@@ -146,6 +244,23 @@ function handleToggleLayout() {
|
||||
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;
|
||||
@@ -230,7 +345,7 @@ function handleToggleLayout() {
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-nav__wrapper {
|
||||
padding: 1rem 2rem;
|
||||
padding: 0.5rem 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+286
-123
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted, computed, nextTick, watch } from 'vue';
|
||||
import { ref, onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
|
||||
import { useFeeds } from '@/composables/useFeeds';
|
||||
|
||||
const {
|
||||
@@ -8,15 +8,15 @@ const {
|
||||
message,
|
||||
viewMode,
|
||||
currentIndex,
|
||||
leaveArticleView,
|
||||
layout,
|
||||
nextArticle,
|
||||
prevArticle,
|
||||
fetchData,
|
||||
sync,
|
||||
getReadable,
|
||||
disconnectObserver,
|
||||
setInitialLoad,
|
||||
showMessageForXSeconds,
|
||||
navTitleVisible,
|
||||
} = useFeeds()
|
||||
|
||||
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
|
||||
@@ -34,6 +34,126 @@ function scrollToNextArticle() {
|
||||
}
|
||||
}
|
||||
|
||||
// Swipe-to-navigate in article view. The article visually tracks the finger
|
||||
// while dragging (dragOffsetX/isDragging below), then either completes the
|
||||
// page-turn or springs back to center on release. This is transient
|
||||
// per-gesture state, not shared app state, so it's kept local to this
|
||||
// component rather than in useFeeds() — nothing outside this component needs
|
||||
// it.
|
||||
const dragOffsetX = ref(0) // live translateX (px) applied to the current article while dragging or springing back
|
||||
const isDragging = ref(false) // true only while actively tracking touchmove — disables the CSS transition so the article follows the finger with zero lag; the spring-back and the eventual page-turn both happen after this flips back to false, so they use the (slower, already-tuned) CSS transitions instead
|
||||
|
||||
let touchStartX = null
|
||||
let touchStartY = null
|
||||
let touchStartTime = null
|
||||
let gestureAxis = null // 'horizontal' | 'vertical' | null (undecided) — locked in after DIRECTION_LOCK_THRESHOLD of movement, so a single gesture can't both page the article and scroll the page
|
||||
let articleWidth = 0 // captured on touchstart; the live drag and the commit-distance check below are relative to it
|
||||
let dragRafPending = false
|
||||
let pendingDragOffset = 0
|
||||
|
||||
const DIRECTION_LOCK_THRESHOLD = 10 // px of movement before an ambiguous drag commits to horizontal (swipe) or vertical (scroll)
|
||||
const COMMIT_DISTANCE_RATIO = 0.35 // fraction of the article's width dragged that counts as "far enough" to complete the page-turn
|
||||
const COMMIT_VELOCITY = 0.5 // px/ms — a flick at least this fast completes the page-turn even short of the distance ratio
|
||||
const BOUNDARY_DAMPING = 0.35 // drag multiplier applied when dragging past the first/last article, so it still moves a little (feedback) but resists
|
||||
|
||||
// Drives which slide-in/slide-out transition ('slide-next' / 'slide-prev') the
|
||||
// article view uses — set right before the index actually changes, by both
|
||||
// the nav buttons and the swipe handler below, so the two triggers animate
|
||||
// identically.
|
||||
const slideDirection = ref('next')
|
||||
|
||||
function navigateToNextArticle() {
|
||||
slideDirection.value = 'next'
|
||||
nextArticle()
|
||||
}
|
||||
|
||||
function navigateToPrevArticle() {
|
||||
slideDirection.value = 'prev'
|
||||
prevArticle()
|
||||
}
|
||||
|
||||
function onArticleTouchStart(event) {
|
||||
// Ignore multi-touch (pinch-zoom) and swipes starting inside a
|
||||
// horizontally-scrollable code block (.article-feature__content :deep(pre))
|
||||
// — those should scroll the block, not page the article.
|
||||
if (event.touches.length !== 1 || event.target.closest('pre')) {
|
||||
touchStartX = null
|
||||
return
|
||||
}
|
||||
const touch = event.touches[0]
|
||||
touchStartX = touch.clientX
|
||||
touchStartY = touch.clientY
|
||||
touchStartTime = Date.now()
|
||||
gestureAxis = null
|
||||
const article = event.currentTarget.querySelector('.article-feature')
|
||||
articleWidth = article ? article.offsetWidth : window.innerWidth
|
||||
}
|
||||
|
||||
function onArticleTouchMove(event) {
|
||||
if (touchStartX === null || event.touches.length !== 1) return
|
||||
const touch = event.touches[0]
|
||||
const deltaX = touch.clientX - touchStartX
|
||||
const deltaY = touch.clientY - touchStartY
|
||||
|
||||
if (gestureAxis === null) {
|
||||
if (Math.abs(deltaX) < DIRECTION_LOCK_THRESHOLD && Math.abs(deltaY) < DIRECTION_LOCK_THRESHOLD) return
|
||||
gestureAxis = Math.abs(deltaX) > Math.abs(deltaY) ? 'horizontal' : 'vertical'
|
||||
if (gestureAxis === 'horizontal') isDragging.value = true
|
||||
}
|
||||
if (gestureAxis !== 'horizontal') return // let the page scroll normally instead
|
||||
|
||||
// Stop the page from also scrolling vertically (and the browser's own
|
||||
// horizontal-overscroll gesture) while we're driving the drag ourselves.
|
||||
// Requires this listener to not be passive.
|
||||
event.preventDefault()
|
||||
|
||||
const atBoundary = (deltaX < 0 && currentIndex.value === feeds.value.length - 1) ||
|
||||
(deltaX > 0 && currentIndex.value === 0)
|
||||
pendingDragOffset = atBoundary ? deltaX * BOUNDARY_DAMPING : deltaX
|
||||
|
||||
// rAF-throttled: touchmove can fire faster than the display refreshes. If a
|
||||
// frame is already scheduled, later events in that window just update
|
||||
// pendingDragOffset above — the callback always reads the latest value
|
||||
// once it actually runs, rather than whatever triggered the first one.
|
||||
if (dragRafPending) return
|
||||
dragRafPending = true
|
||||
requestAnimationFrame(() => {
|
||||
dragOffsetX.value = pendingDragOffset
|
||||
dragRafPending = false
|
||||
})
|
||||
}
|
||||
|
||||
function onArticleTouchEnd(event) {
|
||||
if (touchStartX === null) return
|
||||
const wasDraggingHorizontally = gestureAxis === 'horizontal'
|
||||
// Computed fresh from the release position rather than read back from
|
||||
// dragOffsetX, which is rAF-throttled and so can be a frame stale.
|
||||
const deltaX = event.changedTouches[0].clientX - touchStartX
|
||||
const elapsed = Date.now() - touchStartTime
|
||||
|
||||
touchStartX = null
|
||||
touchStartY = null
|
||||
gestureAxis = null
|
||||
isDragging.value = false
|
||||
|
||||
if (!wasDraggingHorizontally) return
|
||||
|
||||
// Date.now() only has ~1ms resolution, so a genuinely fast flick often
|
||||
// measures as elapsed === 0 — treat that as "as fast as it gets" (clears
|
||||
// the flick check) rather than dividing by zero into "no velocity at all".
|
||||
const isFastFlick = elapsed > 0 ? Math.abs(deltaX / elapsed) > COMMIT_VELOCITY : deltaX !== 0
|
||||
const shouldComplete = Math.abs(deltaX) > articleWidth * COMMIT_DISTANCE_RATIO || isFastFlick
|
||||
dragOffsetX.value = 0 // springs back to center — or, if completing, the (now off-screen-bound) article being replaced
|
||||
|
||||
if (!shouldComplete) return
|
||||
|
||||
if (deltaX < 0) {
|
||||
navigateToNextArticle()
|
||||
} else {
|
||||
navigateToPrevArticle()
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -41,7 +161,7 @@ function scrollToNextArticle() {
|
||||
const SMALL_IMAGE_THRESHOLD = 200
|
||||
|
||||
function markSmallImages() {
|
||||
document.querySelectorAll('.article-feature__content--readable img').forEach(img => {
|
||||
document.querySelectorAll('.article-feature__content--readable img, .feed-content--readable img').forEach(img => {
|
||||
const checkSize = () => {
|
||||
if (img.naturalWidth && img.naturalWidth <= SMALL_IMAGE_THRESHOLD) {
|
||||
img.classList.add('article-feature__image--small')
|
||||
@@ -60,6 +180,12 @@ watch(() => feeds.value[currentIndex.value]?.content, async () => {
|
||||
markSmallImages()
|
||||
})
|
||||
|
||||
async function loadReadable(feed, index) {
|
||||
await getReadable(feed, index)
|
||||
await nextTick()
|
||||
markSmallImages()
|
||||
}
|
||||
|
||||
async function shareUrl(url) {
|
||||
if (navigator.share) {
|
||||
await navigator.share({ url })
|
||||
@@ -69,9 +195,15 @@ async function shareUrl(url) {
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disconnectObserver()
|
||||
setInitialLoad(false)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
setInitialLoad(false)
|
||||
await fetchData()
|
||||
sync(true)
|
||||
setTimeout(function () {
|
||||
setInitialLoad(true)
|
||||
console.log('set to true')
|
||||
@@ -84,10 +216,6 @@ 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 ↓</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"/>
|
||||
@@ -95,29 +223,36 @@ onMounted(async () => {
|
||||
</svg>
|
||||
<p class="empty-state__label">All caught up</p>
|
||||
</div>
|
||||
<template v-for="( feed, index ) in feeds ">
|
||||
<template v-for="( feed, index ) in feeds " :key="feed.id">
|
||||
<div v-bind:id="index" class="observe">
|
||||
<p class="feed-source">{{ feed.feedTitle }}</p>
|
||||
<h2 @click="getReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
|
||||
<h2 @click="loadReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
|
||||
<h3>{{ feed.timestamp }}</h3>
|
||||
<p v-if="!feed.readable" class="feed-original-link">
|
||||
<p class="feed-original-link">
|
||||
<a :href="feed.url" target="_blank" rel="noopener noreferrer">Read original article ↗</a>
|
||||
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feed.url)" :aria-label="shareLabel">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
|
||||
</button>
|
||||
</p>
|
||||
<p class="feed-content" v-html='feed.content'></p>
|
||||
<p class="feed-content" :class="{ 'feed-content--readable': feed.readable }" v-html='feed.content'></p>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
v-if="feeds.length"
|
||||
type="button"
|
||||
class="article-nav__btn list-skip-btn"
|
||||
aria-label="Skip to next article"
|
||||
@click="scrollToNextArticle"
|
||||
>↓</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="article-single">
|
||||
<div class="article-single__topbar">
|
||||
<div class="article-single__topbar-inner">
|
||||
<button type="button" class="article-single__back" @click="leaveArticleView">← Back to list</button>
|
||||
<span v-if="feeds.length" class="article-single__progress">{{ currentIndex + 1 }} / {{ feeds.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="article-single"
|
||||
@touchstart.passive="onArticleTouchStart"
|
||||
@touchmove="onArticleTouchMove"
|
||||
@touchend.passive="onArticleTouchEnd"
|
||||
>
|
||||
<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"/>
|
||||
@@ -126,16 +261,23 @@ onMounted(async () => {
|
||||
<p class="empty-state__label">All caught up</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<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 ↗</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>
|
||||
<Transition :name="`slide-${slideDirection}`">
|
||||
<article
|
||||
class="article-feature"
|
||||
:class="{ 'article-feature--dragging': isDragging }"
|
||||
:style="dragOffsetX ? { transform: `translateX(${dragOffsetX}px)` } : null"
|
||||
:key="currentIndex"
|
||||
>
|
||||
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
|
||||
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
|
||||
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
|
||||
<p class="feed-original-link">
|
||||
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article ↗</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>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<div class="article-nav">
|
||||
@@ -144,14 +286,14 @@ onMounted(async () => {
|
||||
class="article-nav__btn"
|
||||
:disabled="currentIndex === 0"
|
||||
aria-label="Previous article"
|
||||
@click="prevArticle"
|
||||
@click="navigateToPrevArticle"
|
||||
>↑</button>
|
||||
<button
|
||||
type="button"
|
||||
class="article-nav__btn"
|
||||
:disabled="feeds.length === 0 || currentIndex === feeds.length - 1"
|
||||
aria-label="Next article"
|
||||
@click="nextArticle"
|
||||
@click="navigateToNextArticle"
|
||||
>↓</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,48 +301,15 @@ 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);
|
||||
.list-skip-btn {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 1.5rem;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.observe {
|
||||
scroll-margin-top: 3.5rem;
|
||||
scroll-margin-top: var(--app-nav-height, 4.5rem);
|
||||
}
|
||||
|
||||
/* Plain vertical stack of bordered "cards" — deliberately not flex/grid, and
|
||||
@@ -256,6 +365,38 @@ onMounted(async () => {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.feed-content--readable :deep(img),
|
||||
.feed-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%);
|
||||
}
|
||||
|
||||
.feed-content--readable :deep(img.article-feature__image--small) {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
margin: 1.5em auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.feed-content--readable :deep(img),
|
||||
.feed-content--readable :deep(video) {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 1.5em auto;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.feed-original-link {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -300,58 +441,64 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 1em;
|
||||
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;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.article-single__back:hover {
|
||||
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;
|
||||
/* Governs the drag-release spring-back (dragOffsetX snapping to 0 after a
|
||||
cancelled swipe). Overridden by .article-feature--dragging (below, so it
|
||||
wins the tie) while actively tracking a touchmove, and by the
|
||||
.slide-next/.slide-prev *-active rules (further below, so they win too)
|
||||
during an actual page-turn — both need their own transform transition
|
||||
timing instead. */
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.article-feature--dragging {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Slide-in/out when paging between articles (swipe or nav buttons). The
|
||||
outgoing article is pulled out of flex flow during its leave transition so
|
||||
the incoming one can take its place immediately, instead of the two
|
||||
stacking and doubling the page height for the transition's duration. */
|
||||
.slide-next-enter-active,
|
||||
.slide-next-leave-active,
|
||||
.slide-prev-enter-active,
|
||||
.slide-prev-leave-active {
|
||||
transition: transform 0.28s ease, opacity 0.28s ease;
|
||||
}
|
||||
|
||||
.slide-next-leave-active,
|
||||
.slide-prev-leave-active {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.slide-next-enter-from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-next-leave-to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-prev-enter-from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-prev-leave-to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.article-feature__source {
|
||||
@@ -368,8 +515,8 @@ onMounted(async () => {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
padding: 0 1rem;
|
||||
font-family: 'Courier New';
|
||||
font-size: clamp(1.75rem, 6vw, 2.75rem);
|
||||
font-family: var(--headline-font-family);
|
||||
font-size: calc(clamp(1.4rem, 5vw, 2rem) * var(--headline-font-size-scale));
|
||||
font-weight: bold;
|
||||
line-height: 1.15;
|
||||
color: var(--color-accent-2);
|
||||
@@ -395,9 +542,10 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.article-feature__content {
|
||||
padding: 0 1rem;
|
||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||
font-size: clamp(1rem, 3.5vw, 1.25rem);
|
||||
padding: 0 var(--content-padding);
|
||||
text-align: var(--content-text-align);
|
||||
font-family: var(--content-font-family);
|
||||
font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale));
|
||||
line-height: 1.75;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
@@ -408,7 +556,7 @@ onMounted(async () => {
|
||||
|
||||
.article-feature__content :deep(h3) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -440,6 +588,21 @@ onMounted(async () => {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* On desktop the viewport is much wider than the article column, so the
|
||||
full-bleed 100vw treatment above would blow images up far beyond their
|
||||
natural resolution. Keep them at natural size, centered in the text. */
|
||||
@media (min-width: 720px) {
|
||||
.article-feature__content--readable :deep(img),
|
||||
.article-feature__content--readable :deep(video) {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 1.5em auto;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.article-feature__content :deep(a) {
|
||||
color: var(--color-accent);
|
||||
text-decoration-color: var(--color-accent-hover);
|
||||
@@ -450,7 +613,7 @@ onMounted(async () => {
|
||||
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-family: var(--content-font-family);
|
||||
font-size: 1.25em;
|
||||
font-style: italic;
|
||||
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 { nextTick } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import AppNav from '../AppNav.vue'
|
||||
@@ -7,6 +8,15 @@ import { useFeeds } from '../../composables/useFeeds'
|
||||
|
||||
vi.mock('axios')
|
||||
|
||||
// jsdom does not implement IntersectionObserver, but AppNav sets one up on mount
|
||||
// to track whether the list view's title is scrolled into view.
|
||||
class FakeIntersectionObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||
|
||||
describe('AppNav', () => {
|
||||
let router
|
||||
|
||||
@@ -15,8 +25,13 @@ describe('AppNav', () => {
|
||||
localStorage.setItem('user-id', '7')
|
||||
vi.clearAllMocks()
|
||||
|
||||
const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
|
||||
const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
|
||||
feeds.value = []
|
||||
allItems.value = []
|
||||
feedFilter.value = null
|
||||
// Module-singleton state: a prior test may have stamped this via a mocked
|
||||
// performance.now(); reset it so the scroll-driven reveal gate starts clean.
|
||||
lastProgrammaticScroll.value = 0
|
||||
showMessage.value = false
|
||||
message.value = ''
|
||||
showModal.value = false
|
||||
@@ -35,15 +50,35 @@ describe('AppNav', () => {
|
||||
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() {
|
||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
||||
const wrapper = mountNav()
|
||||
await wrapper.find('.app-nav__hamburger').trigger('click')
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -54,6 +89,17 @@ describe('AppNav', () => {
|
||||
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('closes the menu on a click outside of it', async () => {
|
||||
const wrapper = await mountWithMenuOpen()
|
||||
expect(wrapper.find('.app-nav__menu').exists()).toBe(true)
|
||||
|
||||
// A click anywhere outside the menu strip (e.g. on page content) closes it.
|
||||
document.body.dispatchEvent(new Event('click', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('clears stored credentials and redirects to login on logout', async () => {
|
||||
const wrapper = await mountWithMenuOpen()
|
||||
|
||||
@@ -149,38 +195,212 @@ describe('AppNav', () => {
|
||||
})
|
||||
|
||||
it('shows the unread count in the title when there are articles', async () => {
|
||||
const { feeds } = useFeeds()
|
||||
feeds.value = [
|
||||
// The badge is the global unread total, sourced from the master list
|
||||
// (allItems) so it stays correct regardless of any active feed filter.
|
||||
const { allItems } = useFeeds()
|
||||
allItems.value = [
|
||||
{ id: 1, title: 'Article one', content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||
{ 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()
|
||||
|
||||
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
||||
})
|
||||
|
||||
it('excludes already-read articles from the counter while in article view', async () => {
|
||||
const { feeds } = useFeeds()
|
||||
feeds.value = [
|
||||
const { allItems } = useFeeds()
|
||||
allItems.value = [
|
||||
{ id: 1, title: 'Article one', read: true, content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||
{ 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()
|
||||
|
||||
expect(wrapper.find('.app-nav__title').text()).toContain('(1)')
|
||||
})
|
||||
|
||||
it('hides the unread count when there are no articles', async () => {
|
||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
||||
const wrapper = mountNav()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders a feed filter with an "All feeds" option plus one per distinct feed', async () => {
|
||||
const { allItems } = useFeeds()
|
||||
allItems.value = [
|
||||
{ id: 1, feedTitle: 'Feed B', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-02' },
|
||||
{ id: 2, feedTitle: 'Feed A', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-01' },
|
||||
{ id: 3, feedTitle: 'Feed A', title: 'c', url: 'https://example.test/3', timestamp: '2026-01-03' },
|
||||
]
|
||||
|
||||
const wrapper = mountNav()
|
||||
await flushPromises()
|
||||
|
||||
const options = wrapper.find('.app-nav__filter').findAll('option').map(o => o.text())
|
||||
// "All feeds" first, then distinct titles sorted alphabetically, each with
|
||||
// its unread count.
|
||||
expect(options).toEqual(['All feeds', 'Feed A (2)', 'Feed B (1)'])
|
||||
})
|
||||
|
||||
it('does not render the feed filter when there are no feeds', async () => {
|
||||
const wrapper = mountNav()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.app-nav__filter').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('applies the selected feed to the displayed list, and "All feeds" restores it', async () => {
|
||||
const { allItems, feeds, feedFilter } = useFeeds()
|
||||
allItems.value = [
|
||||
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||
{ id: 2, feedTitle: 'Feed B', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||
]
|
||||
|
||||
const wrapper = mountNav()
|
||||
await flushPromises()
|
||||
|
||||
const select = wrapper.find('.app-nav__filter')
|
||||
await select.setValue('Feed A')
|
||||
await flushPromises()
|
||||
|
||||
expect(feedFilter.value).toBe('Feed A')
|
||||
expect(feeds.value.map(f => f.id)).toEqual([1])
|
||||
|
||||
await select.setValue('')
|
||||
await flushPromises()
|
||||
|
||||
expect(feedFilter.value).toBeNull()
|
||||
expect(feeds.value.map(f => f.id)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('shows the selected feed\'s unread count in the title when a filter is active', async () => {
|
||||
const { allItems } = useFeeds()
|
||||
allItems.value = [
|
||||
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||
{ id: 2, feedTitle: 'Feed A', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||
{ id: 3, feedTitle: 'Feed B', title: 'c', url: 'https://example.test/3', timestamp: '2026-01-03' },
|
||||
]
|
||||
|
||||
const wrapper = mountNav()
|
||||
await flushPromises()
|
||||
|
||||
// No filter → global total (3).
|
||||
expect(wrapper.find('.app-nav__title').text()).toContain('(3)')
|
||||
|
||||
await wrapper.find('.app-nav__filter').setValue('Feed A')
|
||||
await flushPromises()
|
||||
|
||||
// Filtered → unread in Feed A only (2).
|
||||
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const { feeds } = useFeeds()
|
||||
feeds.value = [
|
||||
@@ -199,4 +419,5 @@ describe('AppNav', () => {
|
||||
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -15,6 +15,20 @@ class FakeIntersectionObserver {
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||
|
||||
// Shared by the swipe-navigation tests below, which all start from the same
|
||||
// two-article article view.
|
||||
function twoArticleFeed() {
|
||||
return [
|
||||
{
|
||||
title: 'My Feed',
|
||||
items: [
|
||||
{ id: 1, title: 'Article one', content: '<p>one</p>', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' },
|
||||
{ id: 2, title: 'Article two', content: '<p>two</p>', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
describe('RssFeeds', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('user-token', 'test-token')
|
||||
@@ -119,7 +133,14 @@ describe('RssFeeds', () => {
|
||||
],
|
||||
},
|
||||
})
|
||||
axios.post.mockResolvedValueOnce({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
|
||||
// axios.post is also hit by the sync triggered on mount, so branch on the
|
||||
// URL rather than relying on call order via `mockResolvedValueOnce`.
|
||||
axios.post.mockImplementation((url) => {
|
||||
if (url === '/api/v1/article/sync') {
|
||||
return Promise.resolve({ status: 200 })
|
||||
}
|
||||
return Promise.resolve({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
|
||||
})
|
||||
|
||||
const { layout } = useFeeds()
|
||||
layout.value = 'cards'
|
||||
@@ -177,10 +198,7 @@ describe('RssFeeds', () => {
|
||||
expect(titles).toEqual(['Newer article', 'Older article'])
|
||||
})
|
||||
|
||||
it('shows a link to the original article until the readable version is loaded', async () => {
|
||||
// The API returns each item with a short summary already in `content` —
|
||||
// the link must key off the `readable` flag (set once Readability has
|
||||
// parsed the full article), not off `content` truthiness.
|
||||
it('keeps a link to the original article visible after the readable version is loaded', async () => {
|
||||
axios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
feeds: [
|
||||
@@ -199,7 +217,14 @@ describe('RssFeeds', () => {
|
||||
],
|
||||
},
|
||||
})
|
||||
axios.post.mockResolvedValueOnce({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
|
||||
// axios.post is also hit by the sync triggered on mount, so branch on the
|
||||
// URL rather than relying on call order via `mockResolvedValueOnce`.
|
||||
axios.post.mockImplementation((url) => {
|
||||
if (url === '/api/v1/article/sync') {
|
||||
return Promise.resolve({ status: 200 })
|
||||
}
|
||||
return Promise.resolve({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
|
||||
})
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
@@ -212,7 +237,9 @@ describe('RssFeeds', () => {
|
||||
await wrapper.find('.feed-title').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.feed-original-link a').exists()).toBe(false)
|
||||
const linkAfter = wrapper.find('.feed-original-link a')
|
||||
expect(linkAfter.exists()).toBe(true)
|
||||
expect(linkAfter.attributes('href')).toBe('https://example.test/1')
|
||||
})
|
||||
|
||||
it('switches to article view and navigates between articles', async () => {
|
||||
@@ -252,30 +279,31 @@ describe('RssFeeds', () => {
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article one')
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
// Same as in list view: the readable content is loaded on demand by
|
||||
// clicking the headline, not fetched automatically on entering the view.
|
||||
expect(axios.post).not.toHaveBeenCalled()
|
||||
// (axios.post is also hit by the sync triggered on mount.)
|
||||
expect(axios.post).not.toHaveBeenCalledWith('/api/v1/article/read', expect.anything(), expect.anything())
|
||||
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(true)
|
||||
|
||||
await wrapper.find('.article-single .feed-title').trigger('click')
|
||||
await wrapper.find('.article-single .article-feature__title').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith('/api/v1/article/read', { url: 'https://example.test/1' }, expect.anything())
|
||||
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(false)
|
||||
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(true)
|
||||
|
||||
expect(wrapper.findAll('.article-nav__btn')[0].attributes('disabled')).toBeDefined()
|
||||
|
||||
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article two')
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
||||
expect(wrapper.findAll('.article-nav__btn')[1].attributes('disabled')).toBeDefined()
|
||||
|
||||
await wrapper.findAll('.article-nav__btn')[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article one')
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
})
|
||||
|
||||
it('drops articles read while paging through article view once back in the list', async () => {
|
||||
@@ -331,4 +359,262 @@ describe('RssFeeds', () => {
|
||||
const titles = wrapper.findAll('.feed-title').map(el => el.text())
|
||||
expect(titles).toEqual(['Article three'])
|
||||
})
|
||||
|
||||
describe('swipe navigation in article view', () => {
|
||||
// The drag-follow handler is rAF-throttled (dragOffsetX only updates once
|
||||
// per frame) and reads the article's real offsetWidth for the
|
||||
// commit-distance check — stub both so gestures are deterministic. Per
|
||||
// the CLAUDE.md Vitest gotcha, avoid bare fake timers here since they'd
|
||||
// also fake rAF and clobber this manual stub. (Faking Date, even scoped,
|
||||
// was tried for the release-velocity check below and broke test-utils'
|
||||
// event dispatch outright — real small setTimeout delays instead.)
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(400)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Not vi.unstubAllGlobals(): it would also remove the file-level
|
||||
// IntersectionObserver stub (stubGlobal isn't describe-scoped), and
|
||||
// every test in here re-stubs requestAnimationFrame in beforeEach
|
||||
// anyway, so there's nothing to reset it back to for.
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// Drives one full touchstart -> touchmove -> touchend gesture. delayMs is
|
||||
// a genuine wall-clock wait before releasing — it's how tests control the
|
||||
// release velocity (deltaX / elapsed) the component computes, alongside
|
||||
// the distance dragged.
|
||||
async function swipe(wrapper, { startX, startY = 100, endX, endY = 100, delayMs = 0 }) {
|
||||
const article = wrapper.find('.article-single')
|
||||
await article.trigger('touchstart', { touches: [{ clientX: startX, clientY: startY }] })
|
||||
await article.trigger('touchmove', { touches: [{ clientX: endX, clientY: endY }] })
|
||||
if (delayMs > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs))
|
||||
}
|
||||
await article.trigger('touchend', { changedTouches: [{ clientX: endX, clientY: endY }] })
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('drags the article to follow the finger while swiping', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
const article = wrapper.find('.article-single')
|
||||
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
||||
await article.trigger('touchmove', { touches: [{ clientX: 150, clientY: 100 }] })
|
||||
|
||||
const dragged = wrapper.find('.article-feature')
|
||||
expect(dragged.classes()).toContain('article-feature--dragging')
|
||||
expect(dragged.attributes('style')).toContain('translateX(-50px)')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('completes the page-turn on a swipe past the distance threshold', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
// 400px-wide article (mocked above), 35% commit ratio -> needs >140px.
|
||||
await swipe(wrapper, { startX: 300, endX: 80 })
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('completes the page-turn backwards on a swipe past the distance threshold', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
||||
|
||||
await swipe(wrapper, { startX: 80, endX: 300 })
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('springs back without navigating on a short, slow drag', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
// Only 20px of a 400px-wide article (well under the 140px distance
|
||||
// threshold), released slowly enough that velocity doesn't qualify
|
||||
// either (well under the 0.5px/ms flick threshold).
|
||||
await swipe(wrapper, { startX: 200, endX: 180, delayMs: 150 })
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
expect(wrapper.find('.article-feature').classes()).not.toContain('article-feature--dragging')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('completes the page-turn on a fast flick even under the distance threshold', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
// Only 60px (well under the 140px distance threshold), released with
|
||||
// no extra delay. Date.now() only has ~1ms resolution, so a gesture
|
||||
// this fast typically measures as 0ms elapsed — the component treats
|
||||
// that as clearing the flick check outright (see the comment by
|
||||
// isFastFlick in onArticleTouchEnd) rather than as "no velocity".
|
||||
await swipe(wrapper, { startX: 200, endX: 140 })
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not drag or navigate on a mostly-vertical drag', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
await swipe(wrapper, { startX: 200, startY: 100, endX: 205, endY: 250 })
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not navigate when the vertical component dominates a diagonal drag', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
// More horizontal travel (70px) than in the previous test, but the
|
||||
// vertical component (100px) is still larger — locks as a vertical
|
||||
// scroll, not a swipe, regardless of the sizeable deltaX.
|
||||
await swipe(wrapper, { startX: 200, startY: 100, endX: 130, endY: 200 })
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('applies resistance when dragging past the last article', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
await wrapper.findAll('.article-nav__btn')[1].trigger('click') // now on the last article
|
||||
await flushPromises()
|
||||
|
||||
const article = wrapper.find('.article-single')
|
||||
await article.trigger('touchstart', { touches: [{ clientX: 300, clientY: 100 }] })
|
||||
await article.trigger('touchmove', { touches: [{ clientX: 200, clientY: 100 }] }) // still swiping towards "next"
|
||||
|
||||
// Damped to 35% of the raw -100px drag, not the full amount.
|
||||
expect(wrapper.find('.article-feature').attributes('style')).toContain('translateX(-35px)')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('ignores a multi-touch gesture (pinch-zoom)', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
const article = wrapper.find('.article-single')
|
||||
await article.trigger('touchstart', {
|
||||
touches: [{ clientX: 200, clientY: 100 }, { clientX: 50, clientY: 100 }],
|
||||
})
|
||||
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not drag or navigate when a swipe starts inside a code block', async () => {
|
||||
axios.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
feeds: [
|
||||
{
|
||||
title: 'My Feed',
|
||||
items: [
|
||||
{ id: 1, title: 'Article one', content: '<pre>some code</pre>', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' },
|
||||
{ id: 2, title: 'Article two', content: '<p>two</p>', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('pre').trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
||||
await wrapper.find('.article-single').trigger('touchmove', { touches: [{ clientX: 130, clientY: 100 }] })
|
||||
await wrapper.find('.article-single').trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||
expect(wrapper.find('.article-feature').classes()).not.toContain('article-feature--dragging')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not throw when swiping with an empty feed list', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
useFeeds().toggleViewMode()
|
||||
await flushPromises()
|
||||
|
||||
await swipe(wrapper, { startX: 200, endX: 80 })
|
||||
|
||||
expect(wrapper.text()).toContain('All caught up')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import axios from 'axios'
|
||||
import { useFeeds } from '../useFeeds'
|
||||
|
||||
@@ -12,7 +13,7 @@ class FakeIntersectionObserver {
|
||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||
|
||||
describe('useFeeds', () => {
|
||||
const { feeds, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection } = useFeeds()
|
||||
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, setupIntersectionObserver } = useFeeds()
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('user-token', 'test-token')
|
||||
@@ -20,6 +21,8 @@ describe('useFeeds', () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
feeds.value = []
|
||||
allItems.value = []
|
||||
feedFilter.value = null
|
||||
showMessage.value = false
|
||||
message.value = ''
|
||||
showModal.value = false
|
||||
@@ -116,6 +119,153 @@ describe('useFeeds', () => {
|
||||
setInitialLoad(false)
|
||||
})
|
||||
|
||||
it('ignores the initial observer snapshot on connect so a scrolled-down reconnect cannot mass-mark', async () => {
|
||||
feeds.value = [
|
||||
{ id: 201, title: 'First' },
|
||||
{ id: 202, title: 'Second' },
|
||||
]
|
||||
setInitialLoad(true)
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
// A .observe node must exist for setupIntersectionObserver to arm the skip.
|
||||
const node = document.createElement('div')
|
||||
node.className = 'observe'
|
||||
document.body.appendChild(node)
|
||||
setupIntersectionObserver()
|
||||
|
||||
// First callback after connect is the initial snapshot — must be dropped
|
||||
// even though both entries look "scrolled past" (above the topbar).
|
||||
await handleIntersection([
|
||||
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
|
||||
{ isIntersecting: false, boundingClientRect: { y: -5 }, target: { id: '1' } },
|
||||
])
|
||||
expect(axios.put).not.toHaveBeenCalled()
|
||||
expect(feeds.value).toHaveLength(2)
|
||||
|
||||
// A genuine later scroll-past still marks read.
|
||||
await handleIntersection([
|
||||
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
|
||||
])
|
||||
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/201', null, expect.anything())
|
||||
|
||||
document.body.removeChild(node)
|
||||
setInitialLoad(false)
|
||||
})
|
||||
|
||||
describe('feed filter', () => {
|
||||
const twoFeedsResponse = {
|
||||
data: {
|
||||
feeds: [
|
||||
{
|
||||
title: 'Feed A',
|
||||
items: [
|
||||
{ id: 1, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' },
|
||||
{ id: 3, title: 'A2', content: '', url: 'https://example.test/a2', timestamp: '2026-01-03 10:00:00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Feed B',
|
||||
items: [
|
||||
{ id: 2, title: 'B1', content: '', url: 'https://example.test/b1', timestamp: '2026-01-02 10:00:00' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
it('narrows the displayed list to a single feed and restores it on "All feeds"', async () => {
|
||||
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||
await fetchData()
|
||||
|
||||
// Options are the distinct feed titles, sorted.
|
||||
expect(feedTitles.value).toEqual(['Feed A', 'Feed B'])
|
||||
// Unfiltered: everything, newest first (Jan 3, Jan 2, Jan 1).
|
||||
expect(feeds.value.map(f => f.id)).toEqual([3, 2, 1])
|
||||
|
||||
await setFeedFilter('Feed A')
|
||||
expect(feeds.value.map(f => f.id)).toEqual([3, 1])
|
||||
|
||||
await setFeedFilter(null)
|
||||
expect(feeds.value.map(f => f.id)).toEqual([3, 2, 1])
|
||||
})
|
||||
|
||||
it('does not resurrect read articles when the filter is cleared', async () => {
|
||||
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
await fetchData()
|
||||
|
||||
await setFeedFilter('Feed A')
|
||||
setInitialLoad(true)
|
||||
|
||||
// The first Feed A article scrolls above the viewport → marked read and
|
||||
// dropped from both the filtered list and the master.
|
||||
await handleIntersection([
|
||||
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
|
||||
])
|
||||
setInitialLoad(false)
|
||||
|
||||
expect(feeds.value.map(f => f.id)).toEqual([1])
|
||||
|
||||
await setFeedFilter(null)
|
||||
// Article 3 stays gone; the still-unread articles remain, newest first.
|
||||
expect(feeds.value.map(f => f.id)).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('keeps an emptied filtered feed selected and selectable, showing "All caught up"', async () => {
|
||||
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
await fetchData()
|
||||
|
||||
await setFeedFilter('Feed B')
|
||||
expect(feeds.value.map(f => f.id)).toEqual([2])
|
||||
|
||||
// Mark all (visible = just Feed B) read — Feed B has no unread items left.
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
await markAllRead()
|
||||
|
||||
// The list empties, but the filter stays put and the feed remains a
|
||||
// selectable option (so the <select> never dangles) even though it no
|
||||
// longer has unread items.
|
||||
expect(feeds.value).toEqual([])
|
||||
expect(feedFilter.value).toBe('Feed B')
|
||||
expect(feedTitles.value).toEqual(['Feed A', 'Feed B'])
|
||||
|
||||
// Switching away drops the now-empty feed from the options.
|
||||
await setFeedFilter(null)
|
||||
expect(feedTitles.value).toEqual(['Feed A'])
|
||||
expect(feeds.value.map(f => f.id)).toEqual([3, 1])
|
||||
})
|
||||
|
||||
it('counts unread items in the selected feed when a filter is active, else the global total', async () => {
|
||||
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||
await fetchData()
|
||||
|
||||
// No filter: global unread total across both feeds.
|
||||
expect(unreadCount.value).toBe(3)
|
||||
|
||||
// Filtered: only the selected feed's unread items.
|
||||
await setFeedFilter('Feed B')
|
||||
expect(unreadCount.value).toBe(1)
|
||||
await setFeedFilter('Feed A')
|
||||
expect(unreadCount.value).toBe(2)
|
||||
|
||||
await setFeedFilter(null)
|
||||
expect(unreadCount.value).toBe(3)
|
||||
})
|
||||
|
||||
it('exposes per-feed unread counts, excluding items marked read in place', async () => {
|
||||
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||
await fetchData()
|
||||
|
||||
expect(feedUnreadCounts.value).toEqual({ 'Feed A': 2, 'Feed B': 1 })
|
||||
|
||||
// Article view marks items read in place (feed.read = true) without
|
||||
// removing them from the master — the counts must not include those.
|
||||
allItems.value.find(i => i.id === 3).read = true
|
||||
expect(feedUnreadCounts.value).toEqual({ 'Feed A': 1, 'Feed B': 1 })
|
||||
})
|
||||
})
|
||||
|
||||
it('strips leftover embedded-video placeholder headings', async () => {
|
||||
feeds.value = [{
|
||||
id: 1,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Reproduces the "reload the tab while in article view" boot sequence:
|
||||
// module-scope init must pick up the persisted viewMode, and RssFeeds'
|
||||
// mount flow (fetchData + sync) must not reset it back to 'list'.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
vi.mock('axios')
|
||||
|
||||
class FakeIntersectionObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||
|
||||
describe('viewMode boot persistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
localStorage.clear()
|
||||
localStorage.setItem('user-token', 'test-token')
|
||||
localStorage.setItem('user-id', '7')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('boots into article view when localStorage says so, surviving the mount fetch/sync', async () => {
|
||||
localStorage.setItem('viewMode', 'article')
|
||||
|
||||
const axios = (await import('axios')).default
|
||||
axios.get.mockResolvedValue({
|
||||
data: {
|
||||
feeds: [
|
||||
{
|
||||
title: 'My Feed',
|
||||
items: [
|
||||
{ id: 1, title: 'Article one', content: '<p>hi</p>', url: 'https://example.test/1', timestamp: '2026-01-01 10:00:00' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
axios.post.mockResolvedValue({ status: 200 })
|
||||
axios.put.mockResolvedValue({ status: 200 })
|
||||
|
||||
// Import AFTER setting storage — module-scope refs initialize at import.
|
||||
const { useFeeds } = await import('../useFeeds')
|
||||
const { viewMode } = useFeeds()
|
||||
expect(viewMode.value).toBe('article')
|
||||
|
||||
const RssFeeds = (await import('../../components/RssFeeds.vue')).default
|
||||
const wrapper = mount(RssFeeds)
|
||||
await flushPromises()
|
||||
|
||||
expect(viewMode.value).toBe('article')
|
||||
expect(wrapper.find('.article-single').exists()).toBe(true)
|
||||
expect(wrapper.find('#article').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('boots into list view when the key is absent', async () => {
|
||||
const axios = (await import('axios')).default
|
||||
axios.get.mockResolvedValue({ data: { feeds: [] } })
|
||||
axios.post.mockResolvedValue({ status: 200 })
|
||||
|
||||
const { useFeeds } = await import('../useFeeds')
|
||||
const { viewMode } = useFeeds()
|
||||
expect(viewMode.value).toBe('list')
|
||||
})
|
||||
})
|
||||
+210
-25
@@ -1,20 +1,75 @@
|
||||
import { ref, nextTick } from 'vue';
|
||||
import { ref, computed, nextTick } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { Readability } from '@mozilla/readability';
|
||||
|
||||
// Module-level state — declared outside useFeeds() so every caller shares the
|
||||
// same singleton refs (a Pinia-free "store" for the feed list and its UI state).
|
||||
const showMessage = ref(false)
|
||||
// `allItems` is the full flattened+sorted stream from the last fetch (the
|
||||
// master); `feeds` is the currently displayed projection of it (all items, or a
|
||||
// single feed when a filter is active). Every view/observer consumer operates on
|
||||
// `feeds` — the filter layer only ever swaps what `feeds` points at, so none of
|
||||
// the index-based observer / currentIndex logic has to know a filter exists.
|
||||
const allItems = ref([]);
|
||||
const feeds = ref([]);
|
||||
const feedFilter = ref(null) // selected feedTitle, or null = all feeds (not persisted)
|
||||
// Distinct feed titles present in the loaded (unread) items — the filter options.
|
||||
// The active filter is always kept in the list even once its feed runs out of
|
||||
// unread items, so the <select> never binds to a value that isn't an option
|
||||
// (it just shows "All caught up") and the feed stays re-selectable.
|
||||
const feedTitles = computed(() => {
|
||||
const titles = new Set(allItems.value.map(i => i.feedTitle))
|
||||
if (feedFilter.value) titles.add(feedFilter.value)
|
||||
return [...titles].sort((a, b) => a.localeCompare(b))
|
||||
})
|
||||
// Unread count per feed title, shown next to each option in the filter
|
||||
// <select>. Derived from the master (allItems) so article view's
|
||||
// read-but-still-shown items are excluded. A title kept in feedTitles with no
|
||||
// unread items left has no key here — render it as 0.
|
||||
const feedUnreadCounts = computed(() => {
|
||||
const counts = {}
|
||||
for (const item of allItems.value) {
|
||||
if (!item.read) counts[item.feedTitle] = (counts[item.feedTitle] ?? 0) + 1
|
||||
}
|
||||
return counts
|
||||
})
|
||||
// The header badge count: unread items in the selected feed when a filter is
|
||||
// active, otherwise the global unread total. Derived from the master (allItems)
|
||||
// rather than the displayed `feeds` so article view's read-but-still-shown
|
||||
// items are excluded and the count matches the filter regardless of view.
|
||||
const unreadCount = computed(() => {
|
||||
const items = feedFilter.value
|
||||
? allItems.value.filter(i => i.feedTitle === feedFilter.value)
|
||||
: allItems.value
|
||||
return items.filter(i => !i.read).length
|
||||
})
|
||||
const message = ref('')
|
||||
const showModal = ref(false)
|
||||
const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu
|
||||
const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
|
||||
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
|
||||
// An IntersectionObserver always delivers an initial snapshot for every element
|
||||
// it starts observing. If the observer (re)connects while the page is scrolled
|
||||
// down — e.g. leaving a scrolled article view, an HMR module reload, or the
|
||||
// post-splice re-setup in handleIntersection — that snapshot reports every
|
||||
// article above the viewport as "not intersecting / scrolled past" and would
|
||||
// mark them all read at once. So each setupIntersectionObserver() sets this and
|
||||
// handleIntersection() drops exactly the first callback after a connect; only
|
||||
// genuine scroll-driven exits mark read after that.
|
||||
let skipNextObservation = 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() {
|
||||
return {
|
||||
@@ -121,7 +176,35 @@ async function getReadable(feed, index) {
|
||||
el.remove()
|
||||
}
|
||||
})
|
||||
// Alpine.js widget overlays: x-cloak marks elements that should be hidden
|
||||
// until Alpine.js initialises (prevents FOUC). These are always widget
|
||||
// containers (e.g. taz's "taz schneller googeln" promo), never article
|
||||
// content, so they're safe to remove unconditionally.
|
||||
doc.querySelectorAll('[x-cloak]').forEach(el => el.remove())
|
||||
// taz subscription promo blocks: a standalone <section> whose link(s) point
|
||||
// to an /abo/ subscription page. Only climb to <section>, not <article>,
|
||||
// to avoid accidentally removing the main article body.
|
||||
doc.querySelectorAll('a[href*="/abo/"]').forEach(el => {
|
||||
const container = el.closest('section')
|
||||
if (container) container.remove()
|
||||
})
|
||||
// taz "Mehr zum Thema" related-articles teaser section.
|
||||
doc.querySelectorAll('#articleTeaser').forEach(el => el.remove())
|
||||
// taz subsidiary magazine promo blocks (e.g. taz FUTURZWEI): either the
|
||||
// <article> itself or its direct <a> child carries an aria-label containing "Abo".
|
||||
doc.querySelectorAll('article[aria-label*="Abo"]').forEach(el => {
|
||||
const container = el.closest('section') ?? el
|
||||
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();
|
||||
if (!article) {
|
||||
showMessageForXSeconds('Could not extract readable content.', 5)
|
||||
return
|
||||
}
|
||||
feeds.value[index].content = article.content;
|
||||
feeds.value[index].readable = true;
|
||||
} catch (error) {
|
||||
@@ -139,6 +222,30 @@ async function markRead(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// Projects the master list onto `feeds` through the active filter. A filtered
|
||||
// feed with no remaining unread items projects to an empty list ("All caught
|
||||
// up") while staying selected — see feedTitles.
|
||||
function applyFilter() {
|
||||
feeds.value = feedFilter.value
|
||||
? allItems.value.filter(i => i.feedTitle === feedFilter.value)
|
||||
: allItems.value.slice()
|
||||
}
|
||||
|
||||
// Changes the active feed filter and re-projects. Mirrors toggleLayout's
|
||||
// observer/scroll-safe pattern: disconnect first, scroll to top, then re-point
|
||||
// the observer at the new .observe nodes after Vue has re-rendered. Works in
|
||||
// article view too — there are no .observe nodes there so the setup is a no-op,
|
||||
// and resetting currentIndex keeps paging valid against the new list.
|
||||
async function setFeedFilter(title) {
|
||||
disconnectObserver()
|
||||
window.scrollTo(0, 0)
|
||||
feedFilter.value = title // null for "All feeds"
|
||||
currentIndex.value = 0
|
||||
applyFilter()
|
||||
await nextTick()
|
||||
setupIntersectionObserver()
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
const user_id = localStorage.getItem("user-id")
|
||||
try {
|
||||
@@ -150,7 +257,8 @@ const fetchData = async () => {
|
||||
// timestamps are zero-padded "YYYY-MM-DD HH:MM:SS" strings, so a plain
|
||||
// lexicographic comparison sorts them chronologically.
|
||||
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
feeds.value = items;
|
||||
allItems.value = items;
|
||||
applyFilter();
|
||||
await nextTick();
|
||||
setupIntersectionObserver();
|
||||
} catch (error) {
|
||||
@@ -159,19 +267,21 @@ const fetchData = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
async function sync() {
|
||||
async function sync(silent = false) {
|
||||
try {
|
||||
const response = await axios.post('/api/v1/article/sync', {
|
||||
user_id: parseInt(localStorage.getItem("user-id"))
|
||||
}, authHeaders())
|
||||
|
||||
if (response.status == 200) {
|
||||
if (response.status == 200 && !silent) {
|
||||
showMessageForXSeconds('Sync successful.', 5)
|
||||
}
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error sync', error)
|
||||
showMessageForXSeconds(error, 5)
|
||||
if (!silent) {
|
||||
showMessageForXSeconds(error, 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +293,7 @@ function setupIntersectionObserver() {
|
||||
// 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;
|
||||
const topbarHeight = document.querySelector('.app-nav')?.getBoundingClientRect().height ?? 0;
|
||||
|
||||
observer = new IntersectionObserver((entries) => handleIntersection(entries, topbarHeight), {
|
||||
root: null, // Use the viewport as the root
|
||||
@@ -192,20 +302,27 @@ function setupIntersectionObserver() {
|
||||
});
|
||||
|
||||
const observedDivs = document.querySelectorAll(".observe");
|
||||
if (observedDivs.length > 0) {
|
||||
observedDivs.forEach(observedDiv => {
|
||||
observer.observe(observedDiv);
|
||||
})
|
||||
}
|
||||
// Arm the skip only when we actually observe nodes: an observe() batch fires
|
||||
// an initial snapshot to drop (a connect while scrolled down would otherwise
|
||||
// mass-mark everything above the viewport), but with nothing observed there's
|
||||
// no snapshot — and assigning unconditionally clears any stale flag from a
|
||||
// prior no-op setup (e.g. one run in article view, which has no .observe nodes).
|
||||
skipNextObservation = observedDivs.length > 0
|
||||
observedDivs.forEach(observedDiv => {
|
||||
observer.observe(observedDiv);
|
||||
})
|
||||
}
|
||||
|
||||
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.
|
||||
function handleIntersection(entries, topbarHeight = 0) {
|
||||
// Drop the initial snapshot fired on (re)connect — see skipNextObservation.
|
||||
// The old observer is disconnected before this new one observes, so only the
|
||||
// latest observer's initial callback reaches here; one skip is enough.
|
||||
if (skipNextObservation) {
|
||||
skipNextObservation = false
|
||||
return
|
||||
}
|
||||
// Resolve all affected feeds before touching feeds.value — the target.id
|
||||
// indices are render-time positions that shift once we splice the array.
|
||||
const readFeeds = entries
|
||||
.filter(entry => initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < topbarHeight)
|
||||
.map(entry => feeds.value[entry.target.id])
|
||||
@@ -213,13 +330,51 @@ async function handleIntersection(entries, topbarHeight = 0) {
|
||||
|
||||
if (readFeeds.length === 0) return
|
||||
|
||||
for (const feed of readFeeds) {
|
||||
await markRead(feed.id)
|
||||
// Disconnect before the DOM mutation. In card layout the cards are short
|
||||
// enough that the shift caused by removing one can push the next card above
|
||||
// the header, which the observer would immediately treat as another read —
|
||||
// cascading until many articles disappear at once.
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
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))
|
||||
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
|
||||
document.getElementById(0)?.scrollIntoView()
|
||||
// Mirror into the master so cleared filters don't resurrect read articles.
|
||||
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
|
||||
|
||||
for (const feed of readFeeds) {
|
||||
markRead(feed.id)
|
||||
}
|
||||
|
||||
nextTick().then(() => {
|
||||
// If scroll anchoring didn't compensate for the removed content (common
|
||||
// with position:fixed headers and overflow-x:hidden on body), the first
|
||||
// remaining article will have drifted above the header. Correct the scroll
|
||||
// position so it sits exactly at the header bottom before reconnecting —
|
||||
// otherwise the initial observation would immediately mark everything above
|
||||
// the topbar as read and cascade until the list is empty.
|
||||
const first = document.querySelector('.observe')
|
||||
if (first) {
|
||||
const top = first.getBoundingClientRect().top
|
||||
if (top < topbarHeight) {
|
||||
markProgrammaticScroll()
|
||||
window.scrollBy(0, top - topbarHeight)
|
||||
}
|
||||
}
|
||||
setupIntersectionObserver()
|
||||
})
|
||||
}
|
||||
|
||||
function disconnectObserver() {
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
observer = null
|
||||
}
|
||||
}
|
||||
|
||||
function setInitialLoad(value) {
|
||||
@@ -231,7 +386,11 @@ async function markAllRead() {
|
||||
if (!window.confirm('Mark all articles as read?')) return
|
||||
|
||||
const ids = feeds.value.map(feed => feed.id)
|
||||
const readIds = new Set(ids)
|
||||
feeds.value = []
|
||||
// markAllRead operates on the visible subset (only the filtered feed, if one
|
||||
// is active) — drop exactly those from the master too.
|
||||
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
|
||||
currentIndex.value = 0
|
||||
// markRead swallows its own errors, so Promise.all can't reject here.
|
||||
await Promise.all(ids.map(id => markRead(id)))
|
||||
@@ -254,8 +413,11 @@ async function leaveArticleView() {
|
||||
// in place so currentIndex stayed valid — drop them now so they don't keep
|
||||
// showing up in the list view.
|
||||
feeds.value = feeds.value.filter(feed => !feed.read)
|
||||
// Shared references — the paged-past objects carry .read on the master too.
|
||||
allItems.value = allItems.value.filter(feed => !feed.read)
|
||||
currentIndex.value = 0
|
||||
viewMode.value = 'list'
|
||||
localStorage.setItem('viewMode', viewMode.value)
|
||||
// The v-if on the list container tears down and recreates all .observe DOM
|
||||
// nodes when switching views, so the intersection observer must be
|
||||
// re-pointed at the new elements after Vue has finished rendering.
|
||||
@@ -267,15 +429,30 @@ function toggleViewMode() {
|
||||
if (viewMode.value === 'article') {
|
||||
leaveArticleView()
|
||||
} else {
|
||||
// Disconnect first: the v-if switch is about to unmount all .observe
|
||||
// elements, which would otherwise fire intersection callbacks reporting
|
||||
// them as no-longer-intersecting and mark every visible article read.
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
observer = null
|
||||
}
|
||||
viewMode.value = 'article'
|
||||
localStorage.setItem('viewMode', viewMode.value)
|
||||
currentIndex.value = 0
|
||||
markCurrentArticleRead()
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLayout() {
|
||||
async function toggleLayout() {
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
observer = null
|
||||
}
|
||||
window.scrollTo(0, 0)
|
||||
layout.value = layout.value === 'list' ? 'cards' : 'list'
|
||||
localStorage.setItem('layout', layout.value)
|
||||
await nextTick()
|
||||
setupIntersectionObserver()
|
||||
}
|
||||
|
||||
function nextArticle() {
|
||||
@@ -297,6 +474,12 @@ function prevArticle() {
|
||||
export function useFeeds() {
|
||||
return {
|
||||
feeds,
|
||||
allItems,
|
||||
feedFilter,
|
||||
feedTitles,
|
||||
feedUnreadCounts,
|
||||
unreadCount,
|
||||
setFeedFilter,
|
||||
showMessage,
|
||||
message,
|
||||
showModal,
|
||||
@@ -306,7 +489,6 @@ export function useFeeds() {
|
||||
leaveArticleView,
|
||||
layout,
|
||||
toggleLayout,
|
||||
navTitleVisible,
|
||||
nextArticle,
|
||||
prevArticle,
|
||||
fetchData,
|
||||
@@ -316,7 +498,10 @@ export function useFeeds() {
|
||||
markAllRead,
|
||||
showMessageForXSeconds,
|
||||
setupIntersectionObserver,
|
||||
disconnectObserver,
|
||||
setInitialLoad,
|
||||
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({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
scrollBehavior: () => ({ top: 0, behavior: 'instant' }),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
@@ -30,23 +31,13 @@ const router = createRouter({
|
||||
|
||||
]
|
||||
})
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.meta.requiresAuth) {
|
||||
let isAuthenticated = false;
|
||||
if (localStorage.getItem("user-token") != null){
|
||||
isAuthenticated = true;
|
||||
}
|
||||
|
||||
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();
|
||||
router.beforeEach((to) => {
|
||||
const isAuthenticated = localStorage.getItem("user-token") != null;
|
||||
// Redirect unauthenticated users hitting a protected route to login;
|
||||
// returning a value (instead of the deprecated next() callback) is the
|
||||
// modern vue-router guard API. Returning nothing lets navigation proceed.
|
||||
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||
return '/login';
|
||||
}
|
||||
});
|
||||
export default router
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
import AdminFeeds from '../components/AdminFeeds.vue'
|
||||
import AdminSettings from '../components/AdminSettings.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<AdminSettings />
|
||||
<AdminFeeds />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user