Skip to content

Commit ff0f296

Browse files
authored
Merge pull request #133 from hfellerhoff/surface-feed-statuses
Surface feed statuses
2 parents e3d715b + 4b9caa7 commit ff0f296

File tree

10 files changed

+419
-151
lines changed

10 files changed

+419
-151
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@radix-ui/react-icons": "^1.3.2",
3939
"@radix-ui/react-label": "^2.1.8",
4040
"@radix-ui/react-popover": "^1.1.15",
41+
"@radix-ui/react-progress": "^1.1.8",
4142
"@radix-ui/react-radio-group": "^1.3.8",
4243
"@radix-ui/react-scroll-area": "^1.2.10",
4344
"@radix-ui/react-select": "^2.2.6",

pnpm-lock.yaml

Lines changed: 74 additions & 33 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/(feed)/feed/FeedLoader.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import clsx from "clsx";
2+
import { Progress } from "~/components/ui/progress";
3+
import { useFeeds } from "~/lib/data/feeds";
4+
import { useFeedStatusDict, useFetchFeedItemsStatus } from "~/lib/data/store";
5+
6+
export function FeedLoader() {
7+
const { feeds } = useFeeds();
8+
const feedStatusDict = useFeedStatusDict();
9+
10+
const fetchedFeedsCount = Object.keys(feedStatusDict).length;
11+
const status = useFetchFeedItemsStatus();
12+
13+
const isFetching = status === "fetching";
14+
15+
const value = isFetching ? fetchedFeedsCount : 0;
16+
17+
return (
18+
<div
19+
className={clsx("w-32 transition-opacity", {
20+
"opacity-0": !isFetching,
21+
"opacity-100": isFetching,
22+
})}
23+
>
24+
<Progress max={feeds.length || 100} value={value} className="w-full" />
25+
</div>
26+
);
27+
}

src/app/(feed)/feed/Header.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useShortcut } from "~/lib/hooks/useShortcut";
44
import { TopLeftButton } from "./TopLeftButton";
55
import { TopRightHeaderContent } from "./TopRightHeaderContent";
66
import { usePathname, useRouter } from "next/navigation";
7+
import { FeedLoader } from "./FeedLoader";
78

