aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend/src/pages
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 /frontend/src/pages
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>
Diffstat (limited to 'frontend/src/pages')
-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
4 files changed, 172 insertions, 83 deletions
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()}
+ />
+ </>
+ );
+}