aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'frontend')
-rw-r--r--frontend/package-lock.json69
-rw-r--r--frontend/package.json3
-rw-r--r--frontend/src/App.tsx39
-rw-r--r--frontend/src/components/AddFeedForm.tsx89
-rw-r--r--frontend/src/components/ArticleList.tsx137
-rw-r--r--frontend/src/components/FeedList.tsx150
-rw-r--r--frontend/src/components/Layout.tsx15
-rw-r--r--frontend/src/components/MenuItem.tsx28
-rw-r--r--frontend/src/components/Navigation.tsx28
-rw-r--r--frontend/src/components/index.ts6
-rw-r--r--frontend/src/main.tsx6
-rw-r--r--frontend/src/pages/NotFound.tsx15
-rw-r--r--frontend/src/pages/ReadArticles.tsx34
-rw-r--r--frontend/src/pages/Settings.tsx155
-rw-r--r--frontend/src/pages/UnreadArticles.tsx34
-rw-r--r--frontend/src/pages/index.ts4
-rw-r--r--frontend/src/services/graphql-client.ts6
17 files changed, 787 insertions, 31 deletions
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index bcc0bdc..f949145 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,6 +8,9 @@
"name": "feedaka-frontend",
"version": "0.0.0",
"dependencies": {
+ "@fortawesome/fontawesome-svg-core": "^6.7.2",
+ "@fortawesome/free-solid-svg-icons": "^6.7.2",
+ "@fortawesome/react-fontawesome": "^0.2.2",
"@tailwindcss/vite": "^4.1.11",
"graphql": "^16.11.0",
"react": "^19.1.0",
@@ -1018,6 +1021,52 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@fortawesome/fontawesome-common-types": {
+ "version": "6.7.2",
+ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz",
+ "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@fortawesome/fontawesome-svg-core": {
+ "version": "6.7.2",
+ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz",
+ "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==",
+ "license": "MIT",
+ "dependencies": {
+ "@fortawesome/fontawesome-common-types": "6.7.2"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@fortawesome/free-solid-svg-icons": {
+ "version": "6.7.2",
+ "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz",
+ "integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==",
+ "license": "(CC-BY-4.0 AND MIT)",
+ "dependencies": {
+ "@fortawesome/fontawesome-common-types": "6.7.2"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@fortawesome/react-fontawesome": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz",
+ "integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==",
+ "license": "MIT",
+ "dependencies": {
+ "prop-types": "^15.8.1"
+ },
+ "peerDependencies": {
+ "@fortawesome/fontawesome-svg-core": "~1 || ~6",
+ "react": ">=16.3"
+ }
+ },
"node_modules/@graphql-codegen/add": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-5.0.3.tgz",
@@ -4231,7 +4280,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -4647,7 +4695,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
- "dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -4938,7 +4985,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5196,6 +5242,17 @@
"asap": "~2.0.3"
}
},
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5238,6 +5295,12 @@
"react": "^19.1.0"
}
},
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+ "license": "MIT"
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 6e8d3ad..a4121dc 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,6 +12,9 @@
"preview": "vite preview"
},
"dependencies": {
+ "@fortawesome/fontawesome-svg-core": "^6.7.2",
+ "@fortawesome/free-solid-svg-icons": "^6.7.2",
+ "@fortawesome/react-fontawesome": "^0.2.2",
"@tailwindcss/vite": "^4.1.11",
"graphql": "^16.11.0",
"react": "^19.1.0",
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],
+});