diff --git a/package.json b/package.json
index 8ae10fe..a1dddf2 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6099717..df62107 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -78,6 +78,9 @@ importers:
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-progress':
+ specifier: ^1.1.8
+ version: 1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-radio-group':
specifier: ^1.3.8
version: 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -137,7 +140,7 @@ importers:
version: 11.7.1(@trpc/server@11.7.1(typescript@5.9.3))(typescript@5.9.3)
'@trpc/next':
specifier: 11.7.1
- version: 11.7.1(@tanstack/react-query@5.90.7(react@19.2.0))(@trpc/client@11.7.1(@trpc/server@11.7.1(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.7.1(typescript@5.9.3))(next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
+ version: 11.7.1(@tanstack/react-query@5.90.7(react@19.2.0))(@trpc/client@11.7.1(@trpc/server@11.7.1(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.7.1(typescript@5.9.3))(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
'@trpc/server':
specifier: ^11.7.1
version: 11.7.1(typescript@5.9.3)
@@ -152,7 +155,7 @@ importers:
version: 19.1.0-rc.2
better-auth:
specifier: ^1.3.34
- version: 1.3.34(next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ version: 1.3.34(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -185,22 +188,22 @@ importers:
version: 1.6.0
jotai:
specifier: ^2.15.1
- version: 2.15.1(@babel/core@7.26.10)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0)
+ version: 2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0)
jotai-optics:
specifier: ^0.4.0
- version: 0.4.0(jotai@2.15.1(@babel/core@7.26.10)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0))(optics-ts@2.4.1)
+ version: 0.4.0(jotai@2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0))(optics-ts@2.4.1)
lucide-react:
specifier: ^0.553.0
version: 0.553.0(react@19.2.0)
next:
specifier: 16.0.1
- version: 16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ version: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next-mdx-remote:
specifier: ^5.0.0
version: 5.0.0(@types/react@19.2.2)(react@19.2.0)
next-pwa:
specifier: ^5.6.0
- version: 5.6.0(@babel/core@7.26.10)(esbuild@0.25.12)(next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(webpack@5.102.1(esbuild@0.25.10))
+ version: 5.6.0(@babel/core@7.28.5)(esbuild@0.25.12)(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(webpack@5.102.1(esbuild@0.25.10))
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -279,7 +282,7 @@ importers:
version: 9.6.1
'@types/next-pwa':
specifier: ^5.6.9
- version: 5.6.9(@babel/core@7.26.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ version: 5.6.9(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@types/node':
specifier: ^24.10.0
version: 24.10.0
@@ -2297,6 +2300,15 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-context@1.1.3':
+ resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==}
+ peerDependencies:
+ '@types/react': 19.2.2
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@radix-ui/react-dialog@1.1.15':
resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
peerDependencies:
@@ -2485,6 +2497,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-progress@1.1.8':
+ resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==}
+ peerDependencies:
+ '@types/react': 19.2.2
+ '@types/react-dom': 19.2.2
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-radio-group@1.3.8':
resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==}
peerDependencies:
@@ -9632,6 +9657,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.2
+ '@radix-ui/react-context@1.1.3(@types/react@19.2.2)(react@19.2.0)':
+ dependencies:
+ react: 19.2.0
+ optionalDependencies:
+ '@types/react': 19.2.2
+
'@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -9984,6 +10015,16 @@ snapshots:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/react@19.2.2)
+ '@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/react-context': 1.1.3(@types/react@19.2.2)(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.2
+ '@types/react-dom': 19.2.2(@types/react@19.2.2)
+
'@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -10491,7 +10532,7 @@ snapshots:
log-symbols: 4.1.0
module-punycode: punycode@2.3.1
next: 16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
- next-safe-action: 8.0.11(next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ next-safe-action: 8.0.11(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
node-html-parser: 7.0.1
ora: 5.4.1
pretty-bytes: 6.1.1
@@ -10831,11 +10872,11 @@ snapshots:
'@trpc/server': 11.7.1(typescript@5.9.3)
typescript: 5.9.3
- '@trpc/next@11.7.1(@tanstack/react-query@5.90.7(react@19.2.0))(@trpc/client@11.7.1(@trpc/server@11.7.1(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.7.1(typescript@5.9.3))(next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)':
+ '@trpc/next@11.7.1(@tanstack/react-query@5.90.7(react@19.2.0))(@trpc/client@11.7.1(@trpc/server@11.7.1(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.7.1(typescript@5.9.3))(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)':
dependencies:
'@trpc/client': 11.7.1(@trpc/server@11.7.1(typescript@5.9.3))(typescript@5.9.3)
'@trpc/server': 11.7.1(typescript@5.9.3)
- next: 16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ next: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
typescript: 5.9.3
@@ -10943,12 +10984,12 @@ snapshots:
'@types/ms@2.1.0': {}
- '@types/next-pwa@5.6.9(@babel/core@7.26.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ '@types/next-pwa@5.6.9(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@types/node': 24.10.0
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/react@19.2.2)
- next: 13.5.11(@babel/core@7.26.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ next: 13.5.11(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
workbox-build: 6.6.0
transitivePeerDependencies:
- '@babel/core'
@@ -11456,9 +11497,9 @@ snapshots:
axobject-query@4.1.0: {}
- babel-loader@8.4.1(@babel/core@7.26.10)(webpack@5.102.1(esbuild@0.25.10)):
+ babel-loader@8.4.1(@babel/core@7.28.5)(webpack@5.102.1(esbuild@0.25.10)):
dependencies:
- '@babel/core': 7.26.10
+ '@babel/core': 7.28.5
find-cache-dir: 3.3.2
loader-utils: 2.0.4
make-dir: 3.1.0
@@ -11503,7 +11544,7 @@ snapshots:
baseline-browser-mapping@2.8.25: {}
- better-auth@1.3.34(next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
+ better-auth@1.3.34(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@better-auth/core': 1.3.34(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
'@better-auth/telemetry': 1.3.34(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
@@ -11520,7 +11561,7 @@ snapshots:
nanostores: 1.0.1
zod: 4.1.12
optionalDependencies:
- next: 16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ next: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
@@ -13260,14 +13301,14 @@ snapshots:
jose@6.1.0: {}
- jotai-optics@0.4.0(jotai@2.15.1(@babel/core@7.26.10)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0))(optics-ts@2.4.1):
+ jotai-optics@0.4.0(jotai@2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0))(optics-ts@2.4.1):
dependencies:
- jotai: 2.15.1(@babel/core@7.26.10)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0)
+ jotai: 2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0)
optics-ts: 2.4.1
- jotai@2.15.1(@babel/core@7.26.10)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0):
+ jotai@2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0):
optionalDependencies:
- '@babel/core': 7.26.10
+ '@babel/core': 7.28.5
'@babel/template': 7.27.2
'@types/react': 19.2.2
react: 19.2.0
@@ -13912,12 +13953,12 @@ snapshots:
- '@types/react'
- supports-color
- next-pwa@5.6.0(@babel/core@7.26.10)(esbuild@0.25.12)(next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(webpack@5.102.1(esbuild@0.25.10)):
+ next-pwa@5.6.0(@babel/core@7.28.5)(esbuild@0.25.12)(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(webpack@5.102.1(esbuild@0.25.10)):
dependencies:
- babel-loader: 8.4.1(@babel/core@7.26.10)(webpack@5.102.1(esbuild@0.25.10))
+ babel-loader: 8.4.1(@babel/core@7.28.5)(webpack@5.102.1(esbuild@0.25.10))
clean-webpack-plugin: 4.0.0(webpack@5.102.1(esbuild@0.25.10))
globby: 11.1.0
- next: 16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ next: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
terser-webpack-plugin: 5.3.14(esbuild@0.25.12)(webpack@5.102.1(esbuild@0.25.10))
workbox-webpack-plugin: 6.6.0(webpack@5.102.1(esbuild@0.25.10))
workbox-window: 6.6.0
@@ -13930,9 +13971,9 @@ snapshots:
- uglify-js
- webpack
- next-safe-action@8.0.11(next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
+ next-safe-action@8.0.11(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
- next: 16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ next: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
@@ -13941,7 +13982,7 @@ snapshots:
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
- next@13.5.11(@babel/core@7.26.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
+ next@13.5.11(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 13.5.11
'@swc/helpers': 0.5.2
@@ -13950,7 +13991,7 @@ snapshots:
postcss: 8.4.31
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
- styled-jsx: 5.1.1(@babel/core@7.26.10)(react@19.2.0)
+ styled-jsx: 5.1.1(@babel/core@7.28.5)(react@19.2.0)
watchpack: 2.4.0
optionalDependencies:
'@next/swc-darwin-arm64': 13.5.9
@@ -13990,7 +14031,7 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
- next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
+ next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 16.0.1
'@swc/helpers': 0.5.15
@@ -13998,7 +14039,7 @@ snapshots:
postcss: 8.4.31
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
- styled-jsx: 5.1.6(@babel/core@7.26.10)(react@19.2.0)
+ styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.0)
optionalDependencies:
'@next/swc-darwin-arm64': 16.0.1
'@next/swc-darwin-x64': 16.0.1
@@ -15186,12 +15227,12 @@ snapshots:
dependencies:
inline-style-parser: 0.2.6
- styled-jsx@5.1.1(@babel/core@7.26.10)(react@19.2.0):
+ styled-jsx@5.1.1(@babel/core@7.28.5)(react@19.2.0):
dependencies:
client-only: 0.0.1
react: 19.2.0
optionalDependencies:
- '@babel/core': 7.26.10
+ '@babel/core': 7.28.5
styled-jsx@5.1.6(@babel/core@7.26.10)(react@19.0.0):
dependencies:
@@ -15200,12 +15241,12 @@ snapshots:
optionalDependencies:
'@babel/core': 7.26.10
- styled-jsx@5.1.6(@babel/core@7.26.10)(react@19.2.0):
+ styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.0):
dependencies:
client-only: 0.0.1
react: 19.2.0
optionalDependencies:
- '@babel/core': 7.26.10
+ '@babel/core': 7.28.5
sucrase@3.35.0:
dependencies:
diff --git a/src/app/(feed)/feed/FeedLoader.tsx b/src/app/(feed)/feed/FeedLoader.tsx
new file mode 100644
index 0000000..d7d24d0
--- /dev/null
+++ b/src/app/(feed)/feed/FeedLoader.tsx
@@ -0,0 +1,27 @@
+import clsx from "clsx";
+import { Progress } from "~/components/ui/progress";
+import { useFeeds } from "~/lib/data/feeds";
+import { useFeedStatusDict, useFetchFeedItemsStatus } from "~/lib/data/store";
+
+export function FeedLoader() {
+ const { feeds } = useFeeds();
+ const feedStatusDict = useFeedStatusDict();
+
+ const fetchedFeedsCount = Object.keys(feedStatusDict).length;
+ const status = useFetchFeedItemsStatus();
+
+ const isFetching = status === "fetching";
+
+ const value = isFetching ? fetchedFeedsCount : 0;
+
+ return (
+
+ );
+}
diff --git a/src/app/(feed)/feed/Header.tsx b/src/app/(feed)/feed/Header.tsx
index 2737c33..d92450f 100644
--- a/src/app/(feed)/feed/Header.tsx
+++ b/src/app/(feed)/feed/Header.tsx
@@ -4,6 +4,7 @@ import { useShortcut } from "~/lib/hooks/useShortcut";
import { TopLeftButton } from "./TopLeftButton";
import { TopRightHeaderContent } from "./TopRightHeaderContent";
import { usePathname, useRouter } from "next/navigation";
+import { FeedLoader } from "./FeedLoader";
export function Header() {
const pathname = usePathname();
@@ -17,6 +18,7 @@ export function Header() {
return (
);
diff --git a/src/app/(feed)/feed/SidebarFeeds.tsx b/src/app/(feed)/feed/SidebarFeeds.tsx
index c0b0caf..081f9a8 100644
--- a/src/app/(feed)/feed/SidebarFeeds.tsx
+++ b/src/app/(feed)/feed/SidebarFeeds.tsx
@@ -1,5 +1,12 @@
import { useAtom, useAtomValue, useSetAtom } from "jotai";
-import { CircleSmall, Edit2Icon, PlusIcon } from "lucide-react";
+import {
+ AlertCircleIcon,
+ AlertTriangleIcon,
+ CircleSmall,
+ Edit2Icon,
+ MinusIcon,
+ PlusIcon,
+} from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { EditFeedDialog } from "~/components/AddFeedDialog";
import { ButtonWithShortcut } from "~/components/ButtonWithShortcut";
@@ -23,7 +30,19 @@ import { doesFeedItemPassFilters } from "~/lib/data/feed-items";
import { useFeeds } from "~/lib/data/feeds";
import { useDeselectViewFilter } from "~/lib/data/views";
import { useDialogStore } from "./dialogStore";
-import { useFeedItemsDict, useFeedItemsOrder } from "~/lib/data/store";
+import {
+ useFeedItemsDict,
+ useFeedItemsOrder,
+ useFeedStatusDict,
+ useFetchFeedItemsStatus,
+} from "~/lib/data/store";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "~/components/ui/tooltip";
+import { error } from "node:console";
+import { ApplicationFeed } from "~/server/db/schema";
function useCheckFilteredFeedItemsForFeed() {
const feedItemsOrder = useFeedItemsOrder();
@@ -91,6 +110,10 @@ function useDebouncedState(defaultValue: string, delay: number) {
return [searchQuery, setDebouncedQuery] as const;
}
+function sortFeedOptions(a: ApplicationFeed, b: ApplicationFeed) {
+ return a.name.localeCompare(b.name);
+}
+
export function SidebarFeeds() {
const [searchQuery, setSearchQuery] = useDebouncedState("", 300);
@@ -107,52 +130,78 @@ export function SidebarFeeds() {
const viewFilter = useAtomValue(viewFilterAtom);
const deselectViewFilter = useDeselectViewFilter();
+ const feedStatusDict = useFeedStatusDict();
+ const fetchFeedItemsStatus = useFetchFeedItemsStatus();
+
const checkFilteredFeedItemsForFeed = useCheckFilteredFeedItemsForFeed();
- const feedOptions = feeds?.map((category) => ({
- ...category,
- hasEntries: !!checkFilteredFeedItemsForFeed(category.id).length,
+ const feedOptions = feeds?.map((feed) => ({
+ ...feed,
+ hasEntries: !!checkFilteredFeedItemsForFeed(feed.id).length,
}));
- const preferredFeedOptions = feedOptions
- ?.filter((feedOption) => {
+ const {
+ preferredFeedOptions,
+ feedOptionsWithContent,
+ emptyFeedOptions,
+ errorFeedOptions,
+ } = feedOptions?.reduce(
+ (acc, feedOption) => {
+ const {
+ preferredFeedOptions,
+ feedOptionsWithContent,
+ emptyFeedOptions,
+ errorFeedOptions,
+ } = acc;
if (!!searchQuery) {
const lowercaseQuery = searchQuery.toLowerCase();
const lowercaseName = feedOption.name.toLowerCase();
if (lowercaseName.includes(lowercaseQuery)) {
- return true;
+ preferredFeedOptions.push(feedOption);
+ preferredFeedOptions.sort(sortFeedOptions);
+ return acc;
}
} else {
- if (feedOption.hasEntries) return true;
+ if (feedOption.hasEntries) {
+ preferredFeedOptions.push(feedOption);
+ preferredFeedOptions.sort(sortFeedOptions);
+ return acc;
+ }
}
if (feedOption.id === feedFilter) {
- return true;
+ preferredFeedOptions.push(feedOption);
+ preferredFeedOptions.sort(sortFeedOptions);
+ return acc;
}
- return false;
- })
- .toSorted((a, b) => {
- if (a.id === feedFilter) {
- return -1;
- }
- if (b.id === feedFilter) {
- return 1;
- }
+ const feedStatus = !!feedStatusDict[feedOption.id]
+ ? feedStatusDict[feedOption.id]
+ : fetchFeedItemsStatus === "fetching"
+ ? "success"
+ : "empty";
- return a.name.localeCompare(b.name);
- });
+ if (feedStatus === "success") {
+ feedOptionsWithContent.push(feedOption);
+ feedOptionsWithContent.sort(sortFeedOptions);
+ } else if (feedStatus === "empty") {
+ emptyFeedOptions.push(feedOption);
+ emptyFeedOptions.sort(sortFeedOptions);
+ } else if (feedStatus === "error") {
+ errorFeedOptions.push(feedOption);
+ errorFeedOptions.sort(sortFeedOptions);
+ }
- const otherFeedOptions = feedOptions
- ?.filter((feedOption) => {
- return !preferredFeedOptions.some(
- (option) => option.id === feedOption.id,
- );
- })
- .toSorted((a, b) => {
- return a.name.localeCompare(b.name);
- });
+ return acc;
+ },
+ {
+ preferredFeedOptions: [] as typeof feedOptions,
+ feedOptionsWithContent: [] as typeof feedOptions,
+ emptyFeedOptions: [] as typeof feedOptions,
+ errorFeedOptions: [] as typeof feedOptions,
+ },
+ );
const hasAnyItems = !!checkFilteredFeedItemsForFeed(-1).length;
@@ -207,6 +256,14 @@ export function SidebarFeeds() {
{preferredFeedOptions.map((feed) => {
+ const feedStatus = !!feedStatusDict[feed.id]
+ ? feedStatusDict[feed.id]
+ : fetchFeedItemsStatus === "fetching"
+ ? "success"
+ : "empty";
+
+ const isSuccess = feedStatus === "success";
+
return (
- {!feed.hasEntries && (
+ {feedStatus === "error" && (
+
+
+
+
+
+ Something went wrong fetching content for this feed. If
+ this continues, try deleting this feed and adding it
+ again with the correct URL.
+
+
+ )}
+ {feedStatus === "empty" && (
+
+
+
+
+
+ This feed has no new content within the last 30 days.
+
+
+ )}
+ {isSuccess && !feed.hasEntries && (
)}
- {feed.hasEntries && (
+ {isSuccess && feed.hasEntries && (
@@ -238,10 +320,10 @@ export function SidebarFeeds() {
);
})}
- {!!preferredFeedOptions.length && !!otherFeedOptions.length && (
+ {!!preferredFeedOptions.length && !!feedOptionsWithContent.length && (
)}
- {otherFeedOptions.map((feed) => {
+ {feedOptionsWithContent.map((feed) => {
return (
);
})}
+ {!!feedOptionsWithContent.length && !!emptyFeedOptions.length && (
+
+ )}
+ {emptyFeedOptions.map((feed) => {
+ return (
+
+ {
+ setFeedFilter(feed.id);
+ if (!feed.hasEntries) {
+ deselectViewFilter();
+ }
+ }}
+ >
+
+
+
+
+
+ This feed has no new content within the last 30 days.
+
+
+ {feed.name}
+
+
+ setSelectedFeedForEditing(feed.id)}
+ >
+
+
+
+
+ );
+ })}
+ {!!emptyFeedOptions.length && !!errorFeedOptions.length && (
+
+ )}
+ {errorFeedOptions.map((feed) => {
+ return (
+
+ {
+ setFeedFilter(feed.id);
+ if (!feed.hasEntries) {
+ deselectViewFilter();
+ }
+ }}
+ >
+
+
+
+
+
+ Something went wrong fetching content for this feed. If
+ this continues, try deleting this feed and adding it again
+ with the correct URL.
+
+
+
+ {feed.name}
+
+
+ setSelectedFeedForEditing(feed.id)}
+ >
+
+
+
+
+ );
+ })}
>
diff --git a/src/app/(feed)/feed/TodayItems.tsx b/src/app/(feed)/feed/TodayItems.tsx
index f0ea0d4..fdcc55e 100644
--- a/src/app/(feed)/feed/TodayItems.tsx
+++ b/src/app/(feed)/feed/TodayItems.tsx
@@ -30,15 +30,13 @@ import {
useFeedItemsSetWatchLaterValueMutation,
} from "~/lib/data/feed-items/mutations";
import { useFeeds } from "~/lib/data/feeds";
-import { useViews } from "~/lib/data/views";
-import { useDialogStore } from "./dialogStore";
-import { memo } from "react";
import {
- feedItemsStore,
- useFeedItemsLastFetchedAt,
useFeedItemValue,
+ useFetchFeedItemsLastFetchedAt,
useFetchFeedItemsStatus,
} from "~/lib/data/store";
+import { useViews } from "~/lib/data/views";
+import { useDialogStore } from "./dialogStore";
function timeAgo(date: string | Date) {
const diff = dayjs().diff(date);
@@ -132,33 +130,6 @@ function TodayItemsFeedEmptyState() {
);
}
-function LoaderDisplay() {
- const feedItemsLastFetchedAt = useFeedItemsLastFetchedAt();
- const feedItemsFetchStatus = useFetchFeedItemsStatus();
-
- if (
- feedItemsFetchStatus !== "fetching" ||
- (feedItemsFetchStatus === "fetching" && feedItemsLastFetchedAt !== null)
- ) {
- return null;
- }
-
- return (
-
-
-
-
- Fetching data...
-
-
-
- );
-}
-
function ItemDisplay({ contentId }: { contentId: string }) {
const { feeds } = useFeeds();
const item = useFeedItemValue(contentId);
@@ -268,7 +239,7 @@ export function TodayItems() {
const { feeds, hasFetchedFeeds } = useFeeds();
const { hasFetchedFeedCategories } = useFeedCategories();
const { views } = useViews();
- const feedItemsLastFetchedAt = useFeedItemsLastFetchedAt();
+ const feedItemsLastFetchedAt = useFetchFeedItemsLastFetchedAt();
const filteredFeedItemsOrder = useFilteredFeedItemsOrder();
@@ -293,7 +264,6 @@ export function TodayItems() {
return (
-
{filteredFeedItemsOrder.map((contentId) => (
))}
diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx
new file mode 100644
index 0000000..aa9e384
--- /dev/null
+++ b/src/components/ui/progress.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import * as React from "react";
+import * as ProgressPrimitive from "@radix-ui/react-progress";
+
+import { cn } from "~/lib/utils";
+
+const Progress = React.forwardRef<
+ React.ElementRef
,
+ React.ComponentPropsWithoutRef
+>(({ className, value, ...props }, ref) => (
+
+
+
+));
+Progress.displayName = ProgressPrimitive.Root.displayName;
+
+export { Progress };
diff --git a/src/lib/data/store.ts b/src/lib/data/store.ts
index 7056d2d..e3be27a 100644
--- a/src/lib/data/store.ts
+++ b/src/lib/data/store.ts
@@ -4,12 +4,14 @@ import { ApplicationFeedItem } from "~/server/db/schema";
import { orpcRouterClient } from "../orpc";
import { createSelectorHooks } from "./createSelectorHooks";
import { sortFeedItemsOrderByDate } from "../sortFeedItems";
+import { FetchFeedsStatus } from "~/server/rss/fetchFeeds";
export type ApplicationStore = {
reset: () => void;
feedItemsOrder: string[];
setFeedItemsOrder: (itemsOrder: string[]) => void;
feedItemsDict: Record;
+ feedStatusDict: Record;
setFeedItemsDict: (itemsDict: Record) => void;
setFeedItem: (id: string, item: ApplicationFeedItem) => void;
fetchFeedItems: () => Promise;
@@ -24,12 +26,14 @@ const vanillaApplicationStore = createStore()(
set({
feedItemsOrder: [],
feedItemsDict: {},
+ feedStatusDict: {},
fetchFeedItemsLastFetchedAt: null,
fetchFeedItemsStatus: "idle",
}),
feedItemsOrder: [],
setFeedItemsOrder: (itemsOrder) => set({ feedItemsOrder: itemsOrder }),
feedItemsDict: {},
+ feedStatusDict: {},
setFeedItemsDict: (itemsDict) => set({ feedItemsDict: itemsDict }),
setFeedItem: (id, item) =>
set({
@@ -46,57 +50,60 @@ const vanillaApplicationStore = createStore()(
set({
fetchFeedItemsStatus: "fetching",
+ feedStatusDict: {},
});
let lastUpdateTime = 0;
- const DEBOUNCE_TIME = 500;
+ const DEBOUNCE_TIME = 1000;
- for await (const incomingFeedItems of await orpcRouterClient.feedItem.getAll()) {
+ for await (const incomingChunk of await orpcRouterClient.feedItem.getAll()) {
const timeSinceLastUpdate = Date.now() - lastUpdateTime;
const timeToWait = DEBOUNCE_TIME - timeSinceLastUpdate;
const shouldWaitToRender = timeToWait > 0;
- const initialItemsDict = shouldWaitToRender
+ const feedStatusDict = shouldWaitToRender
+ ? get().feedStatusDict
+ : {
+ ...get().feedStatusDict,
+ };
+
+ const feedItemsDict = shouldWaitToRender
? get().feedItemsDict
: {
...get().feedItemsDict,
};
- const initialItemsOrder = shouldWaitToRender
+ const feedItemsOrder = shouldWaitToRender
? get().feedItemsOrder
: [...get().feedItemsOrder];
- const { updatedItemsDict, updatedItemsOrder } =
- incomingFeedItems.reduce(
- ({ updatedItemsDict, updatedItemsOrder }, item) => {
- updatedItemsDict[item.id] = item;
-
- if (!updatedItemsOrder.find((id) => id === item.id)) {
- updatedItemsOrder.push(item.id);
- }
-
- return {
- updatedItemsDict,
- updatedItemsOrder,
- };
- },
- {
- updatedItemsDict: initialItemsDict,
- updatedItemsOrder: initialItemsOrder,
- },
- );
+ if (incomingChunk.type === "feed-status") {
+ feedStatusDict[incomingChunk.feedId] = incomingChunk.status;
+ } else if (incomingChunk.type === "feed-items") {
+ const incomingFeedItems = incomingChunk.feedItems;
+
+ incomingFeedItems.forEach((item) => {
+ feedItemsDict[item.id] = item;
+
+ if (!feedItemsOrder.find((id) => id === item.id)) {
+ feedItemsOrder.push(item.id);
+ }
+ });
+ }
set({
- feedItemsDict: updatedItemsDict,
- feedItemsOrder: updatedItemsOrder.sort(
+ feedItemsDict: feedItemsDict,
+ feedItemsOrder: feedItemsOrder.sort(
sortFeedItemsOrderByDate(get().feedItemsDict),
),
+ feedStatusDict: feedStatusDict,
});
if (!shouldWaitToRender) {
lastUpdateTime = Date.now();
}
}
+
set({
fetchFeedItemsStatus: "success",
fetchFeedItemsLastFetchedAt: Date.now(),
@@ -104,6 +111,7 @@ const vanillaApplicationStore = createStore()(
feedItemsOrder: [...get().feedItemsOrder].sort(
sortFeedItemsOrderByDate(get().feedItemsDict),
),
+ feedStatusDict: { ...get().feedStatusDict },
});
},
}),
@@ -121,13 +129,14 @@ const vanillaApplicationStore = createStore()(
export const feedItemsStore = createSelectorHooks(vanillaApplicationStore);
-export const useFeedItemsDict = feedItemsStore.useFeedItemsDict;
-export const useFeedItemsOrder = feedItemsStore.useFeedItemsOrder;
-
-export const useFeedItemsLastFetchedAt =
- feedItemsStore.useFetchFeedItemsLastFetchedAt;
-export const useFetchFeedItemsStatus = feedItemsStore.useFetchFeedItemsStatus;
-export const useFetchFeedItems = feedItemsStore.useFetchFeedItems;
+export const {
+ useFeedItemsDict,
+ useFeedItemsOrder,
+ useFeedStatusDict,
+ useFetchFeedItemsLastFetchedAt,
+ useFetchFeedItemsStatus,
+ useFetchFeedItems,
+} = feedItemsStore;
export const useFeedItemValue = (id: string) => {
return useStore(
diff --git a/src/server/api/routers/feedItemRouter.ts b/src/server/api/routers/feedItemRouter.ts
index cdad52f..eff6556 100644
--- a/src/server/api/routers/feedItemRouter.ts
+++ b/src/server/api/routers/feedItemRouter.ts
@@ -5,7 +5,21 @@ import { prepareArrayChunks } from "~/lib/iterators";
import { type ApplicationFeedItem, feedItems, feeds } from "~/server/db/schema";
import { protectedProcedure } from "~/server/orpc/base";
-import { fetchAndInsertFeedData } from "~/server/rss/fetchFeeds";
+import {
+ fetchAndInsertFeedData,
+ FetchFeedsStatus,
+} from "~/server/rss/fetchFeeds";
+
+type GetAllItemsChunk =
+ | {
+ type: "feed-items";
+ feedItems: ApplicationFeedItem[];
+ }
+ | {
+ type: "feed-status";
+ feedId: number;
+ status: FetchFeedsStatus;
+ };
const isWithinLastMonth = gte(
feedItems.postedAt,
@@ -35,13 +49,29 @@ export const getAll = protectedProcedure.handler(async function* ({ context }) {
// Send existing feed items to user
for (const chunk of prepareArrayChunks(existingApplicationFeedItems, 50)) {
- yield chunk;
+ yield {
+ type: "feed-items",
+ feedItems: chunk,
+ } as GetAllItemsChunk;
}
// Send new feed items to user as they come in
- for await (const feedItems of fetchAndInsertFeedData(context, feedsList)) {
- for (const chunk of prepareArrayChunks(feedItems, 50)) {
- yield chunk;
+ for await (const feedResult of fetchAndInsertFeedData(context, feedsList)) {
+ yield {
+ type: "feed-status",
+ status: feedResult.status,
+ feedId: feedResult.id,
+ } as GetAllItemsChunk;
+
+ if (feedResult.status !== "success") {
+ continue;
+ }
+
+ for (const chunk of prepareArrayChunks(feedResult.feedItems, 50)) {
+ yield {
+ type: "feed-items",
+ feedItems: chunk,
+ } as GetAllItemsChunk;
}
}
diff --git a/src/server/rss/fetchFeeds.ts b/src/server/rss/fetchFeeds.ts
index 179d400..7bdd3b6 100644
--- a/src/server/rss/fetchFeeds.ts
+++ b/src/server/rss/fetchFeeds.ts
@@ -11,6 +11,8 @@ import {
} from "./parsers/youtube";
import { type NewFeedDetails, type RSSFeed } from "./types";
+export type FetchFeedsStatus = "success" | "empty" | "error";
+
export async function fetchNewFeedDetails(
url: string,
): Promise {
@@ -49,12 +51,12 @@ export async function fetchNewFeedDetails(
type FeedResult =
| {
- success: true;
+ status: "success";
feedItems: ApplicationFeedItem[];
id: number;
}
| {
- success: false;
+ status: "empty" | "error";
id: number;
};
@@ -77,9 +79,15 @@ export async function* fetchAndInsertFeedData(
feedData = await fetchWebsiteFeedData(feed);
}
- if (!feedData || !feedData.items.length) {
+ if (!feedData) {
+ return {
+ status: "error",
+ id: feed.id,
+ };
+ }
+ if (!feedData.items.length) {
return {
- success: false,
+ status: "empty",
id: feed.id,
};
}
@@ -132,13 +140,13 @@ export async function* fetchAndInsertFeedData(
});
return {
- success: true,
+ status: "success",
feedItems: applicationFeedItems,
id: feed.id,
};
} catch (err) {
return {
- success: false,
+ status: "error",
id: feed.id,
};
}
@@ -151,13 +159,7 @@ export async function* fetchAndInsertFeedData(
feedPromises.splice(resultIndex, 1);
feedIds.splice(resultIndex, 1);
- if (!result.success) {
- continue;
- }
-
- if (result.success) {
- yield result.feedItems;
- }
+ yield result;
}
return;