1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
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>
);
}
|