src/components/app-sidebar.tsx 17.5 K raw
1
import * as React from "react";
2
import { NavUser } from "@/components/nav-user";
3
import { NavFeeds } from "@/components/nav-feeds";
4
import { FeedActions } from "@/components/feed-actions";
5
import { AddFeedDialog } from "@/components/add-feed-dialog";
6
import { PostsList } from "@/components/posts-list";
7
import { MobilePostsHeader } from "@/components/mobile-posts-header";
8
import { CategoryEditDialog } from "@/components/category-edit-dialog";
9
import { ChangeCategoryDialog } from "@/components/change-category-dialog";
10
import {
11
	Sidebar,
12
	SidebarContent,
13
	SidebarFooter,
14
	SidebarHeader,
15
	SidebarMenu,
16
	SidebarMenuItem,
17
	useSidebar,
18
} from "@/components/ui/sidebar";
19
import { toast } from "sonner";
20
import {
21
	allFeedsQuery,
22
	allPostsQuery,
23
	postsByFeedQuery,
24
	allReadStatusesQuery,
25
	allReadStatusesWithUnreadQuery,
26
	useEvolu,
27
	evolu as evoluInstance,
28
} from "@/lib/evolu";
29
import * as Evolu from "@evolu/common";
30
import { useQuery } from "@evolu/react";
31
import {
32
	fetchFeedWithFallback,
33
	parseFeedXml,
34
	extractPostLink,
35
	extractPostAuthor,
36
	extractPostContent,
37
	extractPostDate,
38
} from "@/lib/feed-operations";
39
import { formatTypeError } from "@/lib/format-error";
40
41
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
42
	selectedFeedId?: string | null;
43
	onFeedSelect?: (feedId: string | null) => void;
44
	selectedPostId?: string | null;
45
	onPostSelect?: (postId: string) => void;
