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