src/components/dashboard.tsx 13.2 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
41
	const evolu = useEvolu();
42
	const allFeeds = useQuery(allFeedsQuery);
43
	const allPosts = useQuery(allPostsQuery);
44
	const feedPostsQuery = useQuery(postsByFeedQuery(selectedFeedId || ""));
45
	const allReadStatuses = useQuery(allReadStatusesQuery);
46
	const allReadStatusesWithUnread = useQuery(allReadStatusesWithUnreadQuery);
47
	console.log(allPosts);
48
49
	// Check if a post is read
50
	const isPostRead = React.useCallback(
51
		(postId: string) => {
52
			return allReadStatuses.some((status) => status.postId === postId);
53
		},
54
		[allReadStatuses],
55
	);
56
57
	// Get the first post (most recent) to use as default
58
	const firstPostId = React.useMemo(() => {
59
		if (allPosts.length === 0) return null;
60
61
		const sortedPosts = [...allPosts].sort((a, b) => {
62
			if (!a.publishedDate) return 1;
63
			if (!b.publishedDate) return -1;
64
			return b.publishedDate.localeCompare(a.publishedDate);
65
		});
66
67
		return sortedPosts[0]?.id || null;
68
	}, [allPosts]);
69
70
	const [selectedPostId, setSelectedPostId] = React.useState<string | null>(
71
		firstPostId,
72
	);
73
74
	const selectedFeed = selectedFeedId
75
		? allFeeds.find((f) => f.id === selectedFeedId)
76
		: null;
77
78
	const selectedPost = selectedPostId
79
		? allPosts.find((p) => p.id === selectedPostId)
80
		: null;
81
82
	// Get the feed for the selected post to check if it's YouTube
83
	const selectedPostFeed = React.useMemo(() => {
84
		if (!selectedPost?.feedId) return null;
85
		return allFeeds.find((f) => f.id === selectedPost.feedId);
86
	}, [selectedPost?.feedId, allFeeds]);
87
88
	// Get sorted posts for navigation
89
	const sortedPosts = React.useMemo(() => {
90
		// Filter posts based on selected feed
91
		let postsToSort = allPosts;
92
		if (selectedFeedId === "unread") {
93
			// Show only unread posts from all feeds
94
			postsToSort = allPosts.filter((post) => !isPostRead(post.id));
95
		} else if (selectedFeedId) {
96
			// Show posts from specific feed
97
			postsToSort = feedPostsQuery;
98
		}
99
		// Sort by published date (most recent first)
100
		return [...postsToSort].sort((a, b) => {
101
			if (!a.publishedDate) return 1;
102
			if (!b.publishedDate) return -1;
103
			return b.publishedDate.localeCompare(a.publishedDate);
104
		});
105
	}, [allPosts, selectedFeedId, feedPostsQuery, isPostRead]);
106
107
	// Get current post index and navigation info
108
	const currentPostIndex = React.useMemo(() => {
109
		if (!selectedPostId) return -1;
110
		return sortedPosts.findIndex((p) => p.id === selectedPostId);
111
	}, [selectedPostId, sortedPosts]);
112
113
	const hasPreviousPost = currentPostIndex > 0;
114
	const hasNextPost =
115
		currentPostIndex >= 0 && currentPostIndex < sortedPosts.length - 1;
116
117
	// Navigation handlers
118
	const goToPreviousPost = React.useCallback(() => {
119
		if (hasPreviousPost) {
120
			setSelectedPostId(sortedPosts[currentPostIndex - 1].id);
121
		}
122
	}, [hasPreviousPost, sortedPosts, currentPostIndex]);
123
124
	const goToNextPost = React.useCallback(() => {
125
		if (hasNextPost) {
126
			setSelectedPostId(sortedPosts[currentPostIndex + 1].id);
127
		}
128
	}, [hasNextPost, sortedPosts, currentPostIndex]);
129
130
	// Check if current post is read
131
	const isCurrentPostRead = React.useMemo(() => {
132
		if (!selectedPostId) return false;
133
		return allReadStatuses.some((status) => status.postId === selectedPostId);
134
	}, [selectedPostId, allReadStatuses]);
135
136
	// Toggle read/unread status for current post
137
	const toggleReadStatus = React.useCallback(() => {
138
		if (!selectedPostId || !selectedPost) return;
139
140
		const existingStatus = allReadStatusesWithUnread.find(
141
			(status) => status.postId === selectedPostId,
142
		);
143
144
		if (existingStatus) {
145
			// Update existing status
146
			const newReadStatus = existingStatus.isRead ? 0 : 1;
147
			evolu.update("readStatus", {
148
				id: existingStatus.id as any,
149
				isRead: newReadStatus,
150
			});
151
			toast.success(newReadStatus ? "Marked as read" : "Marked as unread");
152
		} else if (selectedPost.feedId) {
153
			// Create new read status (mark as read)
154
			evolu.insert("readStatus", {
155
				postId: selectedPostId,
156
				feedId: selectedPost.feedId,
157
				isRead: 1,
158
			});
159
			toast.success("Marked as read");
160
		}
161
	}, [selectedPostId, selectedPost, allReadStatusesWithUnread, evolu]);
