| 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; |