46
}
47
48
export function AppSidebar({
49
	selectedFeedId = null,
50
	onFeedSelect = () => {},
51
	selectedPostId = null,
52
	onPostSelect = () => {},
53
	...props
54
}: AppSidebarProps) {
55
	const [dialogOpen, setDialogOpen] = React.useState(false);
56
	const [categoryEditDialogOpen, setCategoryEditDialogOpen] =
57
		React.useState(false);
58
	const [changeCategoryDialogOpen, setChangeCategoryDialogOpen] =
59
		React.useState(false);
60
	const [categoryToEdit, setCategoryToEdit] = React.useState("");
61
	const [searchQuery, setSearchQuery] = React.useState("");
62
	const [mobileView, setMobileView] = React.useState<"feeds" | "posts">(
63
		"feeds",
64
	);
65
	const hasRefreshedOnMount = React.useRef(false);
66
67
	const { hidden, isMobile, setOpenMobile } = useSidebar();
68
	const evolu = useEvolu();
69
	const allFeeds = useQuery(allFeedsQuery);
70
	const allReadStatuses = useQuery(allReadStatusesQuery);
71
	const allReadStatusesWithUnread = useQuery(allReadStatusesWithUnreadQuery);
72
73
	// Get posts based on selected feed
74
	const allPosts = useQuery(allPostsQuery);
75
	const feedPostsQuery = useQuery(postsByFeedQuery(selectedFeedId || ""));
76
77
	// Check if a post is read
78
	const isPostRead = React.useCallback(
79
		(postId: string) => {
80
			return allReadStatuses.some((status) => status.postId === postId);
81
		},
82
		[allReadStatuses],
83
	);
84
85
	// Determine which posts to show based on selection
86
	const feedPosts = React.useMemo(() => {
87
		if (selectedFeedId === "unread") {
88
			// Show only unread posts from all feeds
89
			return allPosts.filter((post) => !isPostRead(post.id));
90
		} else if (selectedFeedId) {
91
			// Show posts from specific feed
92
			return feedPostsQuery;
93
		} else {
94
			// Show all posts
95
			return allPosts;
96
		}
97
	}, [selectedFeedId, allPosts, feedPostsQuery, isPostRead]);
98
99
	// Filter and sort posts by search query and date
100
	const filteredPosts = React.useMemo(() => {
101
		const filtered = searchQuery
102
			? feedPosts.filter((post) =>
103
					post.title?.toLowerCase().includes(searchQuery.toLowerCase()),
104
				)
105
			: feedPosts;
106
107
		// Sort by publishedDate (most recent first)
108
		return [...filtered].sort((a, b) => {
109
			// Handle null dates - put them at the end
110
			if (!a.publishedDate) return 1;
111
			if (!b.publishedDate) return -1;
112
			// Most recent first (descending order)
113
			return b.publishedDate.localeCompare(a.publishedDate);
114
		});
115
	}, [feedPosts, searchQuery]);
116
117
	// Handle feed selection - on mobile, navigate to posts view
118
	const handleFeedSelect = React.useCallback(
119
		(feedId: string | null) => {
120
			onFeedSelect(feedId);
121
			// Navigate to posts view on mobile for any feed selection (including "All Feeds")
122
			if (isMobile) {
123
				setMobileView("posts");
124
			}
125
		},
126
		[onFeedSelect, isMobile],
127
	);
128
129
	// Handle back to feeds on mobile
130
	const handleBackToFeeds = React.useCallback(() => {
131
		setMobileView("feeds");
132
		onFeedSelect(null);
133
	}, [onFeedSelect]);
134
135
	// Handle post selection and mark as read
136
	const handlePostSelect = React.useCallback(
137
		(postId: string) => {
138
			// Mark as read
139
			const existingStatus = allReadStatusesWithUnread.find(
140
				(status) => status.postId === postId,
141
			);
142
			const post = feedPosts.find((p) => p.id === postId);
143
144
			if (existingStatus) {
145
				// Update existing status to read
146
				const updateResult = evolu.update("readStatus", {
147
					id: existingStatus.id as any,
148
					isRead: 1,
149
				});
150
				if (!updateResult.ok) {
151
					console.warn(
152
						"Failed to update read status:",
153
						formatTypeError(updateResult.error),
154
					);
155
				}
156
			} else if (post && post.feedId) {
157
				// Create new read status
158
				const insertResult = evolu.insert("readStatus", {
159
					postId: postId,
160
					feedId: post.feedId,
161
					isRead: 1,
162
				});
163
				if (!insertResult.ok) {
164
					console.warn(
165
						"Failed to insert read status:",
166
						formatTypeError(insertResult.error),
167
					);
168
				}
169
			}
170
171
			// Call the original onPostSelect
172
			onPostSelect(postId);
173
174
			// On mobile, close the sidebar after selecting a post
175
			// Keep the current view (posts) so user can continue where they left off
176
			if (isMobile) {
177
				setOpenMobile(false);
178
			}
179
		},
180
		[
181
			allReadStatusesWithUnread,
182
			feedPosts,
183
			evolu,
184
			onPostSelect,
185
			isMobile,
186
			setOpenMobile,
187
		],
188
	);
189
190
	// Mark all visible posts as read
191
	const handleMarkAllAsRead = React.useCallback(() => {
192
		let markedCount = 0;
193
		filteredPosts.forEach((post) => {
194
			const existingStatus = allReadStatusesWithUnread.find(
195
				(status) => status.postId === post.id,
196
			);
197
198
			if (existingStatus && !existingStatus.isRead) {
199
				// Update existing status to read
200
				const updateResult = evolu.update("readStatus", {
201
					id: existingStatus.id as any,
202
					isRead: 1,
203
				});
204
				if (updateResult.ok) {
205
					markedCount++;
206
				}
207
			} else if (!existingStatus && post.feedId) {
208
				// Create new read status
209
				const insertResult = evolu.insert("readStatus", {
210
					postId: post.id,
211
					feedId: post.feedId,
212
					isRead: 1,
213
				});
214
				if (insertResult.ok) {
215
					markedCount++;
216
				}
217
			}
218
		});
219
		toast.success(
220
			`Marked ${markedCount} post${markedCount !== 1 ? "s" : ""} as read`,
221
		);
222
	}, [filteredPosts, allReadStatusesWithUnread, evolu]);
223
224
	// Mark all visible posts as unread
225
	const handleMarkAllAsUnread = React.useCallback(() => {
226
		let unmarkedCount = 0;
227
		filteredPosts.forEach((post) => {
228
			const existingStatus = allReadStatusesWithUnread.find(
229
				(status) => status.postId === post.id,
230
			);
231
232
			if (existingStatus && existingStatus.isRead) {
233
				// Update existing status to unread
234
				const updateResult = evolu.update("readStatus", {
235
					id: existingStatus.id as any,
236
					isRead: 0,
237
				});
238
				if (updateResult.ok) {
239
					unmarkedCount++;
240
				}
241
			} else if (!existingStatus && post.feedId) {
242
				// Create new unread status
243
				const insertResult = evolu.insert("readStatus", {
244
					postId: post.id,
245
					feedId: post.feedId,
246
					isRead: 0,
247
				});
248
				if (insertResult.ok) {
249
					unmarkedCount++;
250
				}
251
			}
252
		});
253
		toast.success(
254
			`Marked ${unmarkedCount} post${unmarkedCount !== 1 ? "s" : ""} as unread`,
255
		);
256
	}, [filteredPosts, allReadStatusesWithUnread, evolu]);
257
258
	// Delete feed (soft delete using isDeleted flag)
259
	const handleDeleteFeed = React.useCallback(() => {
260
		if (!selectedFeedId) return;
261
262
		const feedToDelete = allFeeds.find((f) => f.id === selectedFeedId);
263
		if (!feedToDelete) return;
264
265
		// Soft delete the feed (CRDT-friendly, preserves sync history)
266
		evoluInstance.update("rssFeed", {
267
			id: selectedFeedId as any,
268
			isDeleted: Evolu.sqliteTrue,
269
		});
270
271
		// Also soft delete all posts associated with this feed
272
		const postsToDelete = allPosts.filter((p) => p.feedId === selectedFeedId);
273
		postsToDelete.forEach((post) => {
274
			evoluInstance.update("rssPost", {
275
				id: post.id as any,
276
				isDeleted: Evolu.sqliteTrue,
277
			});
278
		});
279
280
		toast.success(
281
			`Deleted feed "${feedToDelete.title}" and ${postsToDelete.length} post${postsToDelete.length !== 1 ? "s" : ""}`,
282
		);
283
284
		// Navigate back to all feeds
285
		onFeedSelect(null);
286
	}, [selectedFeedId, allFeeds, allPosts, onFeedSelect]);
287
288
	// Delete category (uncategorize all feeds in the category)
289
	const handleDeleteCategory = React.useCallback(() => {
290
		const selectedFeed = allFeeds.find((f) => f.id === selectedFeedId);
291
		if (!selectedFeed || !selectedFeed.category) return;
292
293
		const categoryToDelete = selectedFeed.category;
294
295
		// Find all feeds in this category
296
		const feedsInCategory = allFeeds.filter(
297
			(f) => f.category === categoryToDelete,
298
		);
299
300
		// Set category to null for all feeds in this category
301
		feedsInCategory.forEach((feed) => {
302
			evoluInstance.update("rssFeed", {
303
				id: feed.id as any,
304
				category: null,
305
			});
306
		});
307
308
		toast.success(
309
			`Removed category "${categoryToDelete}" from ${feedsInCategory.length} feed${feedsInCategory.length !== 1 ? "s" : ""}`,
310
		);
311
312
		// Navigate back to all feeds
313
		onFeedSelect(null);
314
	}, [selectedFeedId, allFeeds, onFeedSelect]);
315
316
	// Handle category edit from context menu
317
	const handleCategoryEdit = React.useCallback((category: string) => {
318
		setCategoryToEdit(category);
319
		setCategoryEditDialogOpen(true);
320
	}, []);
321
322
	// Handle category delete from context menu
323
	const handleCategoryDelete = React.useCallback(
324
		(category: string) => {
325
			// Find all feeds in this category
326
			const feedsInCategory = allFeeds.filter((f) => f.category === category);
327
328
			// Set category to null for all feeds in this category
329
			feedsInCategory.forEach((feed) => {
330
				evoluInstance.update("rssFeed", {
331
					id: feed.id as any,
332
					category: null,
333
				});
334
			});
335
336
			toast.success(
337
				`Removed category "${category}" from ${feedsInCategory.length} feed${feedsInCategory.length !== 1 ? "s" : ""}`,
338
			);
339
340
			// Navigate back to all feeds
341
			onFeedSelect(null);
342
		},
343
		[allFeeds, onFeedSelect],
344
	);
345
346
	// Handle category rename
347
	const handleCategoryRename = React.useCallback(
348
		(newName: string) => {
349
			if (!categoryToEdit) return;
350
351
			// Find all feeds in the old category
352
			const feedsInCategory = allFeeds.filter(
353
				(f) => f.category === categoryToEdit,
354
			);
355
356
			// Update category for all feeds
357
			feedsInCategory.forEach((feed) => {
358
				evoluInstance.update("rssFeed", {
359
					id: feed.id as any,
360
					category: newName as any,
361
				});
362
			});
363
364
			toast.success(
365
				`Renamed category "${categoryToEdit}" to "${newName}" for ${feedsInCategory.length} feed${feedsInCategory.length !== 1 ? "s" : ""}`,
366
			);
367
		},
368
		[categoryToEdit, allFeeds],
369
	);
370
371
	// Handle change category for a specific feed
372
	const handleChangeCategory = React.useCallback(() => {
373
		const selectedFeed = allFeeds.find((f) => f.id === selectedFeedId);
374
		if (!selectedFeed) return;
375
376
		setCategoryToEdit(selectedFeed.category || "");
377
		setChangeCategoryDialogOpen(true);
378
	}, [selectedFeedId, allFeeds]);
379
380
	// Handle feed category change
381
	const handleFeedCategoryChange = React.useCallback(
382
		(newCategory: string | null) => {
383
			if (!selectedFeedId) return;
384
385
			const selectedFeed = allFeeds.find((f) => f.id === selectedFeedId);
386
			if (!selectedFeed) return;
387
388
			evoluInstance.update("rssFeed", {
389
				id: selectedFeedId as any,
390
				category: newCategory as any,
391
			});
392
393
			const categoryText = newCategory ? `"${newCategory}"` : "Uncategorized";
394
			toast.success(`Moved feed to ${categoryText}`);
395
		},
396
		[selectedFeedId, allFeeds],
397
	);
398
399
	const refreshFeeds = React.useCallback(async () => {
400
		if (allFeeds.length === 0) {
401
			toast.error("No feeds to refresh");
402
			return;
403
		}
404
405
		toast.info(
406
			`Refreshing ${allFeeds.length} feed${allFeeds.length !== 1 ? "s" : ""}...`,
407
		);
408
		let totalNewPosts = 0;
409
410
		try {
411
			for (const feed of allFeeds) {
412
				try {
413
					if (!feed.feedUrl) continue;
414
415
					const xmlData = await fetchFeedWithFallback(feed.feedUrl);
416
					const { feedData, posts, isAtom } = parseFeedXml(xmlData);
417
418
					// Get existing posts for this feed to avoid duplicates
419
					const existingPosts = allPosts.filter((p) => p.feedId === feed.id);
420
					const existingLinks = new Set(existingPosts.map((p) => p.link));
421
422
					// Process new posts/entries
423
					let newPostsCount = 0;
424
					for (const post of posts) {
425
						const postLink = extractPostLink(post, isAtom);
426
427
						// Skip if we already have this post
428
						if (existingLinks.has(postLink as any)) {
429
							continue;
430
						}
431
432
						const postResult = evolu.insert("rssPost", {
433
							title: post.title,
434
							author: extractPostAuthor(post, isAtom, feedData.title),
435
							feedTitle: feed.title,
436
							publishedDate: extractPostDate(post),
437
							link: postLink,
438
							feedId: feed.id,
439
							content: extractPostContent(post, postLink),
440
						});
441
						if (postResult.ok) {
442
							newPostsCount++;
443
						}
444
					}
445
446
					totalNewPosts += newPostsCount;
447
448
					// Update feed's dateUpdated
449
					const updateResult = evolu.update("rssFeed", {
450
						id: feed.id as any,
451
						dateUpdated: new Date().toISOString(),
452
					});
453
					if (!updateResult.ok) {
454
						console.warn(
455
							"Failed to update feed dateUpdated:",
456
							formatTypeError(updateResult.error),
457
						);
458
					}
459
				} catch (error) {
460
					console.error(`Error refreshing feed "${feed.title}":`, error);
461
					// Continue with other feeds even if one fails
462
				}
463
			}
464
465
			if (totalNewPosts > 0) {
466
				toast.success(
467
					`Refreshed feeds and found ${totalNewPosts} new post${totalNewPosts !== 1 ? "s" : ""}`,
468
				);
469
			} else {
470
				toast.success("All feeds up to date");
471
			}
472
		} catch (error) {
473
			console.error("Error refreshing feeds:", error);
474
			toast.error("Failed to refresh feeds");
475
		}
476
	}, [allFeeds, allPosts, evolu]);
477
478
	// Run refresh on component mount (only once, even in strict mode)
479
	React.useEffect(() => {
480
		if (!hasRefreshedOnMount.current) {
481
			hasRefreshedOnMount.current = true;
482
			refreshFeeds();
483
		}
484
		// eslint-disable-next-line react-hooks/exhaustive-deps
485
	}, []); // Only run once on mount
486
487
	const selectedFeed =
488
		selectedFeedId && selectedFeedId !== "unread"
489
			? allFeeds.find((f) => f.id === selectedFeedId)
490
			: null;
491
	const selectedFeedTitle =
492
		selectedFeedId === "unread" ? "Unread" : selectedFeed?.title || "All Posts";
493
	const selectedFeedCategory = selectedFeed?.category || null;
494
495
	// Get list of existing categories for the change category dialog
496
	const existingCategories = React.useMemo(() => {
497
		const categories = new Set<string>();
498
		allFeeds.forEach((feed) => {
499
			if (feed.category) {
500
				categories.add(feed.category);
501
			}
502
		});
503
		return Array.from(categories).sort();
504
	}, [allFeeds]);
505
506
	return (
507
		<>
508
			<AddFeedDialog open={dialogOpen} onOpenChange={setDialogOpen} />
509
			<CategoryEditDialog
510
				open={categoryEditDialogOpen}
511
				onOpenChange={setCategoryEditDialogOpen}
512
				currentCategory={categoryToEdit}
513
				onRename={handleCategoryRename}
514
			/>
515
			<ChangeCategoryDialog
516
				open={changeCategoryDialogOpen}
517
				onOpenChange={setChangeCategoryDialogOpen}
518
				currentCategory={categoryToEdit}
519
				existingCategories={existingCategories}
520
				onChangeCategory={handleFeedCategoryChange}
521
			/>
522
523
			<Sidebar collapsible="offcanvas" {...props}>
524
				<SidebarHeader>
525
					<SidebarMenu>
526
						<SidebarMenuItem>
527
							{isMobile && mobileView === "posts" ? (
528
								<MobilePostsHeader
529
									feedTitle={selectedFeedTitle}
530
									onBack={handleBackToFeeds}
531
								/>
532
							) : (
533
								<a href="#">
534
									<div className="grid flex-1 text-left text-xl px-2 pt-2">
535
										<span className="truncate font-bold">Alcove</span>
536
									</div>
537
								</a>
538
							)}
539
						</SidebarMenuItem>
540
					</SidebarMenu>
541
				</SidebarHeader>
542
				<SidebarContent>
543
					{isMobile && mobileView === "posts" ? (
544
						// Mobile posts view
545
						<div className="flex flex-col h-full">
546
							<PostsList
547
								posts={filteredPosts}
548
								selectedPostId={selectedPostId}
549
								onPostSelect={handlePostSelect}
550
								searchQuery={searchQuery}
551
								onSearchChange={setSearchQuery}
552
								feedTitle={`${filteredPosts.length} post${filteredPosts.length !== 1 ? "s" : ""}`}
553
								isPostRead={isPostRead}
554
								onMarkAllAsRead={handleMarkAllAsRead}
555
								onMarkAllAsUnread={handleMarkAllAsUnread}
556
								onDeleteFeed={handleDeleteFeed}
557
								onDeleteCategory={handleDeleteCategory}
558
								onChangeCategory={handleChangeCategory}
559
								selectedFeedId={selectedFeedId}
560
								selectedFeedCategory={selectedFeedCategory}
561
								className="border-0"
562
							/>
563
						</div>
564
					) : (
565
						// Feeds view (desktop and mobile default)
566
						<>
567
							<FeedActions
568
								onAddFeed={() => setDialogOpen(true)}
569
								onRefresh={refreshFeeds}
570
							/>
571
							<NavFeeds
572
								feeds={allFeeds}
573
								selectedFeedId={selectedFeedId}
574
								onFeedSelect={handleFeedSelect}
575
								onCategoryEdit={handleCategoryEdit}
576
								onCategoryDelete={handleCategoryDelete}
577
							/>
578
						</>
579
					)}
580
				</SidebarContent>
581
				<SidebarFooter>
582
					{!isMobile || mobileView === "feeds" ? <NavUser /> : null}
583
				</SidebarFooter>
584
			</Sidebar>
585
586
			{/* Posts List Panel - Separate from main sidebar (Desktop only) */}
587
			<PostsList
588
				posts={filteredPosts}
589
				selectedPostId={selectedPostId}
590
				onPostSelect={handlePostSelect}
591
				searchQuery={searchQuery}
592
				onSearchChange={setSearchQuery}
593
				feedTitle={selectedFeedTitle}
594
				isPostRead={isPostRead}
595
				onMarkAllAsRead={handleMarkAllAsRead}
596
				onMarkAllAsUnread={handleMarkAllAsUnread}
597
				onDeleteFeed={handleDeleteFeed}
598
				onDeleteCategory={handleDeleteCategory}
599
				onChangeCategory={handleChangeCategory}
600
				selectedFeedId={selectedFeedId}
601
				selectedFeedCategory={selectedFeedCategory}
602
				className={`bg-sidebar text-sidebar-foreground hidden md:flex ${hidden ? "w-0 min-w-0 border-0 overflow-hidden" : "w-[320px] overflow-y-auto"}`}
603
			/>
604
		</>
605
	);
606
}