162
163
	// Scroll to top when a new post is selected
164
	React.useEffect(() => {
165
		if (selectedPostId && mainContentRef.current) {
166
			mainContentRef.current.scrollTo({ top: 0, behavior: "smooth" });
167
		}
168
	}, [selectedPostId]);
169
170
	// Mark post as read when selected
171
	React.useEffect(() => {
172
		if (!selectedPostId || !selectedPost) return;
173
174
		const existingStatus = allReadStatusesWithUnread.find(
175
			(status) => status.postId === selectedPostId,
176
		);
177
178
		if (existingStatus && existingStatus.isRead === 0) {
179
			// Update existing status to read
180
			evolu.update("readStatus", {
181
				id: existingStatus.id as any,
182
				isRead: 1,
183
			});
184
		} else if (!existingStatus && selectedPost.feedId) {
185
			// Create new read status
186
			evolu.insert("readStatus", {
187
				postId: selectedPostId,
188
				feedId: selectedPost.feedId,
189
				isRead: 1,
190
			});
191
		}
192
	}, [selectedPostId, selectedPost, allReadStatusesWithUnread, evolu]);
193
194
	// Keyboard navigation for posts
195
	React.useEffect(() => {
196
		const handleKeyDown = (e: KeyboardEvent) => {
197
			// Only handle arrow keys if not typing in an input/textarea
198
			if (
199
				e.target instanceof HTMLInputElement ||
200
				e.target instanceof HTMLTextAreaElement
201
			) {
202
				return;
203
			}
204
205
			if (e.key === "ArrowUp") {
206
				e.preventDefault();
207
				goToPreviousPost();
208
			} else if (e.key === "ArrowDown") {
209
				e.preventDefault();
210
				goToNextPost();
211
			}
212
		};
213
214
		window.addEventListener("keydown", handleKeyDown);
215
		return () => window.removeEventListener("keydown", handleKeyDown);
216
	}, [goToPreviousPost, goToNextPost]);
217
218
	// Get base URL from the post link to fix relative image paths
219
	const getBaseUrl = React.useCallback((link: string | null) => {
220
		if (!link) return "";
221
		try {
222
			const url = new URL(link);
223
			return `${url.protocol}//${url.host}`;
224
		} catch {
225
			return "";
226
		}
227
	}, []);
228
229
	// Custom components for ReactMarkdown to fix image URLs
230
	const markdownComponents = React.useMemo(
231
		() => ({
232
			img: ({ src, alt, ...props }: any) => {
233
				let fixedSrc = src;
234
235
				// If src starts with / and we have a base URL from the post link
236
				if (src?.startsWith("/") && selectedPost?.link) {
237
					const baseUrl = getBaseUrl(selectedPost.link);
238
					if (baseUrl) {
239
						fixedSrc = `${baseUrl}${src}`;
240
					}
241
				}
242
243
				return <img src={fixedSrc} alt={alt} {...props} />;
244
			},
245
		}),
246
		[selectedPost?.link, getBaseUrl],
247
	);
