Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f7ddb77c4 | ||
|
|
e51bce4244 | ||
|
|
f31fccdd16 | ||
|
|
a7cfd267c6 | ||
|
|
c584ac4f60 | ||
|
|
9c042411fb | ||
|
|
4d9e899519 | ||
|
|
c290513ed1 | ||
|
|
41a17eba6d | ||
|
|
169c294e62 | ||
|
|
e2df60d112 | ||
|
|
5f816bb0f5 | ||
|
|
67b970df9b | ||
|
|
6c8b4e9e32 | ||
|
|
e2fb32f898 | ||
|
|
a2c947f870 | ||
|
|
abb6002078 | ||
|
|
f447a9a163 | ||
|
|
a26f31a150 | ||
|
|
c911258578 | ||
|
|
d52fa90b6b | ||
|
|
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
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
LEARNINGS.md
|
LEARNINGS.md
|
||||||
|
PLAN.md
|
||||||
|
/memory
|
||||||
|
|||||||
Generated
+74
-244
@@ -251,14 +251,13 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ammonia"
|
name = "ammonia"
|
||||||
version = "4.1.2"
|
version = "4.1.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
|
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cssparser 0.35.0",
|
"cssparser",
|
||||||
"html5ever 0.35.0",
|
"html5ever",
|
||||||
"maplit",
|
"maplit",
|
||||||
"tendril 0.4.3",
|
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -323,15 +322,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.104"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atom_syndication"
|
name = "atom_syndication"
|
||||||
version = "0.12.8"
|
version = "0.12.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "39d8b8ef99e33deb2a51504888222225797f48bace2f0cab01975746a39c05bb"
|
checksum = "d48d93a31c932d58b9fd68664a904ae5b74bea37f06c5f68d5bec92f0cdd106d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"derive_builder",
|
"derive_builder",
|
||||||
@@ -386,12 +385,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bcrypt"
|
name = "base64"
|
||||||
version = "0.19.1"
|
version = "0.23.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24ae5479c93d3720e4c1dbd6b945b97457c50cb672781104768190371df1a905"
|
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bcrypt"
|
||||||
|
version = "0.19.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.23.1",
|
||||||
"blowfish",
|
"blowfish",
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -506,9 +511,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chacha20"
|
name = "chacha20"
|
||||||
version = "0.10.0"
|
version = "0.10.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.3.0",
|
"cpufeatures 0.3.0",
|
||||||
@@ -668,42 +673,19 @@ dependencies = [
|
|||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cssparser"
|
|
||||||
version = "0.35.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
|
|
||||||
dependencies = [
|
|
||||||
"cssparser-macros 0.6.1",
|
|
||||||
"dtoa-short",
|
|
||||||
"itoa",
|
|
||||||
"phf 0.11.3",
|
|
||||||
"smallvec",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cssparser"
|
name = "cssparser"
|
||||||
version = "0.37.0"
|
version = "0.37.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
|
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cssparser-macros 0.7.0",
|
"cssparser-macros",
|
||||||
"dtoa-short",
|
"dtoa-short",
|
||||||
"itoa",
|
"itoa",
|
||||||
"phf 0.13.1",
|
"phf",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cssparser-macros"
|
|
||||||
version = "0.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
|
||||||
dependencies = [
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cssparser-macros"
|
name = "cssparser-macros"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -963,10 +945,10 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dotenv"
|
name = "dotenvy"
|
||||||
version = "0.15.0"
|
version = "0.15.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
|
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "downcast-rs"
|
name = "downcast-rs"
|
||||||
@@ -1124,16 +1106,6 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futf"
|
|
||||||
version = "0.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
|
|
||||||
dependencies = [
|
|
||||||
"mac",
|
|
||||||
"new_debug_unreachable",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -1281,11 +1253,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1332,9 +1306,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.14"
|
version = "0.4.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1396,17 +1370,6 @@ dependencies = [
|
|||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "html5ever"
|
|
||||||
version = "0.35.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"markup5ever 0.35.0",
|
|
||||||
"match_token",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.39.0"
|
version = "0.39.0"
|
||||||
@@ -1414,7 +1377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"markup5ever 0.39.0",
|
"markup5ever",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1492,7 +1455,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"h2 0.4.14",
|
"h2 0.4.19",
|
||||||
"http 1.4.1",
|
"http 1.4.1",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1899,29 +1862,12 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mac"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maplit"
|
name = "maplit"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "markup5ever"
|
|
||||||
version = "0.35.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"tendril 0.4.3",
|
|
||||||
"web_atoms 0.1.3",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.39.0"
|
version = "0.39.0"
|
||||||
@@ -1929,19 +1875,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
|
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"tendril 0.5.0",
|
"tendril",
|
||||||
"web_atoms 0.2.4",
|
"web_atoms",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "match_token"
|
|
||||||
version = "0.35.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2073,55 +2008,25 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "phf"
|
|
||||||
version = "0.11.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
|
||||||
dependencies = [
|
|
||||||
"phf_macros 0.11.3",
|
|
||||||
"phf_shared 0.11.3",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf"
|
name = "phf"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_macros 0.13.1",
|
"phf_macros",
|
||||||
"phf_shared 0.13.1",
|
"phf_shared",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "phf_codegen"
|
|
||||||
version = "0.11.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
|
||||||
dependencies = [
|
|
||||||
"phf_generator 0.11.3",
|
|
||||||
"phf_shared 0.11.3",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_codegen"
|
name = "phf_codegen"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_generator 0.13.1",
|
"phf_generator",
|
||||||
"phf_shared 0.13.1",
|
"phf_shared",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "phf_generator"
|
|
||||||
version = "0.11.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
|
||||||
dependencies = [
|
|
||||||
"phf_shared 0.11.3",
|
|
||||||
"rand 0.8.6",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2131,20 +2036,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"phf_shared 0.13.1",
|
"phf_shared",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "phf_macros"
|
|
||||||
version = "0.11.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
|
||||||
dependencies = [
|
|
||||||
"phf_generator 0.11.3",
|
|
||||||
"phf_shared 0.11.3",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2153,22 +2045,13 @@ version = "0.13.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_generator 0.13.1",
|
"phf_generator",
|
||||||
"phf_shared 0.13.1",
|
"phf_shared",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "phf_shared"
|
|
||||||
version = "0.11.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
|
||||||
dependencies = [
|
|
||||||
"siphasher",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_shared"
|
name = "phf_shared"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@@ -2282,9 +2165,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.39.4"
|
version = "0.41.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
|
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -2312,15 +2195,16 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-proto"
|
name = "quinn-proto"
|
||||||
version = "0.11.14"
|
version = "0.11.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"bytes",
|
"bytes",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.2",
|
||||||
"lru-slab",
|
"lru-slab",
|
||||||
"rand 0.9.4",
|
"rand 0.10.1",
|
||||||
|
"rand_pcg",
|
||||||
"ring",
|
"ring",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -2367,15 +2251,6 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand"
|
|
||||||
version = "0.8.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
|
||||||
dependencies = [
|
|
||||||
"rand_core 0.6.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -2407,12 +2282,6 @@ dependencies = [
|
|||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_core"
|
|
||||||
version = "0.6.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -2428,6 +2297,15 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_pcg"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-cpuid"
|
name = "raw-cpuid"
|
||||||
version = "11.6.0"
|
version = "11.6.0"
|
||||||
@@ -2493,7 +2371,7 @@ dependencies = [
|
|||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2 0.4.14",
|
"h2 0.4.19",
|
||||||
"http 1.4.1",
|
"http 1.4.1",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -2539,9 +2417,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rss"
|
name = "rss"
|
||||||
version = "2.0.13"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0e38781082c53bdde56e6081698c06831f69a27eac2593ed6b6cb2511424ad5"
|
checksum = "dc13570823abc675c60d7837f1fe7daaf18dd181a3cacb3c6ba7a062eef581c0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atom_syndication",
|
"atom_syndication",
|
||||||
"derive_builder",
|
"derive_builder",
|
||||||
@@ -2550,7 +2428,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rss-reader"
|
name = "rss-reader"
|
||||||
version = "0.1.0"
|
version = "0.9.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"actix-cors",
|
"actix-cors",
|
||||||
"actix-governor",
|
"actix-governor",
|
||||||
@@ -2564,7 +2442,7 @@ dependencies = [
|
|||||||
"dateparser",
|
"dateparser",
|
||||||
"diesel",
|
"diesel",
|
||||||
"diesel_migrations",
|
"diesel_migrations",
|
||||||
"dotenv",
|
"dotenvy",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"futures",
|
"futures",
|
||||||
"hmac",
|
"hmac",
|
||||||
@@ -2713,13 +2591,13 @@ version = "0.27.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
|
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cssparser 0.37.0",
|
"cssparser",
|
||||||
"ego-tree",
|
"ego-tree",
|
||||||
"getopts",
|
"getopts",
|
||||||
"html5ever 0.39.0",
|
"html5ever",
|
||||||
"precomputed-hash",
|
"precomputed-hash",
|
||||||
"selectors",
|
"selectors",
|
||||||
"tendril 0.5.0",
|
"tendril",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2752,12 +2630,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
|
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"cssparser 0.37.0",
|
"cssparser",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"log",
|
"log",
|
||||||
"new_debug_unreachable",
|
"new_debug_unreachable",
|
||||||
"phf 0.13.1",
|
"phf",
|
||||||
"phf_codegen 0.13.1",
|
"phf_codegen",
|
||||||
"precomputed-hash",
|
"precomputed-hash",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"servo_arc",
|
"servo_arc",
|
||||||
@@ -2956,19 +2834,6 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "string_cache"
|
|
||||||
version = "0.8.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
|
||||||
dependencies = [
|
|
||||||
"new_debug_unreachable",
|
|
||||||
"parking_lot",
|
|
||||||
"phf_shared 0.11.3",
|
|
||||||
"precomputed-hash",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "string_cache"
|
name = "string_cache"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2977,30 +2842,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"new_debug_unreachable",
|
"new_debug_unreachable",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"phf_shared 0.13.1",
|
"phf_shared",
|
||||||
"precomputed-hash",
|
"precomputed-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "string_cache_codegen"
|
|
||||||
version = "0.5.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
|
||||||
dependencies = [
|
|
||||||
"phf_generator 0.11.3",
|
|
||||||
"phf_shared 0.11.3",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "string_cache_codegen"
|
name = "string_cache_codegen"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_generator 0.13.1",
|
"phf_generator",
|
||||||
"phf_shared 0.13.1",
|
"phf_shared",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
]
|
]
|
||||||
@@ -3069,17 +2922,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tendril"
|
|
||||||
version = "0.4.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
|
|
||||||
dependencies = [
|
|
||||||
"futf",
|
|
||||||
"mac",
|
|
||||||
"utf-8",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tendril"
|
name = "tendril"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -3573,28 +3415,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "web_atoms"
|
|
||||||
version = "0.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
|
|
||||||
dependencies = [
|
|
||||||
"phf 0.11.3",
|
|
||||||
"phf_codegen 0.11.3",
|
|
||||||
"string_cache 0.8.9",
|
|
||||||
"string_cache_codegen 0.5.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
|
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf 0.13.1",
|
"phf",
|
||||||
"phf_codegen 0.13.1",
|
"phf_codegen",
|
||||||
"string_cache 0.9.0",
|
"string_cache",
|
||||||
"string_cache_codegen 0.6.1",
|
"string_cache_codegen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rss-reader"
|
name = "rss-reader"
|
||||||
version = "0.1.0"
|
version = "0.9.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
@@ -18,7 +18,7 @@ serde_derive = "1.0.228"
|
|||||||
actix-service = "2.0.3"
|
actix-service = "2.0.3"
|
||||||
diesel = { version = "2.3", features = ["postgres", "chrono"] }
|
diesel = { version = "2.3", features = ["postgres", "chrono"] }
|
||||||
diesel_migrations = "2.3"
|
diesel_migrations = "2.3"
|
||||||
dotenv = "0.15.0"
|
dotenvy = "0.15"
|
||||||
bcrypt = "0.19"
|
bcrypt = "0.19"
|
||||||
uuid = {version = "1.23", features=["serde", "v4"]}
|
uuid = {version = "1.23", features=["serde", "v4"]}
|
||||||
jwt = "0.16.0"
|
jwt = "0.16.0"
|
||||||
|
|||||||
+4
-2
@@ -7,7 +7,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN cargo build --release
|
RUN cargo build --release && \
|
||||||
|
cp target/release/rss-reader /usr/local/bin/rss-reader && \
|
||||||
|
rm -rf target
|
||||||
|
|
||||||
# --- runtime ---
|
# --- runtime ---
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
@@ -16,7 +18,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libpq5 ca-certificates \
|
libpq5 ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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
|
EXPOSE 8001
|
||||||
CMD ["rss-reader"]
|
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 # stop everything (keeps the postgres_data volume)
|
||||||
docker compose down -v # stop and wipe all data — careful!
|
docker compose down -v # stop and wipe all data — careful!
|
||||||
docker compose up --build -d # rebuild after pulling code changes
|
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
|
### 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.
|
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
|
```sh
|
||||||
docker compose up --build -d
|
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`):
|
**6. Firewall** (run as your normal sudo-capable user — not `rss-svc`):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
container_name: "rss-postgres"
|
container_name: "rss-postgres"
|
||||||
image: "postgres:18"
|
image: "postgres:18"
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
@@ -16,6 +17,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
environment:
|
environment:
|
||||||
@@ -31,6 +33,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./vue
|
context: ./vue
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- This file should undo anything in `up.sql`
|
||||||
|
ALTER TABLE feed_item
|
||||||
|
DROP CONSTRAINT feed_item_feed_id_url_unique;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- Your SQL goes here
|
||||||
|
|
||||||
|
-- Concurrent syncs for the same feed could previously race past the
|
||||||
|
-- application-level "does this article already exist" check and both
|
||||||
|
-- insert, producing duplicate feed items. Before enforcing uniqueness,
|
||||||
|
-- collapse any duplicates that already exist, keyed on the article's link
|
||||||
|
-- (rather than its title) since that's the stable identity RSS items are
|
||||||
|
-- built around — some feeds edit a headline after publishing while keeping
|
||||||
|
-- the same link, which title-based matching would treat as a new article.
|
||||||
|
|
||||||
|
-- If any duplicate in a group was already read, propagate that onto the
|
||||||
|
-- surviving (lowest-id) row first, so this one-time cleanup can't make an
|
||||||
|
-- already-read article look unread again.
|
||||||
|
UPDATE feed_item AS keep
|
||||||
|
SET read = true
|
||||||
|
FROM feed_item AS dupe
|
||||||
|
WHERE keep.feed_id = dupe.feed_id
|
||||||
|
AND keep.url = dupe.url
|
||||||
|
AND keep.id <> dupe.id
|
||||||
|
AND keep.id = (
|
||||||
|
SELECT MIN(candidate.id)
|
||||||
|
FROM feed_item AS candidate
|
||||||
|
WHERE candidate.feed_id = dupe.feed_id
|
||||||
|
AND candidate.url = dupe.url
|
||||||
|
)
|
||||||
|
AND dupe.read = true;
|
||||||
|
|
||||||
|
-- Keep the oldest (lowest id) row of each duplicate set.
|
||||||
|
DELETE FROM feed_item a
|
||||||
|
USING feed_item b
|
||||||
|
WHERE a.feed_id = b.feed_id
|
||||||
|
AND a.url = b.url
|
||||||
|
AND a.id > b.id;
|
||||||
|
|
||||||
|
ALTER TABLE feed_item
|
||||||
|
ADD CONSTRAINT feed_item_feed_id_url_unique UNIQUE (feed_id, url);
|
||||||
+2
-2
@@ -6,14 +6,14 @@ use std::env;
|
|||||||
|
|
||||||
use actix_web::HttpRequest;
|
use actix_web::HttpRequest;
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use dotenv::dotenv;
|
use dotenvy::dotenv;
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
|
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
|
|
||||||
/// How long a freshly issued token remains valid for.
|
/// 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 struct JwtToken {
|
||||||
pub user_id: i32,
|
pub user_id: i32,
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
use diesel::pg::PgConnection;
|
use diesel::pg::PgConnection;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||||
use dotenv::dotenv;
|
use dotenvy::dotenv;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
||||||
|
|||||||
+1
-2
@@ -1,10 +1,9 @@
|
|||||||
extern crate diesel;
|
extern crate diesel;
|
||||||
extern crate dotenv;
|
|
||||||
|
|
||||||
use actix_cors::Cors;
|
use actix_cors::Cors;
|
||||||
use actix_service::Service;
|
use actix_service::Service;
|
||||||
use actix_web::{App, HttpMessage, HttpResponse, HttpServer};
|
use actix_web::{App, HttpMessage, HttpResponse, HttpServer};
|
||||||
use dotenv::dotenv;
|
use dotenvy::dotenv;
|
||||||
use futures::future::{ok, Either};
|
use futures::future::{ok, Either};
|
||||||
use std::env;
|
use std::env;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
|||||||
+76
-19
@@ -4,8 +4,7 @@ use crate::error::AppError;
|
|||||||
use crate::json_serialization::user::JsonUser;
|
use crate::json_serialization::user::JsonUser;
|
||||||
use crate::models::feed::rss_feed::Feed;
|
use crate::models::feed::rss_feed::Feed;
|
||||||
use crate::models::feed_item::new_feed_item::NewFeedItem;
|
use crate::models::feed_item::new_feed_item::NewFeedItem;
|
||||||
use crate::models::feed_item::rss_feed_item::FeedItem;
|
use crate::schema::feed_item::{feed_id, url};
|
||||||
use crate::schema::feed_item::{feed_id, title};
|
|
||||||
use crate::{
|
use crate::{
|
||||||
database::establish_connection,
|
database::establish_connection,
|
||||||
schema::{
|
schema::{
|
||||||
@@ -143,24 +142,29 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> a
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let existing_item: Vec<FeedItem> = feed_item::table
|
let new_feed_item = NewFeedItem::new(
|
||||||
.filter(feed_id.eq(feed.id))
|
feed.id,
|
||||||
.filter(title.eq(&item_title))
|
content.clone(),
|
||||||
.load(connection)?;
|
item_title.clone(),
|
||||||
|
item.link.expect("checked above"),
|
||||||
|
Some(time),
|
||||||
|
);
|
||||||
|
|
||||||
if existing_item.is_empty() {
|
// `on_conflict` on the (feed_id, url) unique constraint makes this
|
||||||
let new_feed_item = NewFeedItem::new(
|
// insert idempotent, so two syncs for the same feed running concurrently
|
||||||
feed.id,
|
// (e.g. a sync button press racing a page-reload sync) can't both pass a
|
||||||
content.clone(),
|
// check-then-insert race and create duplicate items. Keying on the
|
||||||
item_title.clone(),
|
// article's link rather than its title also handles feeds that edit a
|
||||||
item.link.expect("checked above"),
|
// headline after publishing while keeping the same link — the link is
|
||||||
Some(time),
|
// the stable identity.
|
||||||
);
|
let inserted_rows = diesel::insert_into(feed_item::table)
|
||||||
let insert_result = diesel::insert_into(feed_item::table)
|
.values(&new_feed_item)
|
||||||
.values(&new_feed_item)
|
.on_conflict((feed_id, url))
|
||||||
.execute(connection);
|
.do_nothing()
|
||||||
|
.execute(connection)?;
|
||||||
|
|
||||||
log::info!("Insert Result: {:?}", insert_result);
|
if inserted_rows > 0 {
|
||||||
|
log::info!("Inserted item: {}", item_title);
|
||||||
} else {
|
} else {
|
||||||
log::info!("Item {} already exists.", item_title);
|
log::info!("Item {} already exists.", item_title);
|
||||||
}
|
}
|
||||||
@@ -210,10 +214,11 @@ pub async fn sync(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::models::feed::new_feed::NewFeed;
|
use crate::models::feed::new_feed::NewFeed;
|
||||||
|
use crate::models::feed_item::rss_feed_item::FeedItem;
|
||||||
use crate::models::user::new_user::NewUser;
|
use crate::models::user::new_user::NewUser;
|
||||||
use crate::models::user::rss_user::User;
|
use crate::models::user::rss_user::User;
|
||||||
use crate::schema::users;
|
use crate::schema::users;
|
||||||
use crate::test_helpers::unique_suffix;
|
use crate::test_helpers::{delete_feed, delete_user, insert_feed, insert_user, unique_suffix};
|
||||||
use chrono::Duration;
|
use chrono::Duration;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -440,6 +445,58 @@ mod tests {
|
|||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn create_feed_item_is_race_safe_under_concurrent_syncs() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let suffix = unique_suffix();
|
||||||
|
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
let feed = insert_feed(&mut connection, user.id);
|
||||||
|
|
||||||
|
let mut item = Item::default();
|
||||||
|
item.set_title(Some(format!("Race test article {suffix}")));
|
||||||
|
item.set_link(Some(format!("https://example.test/race/{suffix}")));
|
||||||
|
item.set_content(Some("<p>Hello world</p>".to_string()));
|
||||||
|
|
||||||
|
// Simulate two concurrent syncs for the same feed (e.g. a sync
|
||||||
|
// button press racing a page-reload sync) — each on its own
|
||||||
|
// connection/thread, released together so they genuinely overlap —
|
||||||
|
// both inserting the same item.
|
||||||
|
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||||
|
let handles: Vec<_> = (0..2)
|
||||||
|
.map(|_| {
|
||||||
|
let feed = feed.clone();
|
||||||
|
let item = item.clone();
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
barrier.wait();
|
||||||
|
create_feed_item(item, &feed, &mut connection)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for handle in handles {
|
||||||
|
handle.join().unwrap().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: Vec<FeedItem> = feed_item::table
|
||||||
|
.filter(feed_id.eq(feed.id))
|
||||||
|
.load(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
1,
|
||||||
|
items.len(),
|
||||||
|
"concurrent syncs must not create duplicate feed items"
|
||||||
|
);
|
||||||
|
|
||||||
|
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
|
||||||
|
.execute(&mut connection)
|
||||||
|
.ok();
|
||||||
|
delete_feed(&mut connection, feed.id);
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
async fn create_feed_item_strips_onerror_from_feed_image() {
|
async fn create_feed_item_strips_onerror_from_feed_image() {
|
||||||
let mut connection = establish_connection();
|
let mut connection = establish_connection();
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
# --- builder ---
|
# --- 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
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
|
|||||||
@@ -5,8 +5,14 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="alternate icon" href="/favicon.ico">
|
<link rel="alternate icon" href="/favicon.ico">
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<link rel="apple-touch-icon" href="/icon-192.png">
|
||||||
|
<meta name="theme-color" content="#1a8f5e">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>RSS-Reader</title>
|
<title>RSS-Reader</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Lora:ital,wght@0,400;0,700;1,400&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:wght@400;700&family=Raleway:wght@400;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,700;1,8..60,400&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Generated
+50
-40
@@ -1275,9 +1275,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
@@ -1427,9 +1427,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
@@ -1437,12 +1437,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braces": {
|
"node_modules/braces": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fill-range": "^7.0.1"
|
"fill-range": "^7.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -2205,9 +2205,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.0.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"to-regex-range": "^5.0.1"
|
"to-regex-range": "^5.0.1"
|
||||||
@@ -2247,9 +2247,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.2.7",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
@@ -2300,15 +2300,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
"es-set-tostringtag": "^2.1.0",
|
"es-set-tostringtag": "^2.1.0",
|
||||||
"hasown": "^2.0.2",
|
"hasown": "^2.0.4",
|
||||||
"mime-types": "^2.1.12"
|
"mime-types": "^2.1.35"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
@@ -2812,10 +2812,20 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
},
|
},
|
||||||
@@ -3224,9 +3234,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
@@ -3299,12 +3309,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/micromatch": {
|
"node_modules/micromatch": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"braces": "^3.0.2",
|
"braces": "^3.0.3",
|
||||||
"picomatch": "^2.3.1"
|
"picomatch": "^2.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3343,9 +3353,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^1.1.7"
|
"brace-expansion": "^1.1.7"
|
||||||
@@ -3673,9 +3683,9 @@
|
|||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
@@ -4450,9 +4460,9 @@
|
|||||||
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="
|
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.27.2",
|
"version": "7.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||||
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
|
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.18.1"
|
"node": ">=20.18.1"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "RSS Reader",
|
||||||
|
"short_name": "RSS Reader",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#1a8f5e",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
import { RouterView, useRoute } from 'vue-router'
|
import { RouterView, useRoute } from 'vue-router'
|
||||||
import AppNav from './components/AppNav.vue'
|
import AppNav from './components/AppNav.vue'
|
||||||
|
import { useSettings } from './composables/useSettings.js'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { applySettings } = useSettings()
|
||||||
|
onMounted(applySettings)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -23,6 +23,13 @@
|
|||||||
|
|
||||||
/* semantic color variables for this project */
|
/* semantic color variables for this project */
|
||||||
:root {
|
:root {
|
||||||
|
--headline-font-family: Glook, 'Courier New';
|
||||||
|
--content-font-family: Merriweather, Georgia, 'Times New Roman', Times, serif;
|
||||||
|
--headline-font-size-scale: 1;
|
||||||
|
--content-font-size-scale: 1;
|
||||||
|
--content-text-align: left;
|
||||||
|
--content-padding: 1rem;
|
||||||
|
|
||||||
--color-background: var(--vt-c-white);
|
--color-background: var(--vt-c-white);
|
||||||
--color-background-soft: var(--vt-c-white-soft);
|
--color-background-soft: var(--vt-c-white-soft);
|
||||||
--color-background-mute: var(--vt-c-white-mute);
|
--color-background-mute: var(--vt-c-white-mute);
|
||||||
@@ -74,6 +81,12 @@ body {
|
|||||||
wider than the visible content area (scrollbar) and would otherwise
|
wider than the visible content area (scrollbar) and would otherwise
|
||||||
introduce a horizontal scrollbar. */
|
introduce a horizontal scrollbar. */
|
||||||
overflow-x: hidden;
|
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);
|
color: var(--color-text);
|
||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
transition: color 0.5s, background-color 0.5s;
|
transition: color 0.5s, background-color 0.5s;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
|
padding-top: var(--app-nav-height, 4.5rem);
|
||||||
|
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
@@ -68,8 +69,8 @@ a,
|
|||||||
|
|
||||||
.feed-title {
|
.feed-title {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'Courier New';
|
font-family: var(--headline-font-family);
|
||||||
font-size: clamp(1.25rem, 4.5vw, 1.6rem);
|
font-size: calc(clamp(1.4rem, 5vw, 2rem) * var(--headline-font-size-scale));
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--color-accent-2);
|
color: var(--color-accent-2);
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #ccc;
|
||||||
@@ -83,9 +84,10 @@ a,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.feed-content {
|
.feed-content {
|
||||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
font-family: var(--content-font-family);
|
||||||
font-size: clamp(1rem, 3.5vw, 1.25rem);
|
font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale));
|
||||||
padding: 0 1em 1em;
|
text-align: var(--content-text-align);
|
||||||
|
padding: 0 var(--content-padding) 1em;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +102,7 @@ a,
|
|||||||
|
|
||||||
.feed-content h3 {
|
.feed-content h3 {
|
||||||
padding: 0.5em 0;
|
padding: 0.5em 0;
|
||||||
font-size: clamp(1rem, 3vw, 1.3rem);
|
font-size: calc(clamp(1rem, 3vw, 1.3rem) * var(--headline-font-size-scale));
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,5 +113,6 @@ h3 {
|
|||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
#app {
|
#app {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
padding-top: var(--app-nav-height, 4.5rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import Modal from './modal/AddUrl.vue'
|
||||||
|
|
||||||
|
const showModal = ref(false)
|
||||||
const feeds = ref([])
|
const feeds = ref([])
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ onMounted(loadFeeds)
|
|||||||
<template>
|
<template>
|
||||||
<div class="admin">
|
<div class="admin">
|
||||||
<h1 class="admin__heading">Admin</h1>
|
<h1 class="admin__heading">Admin</h1>
|
||||||
|
<button class="admin__add" type="button" @click="showModal = true">Add RSS</button>
|
||||||
<p v-if="error" class="admin__error">{{ error }}</p>
|
<p v-if="error" class="admin__error">{{ error }}</p>
|
||||||
<p v-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p>
|
<p v-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p>
|
||||||
<ul v-else class="admin__list">
|
<ul v-else class="admin__list">
|
||||||
@@ -51,6 +54,14 @@ onMounted(loadFeeds)
|
|||||||
<button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button>
|
<button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<Modal :show="showModal" @close="showModal = false" @added="loadFeeds">
|
||||||
|
<template #header>
|
||||||
|
<h3>Add RSS Feed</h3>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -67,6 +78,24 @@ onMounted(loadFeeds)
|
|||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin__add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44px;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__add:hover {
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.admin__error,
|
.admin__error,
|
||||||
.admin__empty {
|
.admin__empty {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
|||||||
@@ -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>
|
||||||
+188
-31
@@ -2,29 +2,110 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||||
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
|
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
|
||||||
import Modal from './modal/AddUrl.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds, navTitleVisible } = useFeeds()
|
const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll, isSyncing } = useFeeds()
|
||||||
|
|
||||||
const titleRef = ref(null)
|
const headerRef = ref(null)
|
||||||
let titleObserver
|
|
||||||
|
// Native <select>/<option> has no "is open" state — the closed box just shows
|
||||||
|
// the selected option's text verbatim, so per-option unread counts can only be
|
||||||
|
// toggled based on focus, since the popup is only ever shown while focused.
|
||||||
|
// @focus ~ opening (shows counts); @change (picking an option closes the
|
||||||
|
// popup but keeps focus, so no blur fires) and @blur (focus left without
|
||||||
|
// picking) ~ closed (counts hidden, so the closed box just shows the name).
|
||||||
|
const filterOpen = ref(false)
|
||||||
|
|
||||||
|
function onFilterFocus() {
|
||||||
|
filterOpen.value = true
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilterChange(event) {
|
||||||
|
setFeedFilter(event.target.value || null)
|
||||||
|
filterOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll-driven show/hide: the header slides out of view on scroll-down and
|
||||||
|
// back in only on scroll-up — never merely by proximity to the top. This is
|
||||||
|
// show/hide via `transform` (not the old resize behaviour) — the header is
|
||||||
|
// position:fixed, so translating it never reflows content. The app's
|
||||||
|
// programmatic scrolls resolve to sensible states without any top-of-page
|
||||||
|
// special case: scrollTo(0, 0) (article paging, layout toggle) is itself a
|
||||||
|
// large upward jump, so it crosses REVEAL_THRESHOLD like a real scroll-up and
|
||||||
|
// reveals the header that way; the list-view read-correction scrollBy moves
|
||||||
|
// only a few px → stays under the threshold and leaves a hidden header hidden.
|
||||||
|
const hidden = ref(false)
|
||||||
|
const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling
|
||||||
|
// When the feed list mutates itself (read-correction scrollBy + array-splice
|
||||||
|
// scroll anchoring) the page jumps *upward* without user intent. For this long
|
||||||
|
// after such a jump we gate only the reveal direction, so the jump can't pop
|
||||||
|
// the header back into view mid-read. Hiding stays allowed the whole time
|
||||||
|
// (the jump never scrolls down), so scrolling down still hides normally even
|
||||||
|
// while articles are being marked read. See lastProgrammaticScroll in useFeeds.
|
||||||
|
const PROGRAMMATIC_SUPPRESS_MS = 300
|
||||||
|
let lastY = 0
|
||||||
|
let accumulated = 0
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
const y = Math.max(0, window.scrollY)
|
||||||
|
|
||||||
|
// Keep the header visible while the menu is open (the dropdown is anchored
|
||||||
|
// to the header, so hiding it would slide the open menu off-screen).
|
||||||
|
if (menuOpen.value) {
|
||||||
|
hidden.value = false
|
||||||
|
accumulated = 0
|
||||||
|
lastY = y
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = y - lastY
|
||||||
|
lastY = y
|
||||||
|
// Reset the accumulator whenever direction flips, so the threshold is
|
||||||
|
// measured from the last turning point (not from page load).
|
||||||
|
if ((delta > 0) !== (accumulated > 0)) accumulated = 0
|
||||||
|
accumulated += delta
|
||||||
|
|
||||||
|
// Hiding (scroll-down) is always allowed. Revealing (scroll-up) is gated for
|
||||||
|
// a short window after a programmatic list update, whose induced jump is
|
||||||
|
// upward and would otherwise pop the header back into view mid-read. A
|
||||||
|
// genuine scroll-up reveals once the window has elapsed.
|
||||||
|
const afterProgrammatic = performance.now() - lastProgrammaticScroll.value < PROGRAMMATIC_SUPPRESS_MS
|
||||||
|
if (accumulated > REVEAL_THRESHOLD) hidden.value = true // scrolling down
|
||||||
|
else if (accumulated < -REVEAL_THRESHOLD && !afterProgrammatic) hidden.value = false // scrolling up
|
||||||
|
}
|
||||||
|
|
||||||
|
let ticking = false
|
||||||
|
function onScrollRaf() {
|
||||||
|
if (ticking) return
|
||||||
|
ticking = true
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
onScroll()
|
||||||
|
ticking = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
titleObserver = new IntersectionObserver(([entry]) => {
|
// Drives #app's padding-top / RssFeeds' scroll-margin-top so content below
|
||||||
navTitleVisible.value = entry.isIntersecting
|
// 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.
|
||||||
titleObserver.observe(titleRef.value)
|
const h = headerRef.value?.getBoundingClientRect().height ?? 0
|
||||||
|
document.documentElement.style.setProperty('--app-nav-height', `${h}px`)
|
||||||
|
|
||||||
|
lastY = Math.max(0, window.scrollY)
|
||||||
|
window.addEventListener('scroll', onScrollRaf, { passive: true })
|
||||||
|
document.addEventListener('click', onDocumentClick)
|
||||||
|
refreshUnreadDisplay()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
titleObserver?.disconnect()
|
window.removeEventListener('scroll', onScrollRaf)
|
||||||
|
document.removeEventListener('click', onDocumentClick)
|
||||||
})
|
})
|
||||||
|
|
||||||
const onFeedsPage = computed(() => route.path === '/feeds')
|
const onFeedsPage = computed(() => route.path === '/feeds')
|
||||||
|
|
||||||
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
|
|
||||||
|
|
||||||
const menuOpen = ref(false)
|
const menuOpen = ref(false)
|
||||||
|
|
||||||
@@ -36,6 +117,19 @@ function closeMenu() {
|
|||||||
menuOpen.value = false
|
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() {
|
async function logout() {
|
||||||
await logoutSession()
|
await logoutSession()
|
||||||
closeMenu()
|
closeMenu()
|
||||||
@@ -52,11 +146,6 @@ function handleMarkAllRead() {
|
|||||||
closeMenu()
|
closeMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAddModal() {
|
|
||||||
showModal.value = true
|
|
||||||
closeMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleToggleViewMode() {
|
function handleToggleViewMode() {
|
||||||
toggleViewMode()
|
toggleViewMode()
|
||||||
closeMenu()
|
closeMenu()
|
||||||
@@ -69,9 +158,26 @@ function handleToggleLayout() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="app-nav">
|
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
|
||||||
<div class="app-nav__wrapper">
|
<div class="app-nav__wrapper">
|
||||||
<span 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>
|
||||||
|
<span
|
||||||
|
v-if="isSyncing"
|
||||||
|
class="app-nav__sync-spinner"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
<select
|
||||||
|
v-if="onFeedsPage && feedTitles.length"
|
||||||
|
class="app-nav__filter"
|
||||||
|
:value="feedFilter ?? ''"
|
||||||
|
aria-label="Filter by feed"
|
||||||
|
@focus="onFilterFocus"
|
||||||
|
@change="onFilterChange"
|
||||||
|
@blur="filterOpen = false"
|
||||||
|
>
|
||||||
|
<option value="">All feeds</option>
|
||||||
|
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }}<template v-if="filterOpen"> ({{ displayedFeedUnreadCounts[title] ?? 0 }})</template></option>
|
||||||
|
</select>
|
||||||
<button
|
<button
|
||||||
class="app-nav__hamburger"
|
class="app-nav__hamburger"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -105,40 +211,48 @@ function handleToggleLayout() {
|
|||||||
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
|
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
|
||||||
</template>
|
</template>
|
||||||
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
|
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
|
||||||
<button class="app-nav__menu-item" type="button" @click="openAddModal">Add RSS</button>
|
|
||||||
<RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink>
|
<RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink>
|
||||||
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
|
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<Teleport to="body">
|
|
||||||
<Modal :show="showModal" @close="showModal = false">
|
|
||||||
<template #header>
|
|
||||||
<h3>Add RSS Feed</h3>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
</Teleport>
|
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.app-nav {
|
.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 {
|
.app-nav__wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.375rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__title {
|
.app-nav__title {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: clamp(1.1rem, 4vw, 1.4rem);
|
font-size: clamp(0.95rem, 3.5vw, 1.1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__unread {
|
.app-nav__unread {
|
||||||
@@ -146,10 +260,53 @@ function handleToggleLayout() {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-nav__sync-spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-top-color: var(--color-text);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: app-nav-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes app-nav-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.app-nav__sync-spinner {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__filter {
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
max-width: clamp(90px, 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 {
|
.app-nav__hamburger {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
min-width: 44px;
|
min-width: 44px;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
@@ -230,7 +387,7 @@ function handleToggleLayout() {
|
|||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.app-nav__wrapper {
|
.app-nav__wrapper {
|
||||||
padding: 1rem 2rem;
|
padding: 0.5rem 2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+158
-123
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, computed, nextTick, watch } from 'vue';
|
import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
|
||||||
import { useFeeds } from '@/composables/useFeeds';
|
import { useFeeds } from '@/composables/useFeeds';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -8,15 +8,16 @@ const {
|
|||||||
message,
|
message,
|
||||||
viewMode,
|
viewMode,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
leaveArticleView,
|
|
||||||
layout,
|
layout,
|
||||||
nextArticle,
|
nextArticle,
|
||||||
prevArticle,
|
prevArticle,
|
||||||
fetchData,
|
fetchData,
|
||||||
|
sync,
|
||||||
getReadable,
|
getReadable,
|
||||||
|
disconnectObserver,
|
||||||
setInitialLoad,
|
setInitialLoad,
|
||||||
showMessageForXSeconds,
|
showMessageForXSeconds,
|
||||||
navTitleVisible,
|
markCurrentArticleRead,
|
||||||
} = useFeeds()
|
} = useFeeds()
|
||||||
|
|
||||||
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
|
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
|
||||||
@@ -34,6 +35,45 @@ function scrollToNextArticle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Swipe-to-navigate in article view. Transient per-gesture state, not shared
|
||||||
|
// app state, so it's kept as plain component-local variables rather than in
|
||||||
|
// useFeeds() — nothing outside this component needs it.
|
||||||
|
let touchStartX = null
|
||||||
|
let touchStartY = null
|
||||||
|
|
||||||
|
const SWIPE_THRESHOLD_X = 50 // min horizontal travel (px) to count as a swipe
|
||||||
|
const SWIPE_RESTRAINT_Y = 75 // max vertical travel (px) before it's treated as a scroll, not a swipe
|
||||||
|
|
||||||
|
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
|
||||||
|
touchStartY = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
touchStartX = event.touches[0].clientX
|
||||||
|
touchStartY = event.touches[0].clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
function onArticleTouchEnd(event) {
|
||||||
|
if (touchStartX === null) return
|
||||||
|
const touch = event.changedTouches[0]
|
||||||
|
const deltaX = touch.clientX - touchStartX
|
||||||
|
const deltaY = touch.clientY - touchStartY
|
||||||
|
touchStartX = null
|
||||||
|
touchStartY = null
|
||||||
|
|
||||||
|
if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) return
|
||||||
|
|
||||||
|
if (deltaX < 0) {
|
||||||
|
nextArticle()
|
||||||
|
} else {
|
||||||
|
prevArticle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Small images (icons, logos, ...) look bad stretched to the full-bleed
|
// Small images (icons, logos, ...) look bad stretched to the full-bleed
|
||||||
// width used for readable article images — leave them at their natural size
|
// 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
|
// instead. Intrinsic size is only known once the image has loaded, so check
|
||||||
@@ -41,7 +81,7 @@ function scrollToNextArticle() {
|
|||||||
const SMALL_IMAGE_THRESHOLD = 200
|
const SMALL_IMAGE_THRESHOLD = 200
|
||||||
|
|
||||||
function markSmallImages() {
|
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 = () => {
|
const checkSize = () => {
|
||||||
if (img.naturalWidth && img.naturalWidth <= SMALL_IMAGE_THRESHOLD) {
|
if (img.naturalWidth && img.naturalWidth <= SMALL_IMAGE_THRESHOLD) {
|
||||||
img.classList.add('article-feature__image--small')
|
img.classList.add('article-feature__image--small')
|
||||||
@@ -60,6 +100,12 @@ watch(() => feeds.value[currentIndex.value]?.content, async () => {
|
|||||||
markSmallImages()
|
markSmallImages()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadReadable(feed, index) {
|
||||||
|
await getReadable(feed, index)
|
||||||
|
await nextTick()
|
||||||
|
markSmallImages()
|
||||||
|
}
|
||||||
|
|
||||||
async function shareUrl(url) {
|
async function shareUrl(url) {
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
await navigator.share({ url })
|
await navigator.share({ url })
|
||||||
@@ -69,9 +115,20 @@ async function shareUrl(url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disconnectObserver()
|
||||||
|
setInitialLoad(false)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
setInitialLoad(false)
|
setInitialLoad(false)
|
||||||
await fetchData()
|
await fetchData()
|
||||||
|
// viewMode persists across reloads (see useFeeds.js) — if we're booting
|
||||||
|
// straight into article view, the article now on screen needs the same
|
||||||
|
// immediate mark-read that toggleViewMode()/nextArticle()/prevArticle()
|
||||||
|
// give it when reached by in-session navigation.
|
||||||
|
if (viewMode.value === 'article') markCurrentArticleRead()
|
||||||
|
sync(true)
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
setInitialLoad(true)
|
setInitialLoad(true)
|
||||||
console.log('set to true')
|
console.log('set to true')
|
||||||
@@ -84,10 +141,6 @@ onMounted(async () => {
|
|||||||
<div v-if="showMessage" class="message">{{ message }}</div>
|
<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="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">
|
<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">
|
<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"/>
|
<circle cx="12" cy="12" r="10"/>
|
||||||
@@ -95,29 +148,30 @@ onMounted(async () => {
|
|||||||
</svg>
|
</svg>
|
||||||
<p class="empty-state__label">All caught up</p>
|
<p class="empty-state__label">All caught up</p>
|
||||||
</div>
|
</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">
|
<div v-bind:id="index" class="observe">
|
||||||
<p class="feed-source">{{ feed.feedTitle }}</p>
|
<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>
|
<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>
|
<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">
|
<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>
|
<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>
|
</button>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
|
||||||
<div v-else class="article-single">
|
<div v-else class="article-single" @touchstart.passive="onArticleTouchStart" @touchend.passive="onArticleTouchEnd">
|
||||||
<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-if="feeds.length == 0" class="empty-state">
|
<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">
|
<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"/>
|
<circle cx="12" cy="12" r="10"/>
|
||||||
@@ -125,18 +179,22 @@ onMounted(async () => {
|
|||||||
</svg>
|
</svg>
|
||||||
<p class="empty-state__label">All caught up</p>
|
<p class="empty-state__label">All caught up</p>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<!-- Deliberately no enter/leave transition here: Vue's <Transition>
|
||||||
<article class="article-feature">
|
component forces a synchronous whole-document reflow
|
||||||
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
|
(document.body.offsetHeight, in @vue/runtime-dom's forceReflow) on
|
||||||
<h2 @click="getReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
|
every enter/leave, regardless of the app's own CSS — confirmed (via
|
||||||
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
|
a DevTools performance trace) to be what was corrupting the header's
|
||||||
<p v-if="!feeds[currentIndex].readable" class="feed-original-link">
|
native <select>/<button> rendering during article paging. -->
|
||||||
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article ↗</a>
|
<article v-else class="article-feature" :key="currentIndex">
|
||||||
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
|
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
|
||||||
</p>
|
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
|
||||||
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
|
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
|
||||||
</article>
|
<p class="feed-original-link">
|
||||||
</template>
|
<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>
|
||||||
|
|
||||||
<div class="article-nav">
|
<div class="article-nav">
|
||||||
<button
|
<button
|
||||||
@@ -159,48 +217,15 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.list-topbar {
|
.list-skip-btn {
|
||||||
position: sticky;
|
position: fixed;
|
||||||
top: 0;
|
right: 1rem;
|
||||||
z-index: 10;
|
bottom: 1.5rem;
|
||||||
display: flex;
|
z-index: 20;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.observe {
|
.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
|
/* Plain vertical stack of bordered "cards" — deliberately not flex/grid, and
|
||||||
@@ -256,6 +281,38 @@ onMounted(async () => {
|
|||||||
width: auto;
|
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 {
|
.feed-original-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -300,53 +357,15 @@ onMounted(async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding-top: 1em;
|
||||||
padding-bottom: 5rem;
|
padding-bottom: 5rem;
|
||||||
}
|
/* Fill the viewport below the fixed header even when the article content
|
||||||
|
is short, so swipe-to-navigate works anywhere on screen instead of only
|
||||||
.article-single__topbar {
|
over the (possibly small) content area. dvh is the accurate unit on
|
||||||
position: sticky;
|
mobile, where this matters most; vh is the fallback for browsers
|
||||||
top: 0;
|
without dvh support. */
|
||||||
z-index: 10;
|
min-height: calc(100vh - var(--app-nav-height, 4.5rem));
|
||||||
align-self: flex-start;
|
min-height: calc(100dvh - var(--app-nav-height, 4.5rem));
|
||||||
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 {
|
.article-feature {
|
||||||
@@ -368,8 +387,8 @@ onMounted(async () => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
font-family: 'Courier New';
|
font-family: var(--headline-font-family);
|
||||||
font-size: clamp(1.75rem, 6vw, 2.75rem);
|
font-size: calc(clamp(1.4rem, 5vw, 2rem) * var(--headline-font-size-scale));
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
color: var(--color-accent-2);
|
color: var(--color-accent-2);
|
||||||
@@ -395,9 +414,10 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-feature__content {
|
.article-feature__content {
|
||||||
padding: 0 1rem;
|
padding: 0 var(--content-padding);
|
||||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
text-align: var(--content-text-align);
|
||||||
font-size: clamp(1rem, 3.5vw, 1.25rem);
|
font-family: var(--content-font-family);
|
||||||
|
font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale));
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
@@ -408,7 +428,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.article-feature__content :deep(h3) {
|
.article-feature__content :deep(h3) {
|
||||||
padding: 0.5em 0;
|
padding: 0.5em 0;
|
||||||
font-size: clamp(1rem, 3vw, 1.3rem);
|
font-size: calc(clamp(1rem, 3vw, 1.3rem) * var(--headline-font-size-scale));
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +460,21 @@ onMounted(async () => {
|
|||||||
transform: none;
|
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) {
|
.article-feature__content :deep(a) {
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
text-decoration-color: var(--color-accent-hover);
|
text-decoration-color: var(--color-accent-hover);
|
||||||
@@ -450,7 +485,7 @@ onMounted(async () => {
|
|||||||
padding: 1em 0;
|
padding: 1em 0;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
font-family: var(--content-font-family);
|
||||||
font-size: 1.25em;
|
font-size: 1.25em;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import axios from 'axios'
|
||||||
|
import AdminFeeds from '../AdminFeeds.vue'
|
||||||
|
import AddUrl from '../modal/AddUrl.vue'
|
||||||
|
|
||||||
|
vi.mock('axios')
|
||||||
|
|
||||||
|
describe('AdminFeeds', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('user-token', 'test-token')
|
||||||
|
localStorage.setItem('user-id', '7')
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each mount renders the AddUrl modal via <Teleport to="body">, which
|
||||||
|
// survives outside the wrapper's own element — unmount every mounted
|
||||||
|
// instance so a leftover doesn't bleed into a later test's document.body.
|
||||||
|
let mountedWrappers = []
|
||||||
|
function mountAdmin() {
|
||||||
|
const wrapper = mount(AdminFeeds)
|
||||||
|
mountedWrappers.push(wrapper)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const wrapper of mountedWrappers) {
|
||||||
|
try {
|
||||||
|
wrapper.unmount()
|
||||||
|
} catch {
|
||||||
|
// already unmounted by the test itself — fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mountedWrappers = []
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetches and renders the feed list on mount', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({
|
||||||
|
data: { feeds: [{ id: 1, title: 'Feed A', url: 'https://example.test/a.xml' }] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/feeds/7', expect.anything())
|
||||||
|
expect(wrapper.text()).toContain('Feed A')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the empty state when there are no feeds', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('No feeds added yet.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an error message when the feed list fails to load', async () => {
|
||||||
|
axios.get.mockRejectedValueOnce(new Error('Network Error'))
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Failed to load feeds.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes a feed after confirmation', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({
|
||||||
|
data: { feeds: [{ id: 1, title: 'Feed A', url: 'https://example.test/a.xml' }] },
|
||||||
|
})
|
||||||
|
axios.delete.mockResolvedValueOnce({ status: 204 })
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.admin__delete').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(axios.delete).toHaveBeenCalledWith('/api/v1/article/feed/1', expect.anything())
|
||||||
|
expect(wrapper.text()).toContain('No feeds added yet.')
|
||||||
|
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the add-feed modal from the Add RSS button', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.admin__add').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.findComponent(AddUrl).props('show')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads the feed list when the modal reports a feed was added', async () => {
|
||||||
|
axios.get
|
||||||
|
.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
.mockResolvedValueOnce({ data: { feeds: [{ id: 2, title: 'Feed B', url: 'https://example.test/b.xml' }] } })
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('No feeds added yet.')
|
||||||
|
|
||||||
|
await wrapper.findComponent(AddUrl).vm.$emit('added')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledTimes(2)
|
||||||
|
expect(wrapper.text()).toContain('Feed B')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { mount, flushPromises } from '@vue/test-utils'
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import AppNav from '../AppNav.vue'
|
import AppNav from '../AppNav.vue'
|
||||||
@@ -7,6 +8,15 @@ import { useFeeds } from '../../composables/useFeeds'
|
|||||||
|
|
||||||
vi.mock('axios')
|
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', () => {
|
describe('AppNav', () => {
|
||||||
let router
|
let router
|
||||||
|
|
||||||
@@ -15,11 +25,15 @@ describe('AppNav', () => {
|
|||||||
localStorage.setItem('user-id', '7')
|
localStorage.setItem('user-id', '7')
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
||||||
const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
|
const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, viewMode, currentIndex, layout } = useFeeds()
|
||||||
feeds.value = []
|
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
|
showMessage.value = false
|
||||||
message.value = ''
|
message.value = ''
|
||||||
showModal.value = false
|
|
||||||
viewMode.value = 'list'
|
viewMode.value = 'list'
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
layout.value = 'list'
|
layout.value = 'list'
|
||||||
@@ -35,15 +49,35 @@ describe('AppNav', () => {
|
|||||||
await router.isReady()
|
await router.isReady()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Unmount every AppNav mounted via mountNav() after each test so mounted
|
||||||
|
// instances (and their router/menu listeners) don't pile up across the file.
|
||||||
|
let mountedWrappers = []
|
||||||
|
function mountNav(options = { global: { plugins: [router] } }) {
|
||||||
|
const wrapper = mount(AppNav, options)
|
||||||
|
mountedWrappers.push(wrapper)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const wrapper of mountedWrappers) {
|
||||||
|
try {
|
||||||
|
wrapper.unmount()
|
||||||
|
} catch {
|
||||||
|
// already unmounted by the test itself — fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mountedWrappers = []
|
||||||
|
})
|
||||||
|
|
||||||
async function mountWithMenuOpen() {
|
async function mountWithMenuOpen() {
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
await wrapper.find('.app-nav__hamburger').trigger('click')
|
await wrapper.find('.app-nav__hamburger').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
return wrapper
|
return wrapper
|
||||||
}
|
}
|
||||||
|
|
||||||
it('toggles the menu open and closed via the hamburger button', async () => {
|
it('toggles the menu open and closed via the hamburger button', async () => {
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
||||||
|
|
||||||
@@ -54,6 +88,17 @@ describe('AppNav', () => {
|
|||||||
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
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 () => {
|
it('clears stored credentials and redirects to login on logout', async () => {
|
||||||
const wrapper = await mountWithMenuOpen()
|
const wrapper = await mountWithMenuOpen()
|
||||||
|
|
||||||
@@ -84,16 +129,6 @@ describe('AppNav', () => {
|
|||||||
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens the add-feed modal from the menu', async () => {
|
|
||||||
const wrapper = await mountWithMenuOpen()
|
|
||||||
const { showModal } = useFeeds()
|
|
||||||
|
|
||||||
const addButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Add RSS')
|
|
||||||
await addButton.trigger('click')
|
|
||||||
|
|
||||||
expect(showModal.value).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('switches the view mode from the menu and closes it', async () => {
|
it('switches the view mode from the menu and closes it', async () => {
|
||||||
const wrapper = await mountWithMenuOpen()
|
const wrapper = await mountWithMenuOpen()
|
||||||
const { viewMode } = useFeeds()
|
const { viewMode } = useFeeds()
|
||||||
@@ -149,38 +184,312 @@ describe('AppNav', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows the unread count in the title when there are articles', async () => {
|
it('shows the unread count in the title when there are articles', async () => {
|
||||||
const { feeds } = useFeeds()
|
// The badge is the global unread total, sourced from the master list
|
||||||
feeds.value = [
|
// (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: 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' },
|
{ id: 2, title: 'Article two', content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('excludes already-read articles from the counter while in article view', async () => {
|
it('excludes already-read articles from the counter while in article view', async () => {
|
||||||
const { feeds } = useFeeds()
|
const { allItems } = useFeeds()
|
||||||
feeds.value = [
|
allItems.value = [
|
||||||
{ id: 1, title: 'Article one', read: true, content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
{ 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' },
|
{ id: 2, title: 'Article two', read: false, content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__title').text()).toContain('(1)')
|
expect(wrapper.find('.app-nav__title').text()).toContain('(1)')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides the unread count when there are no articles', async () => {
|
it('hides the unread count when there are no articles', async () => {
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
|
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 select = wrapper.find('.app-nav__filter')
|
||||||
|
|
||||||
|
// Counts only show while the dropdown is open (focused) — see AppNav.vue.
|
||||||
|
await select.trigger('focus')
|
||||||
|
|
||||||
|
const options = select.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('hides the per-option unread count once the feed filter is closed', 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' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
const select = wrapper.find('.app-nav__filter')
|
||||||
|
|
||||||
|
await select.trigger('focus')
|
||||||
|
await select.setValue('Feed A') // fires change, which closes the popup but keeps focus
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const feedAOption = select.findAll('option').find(o => o.element.value === 'Feed A')
|
||||||
|
expect(feedAOption.text()).toBe('Feed A')
|
||||||
|
})
|
||||||
|
|
||||||
|
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('sync spinner', () => {
|
||||||
|
it('does not render the spinner, and reserves no space for it, when idle', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders while a sync triggered through the composable is in flight, and disappears once it resolves', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
const { sync } = useFeeds()
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true)
|
||||||
|
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await syncPromise
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders while syncing even on a page where the feed filter itself does not render', async () => {
|
||||||
|
await router.push('/login')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
const { sync } = useFeeds()
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__filter').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true)
|
||||||
|
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await syncPromise
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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 })
|
||||||
|
// getBoundingClientRect() is all-zero in jsdom; give the header a real
|
||||||
|
// height so onMounted()'s one-time measurement (used for the
|
||||||
|
// --app-nav-height CSS var — see AppNav.vue) has a realistic value.
|
||||||
|
// This is a prototype-wide spy (scoped to this describe block only, restored in
|
||||||
|
// afterEach), so it returns a full DOMRect-shaped object rather than a
|
||||||
|
// partial one — no test here happens to call getBoundingClientRect() on
|
||||||
|
// anything but the header, but a bare `{ height: 50 }` would silently
|
||||||
|
// hand back `undefined` for top/left/width/etc. to any code that did.
|
||||||
|
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
height: 50, width: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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('reveals the header on a large upward jump that lands near the top', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
scrollTo(400)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// A jump this large (e.g. article paging's scrollTo(0, 0)) crosses
|
||||||
|
// REVEAL_THRESHOLD on its own — no top-of-page special case needed.
|
||||||
|
scrollTo(10)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not reveal the header merely for landing near the top', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
// Hide it with a jump just past the header's own height (50px, mocked
|
||||||
|
// above) plus the reveal threshold.
|
||||||
|
scrollTo(62)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// Move back to right at the header's own height — within the "near the
|
||||||
|
// top" zone the old code always revealed for — but the upward delta
|
||||||
|
// (12px) doesn't exceed REVEAL_THRESHOLD, so a hidden header stays
|
||||||
|
// hidden: proximity to the top is no longer sufficient on its own.
|
||||||
|
scrollTo(50)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let a programmatic upward jump reveal the header mid-read', async () => {
|
||||||
|
const { markProgrammaticScroll } = useFeeds()
|
||||||
|
const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(1000)
|
||||||
|
const wrapper = mountNav()
|
||||||
|
|
||||||
|
// Hide it first via a normal scroll-down (no programmatic flag active).
|
||||||
|
scrollTo(400)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// A read-correction flags a programmatic scroll, then the page jumps
|
||||||
|
// upward. Within the window that upward jump must NOT reveal the header.
|
||||||
|
markProgrammaticScroll() // records lastProgrammaticScroll = 1000
|
||||||
|
nowSpy.mockReturnValue(1100) // 100ms later — inside the 300ms window
|
||||||
|
scrollTo(200)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// Still allows hiding on scroll-down even while the flag is active.
|
||||||
|
scrollTo(500)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// Once the window elapses, a genuine scroll-up reveals it again.
|
||||||
|
nowSpy.mockReturnValue(1500) // 500ms after the flag — outside the window
|
||||||
|
scrollTo(450)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not toggle on sub-threshold jitter', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
// Start well below the top so the "near the top" guard doesn't apply.
|
||||||
|
scrollTo(300)
|
||||||
|
await nextTick()
|
||||||
|
// Reveal first so we're testing that small moves don't hide it.
|
||||||
|
scrollTo(260)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
scrollTo(268) // +8px, under the 12px threshold
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('does not mark articles as read when the confirmation is dismissed', async () => {
|
it('does not mark articles as read when the confirmation is dismissed', async () => {
|
||||||
const { feeds } = useFeeds()
|
const { feeds } = useFeeds()
|
||||||
feeds.value = [
|
feeds.value = [
|
||||||
@@ -199,4 +508,5 @@ describe('AppNav', () => {
|
|||||||
|
|
||||||
confirmSpy.mockRestore()
|
confirmSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ class FakeIntersectionObserver {
|
|||||||
}
|
}
|
||||||
vi.stubGlobal('IntersectionObserver', 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', () => {
|
describe('RssFeeds', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('user-token', 'test-token')
|
localStorage.setItem('user-token', 'test-token')
|
||||||
@@ -23,11 +37,10 @@ describe('RssFeeds', () => {
|
|||||||
|
|
||||||
// useFeeds() returns module-level singleton refs shared across the whole
|
// useFeeds() returns module-level singleton refs shared across the whole
|
||||||
// app (and this spec file) — reset them so state doesn't leak between tests.
|
// app (and this spec file) — reset them so state doesn't leak between tests.
|
||||||
const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
|
const { feeds, showMessage, message, viewMode, currentIndex, layout } = useFeeds()
|
||||||
feeds.value = []
|
feeds.value = []
|
||||||
showMessage.value = false
|
showMessage.value = false
|
||||||
message.value = ''
|
message.value = ''
|
||||||
showModal.value = false
|
|
||||||
viewMode.value = 'list'
|
viewMode.value = 'list'
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
layout.value = 'list'
|
layout.value = 'list'
|
||||||
@@ -119,7 +132,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()
|
const { layout } = useFeeds()
|
||||||
layout.value = 'cards'
|
layout.value = 'cards'
|
||||||
@@ -177,10 +197,7 @@ describe('RssFeeds', () => {
|
|||||||
expect(titles).toEqual(['Newer article', 'Older article'])
|
expect(titles).toEqual(['Newer article', 'Older article'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows a link to the original article until the readable version is loaded', async () => {
|
it('keeps a link to the original article visible after 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.
|
|
||||||
axios.get.mockResolvedValueOnce({
|
axios.get.mockResolvedValueOnce({
|
||||||
data: {
|
data: {
|
||||||
feeds: [
|
feeds: [
|
||||||
@@ -199,7 +216,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)
|
const wrapper = mount(RssFeeds)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -212,7 +236,9 @@ describe('RssFeeds', () => {
|
|||||||
await wrapper.find('.feed-title').trigger('click')
|
await wrapper.find('.feed-title').trigger('click')
|
||||||
await flushPromises()
|
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 () => {
|
it('switches to article view and navigates between articles', async () => {
|
||||||
@@ -252,30 +278,31 @@ describe('RssFeeds', () => {
|
|||||||
useFeeds().toggleViewMode()
|
useFeeds().toggleViewMode()
|
||||||
await flushPromises()
|
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
|
// Same as in list view: the readable content is loaded on demand by
|
||||||
// clicking the headline, not fetched automatically on entering the view.
|
// 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)
|
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()
|
await flushPromises()
|
||||||
|
|
||||||
expect(axios.post).toHaveBeenCalledWith('/api/v1/article/read', { url: 'https://example.test/1' }, expect.anything())
|
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()
|
expect(wrapper.findAll('.article-nav__btn')[0].attributes('disabled')).toBeDefined()
|
||||||
|
|
||||||
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
|
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
|
||||||
await flushPromises()
|
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()
|
expect(wrapper.findAll('.article-nav__btn')[1].attributes('disabled')).toBeDefined()
|
||||||
|
|
||||||
await wrapper.findAll('.article-nav__btn')[0].trigger('click')
|
await wrapper.findAll('.article-nav__btn')[0].trigger('click')
|
||||||
await flushPromises()
|
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 () => {
|
it('drops articles read while paging through article view once back in the list', async () => {
|
||||||
@@ -331,4 +358,179 @@ describe('RssFeeds', () => {
|
|||||||
const titles = wrapper.findAll('.feed-title').map(el => el.text())
|
const titles = wrapper.findAll('.feed-title').map(el => el.text())
|
||||||
expect(titles).toEqual(['Article three'])
|
expect(titles).toEqual(['Article three'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('swipes left to navigate to the next article', 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('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('swipes right to navigate to the previous 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')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
||||||
|
|
||||||
|
const article = wrapper.find('.article-single')
|
||||||
|
await article.trigger('touchstart', { touches: [{ clientX: 130, clientY: 100 }] })
|
||||||
|
await article.trigger('touchend', { changedTouches: [{ clientX: 200, clientY: 100 }] })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not navigate on a swipe shorter than the horizontal threshold', 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('touchend', { changedTouches: [{ clientX: 180, clientY: 100 }] })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not 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()
|
||||||
|
|
||||||
|
const article = wrapper.find('.article-single')
|
||||||
|
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
||||||
|
await article.trigger('touchend', { changedTouches: [{ clientX: 190, clientY: 250 }] })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not navigate on a diagonal drag that exceeds the vertical restraint, even with a qualifying horizontal delta', 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('touchend', { changedTouches: [{ clientX: 130, clientY: 200 }] })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||||
|
|
||||||
|
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 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('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 throw when swiping with an empty feed list', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
|
||||||
|
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('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('All caught up')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import '@/assets/modal.css';
|
import '@/assets/modal.css';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
show: Boolean
|
show: Boolean
|
||||||
})
|
})
|
||||||
|
const emit = defineEmits(['close', 'added'])
|
||||||
const submitted = ref(false)
|
const submitted = ref(false)
|
||||||
const url = ref('')
|
const url = ref('')
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
@@ -30,6 +31,7 @@ async function save() {
|
|||||||
|
|
||||||
console.log(response)
|
console.log(response)
|
||||||
output.value = 'saved successfully'
|
output.value = 'saved successfully'
|
||||||
|
emit('added')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error.message)
|
console.error(error.message)
|
||||||
output.value = error.message
|
output.value = error.message
|
||||||
@@ -57,7 +59,7 @@ async function save() {
|
|||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<slot name="footer">
|
<slot name="footer">
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
<button class="modal-default-button" @click="$emit('close')">Close</button>
|
<button class="modal-default-button" type="button" @click="$emit('close')">Close</button>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ describe('AddUrl', () => {
|
|||||||
expect.anything(),
|
expect.anything(),
|
||||||
)
|
)
|
||||||
expect(wrapper.text()).toContain('saved successfully')
|
expect(wrapper.text()).toContain('saved successfully')
|
||||||
|
expect(wrapper.emitted('added')).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('surfaces the error message when the request fails', async () => {
|
it('surfaces the error message when the request fails', async () => {
|
||||||
@@ -39,5 +40,17 @@ describe('AddUrl', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Network Error')
|
expect(wrapper.text()).toContain('Network Error')
|
||||||
|
expect(wrapper.emitted('added')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes without submitting when Close is clicked, even with fields filled in', async () => {
|
||||||
|
const wrapper = mount(AddUrl, { props: { show: true } })
|
||||||
|
await wrapper.find('#url').setValue('https://example.test/feed.xml')
|
||||||
|
await wrapper.find('#title').setValue('Example feed')
|
||||||
|
|
||||||
|
await wrapper.find('.modal-default-button').trigger('click')
|
||||||
|
|
||||||
|
expect(axios.post).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useFeeds } from '../useFeeds'
|
import { useFeeds } from '../useFeeds'
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ class FakeIntersectionObserver {
|
|||||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||||
|
|
||||||
describe('useFeeds', () => {
|
describe('useFeeds', () => {
|
||||||
const { feeds, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection } = useFeeds()
|
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, viewMode, currentIndex, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, markRead, markCurrentArticleRead, setupIntersectionObserver, isSyncing } = useFeeds()
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('user-token', 'test-token')
|
localStorage.setItem('user-token', 'test-token')
|
||||||
@@ -20,9 +20,11 @@ describe('useFeeds', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
||||||
feeds.value = []
|
feeds.value = []
|
||||||
|
allItems.value = []
|
||||||
|
feedFilter.value = null
|
||||||
showMessage.value = false
|
showMessage.value = false
|
||||||
message.value = ''
|
message.value = ''
|
||||||
showModal.value = false
|
viewMode.value = 'list'
|
||||||
})
|
})
|
||||||
|
|
||||||
it('fetches and flattens articles for the current user', async () => {
|
it('fetches and flattens articles for the current user', async () => {
|
||||||
@@ -116,6 +118,693 @@ describe('useFeeds', () => {
|
|||||||
setInitialLoad(false)
|
setInitialLoad(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('refreshes the filter dropdown\'s displayed per-feed counts when list-view scrolling marks an article read', async () => {
|
||||||
|
// displayedFeedUnreadCounts is a frozen snapshot of feedUnreadCounts (see
|
||||||
|
// useFeeds.js) — AppNav's filter <select> renders it instead of the live
|
||||||
|
// computed so paging through article view doesn't force it to repaint.
|
||||||
|
// List-view scrolling is a different code path (handleIntersection) and
|
||||||
|
// should keep it live regardless.
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 301, feedTitle: 'Feed A', read: false },
|
||||||
|
{ id: 302, feedTitle: 'Feed A', read: false },
|
||||||
|
]
|
||||||
|
feeds.value = [...allItems.value]
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
expect(displayedFeedUnreadCounts.value).toEqual({ 'Feed A': 2 })
|
||||||
|
|
||||||
|
setInitialLoad(true)
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
await handleIntersection([
|
||||||
|
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(displayedFeedUnreadCounts.value).toEqual({ 'Feed A': 1 })
|
||||||
|
|
||||||
|
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('mark-as-read suppressed while syncing', () => {
|
||||||
|
it('queues list-view scroll marking while a reload (fetchData) is in flight, and flushes it automatically once it finishes', async () => {
|
||||||
|
feeds.value = [{ id: 701, title: 'First' }]
|
||||||
|
setInitialLoad(true)
|
||||||
|
// No .observe nodes exist here, so this deterministically clears
|
||||||
|
// skipNextObservation rather than relying on whatever an earlier test
|
||||||
|
// left it as.
|
||||||
|
setupIntersectionObserver()
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
let resolveGet
|
||||||
|
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
|
||||||
|
const fetchPromise = fetchData()
|
||||||
|
|
||||||
|
await handleIntersection([
|
||||||
|
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
|
||||||
|
])
|
||||||
|
expect(axios.put).not.toHaveBeenCalled()
|
||||||
|
// Queued, not dropped — untouched while the reload is in flight, unlike
|
||||||
|
// a normal scroll-past which removes it immediately.
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([701])
|
||||||
|
|
||||||
|
// The refetch's response still lists it (its read never committed) —
|
||||||
|
// the flush that follows must find and remove it by id.
|
||||||
|
resolveGet({
|
||||||
|
data: {
|
||||||
|
feeds: [{
|
||||||
|
title: 'Feed A',
|
||||||
|
items: [{ id: 701, title: 'First', content: '', url: 'https://example.test/701', timestamp: '2026-01-01 10:00:00' }],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await fetchPromise
|
||||||
|
|
||||||
|
// Flushed automatically once the reload finished — no second scroll needed.
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/701', null, expect.anything())
|
||||||
|
expect(feeds.value).toEqual([])
|
||||||
|
|
||||||
|
setInitialLoad(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queues list-view scroll marking for the whole duration of sync(), and flushes it once sync finishes', async () => {
|
||||||
|
feeds.value = [{ id: 702, title: 'First' }]
|
||||||
|
setInitialLoad(true)
|
||||||
|
setupIntersectionObserver() // deterministically clears skipNextObservation — see the previous test
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
|
||||||
|
// Still mid network round-trip to /api/v1/article/sync — fetchData()
|
||||||
|
// hasn't even started yet, but scroll-marking must already be queued.
|
||||||
|
await handleIntersection([
|
||||||
|
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
|
||||||
|
])
|
||||||
|
expect(axios.put).not.toHaveBeenCalled()
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([702])
|
||||||
|
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await syncPromise
|
||||||
|
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/702', null, expect.anything())
|
||||||
|
expect(feeds.value).toEqual([])
|
||||||
|
|
||||||
|
setInitialLoad(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queues article-view display marking while a reload is in flight, and flushes it in place once the reload finishes', async () => {
|
||||||
|
viewMode.value = 'article' // still on screen when the flush runs — must not be removed out from under it
|
||||||
|
feeds.value = [{ id: 703, title: 'First' }]
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
let resolveGet
|
||||||
|
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
|
||||||
|
const fetchPromise = fetchData()
|
||||||
|
|
||||||
|
markCurrentArticleRead()
|
||||||
|
expect(axios.put).not.toHaveBeenCalled()
|
||||||
|
expect(feeds.value[0].read).toBeFalsy()
|
||||||
|
|
||||||
|
// The refetch's response still lists it (its read never committed), same
|
||||||
|
// id, now at whatever index it re-sorts to — the flush must find it by
|
||||||
|
// id, not by the currentIndex it was queued under.
|
||||||
|
resolveGet({
|
||||||
|
data: {
|
||||||
|
feeds: [{
|
||||||
|
title: 'Feed A',
|
||||||
|
items: [{ id: 703, title: 'First', content: '', url: 'https://example.test/703', timestamp: '2026-01-01 10:00:00' }],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await fetchPromise
|
||||||
|
|
||||||
|
// Flushed automatically once the reload finished — no second call needed.
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/703', null, expect.anything())
|
||||||
|
// Still in article view — marked in place, not removed (removing it here
|
||||||
|
// would yank whatever's on screen out from under currentIndex; the same
|
||||||
|
// invariant markCurrentArticleRead() already keeps while paging).
|
||||||
|
expect(feeds.value.find(f => f.id === 703)).toMatchObject({ read: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a queued article-view read once flushed, if the user already left article view before the reload finished', async () => {
|
||||||
|
feeds.value = [{ id: 707, title: 'First' }]
|
||||||
|
viewMode.value = 'article'
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
let resolveGet
|
||||||
|
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
|
||||||
|
const fetchPromise = fetchData()
|
||||||
|
|
||||||
|
markCurrentArticleRead() // queued: isSyncing is true
|
||||||
|
// The user backs out to list view before the reload finishes —
|
||||||
|
// leaveArticleView()'s dropReadArticles() runs here, but this article
|
||||||
|
// isn't flagged read yet (it's only queued), so it survives that pass.
|
||||||
|
viewMode.value = 'list'
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([707])
|
||||||
|
|
||||||
|
resolveGet({ data: { feeds: [] } })
|
||||||
|
await fetchPromise
|
||||||
|
|
||||||
|
// Now in list view with no further transition to rely on — the flush
|
||||||
|
// must sweep it out itself instead of leaving it stuck looking unread.
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/707', null, expect.anything())
|
||||||
|
expect(feeds.value).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queues every article paged past during a sync, flushing all of them once it ends', async () => {
|
||||||
|
feeds.value = [
|
||||||
|
{ id: 704, title: 'First' },
|
||||||
|
{ id: 705, title: 'Second' },
|
||||||
|
]
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
|
||||||
|
markCurrentArticleRead() // pages onto article 704
|
||||||
|
currentIndex.value = 1
|
||||||
|
markCurrentArticleRead() // pages onto article 705
|
||||||
|
expect(axios.put).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await syncPromise
|
||||||
|
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/704', null, expect.anything())
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/705', null, expect.anything())
|
||||||
|
|
||||||
|
currentIndex.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not reopen the mark-as-read window when an independent fetchData() resolves while a sync() is still in flight', async () => {
|
||||||
|
feeds.value = [{ id: 706, title: 'First' }]
|
||||||
|
setInitialLoad(true)
|
||||||
|
setupIntersectionObserver() // deterministically clears skipNextObservation — see above
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
// sync() starts and is left mid network round-trip.
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
|
||||||
|
// An independent fetchData() call (e.g. a separate reload) runs to
|
||||||
|
// completion while sync()'s POST is still pending.
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
// sync() itself hasn't finished — scroll-marking must still be
|
||||||
|
// suppressed, not reopened by the independent call settling first.
|
||||||
|
feeds.value = [{ id: 706, title: 'First' }]
|
||||||
|
await handleIntersection([
|
||||||
|
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
|
||||||
|
])
|
||||||
|
expect(axios.put).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await syncPromise
|
||||||
|
|
||||||
|
// Only now, with sync() truly finished, does the queued entry flush.
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/706', null, expect.anything())
|
||||||
|
|
||||||
|
setInitialLoad(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isSyncing', () => {
|
||||||
|
// Lets a just-resolved deferred promise's `.then` continuations (e.g.
|
||||||
|
// sync()'s `await fetchData()`) actually run before the next assertion,
|
||||||
|
// without pulling in fake timers (see the CLAUDE.md Vitest gotcha).
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
for (let i = 0; i < 5; i++) await Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
it('is false initially, true while a fetchData() call is in flight, and false again once it resolves', async () => {
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
|
||||||
|
let resolveGet
|
||||||
|
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
|
||||||
|
const fetchPromise = fetchData()
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(true)
|
||||||
|
|
||||||
|
resolveGet({ data: { feeds: [] } })
|
||||||
|
await fetchPromise
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays true for the whole duration of sync(), including its trailing fetchData(), and only clears once sync() fully settles', async () => {
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
let resolveGet
|
||||||
|
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
|
||||||
|
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
expect(isSyncing.value).toBe(true)
|
||||||
|
|
||||||
|
// The sync POST settles, handing off into sync()'s own trailing
|
||||||
|
// fetchData() — isSyncing must not flicker false in between.
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalled()
|
||||||
|
expect(isSyncing.value).toBe(true)
|
||||||
|
|
||||||
|
resolveGet({ data: { feeds: [] } })
|
||||||
|
await syncPromise
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves isSyncing true while an independent fetchData() completes during a still in-flight sync(), clearing only once both finish', async () => {
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
expect(isSyncing.value).toBe(true)
|
||||||
|
|
||||||
|
// An independent fetchData() call runs to completion while sync()'s
|
||||||
|
// POST is still pending.
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
// sync() itself hasn't finished yet.
|
||||||
|
expect(isSyncing.value).toBe(true)
|
||||||
|
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await syncPromise
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears on a rejected fetchData()', async () => {
|
||||||
|
axios.get.mockRejectedValueOnce(new Error('network'))
|
||||||
|
|
||||||
|
await fetchData() // caught internally — resolves rather than rejecting
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears on a rejected sync()', async () => {
|
||||||
|
axios.post.mockRejectedValueOnce(new Error('network'))
|
||||||
|
|
||||||
|
await sync(true) // caught internally — resolves rather than rejecting
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('markRead resilience', () => {
|
||||||
|
// Mirrors useFeeds.js's MARK_READ_RETRY_DELAYS_MS (not exported). Kept in
|
||||||
|
// sync via this comment: if that backoff changes, update this too — too
|
||||||
|
// small a value just makes the affected test's `await` hang until it
|
||||||
|
// times out, rather than pass incorrectly.
|
||||||
|
const RETRY_BACKOFF_SUM_MS = 300 + 1000
|
||||||
|
|
||||||
|
// A failed assertion before a test's own vi.useRealTimers() call would
|
||||||
|
// otherwise leak fake timers into every later test in this file.
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries a failed mark-read PUT before giving up, with a request timeout set', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
axios.put
|
||||||
|
.mockRejectedValueOnce(new Error('network'))
|
||||||
|
.mockRejectedValueOnce(new Error('network'))
|
||||||
|
.mockResolvedValueOnce({ status: 200 })
|
||||||
|
|
||||||
|
const done = markRead(601)
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
|
||||||
|
expect(await done).toBe(true)
|
||||||
|
expect(axios.put).toHaveBeenCalledTimes(3)
|
||||||
|
expect(axios.put).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/article/read/601',
|
||||||
|
null,
|
||||||
|
expect.objectContaining({ timeout: expect.any(Number) }),
|
||||||
|
)
|
||||||
|
expect(showMessage.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports failure once retries are exhausted, and stops withholding the item from a refetch', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
axios.put.mockRejectedValue(new Error('network'))
|
||||||
|
|
||||||
|
const done = markRead(602)
|
||||||
|
// Advance past just the two retry backoffs — not runAllTimersAsync(),
|
||||||
|
// which would also fire showMessageForXSeconds's own 5s auto-hide timer
|
||||||
|
// and reset showMessage before the assertion below.
|
||||||
|
await vi.advanceTimersByTimeAsync(RETRY_BACKOFF_SUM_MS)
|
||||||
|
|
||||||
|
expect(await done).toBe(false)
|
||||||
|
expect(axios.put).toHaveBeenCalledTimes(3)
|
||||||
|
expect(showMessage.value).toBe(true)
|
||||||
|
expect(message.value).toMatch(/could not mark/i)
|
||||||
|
|
||||||
|
// The mark never committed, so the item genuinely is still unread
|
||||||
|
// server-side — a refetch must be free to show it again, not hide it
|
||||||
|
// forever as "pending".
|
||||||
|
axios.get.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
feeds: [{
|
||||||
|
title: 'Feed A',
|
||||||
|
items: [{ id: 602, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await fetchData()
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([602])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not retry a non-retryable 4xx mark-read failure', async () => {
|
||||||
|
const error = new Error('Not Found')
|
||||||
|
error.response = { status: 404 }
|
||||||
|
axios.put.mockRejectedValueOnce(error)
|
||||||
|
|
||||||
|
const result = await markRead(605)
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
expect(axios.put).toHaveBeenCalledTimes(1)
|
||||||
|
expect(showMessage.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a still-unconfirmed read article out of a concurrent background refetch', async () => {
|
||||||
|
let resolvePut
|
||||||
|
axios.put.mockReturnValueOnce(new Promise(resolve => { resolvePut = resolve }))
|
||||||
|
|
||||||
|
const done = markRead(603) // simulates a page-turn's fire-and-forget mark-read, still in flight
|
||||||
|
|
||||||
|
// A background sync's trailing fetchData() resolves while that PUT is
|
||||||
|
// still pending — the server hasn't committed the read yet, so it's
|
||||||
|
// still in the response.
|
||||||
|
axios.get.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
feeds: [{
|
||||||
|
title: 'Feed A',
|
||||||
|
items: [{ id: 603, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
expect(feeds.value).toEqual([])
|
||||||
|
|
||||||
|
resolvePut({ status: 200 })
|
||||||
|
await done
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes an item whose mark-read PUT settles between a refetch\'s request and its response', async () => {
|
||||||
|
// The PUT commits (and clears the pending id) while the GET below is
|
||||||
|
// still in flight — the GET's response nonetheless reflects the DB as
|
||||||
|
// of before that commit, still reporting the item unread. A snapshot
|
||||||
|
// taken only once the GET resolves would miss this; one taken before
|
||||||
|
// it's dispatched (unioned with the live state) catches it too.
|
||||||
|
axios.put.mockResolvedValueOnce({ status: 200 })
|
||||||
|
const markDone = markRead(604)
|
||||||
|
|
||||||
|
let resolveGet
|
||||||
|
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
|
||||||
|
const fetchPromise = fetchData()
|
||||||
|
|
||||||
|
await markDone // the PUT (and pending-id cleanup) settles first...
|
||||||
|
|
||||||
|
resolveGet({ // ...then the already-in-flight GET resolves, still unread.
|
||||||
|
data: {
|
||||||
|
feeds: [{
|
||||||
|
title: 'Feed A',
|
||||||
|
items: [{ id: 604, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await fetchPromise
|
||||||
|
|
||||||
|
expect(feeds.value).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps an item withheld until every overlapping markRead() call for it settles', async () => {
|
||||||
|
// nextArticle()/prevArticle() can both re-mark the same article (page
|
||||||
|
// forward, back, forward again) — two overlapping calls for one id.
|
||||||
|
let resolveFirst
|
||||||
|
let resolveSecond
|
||||||
|
axios.put
|
||||||
|
.mockReturnValueOnce(new Promise(resolve => { resolveFirst = resolve }))
|
||||||
|
.mockReturnValueOnce(new Promise(resolve => { resolveSecond = resolve }))
|
||||||
|
|
||||||
|
const first = markRead(606)
|
||||||
|
const second = markRead(606)
|
||||||
|
|
||||||
|
resolveSecond({ status: 200 }) // the second (later) call settles first
|
||||||
|
await second
|
||||||
|
|
||||||
|
// The first call is still in flight — a refetch must still withhold it.
|
||||||
|
axios.get.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
feeds: [{
|
||||||
|
title: 'Feed A',
|
||||||
|
items: [{ id: 606, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await fetchData()
|
||||||
|
expect(feeds.value).toEqual([])
|
||||||
|
|
||||||
|
resolveFirst({ status: 200 })
|
||||||
|
await first
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('markAllRead resilience', () => {
|
||||||
|
const RETRY_BACKOFF_SUM_MS = 300 + 1000
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a partial failure instead of claiming full success', async () => {
|
||||||
|
feeds.value = [
|
||||||
|
{ id: 701, title: 'First' },
|
||||||
|
{ id: 702, title: 'Second' },
|
||||||
|
]
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
axios.put
|
||||||
|
.mockResolvedValueOnce({ status: 200 }) // 701 succeeds
|
||||||
|
.mockRejectedValue(new Error('network')) // 702 fails every attempt
|
||||||
|
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const done = markAllRead()
|
||||||
|
await vi.advanceTimersByTimeAsync(RETRY_BACKOFF_SUM_MS)
|
||||||
|
await done
|
||||||
|
|
||||||
|
expect(message.value).toContain('1 of 2')
|
||||||
|
expect(message.value).not.toBe('All articles marked as read.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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('does not resurrect an article-view read-in-place article when re-selecting its feed', 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])
|
||||||
|
|
||||||
|
// Article view marks the displayed article read in place (feed.read =
|
||||||
|
// true) without removing it, so currentIndex stays valid while paging —
|
||||||
|
// see markCurrentArticleRead. It's still in the list at this point.
|
||||||
|
markCurrentArticleRead()
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([2])
|
||||||
|
|
||||||
|
// Switching to another feed and back must not resurrect it as if it
|
||||||
|
// were still unread.
|
||||||
|
await setFeedFilter('Feed A')
|
||||||
|
await setFeedFilter('Feed B')
|
||||||
|
|
||||||
|
expect(feeds.value).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the newly displayed article read when the filter is switched while in article view', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
await fetchData()
|
||||||
|
viewMode.value = 'article'
|
||||||
|
|
||||||
|
// Switching into Feed B while in article view immediately displays its
|
||||||
|
// one article's full content — that counts as "viewed", so it's marked
|
||||||
|
// read the same way toggleViewMode()/nextArticle()/prevArticle() do.
|
||||||
|
await setFeedFilter('Feed B')
|
||||||
|
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/2', null, expect.anything())
|
||||||
|
expect(feeds.value[0].read).toBe(true)
|
||||||
|
|
||||||
|
// It doesn't resurface either, once dropped on the next switch.
|
||||||
|
await setFeedFilter('Feed A')
|
||||||
|
await setFeedFilter('Feed B')
|
||||||
|
expect(feeds.value).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not mark the newly displayed article read when the filter is switched in list view', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce(twoFeedsResponse)
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
// List view only shows titles — switching filters there must not mark
|
||||||
|
// anything read; that's still driven solely by scroll (handleIntersection).
|
||||||
|
await setFeedFilter('Feed B')
|
||||||
|
|
||||||
|
expect(axios.put).not.toHaveBeenCalled()
|
||||||
|
expect(feeds.value[0].read).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
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 () => {
|
it('strips leftover embedded-video placeholder headings', async () => {
|
||||||
feeds.value = [{
|
feeds.value = [{
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -245,4 +934,55 @@ describe('useFeeds', () => {
|
|||||||
expect(feeds.value[0].content).not.toContain('%7Bsize%7D')
|
expect(feeds.value[0].content).not.toContain('%7Bsize%7D')
|
||||||
expect(feeds.value[0].content).not.toContain('<img')
|
expect(feeds.value[0].content).not.toContain('<img')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('strips Al-Jazeera-style "Recommended Stories" sections', async () => {
|
||||||
|
feeds.value = [{
|
||||||
|
id: 1,
|
||||||
|
title: 'Article one',
|
||||||
|
url: 'https://www.aljazeera.com/news/2026/9/14/article-one',
|
||||||
|
content: '',
|
||||||
|
}]
|
||||||
|
axios.post.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
content: `<html><body><article>
|
||||||
|
<p>some article text long enough for readability to keep the paragraph as the main content body, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes.</p>
|
||||||
|
<section class="more-on"><h2 class="more-on__heading">Recommended Stories<!-- --> </h2><span>list of 3 items</span><ul>
|
||||||
|
<li><span>list 1 of 3</span><a href="https://www.aljazeera.com/a">Unrelated story one</a></li>
|
||||||
|
<li><span>list 2 of 3</span><a href="https://www.aljazeera.com/b">Unrelated story two</a></li>
|
||||||
|
<li><span>list 3 of 3</span><a href="https://www.aljazeera.com/c">Unrelated story three</a></li>
|
||||||
|
</ul><span>end of list</span></section>
|
||||||
|
<p>more article text long enough to survive readability's content-length heuristics as well, since it needs to look like part of the main body too.</p>
|
||||||
|
</article></body></html>`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await getReadable(feeds.value[0], 0)
|
||||||
|
|
||||||
|
expect(feeds.value[0].readable).toBe(true)
|
||||||
|
expect(feeds.value[0].content).toContain('main content body')
|
||||||
|
expect(feeds.value[0].content).not.toContain('Recommended Stories')
|
||||||
|
expect(feeds.value[0].content).not.toContain('Unrelated story')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps other sections that are not the "Recommended Stories" block', async () => {
|
||||||
|
feeds.value = [{
|
||||||
|
id: 1,
|
||||||
|
title: 'Article one',
|
||||||
|
url: 'https://www.aljazeera.com/news/2026/9/14/article-one',
|
||||||
|
content: '',
|
||||||
|
}]
|
||||||
|
axios.post.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
content: `<html><body><article>
|
||||||
|
<section><p>some article text long enough for readability to keep the paragraph as the main content body, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes.</p></section>
|
||||||
|
<p>more article text long enough to survive readability's content-length heuristics as well, since it needs to look like part of the main body too.</p>
|
||||||
|
</article></body></html>`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await getReadable(feeds.value[0], 0)
|
||||||
|
|
||||||
|
expect(feeds.value[0].readable).toBe(true)
|
||||||
|
expect(feeds.value[0].content).toContain('main content body')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// 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)
|
||||||
|
|
||||||
|
// The article shown right after reload must be marked read immediately —
|
||||||
|
// not only once the user pages forward/back at least once.
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/1', null, expect.anything())
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
+442
-45
@@ -1,20 +1,146 @@
|
|||||||
import { ref, nextTick } from 'vue';
|
import { ref, computed, nextTick } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Readability } from '@mozilla/readability';
|
import { Readability } from '@mozilla/readability';
|
||||||
|
|
||||||
// Module-level state — declared outside useFeeds() so every caller shares the
|
// 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).
|
// same singleton refs (a Pinia-free "store" for the feed list and its UI state).
|
||||||
const showMessage = ref(false)
|
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 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
|
||||||
|
})
|
||||||
|
// AppNav renders this snapshot (refreshed via refreshUnreadDisplay, below)
|
||||||
|
// for the filter <select>'s per-feed option counts, instead of binding
|
||||||
|
// feedUnreadCounts directly — by design, this stays frozen while paging
|
||||||
|
// through article view (markCurrentArticleRead marks one article read per
|
||||||
|
// page turn, which would otherwise recompute it live on every single swipe).
|
||||||
|
// The header's own unread badge stays bound to the live unreadCount (it
|
||||||
|
// should update in real time regardless of view). Everywhere else that marks
|
||||||
|
// articles read — list-view scrolling (handleIntersection), opening/changing
|
||||||
|
// the filter, syncing, mark-all-read, leaving article view — refreshes this
|
||||||
|
// snapshot immediately, so it only ever looks stale during article-view
|
||||||
|
// paging itself.
|
||||||
|
const displayedFeedUnreadCounts = ref({})
|
||||||
|
function refreshUnreadDisplay() {
|
||||||
|
displayedFeedUnreadCounts.value = feedUnreadCounts.value
|
||||||
|
}
|
||||||
const message = ref('')
|
const message = ref('')
|
||||||
const showModal = ref(false)
|
const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
|
||||||
const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu
|
|
||||||
const currentIndex = ref(0)
|
const currentIndex = ref(0)
|
||||||
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu
|
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 observer; // Declare observer outside the setup function
|
||||||
let initialLoad = false
|
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
|
||||||
|
|
||||||
|
// Count of fetchData()/sync() calls currently in flight — a refcount rather
|
||||||
|
// than a plain boolean for the same reason as pendingReadCounts below: sync()
|
||||||
|
// awaits its own trailing fetchData(), so one call nests inside the other, and
|
||||||
|
// fetchData() can also be invoked independently (RssFeeds.vue's boot reload)
|
||||||
|
// while a sync() elsewhere is still mid network round-trip. A bare boolean
|
||||||
|
// would have whichever call finishes first clear it out from under the other
|
||||||
|
// still-running one. Both rebuild allItems/feeds from the server, so the
|
||||||
|
// passive/paging mark-as-read paths — handleIntersection's scroll-marking and
|
||||||
|
// markCurrentArticleRead's display-marking — must not fire while any is in
|
||||||
|
// flight. markAllRead is a deliberate, already-confirmed action and is left
|
||||||
|
// ungated.
|
||||||
|
const activeSyncCount = ref(0)
|
||||||
|
function beginSync() {
|
||||||
|
activeSyncCount.value += 1
|
||||||
|
}
|
||||||
|
// Ids handleIntersection()/markCurrentArticleRead() couldn't mark while
|
||||||
|
// isSyncing (see below) — flushed once every in-flight fetchData()/sync()
|
||||||
|
// has finished, rather than left unread forever. Flushing by id (not by
|
||||||
|
// re-deriving an index) sidesteps fetchData() having replaced
|
||||||
|
// feeds.value/allItems.value wholesale in the meantime — the id is still
|
||||||
|
// valid, wherever the item now sits.
|
||||||
|
const pendingSuppressedReadIds = new Set()
|
||||||
|
|
||||||
|
function endSync() {
|
||||||
|
activeSyncCount.value -= 1
|
||||||
|
if (activeSyncCount.value === 0) {
|
||||||
|
flushPendingSuppressedReads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isSyncing = computed(() => activeSyncCount.value > 0)
|
||||||
|
|
||||||
|
function flushPendingSuppressedReads() {
|
||||||
|
if (pendingSuppressedReadIds.size === 0) return
|
||||||
|
const ids = [...pendingSuppressedReadIds]
|
||||||
|
pendingSuppressedReadIds.clear()
|
||||||
|
for (const id of ids) {
|
||||||
|
// feeds.value is always a filter()/slice() projection of allItems.value
|
||||||
|
// (see applyFilter), so every item in it is the same object reference as
|
||||||
|
// one already in allItems.value — one lookup is enough.
|
||||||
|
const feed = allItems.value.find(f => f.id === id)
|
||||||
|
if (feed) feed.read = true
|
||||||
|
markRead(id)
|
||||||
|
}
|
||||||
|
// A still-open article view marks in place and relies on dropReadArticles()
|
||||||
|
// running at the next leaveArticleView()/setFeedFilter() to clean up — same
|
||||||
|
// invariant markCurrentArticleRead() already keeps while paging, and
|
||||||
|
// removing one here could shift currentIndex out from under whatever's on
|
||||||
|
// screen. List view has no such upcoming transition to rely on (the user
|
||||||
|
// may just keep scrolling), so sweep these out now: this is exactly what a
|
||||||
|
// scroll-triggered mark would already have done immediately, had a sync not
|
||||||
|
// been in flight.
|
||||||
|
if (viewMode.value !== 'article') {
|
||||||
|
dropReadArticles()
|
||||||
|
}
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp (performance.now()) of the most recent programmatic scroll / list
|
||||||
|
// mutation that moves the page without user intent — currently the list-view
|
||||||
|
// read-correction below. AppNav's auto-hide handler resyncs its scroll baseline
|
||||||
|
// (instead of treating the induced jump as a user scroll) for a short window
|
||||||
|
// after this, so removing read articles can't pop the header in/out mid-read.
|
||||||
|
const lastProgrammaticScroll = ref(0)
|
||||||
|
function markProgrammaticScroll() {
|
||||||
|
lastProgrammaticScroll.value = performance.now()
|
||||||
|
}
|
||||||
|
|
||||||
export function authHeaders() {
|
export function authHeaders() {
|
||||||
return {
|
return {
|
||||||
@@ -121,7 +247,40 @@ async function getReadable(feed, index) {
|
|||||||
el.remove()
|
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()
|
||||||
|
})
|
||||||
|
// Al Jazeera embeds a "Recommended Stories" section (a heading followed by
|
||||||
|
// a list of unrelated article teasers), marked up as <section class="more-on">.
|
||||||
|
// It's not part of the article, so strip it before Readability pulls it
|
||||||
|
// into the parsed content.
|
||||||
|
doc.querySelectorAll('section.more-on').forEach(el => el.remove())
|
||||||
const article = new Readability(doc).parse();
|
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].content = article.content;
|
||||||
feeds.value[index].readable = true;
|
feeds.value[index].readable = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -130,48 +289,181 @@ async function getReadable(feed, index) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markRead(id) {
|
// Ids with a mark-read PUT in flight (or being retried) — see markRead() and
|
||||||
try {
|
// fetchData() below. A refcount rather than a plain Set: nextArticle()/
|
||||||
const response = await axios.put("/api/v1/article/read/" + id, null, authHeaders())
|
// prevArticle() can both re-mark the same article (page forward, back,
|
||||||
console.log(response.status)
|
// forward again), so two overlapping markRead() calls for one id must both
|
||||||
} catch (error) {
|
// finish before fetchData() is allowed to trust the server's word on it —
|
||||||
console.log(error)
|
// otherwise the first call to settle would clear the id out from under the
|
||||||
|
// second, still-in-flight one.
|
||||||
|
const pendingReadCounts = new Map()
|
||||||
|
|
||||||
|
function addPendingRead(id) {
|
||||||
|
pendingReadCounts.set(id, (pendingReadCounts.get(id) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePendingRead(id) {
|
||||||
|
const count = pendingReadCounts.get(id) ?? 0
|
||||||
|
if (count <= 1) {
|
||||||
|
pendingReadCounts.delete(id)
|
||||||
|
} else {
|
||||||
|
pendingReadCounts.set(id, count - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchData = async () => {
|
function delay(ms) {
|
||||||
const user_id = localStorage.getItem("user-id")
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short backoff between retries of a failed mark-read PUT — each entry is the
|
||||||
|
// wait before that retry attempt.
|
||||||
|
const MARK_READ_RETRY_DELAYS_MS = [300, 1000]
|
||||||
|
// Bounds how long a hung request (dead connection, captive portal — axios has
|
||||||
|
// no default timeout) can keep an id "pending": without this, a request that
|
||||||
|
// never settles would never leave pendingReadCounts, silently withholding
|
||||||
|
// that article from every fetchData() for the rest of the session.
|
||||||
|
const MARK_READ_TIMEOUT_MS = 10000
|
||||||
|
|
||||||
|
// 4xx responses (expired token, item not found/not owned — see
|
||||||
|
// src/reader/mark_read.rs) mean the request itself is wrong and won't
|
||||||
|
// succeed on retry; only a missing response (network error, timeout) or a
|
||||||
|
// server-side/rate-limit status is worth retrying.
|
||||||
|
function isRetryableMarkReadError(error) {
|
||||||
|
const status = error.response?.status
|
||||||
|
return status === undefined || status >= 500 || status === 429
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves to true once the article is confirmed read server-side, or false
|
||||||
|
// once retries (if any) are exhausted / the error isn't retryable.
|
||||||
|
async function markRead(id) {
|
||||||
|
addPendingRead(id)
|
||||||
try {
|
try {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
await axios.put("/api/v1/article/read/" + id, null, { ...authHeaders(), timeout: MARK_READ_TIMEOUT_MS })
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking article read:', error)
|
||||||
|
if (attempt >= MARK_READ_RETRY_DELAYS_MS.length || !isRetryableMarkReadError(error)) {
|
||||||
|
// Out of retries, or a retry can't help — this mark never committed
|
||||||
|
// server-side, so the item genuinely is still unread there. Surface
|
||||||
|
// it rather than letting it silently vanish from the local list now
|
||||||
|
// only to mysteriously reappear as unread later.
|
||||||
|
showMessageForXSeconds('Could not mark an article as read. It may reappear as unread.', 5)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await delay(MARK_READ_RETRY_DELAYS_MS[attempt])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
removePendingRead(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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Article view marks an article read in place (feed.read = true) without
|
||||||
|
// removing it — see markCurrentArticleRead — so currentIndex stays valid while
|
||||||
|
// paging within that same list. Anything switches the list out from under
|
||||||
|
// that index (leaving article view, or re-filtering while still in it) has to
|
||||||
|
// drop those read-in-place articles from the master here, or they resurface:
|
||||||
|
// re-selecting their feed would otherwise re-project them from allItems as if
|
||||||
|
// still unread.
|
||||||
|
function dropReadArticles() {
|
||||||
|
// The feeds.value filter here is redundant for the setFeedFilter() caller
|
||||||
|
// (applyFilter() overwrites feeds.value again right after) but is what
|
||||||
|
// leaveArticleView() actually needs — kept shared rather than split into two
|
||||||
|
// near-identical branches.
|
||||||
|
feeds.value = feeds.value.filter(feed => !feed.read)
|
||||||
|
allItems.value = allItems.value.filter(feed => !feed.read)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
dropReadArticles()
|
||||||
|
feedFilter.value = title // null for "All feeds"
|
||||||
|
currentIndex.value = 0
|
||||||
|
applyFilter()
|
||||||
|
// In article view the item at currentIndex is fully displayed, not just
|
||||||
|
// listed — unlike a plain currentIndex reset from paging, this one really is
|
||||||
|
// "shown" to the user, so mark it the same way toggleViewMode()/nextArticle()/
|
||||||
|
// prevArticle() do. List view only shows titles here; its own read-marking
|
||||||
|
// happens via scroll (handleIntersection), so this must not fire there.
|
||||||
|
if (viewMode.value === 'article') markCurrentArticleRead()
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
await nextTick()
|
||||||
|
setupIntersectionObserver()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
beginSync()
|
||||||
|
const user_id = localStorage.getItem("user-id")
|
||||||
|
// Snapshot ids pending *before* the GET goes out, not just when its
|
||||||
|
// response comes back. A markRead() PUT that commits while this GET is
|
||||||
|
// in flight is invisible to the check below (its id has already been
|
||||||
|
// cleared by the time the response arrives) even though the server read
|
||||||
|
// the DB before that commit and so still reported the item unread — this
|
||||||
|
// snapshot catches that case too.
|
||||||
|
const pendingBeforeFetch = new Set(pendingReadCounts.keys())
|
||||||
const response = await axios.get("/api/v1/article/get/" + user_id, authHeaders());
|
const response = await axios.get("/api/v1/article/get/" + user_id, authHeaders());
|
||||||
const items = [];
|
const items = [];
|
||||||
response.data.feeds.forEach(feed => {
|
response.data.feeds.forEach(feed => {
|
||||||
feed.items.forEach(item => items.push({ ...item, feedTitle: feed.title }));
|
feed.items.forEach(item => items.push({ ...item, feedTitle: feed.title }));
|
||||||
});
|
});
|
||||||
|
// An item pending at either end of this request may not have committed
|
||||||
|
// server-side yet, so the server can still report it unread here. Drop it
|
||||||
|
// from this snapshot too — otherwise a concurrent background sync's
|
||||||
|
// refetch (see sync()) would resurrect it as unread out from under the
|
||||||
|
// user while markRead() is still confirming or retrying it.
|
||||||
|
const freshItems = items.filter(item => !pendingBeforeFetch.has(item.id) && !pendingReadCounts.has(item.id));
|
||||||
// timestamps are zero-padded "YYYY-MM-DD HH:MM:SS" strings, so a plain
|
// timestamps are zero-padded "YYYY-MM-DD HH:MM:SS" strings, so a plain
|
||||||
// lexicographic comparison sorts them chronologically.
|
// lexicographic comparison sorts them chronologically.
|
||||||
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
freshItems.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||||
feeds.value = items;
|
allItems.value = freshItems;
|
||||||
|
applyFilter();
|
||||||
|
refreshUnreadDisplay();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
setupIntersectionObserver();
|
setupIntersectionObserver();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching data:', error)
|
console.error('Error fetching data:', error)
|
||||||
showMessageForXSeconds(error, 5)
|
showMessageForXSeconds(error, 5)
|
||||||
|
} finally {
|
||||||
|
endSync()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function sync() {
|
async function sync(silent = false) {
|
||||||
|
beginSync()
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/api/v1/article/sync', {
|
const response = await axios.post('/api/v1/article/sync', {
|
||||||
user_id: parseInt(localStorage.getItem("user-id"))
|
user_id: parseInt(localStorage.getItem("user-id"))
|
||||||
}, authHeaders())
|
}, authHeaders())
|
||||||
|
|
||||||
if (response.status == 200) {
|
if (response.status == 200 && !silent) {
|
||||||
showMessageForXSeconds('Sync successful.', 5)
|
showMessageForXSeconds('Sync successful.', 5)
|
||||||
}
|
}
|
||||||
fetchData();
|
await fetchData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sync', error)
|
console.error('Error sync', error)
|
||||||
showMessageForXSeconds(error, 5)
|
if (!silent) {
|
||||||
|
showMessageForXSeconds(error, 5)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
endSync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +475,7 @@ function setupIntersectionObserver() {
|
|||||||
// The sticky topbar overlays the top of the viewport, so an article fully
|
// The sticky topbar overlays the top of the viewport, so an article fully
|
||||||
// hidden behind it should already count as "scrolled past" — shrink the
|
// hidden behind it should already count as "scrolled past" — shrink the
|
||||||
// observer's root by that height so it stops intersecting at that point.
|
// 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), {
|
observer = new IntersectionObserver((entries) => handleIntersection(entries, topbarHeight), {
|
||||||
root: null, // Use the viewport as the root
|
root: null, // Use the viewport as the root
|
||||||
@@ -192,20 +484,27 @@ function setupIntersectionObserver() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const observedDivs = document.querySelectorAll(".observe");
|
const observedDivs = document.querySelectorAll(".observe");
|
||||||
if (observedDivs.length > 0) {
|
// Arm the skip only when we actually observe nodes: an observe() batch fires
|
||||||
observedDivs.forEach(observedDiv => {
|
// an initial snapshot to drop (a connect while scrolled down would otherwise
|
||||||
observer.observe(observedDiv);
|
// 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) {
|
function handleIntersection(entries, topbarHeight = 0) {
|
||||||
// An article that has scrolled past the (possibly sticky-bar-shrunk) top
|
// Drop the initial snapshot fired on (re)connect — see skipNextObservation.
|
||||||
// edge of the viewport (not intersecting, bounding box above that edge)
|
// The old observer is disconnected before this new one observes, so only the
|
||||||
// has been read. Resolve all affected feeds up front, before any removal —
|
// latest observer's initial callback reaches here; one skip is enough.
|
||||||
// splicing `feeds` while iterating would shift the array indices that later
|
if (skipNextObservation) {
|
||||||
// entries' `target.id` refer to, causing the wrong item to be marked read
|
skipNextObservation = false
|
||||||
// and removed.
|
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
|
const readFeeds = entries
|
||||||
.filter(entry => initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < topbarHeight)
|
.filter(entry => initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < topbarHeight)
|
||||||
.map(entry => feeds.value[entry.target.id])
|
.map(entry => feeds.value[entry.target.id])
|
||||||
@@ -213,13 +512,63 @@ async function handleIntersection(entries, topbarHeight = 0) {
|
|||||||
|
|
||||||
if (readFeeds.length === 0) return
|
if (readFeeds.length === 0) return
|
||||||
|
|
||||||
for (const feed of readFeeds) {
|
if (isSyncing.value) {
|
||||||
await markRead(feed.id)
|
// Don't mutate a list that's mid-reload — queue these ids instead of
|
||||||
|
// dropping them; flushPendingSuppressedReads() marks (and removes) them
|
||||||
|
// once every in-flight fetchData()/sync() has finished. Nothing here
|
||||||
|
// touched the DOM, so there's no observer disconnect/reconnect to do.
|
||||||
|
for (const feed of readFeeds) {
|
||||||
|
pendingSuppressedReadIds.add(feed.id)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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))
|
const readIds = new Set(readFeeds.map(feed => feed.id))
|
||||||
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
|
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
|
||||||
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))
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
|
||||||
|
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) {
|
function setInitialLoad(value) {
|
||||||
@@ -231,31 +580,53 @@ async function markAllRead() {
|
|||||||
if (!window.confirm('Mark all articles as read?')) return
|
if (!window.confirm('Mark all articles as read?')) return
|
||||||
|
|
||||||
const ids = feeds.value.map(feed => feed.id)
|
const ids = feeds.value.map(feed => feed.id)
|
||||||
|
const readIds = new Set(ids)
|
||||||
feeds.value = []
|
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
|
currentIndex.value = 0
|
||||||
// markRead swallows its own errors, so Promise.all can't reject here.
|
refreshUnreadDisplay()
|
||||||
await Promise.all(ids.map(id => markRead(id)))
|
// markRead() resolves to true/false rather than rejecting, so Promise.all
|
||||||
showMessageForXSeconds('All articles marked as read.', 5)
|
// can't reject here — but a false means that article is still unread
|
||||||
|
// server-side, which the message below must not contradict.
|
||||||
|
const results = await Promise.all(ids.map(id => markRead(id)))
|
||||||
|
const failedCount = results.filter(ok => !ok).length
|
||||||
|
if (failedCount === 0) {
|
||||||
|
showMessageForXSeconds('All articles marked as read.', 5)
|
||||||
|
} else {
|
||||||
|
// markRead() already surfaced each individual failure — this summarizes
|
||||||
|
// the batch outcome instead of claiming full success over it.
|
||||||
|
showMessageForXSeconds(`Marked ${ids.length - failedCount} of ${ids.length} as read; ${failedCount} failed and may reappear.`, 5)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function markCurrentArticleRead() {
|
function markCurrentArticleRead() {
|
||||||
const feed = feeds.value[currentIndex.value]
|
const feed = feeds.value[currentIndex.value]
|
||||||
|
if (!feed) return
|
||||||
|
// Don't mark against a list that's mid-reload — see isSyncing/activeSyncCount.
|
||||||
|
// Queue this article's id instead of dropping it: flushPendingSuppressedReads()
|
||||||
|
// marks it once every in-flight fetchData()/sync() has finished.
|
||||||
|
if (isSyncing.value) {
|
||||||
|
pendingSuppressedReadIds.add(feed.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Marking read here (rather than via removeFeed, as the scroll-based list
|
// Marking read here (rather than via removeFeed, as the scroll-based list
|
||||||
// view does) keeps the array stable so currentIndex stays valid while paging.
|
// view does) keeps the array stable so currentIndex stays valid while paging.
|
||||||
// The local `read` flag lets leaveArticleView() drop these once we're done.
|
// The local `read` flag lets leaveArticleView() drop these once we're done.
|
||||||
if (feed) {
|
feed.read = true
|
||||||
feed.read = true
|
markRead(feed.id)
|
||||||
markRead(feed.id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function leaveArticleView() {
|
async function leaveArticleView() {
|
||||||
// Articles paged past in article view were marked read but deliberately kept
|
// Articles paged past in article view were marked read but deliberately kept
|
||||||
// in place so currentIndex stayed valid — drop them now so they don't keep
|
// in place so currentIndex stayed valid — drop them now so they don't keep
|
||||||
// showing up in the list view.
|
// showing up in the list view.
|
||||||
feeds.value = feeds.value.filter(feed => !feed.read)
|
dropReadArticles()
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
viewMode.value = 'list'
|
viewMode.value = 'list'
|
||||||
|
localStorage.setItem('viewMode', viewMode.value)
|
||||||
|
refreshUnreadDisplay()
|
||||||
// The v-if on the list container tears down and recreates all .observe DOM
|
// The v-if on the list container tears down and recreates all .observe DOM
|
||||||
// nodes when switching views, so the intersection observer must be
|
// nodes when switching views, so the intersection observer must be
|
||||||
// re-pointed at the new elements after Vue has finished rendering.
|
// re-pointed at the new elements after Vue has finished rendering.
|
||||||
@@ -267,15 +638,30 @@ function toggleViewMode() {
|
|||||||
if (viewMode.value === 'article') {
|
if (viewMode.value === 'article') {
|
||||||
leaveArticleView()
|
leaveArticleView()
|
||||||
} else {
|
} 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'
|
viewMode.value = 'article'
|
||||||
|
localStorage.setItem('viewMode', viewMode.value)
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
markCurrentArticleRead()
|
markCurrentArticleRead()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLayout() {
|
async function toggleLayout() {
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect()
|
||||||
|
observer = null
|
||||||
|
}
|
||||||
|
window.scrollTo(0, 0)
|
||||||
layout.value = layout.value === 'list' ? 'cards' : 'list'
|
layout.value = layout.value === 'list' ? 'cards' : 'list'
|
||||||
localStorage.setItem('layout', layout.value)
|
localStorage.setItem('layout', layout.value)
|
||||||
|
await nextTick()
|
||||||
|
setupIntersectionObserver()
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextArticle() {
|
function nextArticle() {
|
||||||
@@ -297,16 +683,23 @@ function prevArticle() {
|
|||||||
export function useFeeds() {
|
export function useFeeds() {
|
||||||
return {
|
return {
|
||||||
feeds,
|
feeds,
|
||||||
|
allItems,
|
||||||
|
feedFilter,
|
||||||
|
feedTitles,
|
||||||
|
feedUnreadCounts,
|
||||||
|
unreadCount,
|
||||||
|
displayedFeedUnreadCounts,
|
||||||
|
refreshUnreadDisplay,
|
||||||
|
setFeedFilter,
|
||||||
showMessage,
|
showMessage,
|
||||||
message,
|
message,
|
||||||
showModal,
|
|
||||||
viewMode,
|
viewMode,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
toggleViewMode,
|
toggleViewMode,
|
||||||
leaveArticleView,
|
leaveArticleView,
|
||||||
|
markCurrentArticleRead,
|
||||||
layout,
|
layout,
|
||||||
toggleLayout,
|
toggleLayout,
|
||||||
navTitleVisible,
|
|
||||||
nextArticle,
|
nextArticle,
|
||||||
prevArticle,
|
prevArticle,
|
||||||
fetchData,
|
fetchData,
|
||||||
@@ -316,7 +709,11 @@ export function useFeeds() {
|
|||||||
markAllRead,
|
markAllRead,
|
||||||
showMessageForXSeconds,
|
showMessageForXSeconds,
|
||||||
setupIntersectionObserver,
|
setupIntersectionObserver,
|
||||||
|
disconnectObserver,
|
||||||
setInitialLoad,
|
setInitialLoad,
|
||||||
handleIntersection,
|
handleIntersection,
|
||||||
|
lastProgrammaticScroll,
|
||||||
|
markProgrammaticScroll,
|
||||||
|
isSyncing,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const HEADLINE_FONT_OPTIONS = [
|
||||||
|
{ key: 'default', label: 'Default (Glook)', value: "Glook, 'Courier New'" },
|
||||||
|
{ key: 'playfair', label: 'Playfair Display', value: "'Playfair Display', Georgia, serif" },
|
||||||
|
{ key: 'lora', label: 'Lora', value: "Lora, Georgia, serif" },
|
||||||
|
{ key: 'raleway', label: 'Raleway', value: "Raleway, -apple-system, sans-serif" },
|
||||||
|
{ key: 'inter', label: 'Inter', value: "Inter, -apple-system, sans-serif" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CONTENT_FONT_OPTIONS = [
|
||||||
|
{ key: 'default', label: 'Default (Merriweather)', value: "Merriweather, Georgia, 'Times New Roman', Times, serif" },
|
||||||
|
{ key: 'lora', label: 'Lora', value: "Lora, Georgia, serif" },
|
||||||
|
{ key: 'source-serif', label: 'Source Serif 4', value: "'Source Serif 4', Georgia, serif" },
|
||||||
|
{ key: 'inter', label: 'Inter', value: "Inter, -apple-system, sans-serif" },
|
||||||
|
{ key: 'playfair', label: 'Playfair Display', value: "'Playfair Display', Georgia, serif" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SIZE_STEPS = [0.85, 1, 1.2, 1.45]
|
||||||
|
const SIZE_LABELS = ['S', 'M', 'L', 'XL']
|
||||||
|
|
||||||
|
const TEXT_ALIGN_OPTIONS = [
|
||||||
|
{ key: 'left', label: 'Left' },
|
||||||
|
{ key: 'justify', label: 'Justified' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PADDING_STEPS = [1, 0.5, 0.15]
|
||||||
|
const PADDING_LABELS = ['Default', 'Compact', 'Minimal']
|
||||||
|
|
||||||
|
const headlineSizeScale = ref(parseFloat(localStorage.getItem('s-headline-size') ?? '1'))
|
||||||
|
const contentSizeScale = ref(parseFloat(localStorage.getItem('s-content-size') ?? '1'))
|
||||||
|
const headlineFontKey = ref(localStorage.getItem('s-headline-font') ?? 'default')
|
||||||
|
const contentFontKey = ref(localStorage.getItem('s-content-font') ?? 'default')
|
||||||
|
const textAlignKey = ref(localStorage.getItem('s-text-align') ?? 'left')
|
||||||
|
const contentPadding = ref(parseFloat(localStorage.getItem('s-content-padding') ?? '1'))
|
||||||
|
|
||||||
|
function fontValue(options, key) {
|
||||||
|
return (options.find(o => o.key === key) ?? options[0]).value
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettings() {
|
||||||
|
const s = document.documentElement.style
|
||||||
|
s.setProperty('--headline-font-size-scale', headlineSizeScale.value)
|
||||||
|
s.setProperty('--content-font-size-scale', contentSizeScale.value)
|
||||||
|
s.setProperty('--headline-font-family', fontValue(HEADLINE_FONT_OPTIONS, headlineFontKey.value))
|
||||||
|
s.setProperty('--content-font-family', fontValue(CONTENT_FONT_OPTIONS, contentFontKey.value))
|
||||||
|
s.setProperty('--content-text-align', textAlignKey.value)
|
||||||
|
s.setProperty('--content-padding', contentPadding.value + 'rem')
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeadlineSize(scale) {
|
||||||
|
headlineSizeScale.value = scale
|
||||||
|
localStorage.setItem('s-headline-size', scale)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContentSize(scale) {
|
||||||
|
contentSizeScale.value = scale
|
||||||
|
localStorage.setItem('s-content-size', scale)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeadlineFont(key) {
|
||||||
|
headlineFontKey.value = key
|
||||||
|
localStorage.setItem('s-headline-font', key)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContentFont(key) {
|
||||||
|
contentFontKey.value = key
|
||||||
|
localStorage.setItem('s-content-font', key)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTextAlign(key) {
|
||||||
|
textAlignKey.value = key
|
||||||
|
localStorage.setItem('s-text-align', key)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContentPadding(step) {
|
||||||
|
contentPadding.value = step
|
||||||
|
localStorage.setItem('s-content-padding', step)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
return {
|
||||||
|
headlineSizeScale,
|
||||||
|
contentSizeScale,
|
||||||
|
headlineFontKey,
|
||||||
|
contentFontKey,
|
||||||
|
SIZE_STEPS,
|
||||||
|
SIZE_LABELS,
|
||||||
|
HEADLINE_FONT_OPTIONS,
|
||||||
|
CONTENT_FONT_OPTIONS,
|
||||||
|
TEXT_ALIGN_OPTIONS,
|
||||||
|
PADDING_STEPS,
|
||||||
|
PADDING_LABELS,
|
||||||
|
applySettings,
|
||||||
|
setHeadlineSize,
|
||||||
|
setContentSize,
|
||||||
|
setHeadlineFont,
|
||||||
|
setContentFont,
|
||||||
|
setTextAlign,
|
||||||
|
setContentPadding,
|
||||||
|
textAlignKey,
|
||||||
|
contentPadding,
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-17
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
scrollBehavior: () => ({ top: 0, behavior: 'instant' }),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -30,23 +31,13 @@ const router = createRouter({
|
|||||||
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to) => {
|
||||||
if (to.meta.requiresAuth) {
|
const isAuthenticated = localStorage.getItem("user-token") != null;
|
||||||
let isAuthenticated = false;
|
// Redirect unauthenticated users hitting a protected route to login;
|
||||||
if (localStorage.getItem("user-token") != null){
|
// returning a value (instead of the deprecated next() callback) is the
|
||||||
isAuthenticated = true;
|
// modern vue-router guard API. Returning nothing lets navigation proceed.
|
||||||
}
|
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||||
|
return '/login';
|
||||||
if (!isAuthenticated) {
|
|
||||||
// Redirect to the login page
|
|
||||||
next('/login');
|
|
||||||
} else {
|
|
||||||
// Proceed to the protected route
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For routes that don't require authentication, proceed without checking
|
|
||||||
next();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import AdminFeeds from '../components/AdminFeeds.vue'
|
import AdminFeeds from '../components/AdminFeeds.vue'
|
||||||
|
import AdminSettings from '../components/AdminSettings.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
|
<AdminSettings />
|
||||||
<AdminFeeds />
|
<AdminFeeds />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user