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