aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-14 12:17:23 +0900
committernsfisis <nsfisis@gmail.com>2026-02-14 12:17:23 +0900
commitfffd36268a216044523c3f5227c3d375608c36dc (patch)
treeb289735cb9d478af763775af9b15214b9595e747
parent2889b562e64993482bd13fd806af8ed0865bab8b (diff)
downloadfeedaka-fffd36268a216044523c3f5227c3d375608c36dc.tar.gz
feedaka-fffd36268a216044523c3f5227c3d375608c36dc.tar.zst
feedaka-fffd36268a216044523c3f5227c3d375608c36dc.zip
refactor(frontend): migrate state management to jotai and jotai-tanstack-query
Replace React Context + manual useEffect data fetching with jotai atoms for state management and jotai-tanstack-query for server state caching. - Add jotai, jotai-tanstack-query, @tanstack/query-core dependencies - Create atoms for auth (primitive + action), feeds (suspense query), and articles (infinite query with cursor-based pagination) - Wire up Provider, HydrateQueryClient, and StoreInitializer in main.tsx - Migrate all components from useAuth() context to jotai atoms - Replace manual fetch logic in FeedSidebar/FeedList with feedsAtom - Replace usePaginatedArticles hook with articlesInfiniteAtom - Add queryClient.invalidateQueries() after mutations for automatic cache refresh - Add ErrorBoundary and LoadingSpinner components for Suspense support - Remove callback prop chains (onFeedAdded, onFeedChanged, etc.) in favor of query invalidation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--frontend/package-lock.json130
-rw-r--r--frontend/package.json5
-rw-r--r--frontend/src/atoms/articles.ts46
-rw-r--r--frontend/src/atoms/auth.ts46
-rw-r--r--frontend/src/atoms/feeds.ts10
-rw-r--r--frontend/src/atoms/index.ts15
-rw-r--r--frontend/src/components/AddFeedForm.tsx9
-rw-r--r--frontend/src/components/ArticleItem.tsx8
-rw-r--r--frontend/src/components/ErrorBoundary.tsx42
-rw-r--r--frontend/src/components/FeedItem.tsx16
-rw-r--r--frontend/src/components/FeedList.tsx63
-rw-r--r--frontend/src/components/FeedSidebar.tsx26
-rw-r--r--frontend/src/components/LoadingSpinner.tsx18
-rw-r--r--frontend/src/components/Navigation.tsx6
-rw-r--r--frontend/src/components/ProtectedRoute.tsx6
-rw-r--r--frontend/src/components/StoreInitializer.tsx11
-rw-r--r--frontend/src/components/index.ts3
-rw-r--r--frontend/src/contexts/AuthContext.tsx74
-rw-r--r--frontend/src/hooks/usePaginatedArticles.ts80
-rw-r--r--frontend/src/main.tsx26
-rw-r--r--frontend/src/pages/Login.tsx16
-rw-r--r--frontend/src/pages/ReadArticles.tsx109
-rw-r--r--frontend/src/pages/Settings.tsx21
-rw-r--r--frontend/src/pages/UnreadArticles.tsx109
-rw-r--r--frontend/src/queryClient.ts10
25 files changed, 530 insertions, 375 deletions
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 1dfe685..13f97b9 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -12,6 +12,9 @@
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.6",
"@tailwindcss/vite": "^4.1.17",
+ "@tanstack/query-core": "^5.90.20",
+ "jotai": "^2.17.1",
+ "jotai-tanstack-query": "^0.11.0",
"openapi-fetch": "^0.17.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
@@ -52,7 +55,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
@@ -67,7 +70,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
"integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -77,7 +80,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@@ -108,7 +111,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
"integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
@@ -138,7 +141,7 @@
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
"integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.27.2",
@@ -212,7 +215,7 @@
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -236,7 +239,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
@@ -250,7 +253,7 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
"integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.27.1",
@@ -341,7 +344,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -351,7 +354,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -361,7 +364,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -386,7 +389,7 @@
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
"integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.2",
@@ -400,7 +403,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.5"
@@ -1560,7 +1563,7 @@
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@@ -1575,7 +1578,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
"integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@@ -1594,7 +1597,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -3143,6 +3146,16 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.90.20",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
+ "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3209,7 +3222,7 @@
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -3478,7 +3491,7 @@
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.4.tgz",
"integrity": "sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
@@ -3498,7 +3511,7 @@
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
- "dev": true,
+ "devOptional": true,
"funding": [
{
"type": "opencollective",
@@ -3589,7 +3602,7 @@
"version": "1.0.30001759",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz",
"integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==",
- "dev": true,
+ "devOptional": true,
"funding": [
{
"type": "opencollective",
@@ -3647,7 +3660,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/core-js-compat": {
@@ -3693,7 +3706,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/data-view-buffer": {
@@ -3754,7 +3767,7 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -3865,7 +3878,7 @@
"version": "1.5.266",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz",
"integrity": "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==",
- "dev": true,
+ "devOptional": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -4069,7 +4082,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4277,7 +4290,7 @@
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -5037,6 +5050,55 @@
"jiti": "bin/jiti.js"
}
},
+ "node_modules/jotai": {
+ "version": "2.17.1",
+ "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.17.1.tgz",
+ "integrity": "sha512-TFNZZDa/0ewCLQyRC/Sq9crtixNj/Xdf/wmj9631xxMuKToVJZDbqcHIYN0OboH+7kh6P6tpIK7uKWClj86PKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@babel/core": ">=7.0.0",
+ "@babel/template": ">=7.0.0",
+ "@types/react": ">=17.0.0",
+ "react": ">=17.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "@babel/template": {
+ "optional": true
+ },
+ "@types/react": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jotai-tanstack-query": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/jotai-tanstack-query/-/jotai-tanstack-query-0.11.0.tgz",
+ "integrity": "sha512-Ys0u0IuuS6/okUJOulFTdCVfVaeKbm1+lKVSN9zHhIxtrAXl9FM4yu7fNvxM6fSz/NCE9tZOKR0MQ3hvplaH8A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@tanstack/query-core": "*",
+ "@tanstack/react-query": "*",
+ "jotai": ">=2.0.0",
+ "react": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@tanstack/react-query": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
@@ -5070,7 +5132,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
@@ -5097,7 +5159,7 @@
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
@@ -5425,7 +5487,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
- "dev": true,
+ "devOptional": true,
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
@@ -5470,7 +5532,7 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -5495,7 +5557,7 @@
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/object-assign": {
@@ -6124,7 +6186,7 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "dev": true,
+ "devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -6906,7 +6968,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",
"integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==",
- "dev": true,
+ "devOptional": true,
"funding": [
{
"type": "opencollective",
@@ -7534,7 +7596,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
- "dev": true,
+ "devOptional": true,
"license": "ISC"
},
"node_modules/yaml": {
diff --git a/frontend/package.json b/frontend/package.json
index 807431a..04634e8 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"build": "tsc -b && vite build",
- "check": "biome check .",
+ "check": "biome check . && tsc --noEmit",
"dev": "vite",
"fix": "biome check --write .",
"generate": "openapi-typescript ../openapi/openapi.yaml -o src/api/generated.d.ts",
@@ -16,6 +16,9 @@
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.6",
"@tailwindcss/vite": "^4.1.17",
+ "@tanstack/query-core": "^5.90.20",
+ "jotai": "^2.17.1",
+ "jotai-tanstack-query": "^0.11.0",
"openapi-fetch": "^0.17.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
diff --git a/frontend/src/atoms/articles.ts b/frontend/src/atoms/articles.ts
new file mode 100644
index 0000000..46c57a4
--- /dev/null
+++ b/frontend/src/atoms/articles.ts
@@ -0,0 +1,46 @@
+import type { InfiniteData } from "@tanstack/query-core";
+import { atom } from "jotai";
+import { atomWithInfiniteQuery } from "jotai-tanstack-query";
+import type { components } from "../api/generated";
+import { api } from "../services/api-client";
+
+type ArticleConnection = components["schemas"]["ArticleConnection"];
+
+export const articleViewAtom = atom<"read" | "unread">("unread");
+export const articleFeedFilterAtom = atom<string | null>(null);
+
+export const articlesInfiniteAtom = atomWithInfiniteQuery<
+ ArticleConnection,
+ Error,
+ InfiniteData<ArticleConnection, string | null>,
+ string[],
+ string | null
+>((get) => {
+ const view = get(articleViewAtom);
+ const feedId = get(articleFeedFilterAtom);
+
+ return {
+ queryKey: ["articles", view, feedId ?? "all"],
+ queryFn: async ({ pageParam }) => {
+ const query: { feedId?: string; after?: string } = {};
+ if (feedId) query.feedId = feedId;
+ if (pageParam) query.after = pageParam;
+
+ if (view === "read") {
+ const { data } = await api.GET("/api/articles/read", {
+ params: { query },
+ });
+ return data ?? { articles: [], pageInfo: { hasNextPage: false } };
+ }
+ const { data } = await api.GET("/api/articles/unread", {
+ params: { query },
+ });
+ return data ?? { articles: [], pageInfo: { hasNextPage: false } };
+ },
+ initialPageParam: null as string | null,
+ getNextPageParam: (lastPage: ArticleConnection) =>
+ lastPage.pageInfo.hasNextPage
+ ? (lastPage.pageInfo.endCursor ?? null)
+ : null,
+ };
+});
diff --git a/frontend/src/atoms/auth.ts b/frontend/src/atoms/auth.ts
new file mode 100644
index 0000000..dd6f06c
--- /dev/null
+++ b/frontend/src/atoms/auth.ts
@@ -0,0 +1,46 @@
+import { atom, useSetAtom } from "jotai";
+import { useEffect } from "react";
+import type { components } from "../api/generated";
+import { api } from "../services/api-client";
+
+type User = components["schemas"]["User"];
+
+export const userAtom = atom<User | null>(null);
+export const authLoadingAtom = atom<boolean>(true);
+export const isLoggedInAtom = atom<boolean>((get) => get(userAtom) !== null);
+
+export const loginAtom = atom(
+ null,
+ async (
+ _get,
+ set,
+ { username, password }: { username: string; password: string },
+ ) => {
+ const { data, error } = await api.POST("/api/auth/login", {
+ body: { username, password },
+ });
+ if (error) {
+ throw new Error(error.message);
+ }
+ set(userAtom, data.user);
+ },
+);
+
+export const logoutAtom = atom(null, async (_get, set) => {
+ await api.POST("/api/auth/logout");
+ set(userAtom, null);
+});
+
+export function useAuthInit() {
+ const setUser = useSetAtom(userAtom);
+ const setAuthLoading = useSetAtom(authLoadingAtom);
+
+ useEffect(() => {
+ api.GET("/api/auth/me").then(({ data }) => {
+ if (data) {
+ setUser(data);
+ }
+ setAuthLoading(false);
+ });
+ }, [setUser, setAuthLoading]);
+}
diff --git a/frontend/src/atoms/feeds.ts b/frontend/src/atoms/feeds.ts
new file mode 100644
index 0000000..5c39735
--- /dev/null
+++ b/frontend/src/atoms/feeds.ts
@@ -0,0 +1,10 @@
+import { atomWithSuspenseQuery } from "jotai-tanstack-query";
+import { api } from "../services/api-client";
+
+export const feedsAtom = atomWithSuspenseQuery(() => ({
+ queryKey: ["feeds"],
+ queryFn: async () => {
+ const { data } = await api.GET("/api/feeds");
+ return data ?? [];
+ },
+}));
diff --git a/frontend/src/atoms/index.ts b/frontend/src/atoms/index.ts
new file mode 100644
index 0000000..fdcf7e9
--- /dev/null
+++ b/frontend/src/atoms/index.ts
@@ -0,0 +1,15 @@
+export {
+ articleFeedFilterAtom,
+ articlesInfiniteAtom,
+ articleViewAtom,
+} from "./articles";
+export {
+ authLoadingAtom,
+ isLoggedInAtom,
+ loginAtom,
+ logoutAtom,
+ useAuthInit,
+ userAtom,
+} from "./auth";
+
+export { feedsAtom } from "./feeds";
diff --git a/frontend/src/components/AddFeedForm.tsx b/frontend/src/components/AddFeedForm.tsx
index a60d86d..96afd39 100644
--- a/frontend/src/components/AddFeedForm.tsx
+++ b/frontend/src/components/AddFeedForm.tsx
@@ -1,13 +1,10 @@
import { faPlus, faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useState } from "react";
+import { queryClient } from "../queryClient";
import { api } from "../services/api-client";
-interface Props {
- onFeedAdded?: () => void;
-}
-
-export function AddFeedForm({ onFeedAdded }: Props) {
+export function AddFeedForm() {
const [url, setUrl] = useState("");
const [error, setError] = useState<string | null>(null);
const [fetching, setFetching] = useState(false);
@@ -27,7 +24,7 @@ export function AddFeedForm({ onFeedAdded }: Props) {
setError(fetchError.message);
} else if (data) {
setUrl("");
- onFeedAdded?.();
+ queryClient.invalidateQueries({ queryKey: ["feeds"] });
}
} catch (error) {
setError(
diff --git a/frontend/src/components/ArticleItem.tsx b/frontend/src/components/ArticleItem.tsx
index 37664a9..e109455 100644
--- a/frontend/src/components/ArticleItem.tsx
+++ b/frontend/src/components/ArticleItem.tsx
@@ -1,6 +1,7 @@
import { faCheck, faCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { components } from "../api/generated";
+import { queryClient } from "../queryClient";
import { api } from "../services/api-client";
type Article = components["schemas"]["Article"];
@@ -11,6 +12,11 @@ interface Props {
}
export function ArticleItem({ article, onReadChange }: Props) {
+ const invalidate = () => {
+ queryClient.invalidateQueries({ queryKey: ["feeds"] });
+ queryClient.invalidateQueries({ queryKey: ["articles"] });
+ };
+
const handleToggleRead = async (
articleId: string,
isCurrentlyRead: boolean,
@@ -27,6 +33,7 @@ export function ArticleItem({ article, onReadChange }: Props) {
params: { path: { articleId } },
});
}
+ invalidate();
};
const handleArticleClick = async (article: Article) => {
@@ -36,6 +43,7 @@ export function ArticleItem({ article, onReadChange }: Props) {
await api.POST("/api/articles/{articleId}/read", {
params: { path: { articleId: article.id } },
});
+ invalidate();
}
};
diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx
new file mode 100644
index 0000000..7f06085
--- /dev/null
+++ b/frontend/src/components/ErrorBoundary.tsx
@@ -0,0 +1,42 @@
+import { Component, type ReactNode } from "react";
+
+interface ErrorBoundaryProps {
+ children: ReactNode;
+ fallback?: ReactNode;
+}
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+ error: Error | null;
+}
+
+export class ErrorBoundary extends Component<
+ ErrorBoundaryProps,
+ ErrorBoundaryState
+> {
+ override state: ErrorBoundaryState = { hasError: false, error: null };
+
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ return { hasError: true, error };
+ }
+
+ override render() {
+ if (this.state.hasError) {
+ return this.props.fallback ?? <ErrorFallback error={this.state.error} />;
+ }
+ return this.props.children;
+ }
+}
+
+function ErrorFallback({ error }: { error: Error | null }) {
+ return (
+ <div
+ role="alert"
+ className="rounded-lg border border-red-200 bg-red-50 p-4"
+ >
+ <span className="text-sm text-red-600">
+ {error?.message ?? "An error occurred"}
+ </span>
+ </div>
+ );
+}
diff --git a/frontend/src/components/FeedItem.tsx b/frontend/src/components/FeedItem.tsx
index 1fb9001..adc7623 100644
--- a/frontend/src/components/FeedItem.tsx
+++ b/frontend/src/components/FeedItem.tsx
@@ -1,29 +1,33 @@
import { faCheck, faCircle, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { components } from "../api/generated";
+import { queryClient } from "../queryClient";
import { api } from "../services/api-client";
type Feed = components["schemas"]["Feed"];
interface Props {
feed: Feed;
- onFeedUnsubscribed?: () => void;
- onFeedChanged?: () => void;
}
-export function FeedItem({ feed, onFeedUnsubscribed, onFeedChanged }: Props) {
+export function FeedItem({ feed }: Props) {
+ const invalidate = () => {
+ queryClient.invalidateQueries({ queryKey: ["feeds"] });
+ queryClient.invalidateQueries({ queryKey: ["articles"] });
+ };
+
const handleMarkAllRead = async (feedId: string) => {
await api.POST("/api/feeds/{feedId}/read", {
params: { path: { feedId } },
});
- onFeedChanged?.();
+ invalidate();
};
const handleMarkAllUnread = async (feedId: string) => {
await api.POST("/api/feeds/{feedId}/unread", {
params: { path: { feedId } },
});
- onFeedChanged?.();
+ invalidate();
};
const handleUnsubscribeFeed = async (feedId: string) => {
@@ -34,7 +38,7 @@ export function FeedItem({ feed, onFeedUnsubscribed, onFeedChanged }: Props) {
await api.DELETE("/api/feeds/{feedId}", {
params: { path: { feedId } },
});
- onFeedUnsubscribed?.();
+ invalidate();
}
};
diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx
index a3ba124..364444f 100644
--- a/frontend/src/components/FeedList.tsx
+++ b/frontend/src/components/FeedList.tsx
@@ -1,58 +1,10 @@
-import { useCallback, useEffect, useState } from "react";
-import type { components } from "../api/generated";
-import { api } from "../services/api-client";
+import { useAtomValue } from "jotai";
+import { feedsAtom } from "../atoms";
import { FeedItem } from "./FeedItem";
-type Feed = components["schemas"]["Feed"];
+export function FeedList() {
+ const { data: feeds } = useAtomValue(feedsAtom);
-interface Props {
- onFeedUnsubscribed?: () => void;
-}
-
-export function FeedList({ onFeedUnsubscribed }: Props) {
- const [feeds, setFeeds] = useState<Feed[]>([]);
- const [fetching, setFetching] = useState(true);
- const [error, setError] = useState<string | null>(null);
-
- const fetchFeeds = useCallback(async () => {
- setFetching(true);
- const { data } = await api.GET("/api/feeds");
- if (data) {
- setFeeds(data);
- setError(null);
- } else {
- setError("Failed to load feeds");
- }
- setFetching(false);
- }, []);
-
- useEffect(() => {
- fetchFeeds();
- }, [fetchFeeds]);
-
- const handleFeedUnsubscribed = () => {
- fetchFeeds();
- onFeedUnsubscribed?.();
- };
-
- const handleFeedChanged = () => {
- fetchFeeds();
- };
-
- if (fetching) {
- return (
- <div className="py-8 text-center">
- <p className="text-sm text-stone-400">Loading feeds...</p>
- </div>
- );
- }
- if (error) {
- return (
- <div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">
- Error: {error}
- </div>
- );
- }
if (feeds.length === 0) {
return (
<div className="py-8 text-center">
@@ -64,12 +16,7 @@ export function FeedList({ onFeedUnsubscribed }: Props) {
return (
<div className="space-y-3">
{feeds.map((feed) => (
- <FeedItem
- key={feed.id}
- feed={feed}
- onFeedUnsubscribed={handleFeedUnsubscribed}
- onFeedChanged={handleFeedChanged}
- />
+ <FeedItem key={feed.id} feed={feed} />
))}
</div>
);
diff --git a/frontend/src/components/FeedSidebar.tsx b/frontend/src/components/FeedSidebar.tsx
index 4f50566..6c385c5 100644
--- a/frontend/src/components/FeedSidebar.tsx
+++ b/frontend/src/components/FeedSidebar.tsx
@@ -1,9 +1,6 @@
-import { useCallback, useEffect, useState } from "react";
+import { useAtomValue } from "jotai";
import { useLocation, useSearch } from "wouter";
-import type { components } from "../api/generated";
-import { api } from "../services/api-client";
-
-type Feed = components["schemas"]["Feed"];
+import { feedsAtom } from "../atoms";
interface Props {
basePath: string;
@@ -15,21 +12,7 @@ export function FeedSidebar({ basePath }: Props) {
const params = new URLSearchParams(search);
const selectedFeedId = params.get("feed");
- const [feeds, setFeeds] = useState<Feed[]>([]);
- const [fetching, setFetching] = useState(true);
-
- const fetchFeeds = useCallback(async () => {
- setFetching(true);
- const { data } = await api.GET("/api/feeds");
- if (data) {
- setFeeds(data);
- }
- setFetching(false);
- }, []);
-
- useEffect(() => {
- fetchFeeds();
- }, [fetchFeeds]);
+ const { data: feeds } = useAtomValue(feedsAtom);
const handleSelect = (feedId: string | null) => {
if (feedId) {
@@ -58,9 +41,6 @@ export function FeedSidebar({ basePath }: Props) {
All feeds
</button>
</li>
- {fetching && (
- <li className="px-3 py-1.5 text-xs text-stone-400">Loading...</li>
- )}
{feeds.map((feed) => (
<li key={feed.id}>
<button
diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx
new file mode 100644
index 0000000..2e47f28
--- /dev/null
+++ b/frontend/src/components/LoadingSpinner.tsx
@@ -0,0 +1,18 @@
+import { faSpinner } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+interface LoadingSpinnerProps {
+ className?: string;
+}
+
+export function LoadingSpinner({ className = "" }: LoadingSpinnerProps) {
+ return (
+ <div className={`flex items-center justify-center py-12 ${className}`}>
+ <FontAwesomeIcon
+ icon={faSpinner}
+ className="h-8 w-8 animate-spin text-stone-400"
+ aria-hidden="true"
+ />
+ </div>
+ );
+}
diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx
index 1f99cd6..3029e1d 100644
--- a/frontend/src/components/Navigation.tsx
+++ b/frontend/src/components/Navigation.tsx
@@ -5,12 +5,14 @@ import {
faRightFromBracket,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useAtomValue, useSetAtom } from "jotai";
import { Link } from "wouter";
-import { useAuth } from "../contexts/AuthContext";
+import { isLoggedInAtom, logoutAtom } from "../atoms";
import { MenuItem } from "./MenuItem";
export function Navigation() {
- const { logout, isLoggedIn } = useAuth();
+ const isLoggedIn = useAtomValue(isLoggedInAtom);
+ const logout = useSetAtom(logoutAtom);
const handleLogout = async () => {
await logout();
diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx
index e03a4f0..8dd191c 100644
--- a/frontend/src/components/ProtectedRoute.tsx
+++ b/frontend/src/components/ProtectedRoute.tsx
@@ -1,13 +1,15 @@
+import { useAtomValue } from "jotai";
import type { ReactNode } from "react";
import { Redirect } from "wouter";
-import { useAuth } from "../contexts/AuthContext";
+import { authLoadingAtom, isLoggedInAtom } from "../atoms";
interface Props {
children: ReactNode;
}
export function ProtectedRoute({ children }: Props) {
- const { isLoggedIn, isLoading } = useAuth();
+ const isLoggedIn = useAtomValue(isLoggedInAtom);
+ const isLoading = useAtomValue(authLoadingAtom);
if (isLoading) {
return (
diff --git a/frontend/src/components/StoreInitializer.tsx b/frontend/src/components/StoreInitializer.tsx
new file mode 100644
index 0000000..b55c56a
--- /dev/null
+++ b/frontend/src/components/StoreInitializer.tsx
@@ -0,0 +1,11 @@
+import type { ReactNode } from "react";
+import { useAuthInit } from "../atoms";
+
+interface StoreInitializerProps {
+ children: ReactNode;
+}
+
+export function StoreInitializer({ children }: StoreInitializerProps) {
+ useAuthInit();
+ return <>{children}</>;
+}
diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts
index c0797b4..e10b0b8 100644
--- a/frontend/src/components/index.ts
+++ b/frontend/src/components/index.ts
@@ -1,8 +1,11 @@
export { AddFeedForm } from "./AddFeedForm";
export { ArticleList } from "./ArticleList";
+export { ErrorBoundary } from "./ErrorBoundary";
export { FeedList } from "./FeedList";
export { FeedSidebar } from "./FeedSidebar";
export { Layout } from "./Layout";
+export { LoadingSpinner } from "./LoadingSpinner";
export { MenuItem } from "./MenuItem";
export { Navigation } from "./Navigation";
export { ProtectedRoute } from "./ProtectedRoute";
+export { StoreInitializer } from "./StoreInitializer";
diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx
index 9b157cb..e69de29 100644
--- a/frontend/src/contexts/AuthContext.tsx
+++ b/frontend/src/contexts/AuthContext.tsx
@@ -1,74 +0,0 @@
-import {
- createContext,
- type ReactNode,
- useCallback,
- useContext,
- useEffect,
- useState,
-} from "react";
-import { api } from "../services/api-client";
-
-type LoginResult = { success: true } | { success: false; error: string };
-
-interface AuthContextType {
- isLoggedIn: boolean;
- isLoading: boolean;
- login: (username: string, password: string) => Promise<LoginResult>;
- logout: () => Promise<void>;
-}
-
-const AuthContext = createContext<AuthContextType | undefined>(undefined);
-
-export function AuthProvider({ children }: { children: ReactNode }) {
- const [isLoggedIn, setIsLoggedIn] = useState(false);
- const [isLoading, setIsLoading] = useState(true);
-
- const checkAuth = useCallback(async () => {
- const { data } = await api.GET("/api/auth/me");
- setIsLoggedIn(!!data);
- setIsLoading(false);
- }, []);
-
- useEffect(() => {
- checkAuth();
- }, [checkAuth]);
-
- const login = async (
- username: string,
- password: string,
- ): Promise<LoginResult> => {
- const { data, error } = await api.POST("/api/auth/login", {
- body: { username, password },
- });
-
- if (error) {
- return { success: false, error: error.message };
- }
-
- if (data?.user) {
- setIsLoggedIn(true);
- return { success: true };
- }
-
- return { success: false, error: "Invalid username or password" };
- };
-
- const logout = async () => {
- await api.POST("/api/auth/logout");
- setIsLoggedIn(false);
- };
-
- return (
- <AuthContext.Provider value={{ isLoggedIn, isLoading, login, logout }}>
- {children}
- </AuthContext.Provider>
- );
-}
-
-export function useAuth() {
- const context = useContext(AuthContext);
- if (context === undefined) {
- throw new Error("useAuth must be used within an AuthProvider");
- }
- return context;
-}
diff --git a/frontend/src/hooks/usePaginatedArticles.ts b/frontend/src/hooks/usePaginatedArticles.ts
index 5ddf888..e69de29 100644
--- a/frontend/src/hooks/usePaginatedArticles.ts
+++ b/frontend/src/hooks/usePaginatedArticles.ts
@@ -1,80 +0,0 @@
-import { useCallback, useEffect, useState } from "react";
-import type { components } from "../api/generated";
-import { api } from "../services/api-client";
-
-export type ArticleType = components["schemas"]["Article"];
-
-interface UsePaginatedArticlesOptions {
- isReadView: boolean;
- feedId: string | null;
-}
-
-interface UsePaginatedArticlesResult {
- articles: ArticleType[];
- hasNextPage: boolean;
- loading: boolean;
- loadingMore: boolean;
- loadMore: () => void;
- error: Error | null;
-}
-
-export function usePaginatedArticles({
- isReadView,
- feedId,
-}: UsePaginatedArticlesOptions): UsePaginatedArticlesResult {
- const [articles, setArticles] = useState<ArticleType[]>([]);
- const [hasNextPage, setHasNextPage] = useState(false);
- const [endCursor, setEndCursor] = useState<string | null>(null);
- const [loading, setLoading] = useState(true);
- const [loadingMore, setLoadingMore] = useState(false);
- const [error, setError] = useState<Error | null>(null);
-
- const fetchArticles = useCallback(
- async (after: string | null, append: boolean) => {
- const query: { feedId?: string; after?: string } = {};
- if (feedId) query.feedId = feedId;
- if (after) query.after = after;
-
- const endpoint = isReadView
- ? "/api/articles/read"
- : "/api/articles/unread";
-
- const { data } = await api.GET(endpoint, {
- params: { query },
- });
-
- if (!data) {
- setError(new Error("Failed to fetch articles"));
- return;
- }
-
- if (data) {
- setArticles((prev) =>
- append ? [...prev, ...data.articles] : data.articles,
- );
- setHasNextPage(data.pageInfo.hasNextPage);
- setEndCursor(data.pageInfo.endCursor ?? null);
- setError(null);
- }
- },
- [isReadView, feedId],
- );
-
- // Reset and fetch on feedId or view change
- useEffect(() => {
- setArticles([]);
- setEndCursor(null);
- setHasNextPage(false);
- setLoading(true);
- setError(null);
- fetchArticles(null, false).finally(() => setLoading(false));
- }, [fetchArticles]);
-
- const loadMore = useCallback(() => {
- if (!hasNextPage || loadingMore) return;
- setLoadingMore(true);
- fetchArticles(endCursor, true).finally(() => setLoadingMore(false));
- }, [fetchArticles, endCursor, hasNextPage, loadingMore]);
-
- return { articles, hasNextPage, loading, loadingMore, loadMore, error };
-}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index d1dd4d5..5333b77 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,14 +1,28 @@
-import { StrictMode } from "react";
+import { Provider, useStore } from "jotai/react";
+import { useHydrateAtoms } from "jotai/react/utils";
+import { queryClientAtom } from "jotai-tanstack-query";
+import { type ReactNode, StrictMode } from "react";
import { createRoot } from "react-dom/client";
-import "./index.css";
import App from "./App.tsx";
-import { AuthProvider } from "./contexts/AuthContext";
+import { StoreInitializer } from "./components/StoreInitializer";
+import "./index.css";
+import { queryClient } from "./queryClient";
+
+function HydrateQueryClient({ children }: { children: ReactNode }) {
+ const store = useStore();
+ useHydrateAtoms([[queryClientAtom, queryClient]], { store });
+ return <>{children}</>;
+}
// biome-ignore lint/style/noNonNullAssertion: root element is guaranteed to exist
createRoot(document.getElementById("root")!).render(
<StrictMode>
- <AuthProvider>
- <App />
- </AuthProvider>
+ <Provider>
+ <HydrateQueryClient>
+ <StoreInitializer>
+ <App />
+ </StoreInitializer>
+ </HydrateQueryClient>
+ </Provider>
</StrictMode>,
);
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
index 76a775a..7dc71e7 100644
--- a/frontend/src/pages/Login.tsx
+++ b/frontend/src/pages/Login.tsx
@@ -1,13 +1,14 @@
+import { useSetAtom } from "jotai";
import { useState } from "react";
import { useLocation } from "wouter";
-import { useAuth } from "../contexts/AuthContext";
+import { loginAtom } from "../atoms";
export function Login() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
- const { login } = useAuth();
+ const login = useSetAtom(loginAtom);
const [, setLocation] = useLocation();
const handleSubmit = async (e: React.FormEvent) => {
@@ -15,13 +16,14 @@ export function Login() {
setError("");
setIsLoading(true);
- const result = await login(username, password);
- if (result.success) {
+ try {
+ await login({ username, password });
setLocation("/");
- } else {
- setError(result.error);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Login failed");
+ } finally {
+ setIsLoading(false);
}
- setIsLoading(false);
};
return (
diff --git a/frontend/src/pages/ReadArticles.tsx b/frontend/src/pages/ReadArticles.tsx
index 2538446..e231906 100644
--- a/frontend/src/pages/ReadArticles.tsx
+++ b/frontend/src/pages/ReadArticles.tsx
@@ -1,48 +1,91 @@
+import { useAtomValue, useSetAtom } from "jotai";
+import { Suspense, useEffect } from "react";
import { useSearch } from "wouter";
-import { ArticleList, FeedSidebar } from "../components";
-import { usePaginatedArticles } from "../hooks/usePaginatedArticles";
+import {
+ articleFeedFilterAtom,
+ articlesInfiniteAtom,
+ articleViewAtom,
+} from "../atoms";
+import { ArticleList } from "../components/ArticleList";
+import { ErrorBoundary } from "../components/ErrorBoundary";
+import { FeedSidebar } from "../components/FeedSidebar";
+import { LoadingSpinner } from "../components/LoadingSpinner";
export function ReadArticles() {
const search = useSearch();
const params = new URLSearchParams(search);
const feedId = params.get("feed");
- const { articles, hasNextPage, loading, loadingMore, loadMore, error } =
- usePaginatedArticles({ isReadView: true, feedId });
+ const setView = useSetAtom(articleViewAtom);
+ const setFeedFilter = useSetAtom(articleFeedFilterAtom);
+
+ useEffect(() => {
+ setView("read");
+ setFeedFilter(feedId);
+ }, [feedId, setView, setFeedFilter]);
return (
<div className="flex gap-8">
- <FeedSidebar basePath="/read" />
+ <ErrorBoundary>
+ <Suspense fallback={<LoadingSpinner />}>
+ <FeedSidebar basePath="/read" />
+ </Suspense>
+ </ErrorBoundary>
<div className="min-w-0 flex-1">
- <div className="mb-6">
- <h1 className="text-xl font-semibold text-stone-900">Read</h1>
- {!loading && articles.length > 0 && (
- <p className="mt-1 text-sm text-stone-400">
- {articles.length}
- {hasNextPage ? "+" : ""} article
- {articles.length !== 1 ? "s" : ""}
- </p>
- )}
- </div>
- {loading ? (
- <div className="py-8 text-center">
- <p className="text-sm text-stone-400">Loading read articles...</p>
- </div>
- ) : error ? (
- <div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">
- Error: {error.message}
- </div>
- ) : (
- <ArticleList
- articles={articles}
- isReadView={true}
- isSingleFeed={!!feedId}
- hasNextPage={hasNextPage}
- loadingMore={loadingMore}
- onLoadMore={loadMore}
- />
- )}
+ <ReadArticleList feedId={feedId} />
</div>
</div>
);
}
+
+function ReadArticleList({ feedId }: { feedId: string | null }) {
+ const {
+ data,
+ isLoading,
+ isFetchingNextPage,
+ hasNextPage,
+ fetchNextPage,
+ error,
+ } = useAtomValue(articlesInfiniteAtom);
+
+ const articles = data?.pages.flatMap((page) => page.articles) ?? [];
+
+ if (isLoading) {
+ return (
+ <div className="py-8 text-center">
+ <p className="text-sm text-stone-400">Loading read articles...</p>
+ </div>
+ );
+ }
+
+ if (error) {
+ return (
+ <div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">
+ Error: {error.message}
+ </div>
+ );
+ }
+
+ return (
+ <>
+ <div className="mb-6">
+ <h1 className="text-xl font-semibold text-stone-900">Read</h1>
+ {articles.length > 0 && (
+ <p className="mt-1 text-sm text-stone-400">
+ {articles.length}
+ {hasNextPage ? "+" : ""} article
+ {articles.length !== 1 ? "s" : ""}
+ </p>
+ )}
+ </div>
+ <ArticleList
+ articles={articles}
+ isReadView={true}
+ isSingleFeed={!!feedId}
+ hasNextPage={hasNextPage}
+ loadingMore={isFetchingNextPage}
+ onLoadMore={() => fetchNextPage()}
+ />
+ </>
+ );
+}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
index c179fab..48a1f8e 100644
--- a/frontend/src/pages/Settings.tsx
+++ b/frontend/src/pages/Settings.tsx
@@ -1,24 +1,25 @@
-import { useCallback, useState } from "react";
-import { AddFeedForm, FeedList } from "../components";
+import { Suspense } from "react";
+import { AddFeedForm } from "../components/AddFeedForm";
+import { ErrorBoundary } from "../components/ErrorBoundary";
+import { FeedList } from "../components/FeedList";
+import { LoadingSpinner } from "../components/LoadingSpinner";
export function Settings() {
- const [refreshKey, setRefreshKey] = useState(0);
-
- const handleChange = useCallback(() => {
- setRefreshKey((k) => k + 1);
- }, []);
-
return (
<div className="mx-auto max-w-3xl space-y-10">
<section>
- <AddFeedForm onFeedAdded={handleChange} />
+ <AddFeedForm />
</section>
<section>
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-stone-900">
Your Feeds
</h2>
- <FeedList key={refreshKey} onFeedUnsubscribed={handleChange} />
+ <ErrorBoundary>
+ <Suspense fallback={<LoadingSpinner />}>
+ <FeedList />
+ </Suspense>
+ </ErrorBoundary>
</section>
</div>
);
diff --git a/frontend/src/pages/UnreadArticles.tsx b/frontend/src/pages/UnreadArticles.tsx
index eade6fc..291f0ee 100644
--- a/frontend/src/pages/UnreadArticles.tsx
+++ b/frontend/src/pages/UnreadArticles.tsx
@@ -1,48 +1,91 @@
+import { useAtomValue, useSetAtom } from "jotai";
+import { Suspense, useEffect } from "react";
import { useSearch } from "wouter";
-import { ArticleList, FeedSidebar } from "../components";
-import { usePaginatedArticles } from "../hooks/usePaginatedArticles";
+import {
+ articleFeedFilterAtom,
+ articlesInfiniteAtom,
+ articleViewAtom,
+} from "../atoms";
+import { ArticleList } from "../components/ArticleList";
+import { ErrorBoundary } from "../components/ErrorBoundary";
+import { FeedSidebar } from "../components/FeedSidebar";
+import { LoadingSpinner } from "../components/LoadingSpinner";
export function UnreadArticles() {
const search = useSearch();
const params = new URLSearchParams(search);
const feedId = params.get("feed");
- const { articles, hasNextPage, loading, loadingMore, loadMore, error } =
- usePaginatedArticles({ isReadView: false, feedId });
+ const setView = useSetAtom(articleViewAtom);
+ const setFeedFilter = useSetAtom(articleFeedFilterAtom);
+
+ useEffect(() => {
+ setView("unread");
+ setFeedFilter(feedId);
+ }, [feedId, setView, setFeedFilter]);
return (
<div className="flex gap-8">
- <FeedSidebar basePath="/unread" />
+ <ErrorBoundary>
+ <Suspense fallback={<LoadingSpinner />}>
+ <FeedSidebar basePath="/unread" />
+ </Suspense>
+ </ErrorBoundary>
<div className="min-w-0 flex-1">
- <div className="mb-6">
- <h1 className="text-xl font-semibold text-stone-900">Unread</h1>
- {!loading && articles.length > 0 && (
- <p className="mt-1 text-sm text-stone-400">
- {articles.length}
- {hasNextPage ? "+" : ""} article
- {articles.length !== 1 ? "s" : ""} to read
- </p>
- )}
- </div>
- {loading ? (
- <div className="py-8 text-center">
- <p className="text-sm text-stone-400">Loading unread articles...</p>
- </div>
- ) : error ? (
- <div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">
- Error: {error.message}
- </div>
- ) : (
- <ArticleList
- articles={articles}
- isReadView={false}
- isSingleFeed={!!feedId}
- hasNextPage={hasNextPage}
- loadingMore={loadingMore}
- onLoadMore={loadMore}
- />
- )}
+ <UnreadArticleList feedId={feedId} />
</div>
</div>
);
}
+
+function UnreadArticleList({ feedId }: { feedId: string | null }) {
+ const {
+ data,
+ isLoading,
+ isFetchingNextPage,
+ hasNextPage,
+ fetchNextPage,
+ error,
+ } = useAtomValue(articlesInfiniteAtom);
+
+ const articles = data?.pages.flatMap((page) => page.articles) ?? [];
+
+ if (isLoading) {
+ return (
+ <div className="py-8 text-center">
+ <p className="text-sm text-stone-400">Loading unread articles...</p>
+ </div>
+ );
+ }
+
+ if (error) {
+ return (
+ <div className="rounded-lg bg-red-50 p-4 text-sm text-red-600">
+ Error: {error.message}
+ </div>
+ );
+ }
+
+ return (
+ <>
+ <div className="mb-6">
+ <h1 className="text-xl font-semibold text-stone-900">Unread</h1>
+ {articles.length > 0 && (
+ <p className="mt-1 text-sm text-stone-400">
+ {articles.length}
+ {hasNextPage ? "+" : ""} article
+ {articles.length !== 1 ? "s" : ""} to read
+ </p>
+ )}
+ </div>
+ <ArticleList
+ articles={articles}
+ isReadView={false}
+ isSingleFeed={!!feedId}
+ hasNextPage={hasNextPage}
+ loadingMore={isFetchingNextPage}
+ onLoadMore={() => fetchNextPage()}
+ />
+ </>
+ );
+}
diff --git a/frontend/src/queryClient.ts b/frontend/src/queryClient.ts
new file mode 100644
index 0000000..8743543
--- /dev/null
+++ b/frontend/src/queryClient.ts
@@ -0,0 +1,10 @@
+import { QueryClient } from "@tanstack/query-core";
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 0,
+ retry: false,
+ },
+ },
+});