aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components')
-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
11 files changed, 111 insertions, 97 deletions
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";