248
249
	return (
250
		<main className="min-h-screen w-full">
251
			<SidebarProvider
252
				style={
253
					{
254
						"--sidebar-width": "250px",
255
						"--sidebar-width-icon": "3rem",
256
					} as React.CSSProperties
257
				}
258
			>
259
				<AppSidebar
260
					selectedFeedId={selectedFeedId}
261
					onFeedSelect={setSelectedFeedId}
262
					selectedPostId={selectedPostId}
263
					onPostSelect={setSelectedPostId}
264
				/>
265
				<SidebarInset className="flex flex-col h-screen overflow-hidden">
266
					<header className="bg-background flex shrink-0 items-center gap-2 border-b p-4">
267
						<SidebarTrigger className="-ml-1" />
268
						<Separator
269
							orientation="vertical"
270
							className="mr-2 data-[orientation=vertical]:h-4"
271
						/>
272
						<Breadcrumb>
273
							<BreadcrumbList>
274
								<BreadcrumbItem className="hidden md:block">
275
									<BreadcrumbLink
276
										href="#"
277
										onClick={() => {
278
											setSelectedFeedId(null);
279
											setSelectedPostId(null);
280
										}}
281
									>
282
										All Feeds
283
									</BreadcrumbLink>
284
								</BreadcrumbItem>
285
								{selectedFeed && (
286
									<>
287
										<BreadcrumbSeparator className="hidden md:block" />
288
										<BreadcrumbItem>
289
											<BreadcrumbPage>{selectedFeed.title}</BreadcrumbPage>
290
										</BreadcrumbItem>
291
									</>
292
								)}
293
							</BreadcrumbList>
294
						</Breadcrumb>
295
						{selectedPost && (
296
							<div className="ml-auto flex items-center gap-1">
297
								<Button
298
									variant="ghost"
299
									size="icon"
300
									onClick={goToPreviousPost}
301
									disabled={!hasPreviousPost}
302
									className="h-8 w-8"
303
									title="Previous post"
304
								>
305
									<ChevronUp className="h-4 w-4" />
306
								</Button>
307
								<Button
308
									variant="ghost"
309
									size="icon"
310
									onClick={goToNextPost}
311
									disabled={!hasNextPost}
312
									className="h-8 w-8"
313
									title="Next post"
314
								>
315
									<ChevronDown className="h-4 w-4" />
316
								</Button>
317
							</div>
318
						)}
319
					</header>
320
					<div
321
						ref={mainContentRef}
322
						className="h-full flex flex-1 flex-col gap-4 p-4 pb-12 overflow-y-auto"
323
					>
324
						{selectedPost ? (
325
							<div className="flex flex-col gap-6 max-w-4xl mx-auto w-full pb-8">
326
								<div className="flex flex-col gap-3">
327
									<a
328
										href={selectedPost.link ? selectedPost.link : ""}
329
										target="_blank"
330
										rel="noopener noreferrer"
331
										className="text-3xl font-bold tracking-tight hover:underline"
332
									>
333
										{selectedPost.title}
334
									</a>
335
									<div className="flex items-center gap-4 text-sm text-muted-foreground">
336
										{selectedPost.author && (
337
											<>
338
												{" "}
339
												by
340
												<a
341
													target="_blank"
342
													rel="noopener noreferrer"
343
													className="hover:underline"
344
													href={
345
														selectedPost.author && selectedPost.link
346
															? getBaseUrl(selectedPost.link)
347
															: ""
348
													}
349
												>
350
													{selectedPost.author}
351
												</a>
352
											</>
353
										)}
354
										<div className="flex items-center gap-2">
355
											<Button
356
												variant="outline"
357
												size="sm"
358
												onClick={toggleReadStatus}
359
												className="flex items-center gap-2"
360
											>
361
												{isCurrentPostRead ? (
362
													<Circle className="h-3 w-3" />
363
												) : (
364
													<CircleCheckBig className="h-3 w-3" />
365
												)}
366
											</Button>
367
										</div>
368
									</div>
369
									<span className="text-xs text-muted-foreground">
370
										{selectedPost.publishedDate
371
											? new Date(selectedPost.publishedDate).toLocaleDateString(
372
													"en-US",
373
													{
374
														year: "numeric",
375
														month: "long",
376
														day: "numeric",
377
													},
378
												)
379
											: ""}
380
									</span>
381
								</div>
382
								<Separator />
383
								{/* YouTube Embed - show if this is a YouTube video */}
384
								{selectedPostFeed &&
385
									isYouTubePost(selectedPostFeed.feedUrl) &&
386
									selectedPost.link &&
387
									(() => {
388
										const videoId = extractYouTubeVideoId(selectedPost.link);
389
										if (videoId) {
390
											return (
391
												<div className="w-full mb-6">
392
													<div
393
														className="relative w-full"
394
														style={{ paddingBottom: "56.25%" }}
395
													>
396
														<iframe
397
															className="absolute top-0 left-0 w-full h-full rounded-lg"
398
															src={`https://www.youtube.com/embed/${videoId}`}
399
															title={selectedPost.title}
400
															allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
401
															allowFullScreen
402
														/>
403
													</div>
404
												</div>
405
											);
406
										}
407
										return null;
408
									})()}
409
								{selectedPost.content ? (
410
									<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">
411
										<ReactMarkdown
412
											key={selectedPost.id}
413
											remarkPlugins={[remarkGfm]}
414
											rehypePlugins={[rehypeRaw, rehypeSanitize]}
415
											components={markdownComponents}
416
										>
417
											{selectedPost.content}
418
										</ReactMarkdown>
419
									</div>
420
								) : (
421
									<p className="text-muted-foreground">
422
										No content available. Click "Open Original" to read the full
423
										article.
424
									</p>
425
								)}
426
							</div>
427
						) : (
428
							<div className="flex flex-col items-center justify-center h-full text-center gap-4">
429
								<div className="text-muted-foreground">
430
									<p className="text-lg font-medium">No post selected</p>
431
									<p className="text-sm">
432
										Select a post from the sidebar to read it here
433
									</p>
434
								</div>
435
							</div>
436
						)}
437
					</div>
438
				</SidebarInset>
439
			</SidebarProvider>
440
		</main>
441
	);
442
}
443
444
export default Dashboard;