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