diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-14 11:52:56 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-14 11:53:08 +0900 |
| commit | 2889b562e64993482bd13fd806af8ed0865bab8b (patch) | |
| tree | 39400ac4d994fb33d2c544e7d4b9d98f8ecbd86a /frontend/src/components | |
| parent | e216c3bc97994b4172d15d52b46d5f6b75f35ea4 (diff) | |
| download | feedaka-2889b562e64993482bd13fd806af8ed0865bab8b.tar.gz feedaka-2889b562e64993482bd13fd806af8ed0865bab8b.tar.zst feedaka-2889b562e64993482bd13fd806af8ed0865bab8b.zip | |
refactor: migrate API from GraphQL to REST (TypeSpec/OpenAPI)
Replace the entire GraphQL stack (gqlgen, urql, graphql-codegen) with a
TypeSpec → OpenAPI 3.x pipeline using oapi-codegen for Go server stubs
and openapi-fetch + openapi-typescript for the frontend client.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'frontend/src/components')
| -rw-r--r-- | frontend/src/components/AddFeedForm.tsx | 20 | ||||
| -rw-r--r-- | frontend/src/components/ArticleItem.tsx | 33 | ||||
| -rw-r--r-- | frontend/src/components/ArticleList.tsx | 13 | ||||
| -rw-r--r-- | frontend/src/components/FeedItem.tsx | 34 | ||||
| -rw-r--r-- | frontend/src/components/FeedList.tsx | 50 | ||||
| -rw-r--r-- | frontend/src/components/FeedSidebar.tsx | 30 |
6 files changed, 102 insertions, 78 deletions
diff --git a/frontend/src/components/AddFeedForm.tsx b/frontend/src/components/AddFeedForm.tsx index 9a56574..a60d86d 100644 --- a/frontend/src/components/AddFeedForm.tsx +++ b/frontend/src/components/AddFeedForm.tsx @@ -1,31 +1,31 @@ import { faPlus, faSpinner } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useState } from "react"; -import { useMutation } from "urql"; -import { AddFeedDocument } from "../graphql/generated/graphql"; +import { api } from "../services/api-client"; interface Props { onFeedAdded?: () => void; } -const urqlContextFeed = { additionalTypenames: ["Feed"] }; - export function AddFeedForm({ onFeedAdded }: Props) { const [url, setUrl] = useState(""); const [error, setError] = useState<string | null>(null); - const [{ fetching }, addFeed] = useMutation(AddFeedDocument); + const [fetching, setFetching] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!url.trim()) return; setError(null); + setFetching(true); try { - const result = await addFeed({ url: url.trim() }, urqlContextFeed); - if (result.error) { - setError(result.error.message); - } else if (result.data) { + const { data, error: fetchError } = await api.POST("/api/feeds", { + body: { url: url.trim() }, + }); + if (fetchError) { + setError(fetchError.message); + } else if (data) { setUrl(""); onFeedAdded?.(); } @@ -33,6 +33,8 @@ export function AddFeedForm({ onFeedAdded }: Props) { setError( error instanceof Error ? error.message : "Failed to subscribe to feed", ); + } finally { + setFetching(false); } }; diff --git a/frontend/src/components/ArticleItem.tsx b/frontend/src/components/ArticleItem.tsx index dbdaf44..37664a9 100644 --- a/frontend/src/components/ArticleItem.tsx +++ b/frontend/src/components/ArticleItem.tsx @@ -1,30 +1,16 @@ import { faCheck, faCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useMutation } from "urql"; -import type { - GetReadArticlesQuery, - GetUnreadArticlesQuery, -} from "../graphql/generated/graphql"; -import { - MarkArticleReadDocument, - MarkArticleUnreadDocument, -} from "../graphql/generated/graphql"; +import type { components } from "../api/generated"; +import { api } from "../services/api-client"; -type Article = - | GetUnreadArticlesQuery["unreadArticles"]["articles"][number] - | GetReadArticlesQuery["readArticles"]["articles"][number]; +type Article = components["schemas"]["Article"]; interface Props { article: Article; onReadChange?: (articleId: string, isRead: boolean) => void; } -const urqlContextArticle = { additionalTypenames: ["Article"] }; - export function ArticleItem({ article, onReadChange }: Props) { - const [, markArticleRead] = useMutation(MarkArticleReadDocument); - const [, markArticleUnread] = useMutation(MarkArticleUnreadDocument); - const handleToggleRead = async ( articleId: string, isCurrentlyRead: boolean, @@ -33,18 +19,23 @@ export function ArticleItem({ article, onReadChange }: Props) { onReadChange?.(articleId, newReadState); if (isCurrentlyRead) { - await markArticleUnread({ id: articleId }, urqlContextArticle); + await api.POST("/api/articles/{articleId}/unread", { + params: { path: { articleId } }, + }); } else { - await markArticleRead({ id: articleId }, urqlContextArticle); + await api.POST("/api/articles/{articleId}/read", { + params: { path: { articleId } }, + }); } }; const handleArticleClick = async (article: Article) => { - // Open article in new tab and mark as read if it's unread window.open(article.url, "_blank", "noreferrer"); if (!article.isRead) { onReadChange?.(article.id, true); - await markArticleRead({ id: article.id }, urqlContextArticle); + await api.POST("/api/articles/{articleId}/read", { + params: { path: { articleId: article.id } }, + }); } }; diff --git a/frontend/src/components/ArticleList.tsx b/frontend/src/components/ArticleList.tsx index ccf7826..2238a43 100644 --- a/frontend/src/components/ArticleList.tsx +++ b/frontend/src/components/ArticleList.tsx @@ -1,16 +1,11 @@ import { useState } from "react"; -import type { - GetReadArticlesQuery, - GetUnreadArticlesQuery, -} from "../graphql/generated/graphql"; +import type { components } from "../api/generated"; import { ArticleItem } from "./ArticleItem"; -type ArticleType = - | GetUnreadArticlesQuery["unreadArticles"]["articles"] - | GetReadArticlesQuery["readArticles"]["articles"]; +type Article = components["schemas"]["Article"]; interface Props { - articles: ArticleType; + articles: Article[]; isReadView?: boolean; isSingleFeed?: boolean; hasNextPage?: boolean; @@ -82,7 +77,7 @@ export function ArticleList({ }, {} as Record< string, - { feed: { id: string; title: string }; articles: typeof articles } + { feed: { id: string; title: string }; articles: Article[] } >, ); diff --git a/frontend/src/components/FeedItem.tsx b/frontend/src/components/FeedItem.tsx index 8333f75..1fb9001 100644 --- a/frontend/src/components/FeedItem.tsx +++ b/frontend/src/components/FeedItem.tsx @@ -1,33 +1,29 @@ import { faCheck, faCircle, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useMutation } from "urql"; -import type { GetFeedsQuery } from "../graphql/generated/graphql"; -import { - MarkFeedReadDocument, - MarkFeedUnreadDocument, - UnsubscribeFeedDocument, -} from "../graphql/generated/graphql"; +import type { components } from "../api/generated"; +import { api } from "../services/api-client"; -type Feed = NonNullable<GetFeedsQuery["feeds"]>[0]; +type Feed = components["schemas"]["Feed"]; interface Props { feed: Feed; onFeedUnsubscribed?: () => void; + onFeedChanged?: () => void; } -const urqlContextFeed = { additionalTypenames: ["Feed"] }; - -export function FeedItem({ feed, onFeedUnsubscribed }: Props) { - const [, markFeedRead] = useMutation(MarkFeedReadDocument); - const [, markFeedUnread] = useMutation(MarkFeedUnreadDocument); - const [, unsubscribeFeed] = useMutation(UnsubscribeFeedDocument); - +export function FeedItem({ feed, onFeedUnsubscribed, onFeedChanged }: Props) { const handleMarkAllRead = async (feedId: string) => { - await markFeedRead({ id: feedId }, urqlContextFeed); + await api.POST("/api/feeds/{feedId}/read", { + params: { path: { feedId } }, + }); + onFeedChanged?.(); }; const handleMarkAllUnread = async (feedId: string) => { - await markFeedUnread({ id: feedId }, urqlContextFeed); + await api.POST("/api/feeds/{feedId}/unread", { + params: { path: { feedId } }, + }); + onFeedChanged?.(); }; const handleUnsubscribeFeed = async (feedId: string) => { @@ -35,7 +31,9 @@ export function FeedItem({ feed, onFeedUnsubscribed }: Props) { "Are you sure you want to unsubscribe from this feed?", ); if (confirmed) { - await unsubscribeFeed({ id: feedId }, urqlContextFeed); + await api.DELETE("/api/feeds/{feedId}", { + params: { path: { feedId } }, + }); onFeedUnsubscribed?.(); } }; diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx index 24bcfc7..a3ba124 100644 --- a/frontend/src/components/FeedList.tsx +++ b/frontend/src/components/FeedList.tsx @@ -1,18 +1,43 @@ -import { useQuery } from "urql"; -import { GetFeedsDocument } from "../graphql/generated/graphql"; +import { useCallback, useEffect, useState } from "react"; +import type { components } from "../api/generated"; +import { api } from "../services/api-client"; import { FeedItem } from "./FeedItem"; +type Feed = components["schemas"]["Feed"]; + interface Props { onFeedUnsubscribed?: () => void; } -const urqlContextFeed = { additionalTypenames: ["Feed"] }; - export function FeedList({ onFeedUnsubscribed }: Props) { - const [{ data, fetching, error }] = useQuery({ - query: GetFeedsDocument, - context: urqlContextFeed, - }); + 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 ( @@ -24,11 +49,11 @@ export function FeedList({ onFeedUnsubscribed }: Props) { if (error) { return ( <div className="rounded-lg bg-red-50 p-4 text-sm text-red-600"> - Error: {error.message} + Error: {error} </div> ); } - if (!data?.feeds || data.feeds.length === 0) { + if (feeds.length === 0) { return ( <div className="py-8 text-center"> <p className="text-sm text-stone-400">No feeds added yet.</p> @@ -38,11 +63,12 @@ export function FeedList({ onFeedUnsubscribed }: Props) { return ( <div className="space-y-3"> - {data.feeds.map((feed) => ( + {feeds.map((feed) => ( <FeedItem key={feed.id} feed={feed} - onFeedUnsubscribed={onFeedUnsubscribed} + onFeedUnsubscribed={handleFeedUnsubscribed} + onFeedChanged={handleFeedChanged} /> ))} </div> diff --git a/frontend/src/components/FeedSidebar.tsx b/frontend/src/components/FeedSidebar.tsx index 73d9504..4f50566 100644 --- a/frontend/src/components/FeedSidebar.tsx +++ b/frontend/src/components/FeedSidebar.tsx @@ -1,23 +1,35 @@ -import { useQuery } from "urql"; +import { useCallback, useEffect, useState } from "react"; import { useLocation, useSearch } from "wouter"; -import { GetFeedsDocument } from "../graphql/generated/graphql"; +import type { components } from "../api/generated"; +import { api } from "../services/api-client"; + +type Feed = components["schemas"]["Feed"]; interface Props { basePath: string; } -const urqlContextFeed = { additionalTypenames: ["Feed", "Article"] }; - export function FeedSidebar({ basePath }: Props) { const search = useSearch(); const [, setLocation] = useLocation(); const params = new URLSearchParams(search); const selectedFeedId = params.get("feed"); - const [{ data, fetching }] = useQuery({ - query: GetFeedsDocument, - context: urqlContextFeed, - }); + 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 handleSelect = (feedId: string | null) => { if (feedId) { @@ -49,7 +61,7 @@ export function FeedSidebar({ basePath }: Props) { {fetching && ( <li className="px-3 py-1.5 text-xs text-stone-400">Loading...</li> )} - {data?.feeds.map((feed) => ( + {feeds.map((feed) => ( <li key={feed.id}> <button type="button" |