89
export function Header() {
910
const pathname = usePathname();
@@ -17,6 +18,7 @@ export function Header() {
1718
return (
1819
<header className="top-0 z-20 flex w-full flex-wrap items-center justify-between gap-2 bg-transparent px-6 py-6">
1920
<TopLeftButton />
21+
<FeedLoader />
2022
<TopRightHeaderContent />
2123
</header>
2224
);

src/app/(feed)/feed/SidebarFeeds.tsx

Lines changed: 192 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { useAtom, useAtomValue, useSetAtom } from "jotai";
2-
import { CircleSmall, Edit2Icon, PlusIcon } from "lucide-react";
2+
import {
3+
AlertCircleIcon,
4+
AlertTriangleIcon,
5+
CircleSmall,
6+
Edit2Icon,
7+
MinusIcon,
8+
PlusIcon,
9+
} from "lucide-react";
310
import { useCallback, useRef, useState } from "react";
411
import { EditFeedDialog } from "~/components/AddFeedDialog";
512
import { ButtonWithShortcut } from "~/components/ButtonWithShortcut";
@@ -23,7 +30,19 @@ import { doesFeedItemPassFilters } from "~/lib/data/feed-items";
2330
import { useFeeds } from "~/lib/data/feeds";
2431
import { useDeselectViewFilter } from "~/lib/data/views";
2532
import { useDialogStore } from "./dialogStore";
26-
import { useFeedItemsDict, useFeedItemsOrder } from "~/lib/data/store";
33+
import {
34+
useFeedItemsDict,
35+
useFeedItemsOrder,
36+
useFeedStatusDict,
37+
useFetchFeedItemsStatus,
38+
} from "~/lib/data/store";
39+
import {
40+
Tooltip,
41+
TooltipContent,
42+
TooltipTrigger,
43+
} from "~/components/ui/tooltip";
44+
import { error } from "node:console";
45+
import { ApplicationFeed } from "~/server/db/schema";
2746

2847
function useCheckFilteredFeedItemsForFeed() {
2948
const feedItemsOrder = useFeedItemsOrder();
@@ -91,6 +110,10 @@ function useDebouncedState(defaultValue: string, delay: number) {
91110
return [searchQuery, setDebouncedQuery] as const;
92111
}
93112

113+
function sortFeedOptions(a: ApplicationFeed, b: ApplicationFeed) {
114+
return a.name.localeCompare(b.name);
115+
}
116+
94117
export function SidebarFeeds() {
95118
const [searchQuery, setSearchQuery] = useDebouncedState("", 300);
96119

@@ -107,52 +130,78 @@ export function SidebarFeeds() {
107130
const viewFilter = useAtomValue(viewFilterAtom);
108131
const deselectViewFilter = useDeselectViewFilter();
109132

133+
const feedStatusDict = useFeedStatusDict();
134+
const fetchFeedItemsStatus = useFetchFeedItemsStatus();
135+
110136
const checkFilteredFeedItemsForFeed = useCheckFilteredFeedItemsForFeed();
111137

112-
const feedOptions = feeds?.map((category) => ({
113-
...category,
114-
hasEntries: !!checkFilteredFeedItemsForFeed(category.id).length,
138+
const feedOptions = feeds?.map((feed) => ({
139+
...feed,
140+
hasEntries: !!checkFilteredFeedItemsForFeed(feed.id).length,
115141
}));
116142

117-
const preferredFeedOptions = feedOptions
118-
?.filter((feedOption) => {
143+
const {
144+
preferredFeedOptions,
145+
feedOptionsWithContent,
146+
emptyFeedOptions,
147+
errorFeedOptions,
148+
} = feedOptions?.reduce(
149+
(acc, feedOption) => {
150+
const {
151+
preferredFeedOptions,
152+
feedOptionsWithContent,
153+
emptyFeedOptions,
154+
errorFeedOptions,
155+
} = acc;
119156
if (!!searchQuery) {
120157
const lowercaseQuery = searchQuery.toLowerCase();
121158
const lowercaseName = feedOption.name.toLowerCase();
122159

123160
if (lowercaseName.includes(lowercaseQuery)) {
124-
return true;
161+
preferredFeedOptions.push(feedOption);
162+
preferredFeedOptions.sort(sortFeedOptions);
163+
return acc;
125164
}
126165
} else {
127-
if (feedOption.hasEntries) return true;
166+
if (feedOption.hasEntries) {
167+
preferredFeedOptions.push(feedOption);
168+
preferredFeedOptions.sort(sortFeedOptions);
169+
return acc;
170+
}
128171
}
129172

130173
if (feedOption.id === feedFilter) {
131-
return true;
174+
preferredFeedOptions.push(feedOption);
175+
preferredFeedOptions.sort(sortFeedOptions);
176+
return acc;
132177
}
133178

134-
return false;
135-
})
136-
.toSorted((a, b) => {
137-
if (a.id === feedFilter) {
138-
return -1;
139-
}
140-
if (b.id === feedFilter) {
141-
return 1;
142-
}
179+
const feedStatus = !!feedStatusDict[feedOption.id]
180+
? feedStatusDict[feedOption.id]
181+
: fetchFeedItemsStatus === "fetching"
182+
? "success"
183+
: "empty";
143184

144-
return a.name.localeCompare(b.name);
145-
});
185+
if (feedStatus === "success") {
186+
feedOptionsWithContent.push(feedOption);
187+
feedOptionsWithContent.sort(sortFeedOptions);
188+
} else if (feedStatus === "empty") {
189+
emptyFeedOptions.push(feedOption);
190+
emptyFeedOptions.sort(sortFeedOptions);
191+
} else if (feedStatus === "error") {
192+
errorFeedOptions.push(feedOption);
193+
errorFeedOptions.sort(sortFeedOptions);
194+
}
146195

147-
const otherFeedOptions = feedOptions
148-
?.filter((feedOption) => {
149-
return !preferredFeedOptions.some(
150-
(option) => option.id === feedOption.id,
151-
);
152-
})
153-
.toSorted((a, b) => {
154-
return a.name.localeCompare(b.name);
155-
});
196+
return acc;
197+
},
198+
{
199+
preferredFeedOptions: [] as typeof feedOptions,
200+
feedOptionsWithContent: [] as typeof feedOptions,
201+
emptyFeedOptions: [] as typeof feedOptions,
202+
errorFeedOptions: [] as typeof feedOptions,
203+
},
204+
);
156205

157206
const hasAnyItems = !!checkFilteredFeedItemsForFeed(-1).length;
158207

@@ -207,6 +256,14 @@ export function SidebarFeeds() {
207256
</SidebarMenuButton>
208257
</SidebarMenuItem>
209258
{preferredFeedOptions.map((feed) => {
259+
const feedStatus = !!feedStatusDict[feed.id]
260+
? feedStatusDict[feed.id]
261+
: fetchFeedItemsStatus === "fetching"
262+
? "success"
263+
: "empty";
264+
265+
const isSuccess = feedStatus === "success";
266+
210267
return (
211268
<SidebarMenuItem key={feed.id} className="group flex gap-1">
212269
<SidebarMenuButton
@@ -218,10 +275,35 @@ export function SidebarFeeds() {
218275
}
219276
}}
220277
>
221-
{!feed.hasEntries && (
278+
{feedStatus === "error" && (
279+
<Tooltip>
280+
<TooltipTrigger asChild>
281+
<AlertCircleIcon
282+
size={16}
283+
className="text-sidebar-accent"
284+
/>
285+
</TooltipTrigger>
286+
<TooltipContent className="max-w-xs text-center">
287+
Something went wrong fetching content for this feed. If
288+
this continues, try deleting this feed and adding it
289+
again with the correct URL.
290+
</TooltipContent>
291+
</Tooltip>
292+
)}
293+
{feedStatus === "empty" && (
294+
<Tooltip>
295+
<TooltipTrigger asChild>
296+
<MinusIcon size={16} className="text-sidebar-accent" />
297+
</TooltipTrigger>
298+
<TooltipContent>
299+
This feed has no new content within the last 30 days.
300+
</TooltipContent>
301+
</Tooltip>
302+
)}
303+
{isSuccess && !feed.hasEntries && (
222304
<CircleSmall size={16} className="text-sidebar-accent" />
223305
)}
224-
{feed.hasEntries && (
306+
{isSuccess && feed.hasEntries && (
225307
<div className="grid size-4 place-items-center">
226308
<div className="bg-sidebar-accent size-2.5 rounded-full" />
227309
</div>
@@ -238,10 +320,10 @@ export function SidebarFeeds() {
238320
</SidebarMenuItem>
239321
);
240322
})}
241-
{!!preferredFeedOptions.length && !!otherFeedOptions.length && (
323+
{!!preferredFeedOptions.length && !!feedOptionsWithContent.length && (
242324
<hr className="my-2 opacity-50" />
243325
)}
244-
{otherFeedOptions.map((feed) => {
326+
{feedOptionsWithContent.map((feed) => {
245327
return (
246328
<SidebarMenuItem key={feed.id} className="group flex gap-1">
247329
<SidebarMenuButton
@@ -273,6 +355,82 @@ export function SidebarFeeds() {
273355
</SidebarMenuItem>
274356
);
275357
})}
358+
{!!feedOptionsWithContent.length && !!emptyFeedOptions.length && (
359+
<hr className="my-2 opacity-50" />
360+
)}
361+
{emptyFeedOptions.map((feed) => {
362+
return (
363+
<SidebarMenuItem key={feed.id} className="group flex gap-1">
364+
<SidebarMenuButton
365+
variant={feed.id === feedFilter ? "outline" : "default"}
366+
onClick={() => {
367+
setFeedFilter(feed.id);
368+
if (!feed.hasEntries) {
369+
deselectViewFilter();
370+
}
371+
}}
372+
>
373+
<Tooltip>
374+
<TooltipTrigger asChild>
375+
<MinusIcon size={16} className="text-sidebar-accent" />
376+
</TooltipTrigger>
377+
<TooltipContent>
378+
This feed has no new content within the last 30 days.
379+
</TooltipContent>
380+
</Tooltip>
381+
<div className="line-clamp-1">{feed.name}</div>
382+
</SidebarMenuButton>
383+
<div className="group/button flex w-fit items-center justify-end">
384+
<SidebarMenuButton
385+
onClick={() => setSelectedFeedForEditing(feed.id)}
386+
>
387+
<Edit2Icon className="opacity-30 transition-opacity group-hover/button:opacity-100" />
388+
</SidebarMenuButton>
389+
</div>
390+
</SidebarMenuItem>
391+
);
392+
})}
393+
{!!emptyFeedOptions.length && !!errorFeedOptions.length && (
394+
<hr className="my-2 opacity-50" />
395+
)}
396+
{errorFeedOptions.map((feed) => {
397+
return (
398+
<SidebarMenuItem key={feed.id} className="group flex gap-1">
399+
<SidebarMenuButton
400+
variant={feed.id === feedFilter ? "outline" : "default"}
401+
onClick={() => {
402+
setFeedFilter(feed.id);
403+
if (!feed.hasEntries) {
404+
deselectViewFilter();
405+
}
406+
}}
407+
>
408+
<Tooltip>
409+
<TooltipTrigger asChild>
410+
<AlertCircleIcon
411+
size={16}
412+
className="text-sidebar-accent"
413+
/>
414+
</TooltipTrigger>
415+
<TooltipContent className="max-w-xs text-center">
416+
Something went wrong fetching content for this feed. If
417+
this continues, try deleting this feed and adding it again
418+
with the correct URL.
419+
</TooltipContent>
420+
</Tooltip>
421+
422+
<div className="line-clamp-1">{feed.name}</div>
423+
</SidebarMenuButton>
424+
<div className="group/button flex w-fit items-center justify-end">
425+
<SidebarMenuButton
426+
onClick={() => setSelectedFeedForEditing(feed.id)}
427+
>
428+
<Edit2Icon className="opacity-30 transition-opacity group-hover/button:opacity-100" />
429+
</SidebarMenuButton>
430+
</div>
431+
</SidebarMenuItem>
432+
);
433+
})}
276434
</SidebarMenu>
277435
</SidebarGroup>
278436
</>

0 commit comments

Comments
 (0)