src/components/dashboard.tsx 15.3 K raw
1
import * as React from "react";
2
import { AppSidebar } from "@/components/app-sidebar";
3
import {
4
	Breadcrumb,
5
	BreadcrumbItem,
6
	BreadcrumbLink,
7
	BreadcrumbList,
8
	BreadcrumbPage,
9
	BreadcrumbSeparator,
10
} from "@/components/ui/breadcrumb";
11
import { Separator } from "@/components/ui/separator";
12
import {
13
	SidebarInset,
14
	SidebarProvider,
15
	SidebarTrigger,
16
} from "@/components/ui/sidebar";
17
import { Button } from "@/components/ui/button";
18
import { Circle, CircleCheckBig, ChevronUp, ChevronDown } from "lucide-react";
19
import { useQuery } from "@evolu/react";
20
import {
21
	allFeedsQuery,
22
	allPostsQuery,
23
	postsByFeedQuery,
24
	allReadStatusesQuery,
25
	allReadStatusesWithUnreadQuery,
26
	useEvolu,
27
} from "@/lib/evolu";
28
import ReactMarkdown from "react-markdown";
29
import rehypeSanitize from "rehype-sanitize";
30
import rehypeRaw from "rehype-raw";
31
import remarkGfm from "remark-gfm";
32
import { toast } from "sonner";
33
import { extractYouTubeVideoId, isYouTubePost } from "@/lib/feed-operations";
34
35
function Dashboard() {
36
	const [selectedFeedId, setSelectedFeedId] = React.useState<string | null>(
37
		null,
38
	);
39
	const mainContentRef = React.useRef<HTMLDivElement>(null);
40
	const hasUserInteracted = React.useRef(false);
41
	const manualToggleTimestamp = React.useRef<number>(0);
42
	const lastAutoReadPostId = React.useRef<string | null>(null);
43
44
	const evolu = useEvolu();
45
	const allFeeds = useQuery(allFeedsQuery);
46
	const allPosts = useQuery(allPostsQuery);
47
	const feedPostsQuery = useQuery(postsByFeedQuery(selectedFeedId || ""));
48
	const allReadStatuses = useQuery(allReadStatusesQuery);
49
	const allReadStatusesWithUnread = useQuery(allReadStatusesWithUnreadQuery);
50
	console.log(allPosts);
51
52
	// Check if a post is read
53
	const isPostRead = React.useCallback(
54
		(postId: string) => {
55
			return allReadStatuses.some((status) => status.postId === postId);
56
		},
57
		[allReadStatuses],
58
	);
59
60
	// Get the first post (most recent) to use as default
61
	const firstPostId = React.useMemo(() => {
62
		if (allPosts.length === 0) return null;
63
64
		const sortedPosts = [...allPosts].sort((a, b) => {
65
			if (!a.publishedDate) return 1;
66
			if (!b.publishedDate) return -1;
67
			return b.publishedDate.localeCompare(a.publishedDate);
68
		});
69
70
		return sortedPosts[0]?.id || null;
71
	}, [allPosts]);
72
73
	const [selectedPostId, setSelectedPostId] = React.useState<string | null>(
74
		firstPostId,
75
	);
76
	const initialPostId = React.useRef(firstPostId);
77
78
	const selectedFeed = selectedFeedId
79
		? allFeeds.find((f) => f.id === selectedFeedId)
80
		: null;
81
82
	const selectedPost = selectedPostId
83
		? allPosts.find((p) => p.id === selectedPostId)
84
		: null;
85
86
	// Get the feed for the selected post to check if it's YouTube
87
	const selectedPostFeed = React.useMemo(() => {
88
		if (!selectedPost?.feedId) return null;
89
		return allFeeds.find((f) => f.id === selectedPost.feedId);
90
	}, [selectedPost?.feedId, allFeeds]);
91
92
	// Get sorted posts for navigation
93
	const sortedPosts = React.useMemo(() => {
94
		// Filter posts based on selected feed
95
		let postsToSort = allPosts;
96
		if (selectedFeedId === "unread") {
97
			// Show only unread posts from all feeds
98
			postsToSort = allPosts.filter((post) => !isPostRead(post.id));
99
		} else if (selectedFeedId) {
100
			// Show posts from specific feed
101
			postsToSort = feedPostsQuery;
102
		}
103
		// Sort by published date (most recent first)
104
		return [...postsToSort].sort((a, b) => {
105
			if (!a.publishedDate) return 1;
106
			if (!b.publishedDate) return -1;
107
			return b.publishedDate.localeCompare(a.publishedDate);
108
		});
109
	}, [allPosts, selectedFeedId, feedPostsQuery, isPostRead]);
110
111
	// Get current post index and navigation info
112
	const currentPostIndex = React.useMemo(() => {
113
		if (!selectedPostId) return -1;
114
		return sortedPosts.findIndex((p) => p.id === selectedPostId);
115
	}, [selectedPostId, sortedPosts]);
116
117
	const hasPreviousPost = currentPostIndex > 0;
118
	const hasNextPost =
119
		currentPostIndex >= 0 && currentPostIndex < sortedPosts.length - 1;
120
121
	// Navigation handlers
122
	const goToPreviousPost = React.useCallback(() => {
123
		if (hasPreviousPost) {
124
			setSelectedPostId(sortedPosts[currentPostIndex - 1].id);
125
		}
126
	}, [hasPreviousPost, sortedPosts, currentPostIndex]);
127
128
	const goToNextPost = React.useCallback(() => {
129
		if (hasNextPost) {
130
			setSelectedPostId(sortedPosts[currentPostIndex + 1].id);
131
		}
132
	}, [hasNextPost, sortedPosts, currentPostIndex]);
133
134
	// Check if current post is read
135
	const isCurrentPostRead = React.useMemo(() => {
136
		if (!selectedPostId) return false;
137
		return allReadStatuses.some((status) => status.postId === selectedPostId);
138
	}, [selectedPostId, allReadStatuses]);
139
140
	// Toggle read/unread status for current post
141
	const toggleReadStatus = React.useCallback(() => {
142
		if (!selectedPostId || !selectedPost) return;
143
144
		// Record timestamp of manual toggle to prevent auto-read from overriding
145
		manualToggleTimestamp.current = Date.now();
146
147
		// Reset the auto-read tracking so if user marks as unread, it won't auto-mark again
148
		if (lastAutoReadPostId.current === selectedPostId) {
149
			lastAutoReadPostId.current = null;
150
		}
151
152
		const existingStatus = allReadStatusesWithUnread.find(
153
			(status) => status.postId === selectedPostId,
154
		);
155
156
		if (existingStatus) {
157
			// Update existing status
158
			const newReadStatus = existingStatus.isRead ? 0 : 1;
159
			evolu.update("readStatus", {
160
				id: existingStatus.id as any,
161
				isRead: newReadStatus,
162
			});
163
			toast.success(newReadStatus ? "Marked as read" : "Marked as unread");
164
		} else if (selectedPost.feedId) {
165
			// Create new read status (mark as read)
166
			evolu.insert("readStatus", {
167
				postId: selectedPostId,
168
				feedId: selectedPost.feedId,
169
				isRead: 1,
170
			});
171
			toast.success("Marked as read");
172
		}
173
	}, [selectedPostId, selectedPost, allReadStatusesWithUnread, evolu]);
174
175
	// Scroll to top when a new post is selected
176
	React.useEffect(() => {
177
		if (selectedPostId && mainContentRef.current) {
178
			mainContentRef.current.scrollTo({ top: 0, behavior: "smooth" });
179
		}
180
	}, [selectedPostId]);
181
182
	// Mark post as read when selected (with debounce to allow arrow key navigation)
183
	React.useEffect(() => {
184
		if (!selectedPostId || !selectedPost) return;
185
186
		// Don't auto-mark the initial post that was selected on mount
187
		// This allows users to mark the first post as unread without it auto-marking as read
188
		if (
189
			selectedPostId === initialPostId.current &&
190
			!hasUserInteracted.current
191
		) {
192
			hasUserInteracted.current = true;
193
			return;
194
		}
195
196
		// Don't even start the timeout if user manually toggled recently
197
		// This prevents the effect from marking posts as read after bulk operations
198
		const timeSinceManualToggle = Date.now() - manualToggleTimestamp.current;
199
		if (timeSinceManualToggle < 3000) {
200
			return;
201
		}
202
203
		// Don't auto-mark if we already processed this post
204
		// This prevents duplicate auto-reads when dependencies update
205
		if (lastAutoReadPostId.current === selectedPostId) {
206
			return;
207
		}
208
209
		// Debounce the auto-mark as read by 1.5 seconds
210
		// This allows users to navigate with arrow keys without marking everything as read
211
		const timeoutId = setTimeout(() => {
212
			// Double-check timestamp hasn't changed during the delay
213
			// This catches bulk operations that happen during the delay period
214
			const timeSinceManualToggle = Date.now() - manualToggleTimestamp.current;
215
			if (timeSinceManualToggle < 3000) {
216
				return;
217
			}
218
219
			const existingStatus = allReadStatusesWithUnread.find(
220
				(status) => status.postId === selectedPostId,
221
			);
222
223
			if (existingStatus && existingStatus.isRead === 0) {
224
				// Update existing status to read
225
				evolu.update("readStatus", {
226
					id: existingStatus.id as any,
227
					isRead: 1,
228
				});
229
				lastAutoReadPostId.current = selectedPostId;
230
			} else if (!existingStatus && selectedPost.feedId) {
231
				// Create new read status
232
				evolu.insert("readStatus", {
233
					postId: selectedPostId,
234
					feedId: selectedPost.feedId,
235
					isRead: 1,
236
				});
237
				lastAutoReadPostId.current = selectedPostId;
238
			}
239
		}, 1500); // 1.5 second delay
240
241
		// Cleanup timeout if user navigates away before the delay
242
		return () => clearTimeout(timeoutId);
243
	}, [selectedPostId, selectedPost, allReadStatusesWithUnread, evolu]);
244
245
	// Keyboard navigation for posts
246
	React.useEffect(() => {
247
		const handleKeyDown = (e: KeyboardEvent) => {
248
			// Only handle arrow keys if not typing in an input/textarea
249
			if (
250
				e.target instanceof HTMLInputElement ||
251
				e.target instanceof HTMLTextAreaElement
252
			) {
253
				return;
254
			}
255
256
			if (e.key === "ArrowUp") {
257
				e.preventDefault();
258
				goToPreviousPost();
259
			} else if (e.key === "ArrowDown") {
260
				e.preventDefault();
261
				goToNextPost();
262
			}
263
		};
264
265
		window.addEventListener("keydown", handleKeyDown);
266
		return () => window.removeEventListener("keydown", handleKeyDown);
267
	}, [goToPreviousPost, goToNextPost]);
268
269
	// Get base URL from the post link to fix relative image paths
270
	const getBaseUrl = React.useCallback((link: string | null) => {
271
		if (!link) return "";
272
		try {
273
			const url = new URL(link);
274
			return `${url.protocol}//${url.host}`;
275
		} catch {
276
			return "";
277
		}
278
	}, []);
279
280
	// Custom components for ReactMarkdown to fix image URLs
281
	const markdownComponents = React.useMemo(
282
		() => ({
283
			img: ({ src, alt, ...props }: any) => {
284
				let fixedSrc = src;
285
286
				// If src starts with / and we have a base URL from the post link
287
				if (src?.startsWith("/") && selectedPost?.link) {
288
					const baseUrl = getBaseUrl(selectedPost.link);
289
					if (baseUrl) {
290
						fixedSrc = `${baseUrl}${src}`;
291
					}
292
				}
293
294
				return <img src={fixedSrc} alt={alt} {...props} />;
295
			},
296
		}),
297
		[selectedPost?.link, getBaseUrl],
298
	);
299
300
	return (
301
		<main className="min-h-screen w-full">
302
			<SidebarProvider
303
				style={
304
					{
305
						"--sidebar-width": "250px",
306
						"--sidebar-width-icon": "3rem",
307
					} as React.CSSProperties
308
				}
309
			>
310
				<AppSidebar
311
					selectedFeedId={selectedFeedId}
312
					onFeedSelect={setSelectedFeedId}
313
					selectedPostId={selectedPostId}
314
					onPostSelect={setSelectedPostId}
315
					onBulkStatusChange={() => {
316
						manualToggleTimestamp.current = Date.now();
317
					}}
318
				/>
319
				<SidebarInset className="flex flex-col h-screen overflow-hidden">
320
					<header className="bg-background flex shrink-0 items-center gap-2 border-b p-4">
321
						<SidebarTrigger className="-ml-1" />
322
						<Separator
323
							orientation="vertical"
324
							className="mr-2 data-[orientation=vertical]:h-4"
325
						/>
326
						<Breadcrumb>
327
							<BreadcrumbList>
328
								<BreadcrumbItem className="hidden md:block">
329
									<BreadcrumbLink
330
										href="#"
331
										onClick={() => {
332
											setSelectedFeedId(null);
333
											setSelectedPostId(null);
334
										}}
335
									>
336
										All Feeds
337
									</BreadcrumbLink>
338
								</BreadcrumbItem>
339
								{selectedFeed && (
340
									<>
341
										<BreadcrumbSeparator className="hidden md:block" />
342
										<BreadcrumbItem>
343
											<BreadcrumbPage>{selectedFeed.title}</BreadcrumbPage>
344
										</BreadcrumbItem>
345
									</>
346
								)}
347
							</BreadcrumbList>
348
						</Breadcrumb>
349
						{selectedPost && (
350
							<div className="ml-auto flex items-center gap-1">
351
								<Button
352
									variant="ghost"
353
									size="icon"
354
									onClick={goToPreviousPost}
355
									disabled={!hasPreviousPost}
356
									className="h-8 w-8"
357
									title="Previous post"
358
								>
359
									<ChevronUp className="h-4 w-4" />
360
								</Button>
361
								<Button
362
									variant="ghost"
363
									size="icon"
364
									onClick={goToNextPost}
365
									disabled={!hasNextPost}
366
									className="h-8 w-8"
367
									title="Next post"
368
								>
369
									<ChevronDown className="h-4 w-4" />
370
								</Button>
371
							</div>
372
						)}
373
					</header>
374
					<div
375
						ref={mainContentRef}
376
						className="h-full flex flex-1 flex-col gap-4 p-4 pb-12 overflow-y-auto"
377
					>
378
						{selectedPost ? (
379
							<div className="flex flex-col gap-6 max-w-4xl mx-auto w-full pb-8">
380
								<div className="flex flex-col gap-3">
381
									<a
382
										href={selectedPost.link ? selectedPost.link : ""}
383
										target="_blank"
384
										rel="noopener noreferrer"
385
										className="text-3xl font-bold tracking-tight hover:underline"
386
									>
387
										{selectedPost.title}
388
									</a>
389
									<div className="flex items-center gap-4 text-sm text-muted-foreground">
390
										{selectedPost.author && (
391
											<>
392
												{" "}
393
												by
394
												<a
395
													target="_blank"
396
													rel="noopener noreferrer"
397
													className="hover:underline"
398
													href={
399
														selectedPost.author && selectedPost.link
400
															? getBaseUrl(selectedPost.link)
401
															: ""
402
													}
403
												>
404
													{selectedPost.author}
405
												</a>
406
											</>
407
										)}
408
										<div className="flex items-center gap-2">
409
											<Button
410
												variant="outline"
411
												size="sm"
412
												onClick={toggleReadStatus}
413
												className="flex items-center gap-2"
414
											>
415
												{isCurrentPostRead ? (
416
													<Circle className="h-3 w-3" />
417
												) : (
418
													<CircleCheckBig className="h-3 w-3" />
419
												)}
420
											</Button>
421
										</div>
422
									</div>
423
									<span className="text-xs text-muted-foreground">
424
										{selectedPost.publishedDate
425
											? new Date(selectedPost.publishedDate).toLocaleDateString(
426
													"en-US",
427
													{
428
														year: "numeric",
429
														month: "long",
430
														day: "numeric",
431
													},
432
												)
433
											: ""}
434
									</span>
435
								</div>
436
								<Separator />
437
								{/* YouTube Embed - show if this is a YouTube video */}
438
								{selectedPostFeed &&
439
									isYouTubePost(selectedPostFeed.feedUrl) &&
440
									selectedPost.link &&
441
									(() => {
442
										const videoId = extractYouTubeVideoId(selectedPost.link);
443
										if (videoId) {
444
											return (
445
												<div className="w-full mb-6">
446
													<div
447
														className="relative w-full"
448
														style={{ paddingBottom: "56.25%" }}
449
													>
450
														<iframe
451
															className="absolute top-0 left-0 w-full h-full rounded-lg"
452
															src={`https://www.youtube.com/embed/${videoId}`}
453
															title={selectedPost.title}
454
															allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
455
															allowFullScreen
456
														/>
457
													</div>
458
												</div>
459
											);
460
										}
461
										return null;
462
									})()}
463
								{selectedPost.content ? (
464
									<div className="prose prose-gray dark:prose-invert max-w-none prose-headings:text-foreground prose-p:text-foreground prose-a:text-primary prose-strong:text-foreground prose-code:text-foreground prose-pre:bg-muted prose-blockquote:text-muted-foreground prose-li:text-foreground space-y-4">
465
										<ReactMarkdown
466
											key={selectedPost.id}
467
											remarkPlugins={[remarkGfm]}
468
											rehypePlugins={[rehypeRaw, rehypeSanitize]}
469
											components={markdownComponents}
470
										>
471
											{selectedPost.content}
472
										</ReactMarkdown>
473
									</div>
474
								) : (
475
									<p className="text-muted-foreground">
476
										No content available. Click "Open Original" to read the full
477
										article.
478
									</p>
479
								)}
480
							</div>
481
						) : (
482
							<div className="flex flex-col items-center justify-center h-full text-center gap-4">
483
								<div className="text-muted-foreground">
484
									<p className="text-lg font-medium">No post selected</p>
485
									<p className="text-sm">
486
										Select a post from the sidebar to read it here
487
									</p>
488
								</div>
489
							</div>
490
						)}
491
					</div>
492
				</SidebarInset>
493
			</SidebarProvider>
494
		</main>
495
	);
496
}
497
498
export default Dashboard;