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