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