diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-07-12 14:55:19 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-07-12 16:23:37 +0900 |
| commit | 1c73c999ac78d2e6d3a8c68b4e17058046326f55 (patch) | |
| tree | 41a01cc3827098dfc8d108e5b9cfac1fd5e7fb7f /frontend/src | |
| parent | 9da56e3023af305ba7c5fd49caab60ac8bb57100 (diff) | |
| download | feedaka-1c73c999ac78d2e6d3a8c68b4e17058046326f55.tar.gz feedaka-1c73c999ac78d2e6d3a8c68b4e17058046326f55.tar.zst feedaka-1c73c999ac78d2e6d3a8c68b4e17058046326f55.zip | |
feat(frontend): create pages and components
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/App.tsx | 39 | ||||
| -rw-r--r-- | frontend/src/components/AddFeedForm.tsx | 89 | ||||
| -rw-r--r-- | frontend/src/components/ArticleList.tsx | 137 | ||||
| -rw-r--r-- | frontend/src/components/FeedList.tsx | 150 | ||||
| -rw-r--r-- | frontend/src/components/Layout.tsx | 15 | ||||
| -rw-r--r-- | frontend/src/components/MenuItem.tsx | 28 | ||||
| -rw-r--r-- | frontend/src/components/Navigation.tsx | 28 | ||||
| -rw-r--r-- | frontend/src/components/index.ts | 6 | ||||
| -rw-r--r-- | frontend/src/main.tsx | 6 | ||||
| -rw-r--r-- | frontend/src/pages/NotFound.tsx | 15 | ||||
| -rw-r--r-- | frontend/src/pages/ReadArticles.tsx | 34 | ||||
| -rw-r--r-- | frontend/src/pages/Settings.tsx | 155 | ||||
| -rw-r--r-- | frontend/src/pages/UnreadArticles.tsx | 34 | ||||
| -rw-r--r-- | frontend/src/pages/index.ts | 4 | ||||
| -rw-r--r-- | frontend/src/services/graphql-client.ts | 6 |
15 files changed, 718 insertions, 28 deletions
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b055af9..02db0ca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,33 +1,18 @@ -import { useState } from "react"; -import viteLogo from "/vite.svg"; -import reactLogo from "./assets/react.svg"; +import { Redirect, Route, Switch } from "wouter"; +import { Layout } from "./components"; +import { NotFound, ReadArticles, Settings, UnreadArticles } from "./pages"; function App() { - const [count, setCount] = useState(0); - return ( - <> - <div> - <a href="https://vite.dev" target="_blank" rel="noopener"> - <img src={viteLogo} className="logo" alt="Vite logo" /> - </a> - <a href="https://react.dev" target="_blank" rel="noopener"> - <img src={reactLogo} className="logo react" alt="React logo" /> - </a> - </div> - <h1>Vite + React</h1> - <div className="card"> - <button type="button" onClick={() => setCount((count) => count + 1)}> - count is {count} - </button> - <p> - Edit <code>src/App.tsx</code> and save to test HMR - </p> - </div> - <p className="read-the-docs"> - Click on the Vite and React logos to learn more - </p> - </> + <Layout> + <Switch> + <Route path="/" component={() => <Redirect to="/unread" />} /> + <Route path="/unread" component={UnreadArticles} /> + <Route path="/read" component={ReadArticles} /> + <Route path="/settings" component={Settings} /> + <Route component={NotFound} /> + </Switch> + </Layout> ); } diff --git a/frontend/src/components/AddFeedForm.tsx b/frontend/src/components/AddFeedForm.tsx new file mode 100644 index 0000000..79e26e3 --- /dev/null +++ b/frontend/src/components/AddFeedForm.tsx @@ -0,0 +1,89 @@ +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"; + +interface Props { + onFeedAdded?: () => void; +} + +export function AddFeedForm({ onFeedAdded }: Props) { + const [url, setUrl] = useState(""); + const [error, setError] = useState<string | null>(null); + const [{ fetching }, addFeed] = useMutation(AddFeedDocument); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!url.trim()) return; + + setError(null); + + try { + const result = await addFeed({ url: url.trim() }); + if (result.error) { + setError(result.error.message); + } else if (result.data) { + setUrl(""); + onFeedAdded?.(); + } + } catch (error) { + setError(error instanceof Error ? error.message : "Failed to add feed"); + } + }; + + const isValidUrl = (urlString: string) => { + try { + const url = new URL(urlString); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } + }; + + const isUrlValid = !url || isValidUrl(url); + + return ( + <form onSubmit={handleSubmit} className="space-y-4 p-4"> + <div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm"> + <h3 className="text-lg font-semibold text-gray-900 mb-4"> + Add New Feed + </h3> + <div className="flex gap-2"> + <div className="flex-1"> + <input + type="url" + value={url} + onChange={(e) => setUrl(e.target.value)} + placeholder="https://example.com/feed.xml" + className={`w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 ${ + isUrlValid + ? "border-gray-300 focus:border-blue-500 focus:ring-blue-500" + : "border-red-300 focus:border-red-500 focus:ring-red-500" + }`} + disabled={fetching} + /> + {!isUrlValid && ( + <p className="mt-1 text-sm text-red-600"> + Please enter a valid URL (http:// or https://) + </p> + )} + {error && <p className="mt-1 text-sm text-red-600">{error}</p>} + </div> + <button + type="submit" + disabled={fetching || !url.trim() || !isUrlValid} + className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed" + > + {fetching ? ( + <FontAwesomeIcon icon={faSpinner} spin className="mr-2" /> + ) : ( + <FontAwesomeIcon icon={faPlus} className="mr-2" /> + )} + Add Feed + </button> + </div> + </div> + </form> + ); +} diff --git a/frontend/src/components/ArticleList.tsx b/frontend/src/components/ArticleList.tsx new file mode 100644 index 0000000..ee7b187 --- /dev/null +++ b/frontend/src/components/ArticleList.tsx @@ -0,0 +1,137 @@ +import { + faCheck, + faCircle, + faExternalLinkAlt, +} 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"; + +interface Props { + articles: NonNullable< + | GetUnreadArticlesQuery["unreadArticles"] + | GetReadArticlesQuery["readArticles"] + >; + showReadStatus?: boolean; +} + +export function ArticleList({ articles, showReadStatus = true }: Props) { + const [, markArticleRead] = useMutation(MarkArticleReadDocument); + const [, markArticleUnread] = useMutation(MarkArticleUnreadDocument); + + const handleToggleRead = async ( + articleId: string, + isCurrentlyRead: boolean, + ) => { + if (isCurrentlyRead) { + await markArticleUnread({ id: articleId }); + } else { + await markArticleRead({ id: articleId }); + } + }; + + const handleArticleClick = async (article: (typeof articles)[0]) => { + // Open article in new tab and mark as read if it's unread + window.open(article.url, "_blank"); + if (!article.isRead) { + await markArticleRead({ id: article.id }); + } + }; + + if (articles.length === 0) { + return ( + <div className="p-4 text-center text-gray-500">No articles found.</div> + ); + } + + // Group articles by feed + const articlesByFeed = articles.reduce( + (acc, article) => { + const feedId = article.feed.id; + if (!acc[feedId]) { + acc[feedId] = { + feed: article.feed, + articles: [], + }; + } + acc[feedId].articles.push(article); + return acc; + }, + {} as Record< + string, + { feed: { id: string; title: string }; articles: typeof articles } + >, + ); + + return ( + <div className="space-y-6 p-4"> + {Object.values(articlesByFeed).map(({ feed, articles: feedArticles }) => ( + <div key={feed.id} className="space-y-2"> + <h3 className="text-lg font-semibold text-gray-900 border-b border-gray-200 pb-2"> + {feed.title} + <span className="ml-2 text-sm font-normal text-gray-500"> + ({feedArticles.length} article + {feedArticles.length !== 1 ? "s" : ""}) + </span> + </h3> + <div className="space-y-1"> + {feedArticles.map((article) => ( + <div + key={article.id} + className={`group flex items-center gap-3 rounded-lg border p-3 hover:bg-gray-50 ${ + article.isRead + ? "border-gray-200 bg-white" + : "border-blue-200 bg-blue-50" + }`} + > + {showReadStatus && ( + <button + type="button" + onClick={() => handleToggleRead(article.id, article.isRead)} + className={`flex-shrink-0 rounded p-1 transition-colors ${ + article.isRead + ? "text-gray-400 hover:text-gray-600" + : "text-blue-600 hover:text-blue-700" + }`} + title={article.isRead ? "Mark as unread" : "Mark as read"} + > + <FontAwesomeIcon + icon={article.isRead ? faCheck : faCircle} + className="w-4 h-4" + /> + </button> + )} + <div className="flex-1 min-w-0"> + <button + type="button" + onClick={() => handleArticleClick(article)} + className={`text-left w-full group-hover:text-blue-600 transition-colors ${ + article.isRead + ? "text-gray-700" + : "text-gray-900 font-medium" + }`} + > + <div className="flex items-center gap-2"> + <span className="truncate">{article.title}</span> + <FontAwesomeIcon + icon={faExternalLinkAlt} + className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" + /> + </div> + </button> + </div> + </div> + ))} + </div> + </div> + ))} + </div> + ); +} diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx new file mode 100644 index 0000000..7e46e78 --- /dev/null +++ b/frontend/src/components/FeedList.tsx @@ -0,0 +1,150 @@ +import { + faCheckDouble, + faCircle, + faTrash, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useMutation, useQuery } from "urql"; +import { + GetFeedsDocument, + MarkFeedReadDocument, + MarkFeedUnreadDocument, + RemoveFeedDocument, +} from "../graphql/generated/graphql"; + +interface Props { + onFeedDeleted?: () => void; + selectedFeeds?: Set<string>; + onSelectFeed?: (feedId: string, selected: boolean) => void; +} + +export function FeedList({ + onFeedDeleted, + selectedFeeds, + onSelectFeed, +}: Props) { + const [{ data, fetching, error }] = useQuery({ + query: GetFeedsDocument, + }); + + const [, markFeedRead] = useMutation(MarkFeedReadDocument); + const [, markFeedUnread] = useMutation(MarkFeedUnreadDocument); + const [, removeFeed] = useMutation(RemoveFeedDocument); + + const handleMarkAllRead = async (feedId: string) => { + await markFeedRead({ id: feedId }); + }; + + const handleMarkAllUnread = async (feedId: string) => { + await markFeedUnread({ id: feedId }); + }; + + const handleDeleteFeed = async (feedId: string) => { + const confirmed = window.confirm( + "Are you sure you want to delete this feed?", + ); + if (confirmed) { + await removeFeed({ id: feedId }); + onFeedDeleted?.(); + } + }; + + if (fetching) return <div className="p-4">Loading feeds...</div>; + if (error) + return <div className="p-4 text-red-600">Error: {error.message}</div>; + if (!data?.feeds || data.feeds.length === 0) { + return <div className="p-4 text-gray-500">No feeds added yet.</div>; + } + + return ( + <div className="space-y-4 p-4"> + {data.feeds.map((feed) => { + const unreadCount = feed.articles.filter((a) => !a.isRead).length; + const totalCount = feed.articles.length; + + const isSelected = selectedFeeds?.has(feed.id) ?? false; + + return ( + <div + key={feed.id} + className={`rounded-lg border p-4 shadow-sm ${ + isSelected + ? "border-blue-300 bg-blue-50" + : "border-gray-200 bg-white" + }`} + > + <div className="flex items-start justify-between"> + {selectedFeeds && onSelectFeed && ( + <div className="flex items-start gap-3"> + <input + type="checkbox" + checked={isSelected} + onChange={(e) => onSelectFeed(feed.id, e.target.checked)} + className="mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> + <div className="flex-1"> + <h3 className="text-lg font-semibold text-gray-900"> + {feed.title} + </h3> + <p className="mt-1 text-sm text-gray-500">{feed.url}</p> + <div className="mt-2 flex items-center gap-4 text-sm"> + <span className="text-gray-600"> + {unreadCount} unread / {totalCount} total + </span> + <span className="text-gray-400"> + Last fetched:{" "} + {new Date(feed.fetchedAt).toLocaleString()} + </span> + </div> + </div> + </div> + )} + {(!selectedFeeds || !onSelectFeed) && ( + <div className="flex-1"> + <h3 className="text-lg font-semibold text-gray-900"> + {feed.title} + </h3> + <p className="mt-1 text-sm text-gray-500">{feed.url}</p> + <div className="mt-2 flex items-center gap-4 text-sm"> + <span className="text-gray-600"> + {unreadCount} unread / {totalCount} total + </span> + <span className="text-gray-400"> + Last fetched: {new Date(feed.fetchedAt).toLocaleString()} + </span> + </div> + </div> + )} + <div className="flex items-center gap-2"> + <button + type="button" + onClick={() => handleMarkAllRead(feed.id)} + className="rounded p-2 text-gray-600 hover:bg-gray-100 hover:text-gray-900" + title="Mark all as read" + > + <FontAwesomeIcon icon={faCheckDouble} /> + </button> + <button + type="button" + onClick={() => handleMarkAllUnread(feed.id)} + className="rounded p-2 text-gray-600 hover:bg-gray-100 hover:text-gray-900" + title="Mark all as unread" + > + <FontAwesomeIcon icon={faCircle} /> + </button> + <button + type="button" + onClick={() => handleDeleteFeed(feed.id)} + className="rounded p-2 text-red-600 hover:bg-red-50 hover:text-red-700" + title="Delete feed" + > + <FontAwesomeIcon icon={faTrash} /> + </button> + </div> + </div> + </div> + ); + })} + </div> + ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..09a0eb4 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; +import { Navigation } from "./Navigation"; + +interface Props { + children: ReactNode; +} + +export function Layout({ children }: Props) { + return ( + <div className="min-h-screen bg-gray-50"> + <Navigation /> + <main className="container mx-auto px-4 py-8">{children}</main> + </div> + ); +} diff --git a/frontend/src/components/MenuItem.tsx b/frontend/src/components/MenuItem.tsx new file mode 100644 index 0000000..45358c8 --- /dev/null +++ b/frontend/src/components/MenuItem.tsx @@ -0,0 +1,28 @@ +import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useLocation } from "wouter"; + +interface Props { + path: string; + label: string; + icon: IconDefinition; +} + +export function MenuItem({ path, label, icon }: Props) { + const [location] = useLocation(); + const isActive = location === path; + + return ( + <Link + href={path} + className={`flex items-center space-x-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${ + isActive + ? "bg-blue-100 text-blue-700" + : "text-gray-600 hover:text-gray-900 hover:bg-gray-100" + }`} + > + <FontAwesomeIcon icon={icon} /> + <span>{label}</span> + </Link> + ); +} diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx new file mode 100644 index 0000000..f5771df --- /dev/null +++ b/frontend/src/components/Navigation.tsx @@ -0,0 +1,28 @@ +import { + faBookOpen, + faCircleCheck, + faGear, +} from "@fortawesome/free-solid-svg-icons"; +import { Link } from "wouter"; +import { MenuItem } from "./MenuItem"; + +export function Navigation() { + return ( + <nav className="bg-white shadow-sm border-b border-gray-200"> + <div className="container mx-auto px-4"> + <div className="flex items-center justify-between h-16"> + <div className="flex items-center space-x-8"> + <Link href="/" className="text-xl font-bold text-gray-900"> + feedaka + </Link> + <div className="flex space-x-6"> + <MenuItem path="/unread" label="Unread" icon={faBookOpen} /> + <MenuItem path="/read" label="Read" icon={faCircleCheck} /> + <MenuItem path="/settings" label="Settings" icon={faGear} /> + </div> + </div> + </div> + </div> + </nav> + ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts new file mode 100644 index 0000000..8253800 --- /dev/null +++ b/frontend/src/components/index.ts @@ -0,0 +1,6 @@ +export { AddFeedForm } from "./AddFeedForm"; +export { ArticleList } from "./ArticleList"; +export { FeedList } from "./FeedList"; +export { Layout } from "./Layout"; +export { MenuItem } from "./MenuItem"; +export { Navigation } from "./Navigation"; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index fcd9b64..34a72b2 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,11 +1,15 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { Provider } from "urql"; import "./index.css"; import App from "./App.tsx"; +import { client } from "./services/graphql-client"; // biome-ignore lint/style/noNonNullAssertion: root element is guaranteed to exist createRoot(document.getElementById("root")!).render( <StrictMode> - <App /> + <Provider value={client}> + <App /> + </Provider> </StrictMode>, ); diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx new file mode 100644 index 0000000..23c1184 --- /dev/null +++ b/frontend/src/pages/NotFound.tsx @@ -0,0 +1,15 @@ +export function NotFound() { + return ( + <div className="flex min-h-96 flex-col items-center justify-center"> + <div className="text-center"> + <h1 className="text-6xl font-bold text-gray-900">404</h1> + <h2 className="mt-4 text-2xl font-semibold text-gray-700"> + Page Not Found + </h2> + <p className="mt-2 text-gray-500"> + The page you're looking for doesn't exist. + </p> + </div> + </div> + ); +} diff --git a/frontend/src/pages/ReadArticles.tsx b/frontend/src/pages/ReadArticles.tsx new file mode 100644 index 0000000..4863d63 --- /dev/null +++ b/frontend/src/pages/ReadArticles.tsx @@ -0,0 +1,34 @@ +import { useQuery } from "urql"; +import { ArticleList } from "../components"; +import { GetReadArticlesDocument } from "../graphql/generated/graphql"; + +export function ReadArticles() { + const [{ data, fetching, error }] = useQuery({ + query: GetReadArticlesDocument, + }); + + if (fetching) { + return <div className="p-4">Loading read articles...</div>; + } + + if (error) { + return <div className="p-4 text-red-600">Error: {error.message}</div>; + } + + return ( + <div> + <div className="border-b border-gray-200 bg-white px-4 py-3"> + <h1 className="text-xl font-semibold text-gray-900">Read Articles</h1> + {data?.readArticles && ( + <p className="text-sm text-gray-500"> + {data.readArticles.length} article + {data.readArticles.length !== 1 ? "s" : ""} + </p> + )} + </div> + {data?.readArticles && ( + <ArticleList articles={data.readArticles} showReadStatus={true} /> + )} + </div> + ); +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..78fc306 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,155 @@ +import { useState } from "react"; +import { useMutation, useQuery } from "urql"; +import { AddFeedForm, FeedList } from "../components"; +import { + GetFeedsDocument, + MarkFeedReadDocument, + MarkFeedUnreadDocument, + RemoveFeedDocument, +} from "../graphql/generated/graphql"; + +export function Settings() { + const [{ data: feedsData }, refetchFeeds] = useQuery({ + query: GetFeedsDocument, + }); + const [, markFeedRead] = useMutation(MarkFeedReadDocument); + const [, markFeedUnread] = useMutation(MarkFeedUnreadDocument); + const [, removeFeed] = useMutation(RemoveFeedDocument); + + const [selectedFeeds, setSelectedFeeds] = useState<Set<string>>(new Set()); + + const handleFeedAdded = () => { + refetchFeeds(); + }; + + const handleFeedDeleted = () => { + refetchFeeds(); + setSelectedFeeds(new Set()); + }; + + const handleSelectFeed = (feedId: string, selected: boolean) => { + const newSelection = new Set(selectedFeeds); + if (selected) { + newSelection.add(feedId); + } else { + newSelection.delete(feedId); + } + setSelectedFeeds(newSelection); + }; + + const handleSelectAll = () => { + if (!feedsData?.feeds) return; + if (selectedFeeds.size === feedsData.feeds.length) { + setSelectedFeeds(new Set()); + } else { + setSelectedFeeds(new Set(feedsData.feeds.map((feed) => feed.id))); + } + }; + + const handleBulkMarkRead = async () => { + const promises = Array.from(selectedFeeds).map((feedId) => + markFeedRead({ id: feedId }), + ); + await Promise.all(promises); + refetchFeeds(); + }; + + const handleBulkMarkUnread = async () => { + const promises = Array.from(selectedFeeds).map((feedId) => + markFeedUnread({ id: feedId }), + ); + await Promise.all(promises); + refetchFeeds(); + }; + + const handleBulkDelete = async () => { + const confirmed = window.confirm( + `Are you sure you want to delete ${selectedFeeds.size} selected feeds?`, + ); + if (!confirmed) return; + + const promises = Array.from(selectedFeeds).map((feedId) => + removeFeed({ id: feedId }), + ); + await Promise.all(promises); + handleFeedDeleted(); + }; + + const hasFeeds = feedsData?.feeds && feedsData.feeds.length > 0; + const hasSelectedFeeds = selectedFeeds.size > 0; + + return ( + <div className="mx-auto max-w-4xl"> + <h1 className="mb-6 text-2xl font-bold text-gray-900">Feed Settings</h1> + + {/* Add New Feed Section */} + <div className="mb-8"> + <h2 className="mb-4 text-xl font-semibold text-gray-800"> + Add New Feed + </h2> + <AddFeedForm onFeedAdded={handleFeedAdded} /> + </div> + + {/* Manage Feeds Section */} + <div className="mb-8"> + <div className="flex items-center justify-between mb-4"> + <h2 className="text-xl font-semibold text-gray-800">Manage Feeds</h2> + {hasFeeds && ( + <div className="flex items-center gap-4"> + <label className="flex items-center gap-2 text-sm text-gray-600"> + <input + type="checkbox" + checked={selectedFeeds.size === feedsData.feeds.length} + onChange={handleSelectAll} + className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> + Select All ({feedsData.feeds.length} feeds) + </label> + </div> + )} + </div> + + {/* Bulk Operations */} + {hasSelectedFeeds && ( + <div className="mb-4 rounded-lg bg-blue-50 border border-blue-200 p-4"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium text-blue-900"> + {selectedFeeds.size} feed{selectedFeeds.size > 1 ? "s" : ""}{" "} + selected + </span> + <div className="flex gap-2"> + <button + type="button" + onClick={handleBulkMarkRead} + className="rounded px-3 py-1 text-sm font-medium text-blue-700 hover:bg-blue-100" + > + Mark All Read + </button> + <button + type="button" + onClick={handleBulkMarkUnread} + className="rounded px-3 py-1 text-sm font-medium text-blue-700 hover:bg-blue-100" + > + Mark All Unread + </button> + <button + type="button" + onClick={handleBulkDelete} + className="rounded px-3 py-1 text-sm font-medium text-red-700 hover:bg-red-100" + > + Delete Selected + </button> + </div> + </div> + </div> + )} + + <FeedList + onFeedDeleted={handleFeedDeleted} + selectedFeeds={selectedFeeds} + onSelectFeed={handleSelectFeed} + /> + </div> + </div> + ); +} diff --git a/frontend/src/pages/UnreadArticles.tsx b/frontend/src/pages/UnreadArticles.tsx new file mode 100644 index 0000000..38bf077 --- /dev/null +++ b/frontend/src/pages/UnreadArticles.tsx @@ -0,0 +1,34 @@ +import { useQuery } from "urql"; +import { ArticleList } from "../components"; +import { GetUnreadArticlesDocument } from "../graphql/generated/graphql"; + +export function UnreadArticles() { + const [{ data, fetching, error }] = useQuery({ + query: GetUnreadArticlesDocument, + }); + + if (fetching) { + return <div className="p-4">Loading unread articles...</div>; + } + + if (error) { + return <div className="p-4 text-red-600">Error: {error.message}</div>; + } + + return ( + <div> + <div className="border-b border-gray-200 bg-white px-4 py-3"> + <h1 className="text-xl font-semibold text-gray-900">Unread Articles</h1> + {data?.unreadArticles && ( + <p className="text-sm text-gray-500"> + {data.unreadArticles.length} article + {data.unreadArticles.length !== 1 ? "s" : ""} + </p> + )} + </div> + {data?.unreadArticles && ( + <ArticleList articles={data.unreadArticles} showReadStatus={true} /> + )} + </div> + ); +} diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts new file mode 100644 index 0000000..a037a9d --- /dev/null +++ b/frontend/src/pages/index.ts @@ -0,0 +1,4 @@ +export { NotFound } from "./NotFound"; +export { ReadArticles } from "./ReadArticles"; +export { Settings } from "./Settings"; +export { UnreadArticles } from "./UnreadArticles"; diff --git a/frontend/src/services/graphql-client.ts b/frontend/src/services/graphql-client.ts new file mode 100644 index 0000000..ad2680a --- /dev/null +++ b/frontend/src/services/graphql-client.ts @@ -0,0 +1,6 @@ +import { Client, cacheExchange, fetchExchange } from "urql"; + +export const client = new Client({ + url: "/graphql", + exchanges: [cacheExchange, fetchExchange], +}); |
