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;