From 1c73c999ac78d2e6d3a8c68b4e17058046326f55 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 12 Jul 2025 14:55:19 +0900 Subject: feat(frontend): create pages and components --- frontend/package-lock.json | 69 +++++++++++++- frontend/package.json | 3 + frontend/src/App.tsx | 39 +++----- frontend/src/components/AddFeedForm.tsx | 89 ++++++++++++++++++ frontend/src/components/ArticleList.tsx | 137 ++++++++++++++++++++++++++++ frontend/src/components/FeedList.tsx | 150 +++++++++++++++++++++++++++++++ frontend/src/components/Layout.tsx | 15 ++++ frontend/src/components/MenuItem.tsx | 28 ++++++ frontend/src/components/Navigation.tsx | 28 ++++++ frontend/src/components/index.ts | 6 ++ frontend/src/main.tsx | 6 +- frontend/src/pages/NotFound.tsx | 15 ++++ frontend/src/pages/ReadArticles.tsx | 34 +++++++ frontend/src/pages/Settings.tsx | 155 ++++++++++++++++++++++++++++++++ frontend/src/pages/UnreadArticles.tsx | 34 +++++++ frontend/src/pages/index.ts | 4 + frontend/src/services/graphql-client.ts | 6 ++ justfile | 3 + 18 files changed, 790 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/AddFeedForm.tsx create mode 100644 frontend/src/components/ArticleList.tsx create mode 100644 frontend/src/components/FeedList.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/MenuItem.tsx create mode 100644 frontend/src/components/Navigation.tsx create mode 100644 frontend/src/components/index.ts create mode 100644 frontend/src/pages/NotFound.tsx create mode 100644 frontend/src/pages/ReadArticles.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/pages/UnreadArticles.tsx create mode 100644 frontend/src/pages/index.ts create mode 100644 frontend/src/services/graphql-client.ts 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 ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- + + + } /> + + + + + + ); } 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(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 ( +
+
+

+ Add New Feed +

+
+
+ 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 && ( +

+ Please enter a valid URL (http:// or https://) +

+ )} + {error &&

{error}

} +
+ +
+
+
+ ); +} 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 ( +
No articles found.
+ ); + } + + // 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 ( +
+ {Object.values(articlesByFeed).map(({ feed, articles: feedArticles }) => ( +
+

+ {feed.title} + + ({feedArticles.length} article + {feedArticles.length !== 1 ? "s" : ""}) + +

+
+ {feedArticles.map((article) => ( +
+ {showReadStatus && ( + + )} +
+ +
+
+ ))} +
+
+ ))} +
+ ); +} 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; + 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
Loading feeds...
; + if (error) + return
Error: {error.message}
; + if (!data?.feeds || data.feeds.length === 0) { + return
No feeds added yet.
; + } + + return ( +
+ {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 ( +
+
+ {selectedFeeds && onSelectFeed && ( +
+ onSelectFeed(feed.id, e.target.checked)} + className="mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> +
+

+ {feed.title} +

+

{feed.url}

+
+ + {unreadCount} unread / {totalCount} total + + + Last fetched:{" "} + {new Date(feed.fetchedAt).toLocaleString()} + +
+
+
+ )} + {(!selectedFeeds || !onSelectFeed) && ( +
+

+ {feed.title} +

+

{feed.url}

+
+ + {unreadCount} unread / {totalCount} total + + + Last fetched: {new Date(feed.fetchedAt).toLocaleString()} + +
+
+ )} +
+ + + +
+
+
+ ); + })} +
+ ); +} 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 ( +
+ +
{children}
+
+ ); +} 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 ( + + + {label} + + ); +} 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 ( + + ); +} 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( - + + + , ); 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 ( +
+
+

404

+

+ Page Not Found +

+

+ The page you're looking for doesn't exist. +

+
+
+ ); +} 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
Loading read articles...
; + } + + if (error) { + return
Error: {error.message}
; + } + + return ( +
+
+

Read Articles

+ {data?.readArticles && ( +

+ {data.readArticles.length} article + {data.readArticles.length !== 1 ? "s" : ""} +

+ )} +
+ {data?.readArticles && ( + + )} +
+ ); +} 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>(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 ( +
+

Feed Settings

+ + {/* Add New Feed Section */} +
+

+ Add New Feed +

+ +
+ + {/* Manage Feeds Section */} +
+
+

Manage Feeds

+ {hasFeeds && ( +
+ +
+ )} +
+ + {/* Bulk Operations */} + {hasSelectedFeeds && ( +
+
+ + {selectedFeeds.size} feed{selectedFeeds.size > 1 ? "s" : ""}{" "} + selected + +
+ + + +
+
+
+ )} + + +
+
+ ); +} 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
Loading unread articles...
; + } + + if (error) { + return
Error: {error.message}
; + } + + return ( +
+
+

Unread Articles

+ {data?.unreadArticles && ( +

+ {data.unreadArticles.length} article + {data.unreadArticles.length !== 1 ? "s" : ""} +

+ )} +
+ {data?.unreadArticles && ( + + )} +
+ ); +} 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], +}); diff --git a/justfile b/justfile index 8b562e3..8153d6f 100644 --- a/justfile +++ b/justfile @@ -1,3 +1,6 @@ +list: + @just -l + serve: build FEEDAKA_BASE_PATH="" FEEDAKA_PORT=8080 ./backend/feedaka -- cgit v1.2.3-70-g09d2