| 1 | import * as React from "react"; |
| 2 | import { NavUser } from "@/components/nav-user"; |
| 3 | import { NavFeeds } from "@/components/nav-feeds"; |
| 4 | import { FeedActions } from "@/components/feed-actions"; |
| 5 | import { AddFeedDialog } from "@/components/add-feed-dialog"; |
| 6 | import { PostsList } from "@/components/posts-list"; |
| 7 | import { MobilePostsHeader } from "@/components/mobile-posts-header"; |
| 8 | import { CategoryEditDialog } from "@/components/category-edit-dialog"; |
| 9 | import { ChangeCategoryDialog } from "@/components/change-category-dialog"; |
| 10 | import { |
| 11 | Sidebar, |
| 12 | SidebarContent, |
| 13 | SidebarFooter, |
| 14 | SidebarHeader, |
| 15 | SidebarMenu, |
| 16 | SidebarMenuItem, |
| 17 | useSidebar, |
| 18 | } from "@/components/ui/sidebar"; |
| 19 | import { toast } from "sonner"; |
| 20 | import { |
| 21 | allFeedsQuery, |
| 22 | allPostsQuery, |
| 23 | postsByFeedQuery, |
| 24 | allReadStatusesQuery, |
| 25 | allReadStatusesWithUnreadQuery, |
| 26 | useEvolu, |
| 27 | evolu as evoluInstance, |
| 28 | } from "@/lib/evolu"; |
| 29 | import * as Evolu from "@evolu/common"; |
| 30 | import { useQuery } from "@evolu/react"; |
| 31 | import { |
| 32 | fetchFeedWithFallback, |
| 33 | parseFeedXml, |
| 34 | extractPostLink, |
| 35 | extractPostAuthor, |
| 36 | extractPostContent, |
| 37 | extractPostDate, |
| 38 | } from "@/lib/feed-operations"; |
| 39 | import { formatTypeError } from "@/lib/format-error"; |
| 40 | |
| 41 | interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> { |
| 42 | selectedFeedId?: string | null; |
| 43 | onFeedSelect?: (feedId: string | null) => void; |
| 44 | selectedPostId?: string | null; |
| 45 | onPostSelect?: (postId: string) => void; |
| 46 | } |
| 47 | |
| 48 | export function AppSidebar({ |
| 49 | selectedFeedId = null, |
| 50 | onFeedSelect = () => {}, |
| 51 | selectedPostId = null, |
| 52 | onPostSelect = () => {}, |
| 53 | ...props |
| 54 | }: AppSidebarProps) { |
| 55 | const [dialogOpen, setDialogOpen] = React.useState(false); |
| 56 | const [categoryEditDialogOpen, setCategoryEditDialogOpen] = |
| 57 | React.useState(false); |
| 58 | const [changeCategoryDialogOpen, setChangeCategoryDialogOpen] = |
| 59 | React.useState(false); |
| 60 | const [categoryToEdit, setCategoryToEdit] = React.useState(""); |
| 61 | const [searchQuery, setSearchQuery] = React.useState(""); |
| 62 | const [mobileView, setMobileView] = React.useState<"feeds" | "posts">( |
| 63 | "feeds", |
| 64 | ); |
| 65 | const hasRefreshedOnMount = React.useRef(false); |
| 66 | |
| 67 | const { hidden, isMobile, setOpenMobile } = useSidebar(); |
| 68 | const evolu = useEvolu(); |
| 69 | const allFeeds = useQuery(allFeedsQuery); |
| 70 | const allReadStatuses = useQuery(allReadStatusesQuery); |
| 71 | const allReadStatusesWithUnread = useQuery(allReadStatusesWithUnreadQuery); |
| 72 | |
| 73 | // Get posts based on selected feed |
| 74 | const allPosts = useQuery(allPostsQuery); |
| 75 | const feedPostsQuery = useQuery(postsByFeedQuery(selectedFeedId || "")); |
| 76 | |
| 77 | // Check if a post is read |
| 78 | const isPostRead = React.useCallback( |
| 79 | (postId: string) => { |
| 80 | return allReadStatuses.some((status) => status.postId === postId); |
| 81 | }, |
| 82 | [allReadStatuses], |
| 83 | ); |
| 84 | |
| 85 | // Determine which posts to show based on selection |
| 86 | const feedPosts = React.useMemo(() => { |
| 87 | if (selectedFeedId === "unread") { |
| 88 | // Show only unread posts from all feeds |
| 89 | return allPosts.filter((post) => !isPostRead(post.id)); |
| 90 | } else if (selectedFeedId) { |
| 91 | // Show posts from specific feed |
| 92 | return feedPostsQuery; |
| 93 | } else { |
| 94 | // Show all posts |
| 95 | return allPosts; |
| 96 | } |
| 97 | }, [selectedFeedId, allPosts, feedPostsQuery, isPostRead]); |
| 98 | |
| 99 | // Filter and sort posts by search query and date |
| 100 | const filteredPosts = React.useMemo(() => { |
| 101 | const filtered = searchQuery |
| 102 | ? feedPosts.filter((post) => |
| 103 | post.title?.toLowerCase().includes(searchQuery.toLowerCase()), |
| 104 | ) |
| 105 | : feedPosts; |
| 106 | |
| 107 | // Sort by publishedDate (most recent first) |
| 108 | return [...filtered].sort((a, b) => { |
| 109 | // Handle null dates - put them at the end |
| 110 | if (!a.publishedDate) return 1; |
| 111 | if (!b.publishedDate) return -1; |
| 112 | // Most recent first (descending order) |
| 113 | return b.publishedDate.localeCompare(a.publishedDate); |
| 114 | }); |
| 115 | }, [feedPosts, searchQuery]); |
| 116 | |
| 117 | // Handle feed selection - on mobile, navigate to posts view |
| 118 | const handleFeedSelect = React.useCallback( |
| 119 | (feedId: string | null) => { |
| 120 | onFeedSelect(feedId); |
| 121 | // Navigate to posts view on mobile for any feed selection (including "All Feeds") |
| 122 | if (isMobile) { |
| 123 | setMobileView("posts"); |
| 124 | } |
| 125 | }, |
| 126 | [onFeedSelect, isMobile], |
| 127 | ); |
| 128 | |
| 129 | // Handle back to feeds on mobile |
| 130 | const handleBackToFeeds = React.useCallback(() => { |
| 131 | setMobileView("feeds"); |
| 132 | onFeedSelect(null); |
| 133 | }, [onFeedSelect]); |
| 134 | |
| 135 | // Handle post selection and mark as read |
| 136 | const handlePostSelect = React.useCallback( |
| 137 | (postId: string) => { |
| 138 | // Mark as read |
| 139 | const existingStatus = allReadStatusesWithUnread.find( |
| 140 | (status) => status.postId === postId, |
| 141 | ); |
| 142 | const post = feedPosts.find((p) => p.id === postId); |
| 143 | |
| 144 | if (existingStatus) { |
| 145 | // Update existing status to read |
| 146 | const updateResult = evolu.update("readStatus", { |
| 147 | id: existingStatus.id as any, |
| 148 | isRead: 1, |
| 149 | }); |
| 150 | if (!updateResult.ok) { |
| 151 | console.warn( |
| 152 | "Failed to update read status:", |
| 153 | formatTypeError(updateResult.error), |
| 154 | ); |
| 155 | } |
| 156 | } else if (post && post.feedId) { |
| 157 | // Create new read status |
| 158 | const insertResult = evolu.insert("readStatus", { |
| 159 | postId: postId, |
| 160 | feedId: post.feedId, |
| 161 | isRead: 1, |
| 162 | }); |
| 163 | if (!insertResult.ok) { |
| 164 | console.warn( |
| 165 | "Failed to insert read status:", |
| 166 | formatTypeError(insertResult.error), |
| 167 | ); |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | // Call the original onPostSelect |
| 172 | onPostSelect(postId); |
| 173 | |
| 174 | // On mobile, close the sidebar after selecting a post |
| 175 | // Keep the current view (posts) so user can continue where they left off |
| 176 | if (isMobile) { |
| 177 | setOpenMobile(false); |
| 178 | } |
| 179 | }, |
| 180 | [ |
| 181 | allReadStatusesWithUnread, |
| 182 | feedPosts, |
| 183 | evolu, |
| 184 | onPostSelect, |
| 185 | isMobile, |
| 186 | setOpenMobile, |
| 187 | ], |
| 188 | ); |
| 189 | |
| 190 | // Mark all visible posts as read |
| 191 | const handleMarkAllAsRead = React.useCallback(() => { |
| 192 | let markedCount = 0; |
| 193 | filteredPosts.forEach((post) => { |
| 194 | const existingStatus = allReadStatusesWithUnread.find( |
| 195 | (status) => status.postId === post.id, |
| 196 | ); |
| 197 | |
| 198 | if (existingStatus && !existingStatus.isRead) { |
| 199 | // Update existing status to read |
| 200 | const updateResult = evolu.update("readStatus", { |
| 201 | id: existingStatus.id as any, |
| 202 | isRead: 1, |
| 203 | }); |
| 204 | if (updateResult.ok) { |
| 205 | markedCount++; |
| 206 | } |
| 207 | } else if (!existingStatus && post.feedId) { |
| 208 | // Create new read status |
| 209 | const insertResult = evolu.insert("readStatus", { |
| 210 | postId: post.id, |
| 211 | feedId: post.feedId, |
| 212 | isRead: 1, |
| 213 | }); |
| 214 | if (insertResult.ok) { |
| 215 | markedCount++; |
| 216 | } |
| 217 | } |
| 218 | }); |
| 219 | toast.success( |
| 220 | `Marked ${markedCount} post${markedCount !== 1 ? "s" : ""} as read`, |
| 221 | ); |
| 222 | }, [filteredPosts, allReadStatusesWithUnread, evolu]); |
| 223 | |
| 224 | // Mark all visible posts as unread |
| 225 | const handleMarkAllAsUnread = React.useCallback(() => { |
| 226 | let unmarkedCount = 0; |
| 227 | filteredPosts.forEach((post) => { |
| 228 | const existingStatus = allReadStatusesWithUnread.find( |
| 229 | (status) => status.postId === post.id, |
| 230 | ); |
| 231 | |
| 232 | if (existingStatus && existingStatus.isRead) { |
| 233 | // Update existing status to unread |
| 234 | const updateResult = evolu.update("readStatus", { |
| 235 | id: existingStatus.id as any, |
| 236 | isRead: 0, |
| 237 | }); |
| 238 | if (updateResult.ok) { |
| 239 | unmarkedCount++; |
| 240 | } |
| 241 | } else if (!existingStatus && post.feedId) { |
| 242 | // Create new unread status |
| 243 | const insertResult = evolu.insert("readStatus", { |
| 244 | postId: post.id, |
| 245 | feedId: post.feedId, |
| 246 | isRead: 0, |
| 247 | }); |
| 248 | if (insertResult.ok) { |
| 249 | unmarkedCount++; |
| 250 | } |
| 251 | } |
| 252 | }); |
| 253 | toast.success( |
| 254 | `Marked ${unmarkedCount} post${unmarkedCount !== 1 ? "s" : ""} as unread`, |
| 255 | ); |
| 256 | }, [filteredPosts, allReadStatusesWithUnread, evolu]); |
| 257 | |
| 258 | // Delete feed (soft delete using isDeleted flag) |
| 259 | const handleDeleteFeed = React.useCallback(() => { |
| 260 | if (!selectedFeedId) return; |
| 261 | |
| 262 | const feedToDelete = allFeeds.find((f) => f.id === selectedFeedId); |
| 263 | if (!feedToDelete) return; |
| 264 | |
| 265 | // Soft delete the feed (CRDT-friendly, preserves sync history) |
| 266 | evoluInstance.update("rssFeed", { |
| 267 | id: selectedFeedId as any, |
| 268 | isDeleted: Evolu.sqliteTrue, |
| 269 | }); |
| 270 | |
| 271 | // Also soft delete all posts associated with this feed |
| 272 | const postsToDelete = allPosts.filter((p) => p.feedId === selectedFeedId); |
| 273 | postsToDelete.forEach((post) => { |
| 274 | evoluInstance.update("rssPost", { |
| 275 | id: post.id as any, |
| 276 | isDeleted: Evolu.sqliteTrue, |
| 277 | }); |
| 278 | }); |
| 279 | |
| 280 | toast.success( |
| 281 | `Deleted feed "${feedToDelete.title}" and ${postsToDelete.length} post${postsToDelete.length !== 1 ? "s" : ""}`, |
| 282 | ); |
| 283 | |
| 284 | // Navigate back to all feeds |
| 285 | onFeedSelect(null); |
| 286 | }, [selectedFeedId, allFeeds, allPosts, onFeedSelect]); |
| 287 | |
| 288 | // Delete category (uncategorize all feeds in the category) |
| 289 | const handleDeleteCategory = React.useCallback(() => { |
| 290 | const selectedFeed = allFeeds.find((f) => f.id === selectedFeedId); |
| 291 | if (!selectedFeed || !selectedFeed.category) return; |
| 292 | |
| 293 | const categoryToDelete = selectedFeed.category; |
| 294 | |
| 295 | // Find all feeds in this category |
| 296 | const feedsInCategory = allFeeds.filter( |
| 297 | (f) => f.category === categoryToDelete, |
| 298 | ); |
| 299 | |
| 300 | // Set category to null for all feeds in this category |
| 301 | feedsInCategory.forEach((feed) => { |
| 302 | evoluInstance.update("rssFeed", { |
| 303 | id: feed.id as any, |
| 304 | category: null, |
| 305 | }); |
| 306 | }); |
| 307 | |
| 308 | toast.success( |
| 309 | `Removed category "${categoryToDelete}" from ${feedsInCategory.length} feed${feedsInCategory.length !== 1 ? "s" : ""}`, |
| 310 | ); |
| 311 | |
| 312 | // Navigate back to all feeds |
| 313 | onFeedSelect(null); |
| 314 | }, [selectedFeedId, allFeeds, onFeedSelect]); |
| 315 | |
| 316 | // Handle category edit from context menu |
| 317 | const handleCategoryEdit = React.useCallback((category: string) => { |
| 318 | setCategoryToEdit(category); |
| 319 | setCategoryEditDialogOpen(true); |
| 320 | }, []); |
| 321 | |
| 322 | // Handle category delete from context menu |
| 323 | const handleCategoryDelete = React.useCallback( |
| 324 | (category: string) => { |
| 325 | // Find all feeds in this category |
| 326 | const feedsInCategory = allFeeds.filter((f) => f.category === category); |
| 327 | |
| 328 | // Set category to null for all feeds in this category |
| 329 | feedsInCategory.forEach((feed) => { |
| 330 | evoluInstance.update("rssFeed", { |
| 331 | id: feed.id as any, |
| 332 | category: null, |
| 333 | }); |
| 334 | }); |
| 335 | |
| 336 | toast.success( |
| 337 | `Removed category "${category}" from ${feedsInCategory.length} feed${feedsInCategory.length !== 1 ? "s" : ""}`, |
| 338 | ); |
| 339 | |
| 340 | // Navigate back to all feeds |
| 341 | onFeedSelect(null); |
| 342 | }, |
| 343 | [allFeeds, onFeedSelect], |
| 344 | ); |
| 345 | |
| 346 | // Handle category rename |
| 347 | const handleCategoryRename = React.useCallback( |
| 348 | (newName: string) => { |
| 349 | if (!categoryToEdit) return; |
| 350 | |
| 351 | // Find all feeds in the old category |
| 352 | const feedsInCategory = allFeeds.filter( |
| 353 | (f) => f.category === categoryToEdit, |
| 354 | ); |
| 355 | |
| 356 | // Update category for all feeds |
| 357 | feedsInCategory.forEach((feed) => { |
| 358 | evoluInstance.update("rssFeed", { |
| 359 | id: feed.id as any, |
| 360 | category: newName as any, |
| 361 | }); |
| 362 | }); |
| 363 | |
| 364 | toast.success( |
| 365 | `Renamed category "${categoryToEdit}" to "${newName}" for ${feedsInCategory.length} feed${feedsInCategory.length !== 1 ? "s" : ""}`, |
| 366 | ); |
| 367 | }, |
| 368 | [categoryToEdit, allFeeds], |
| 369 | ); |
| 370 | |
| 371 | // Handle change category for a specific feed |
| 372 | const handleChangeCategory = React.useCallback(() => { |
| 373 | const selectedFeed = allFeeds.find((f) => f.id === selectedFeedId); |
| 374 | if (!selectedFeed) return; |
| 375 | |
| 376 | setCategoryToEdit(selectedFeed.category || ""); |
| 377 | setChangeCategoryDialogOpen(true); |
| 378 | }, [selectedFeedId, allFeeds]); |
| 379 | |
| 380 | // Handle feed category change |
| 381 | const handleFeedCategoryChange = React.useCallback( |
| 382 | (newCategory: string | null) => { |
| 383 | if (!selectedFeedId) return; |
| 384 | |
| 385 | const selectedFeed = allFeeds.find((f) => f.id === selectedFeedId); |
| 386 | if (!selectedFeed) return; |
| 387 | |
| 388 | evoluInstance.update("rssFeed", { |
| 389 | id: selectedFeedId as any, |
| 390 | category: newCategory as any, |
| 391 | }); |
| 392 | |
| 393 | const categoryText = newCategory ? `"${newCategory}"` : "Uncategorized"; |
| 394 | toast.success(`Moved feed to ${categoryText}`); |
| 395 | }, |
| 396 | [selectedFeedId, allFeeds], |
| 397 | ); |
| 398 | |
| 399 | const refreshFeeds = React.useCallback(async () => { |
| 400 | if (allFeeds.length === 0) { |
| 401 | toast.error("No feeds to refresh"); |
| 402 | return; |
| 403 | } |
| 404 | |
| 405 | toast.info( |
| 406 | `Refreshing ${allFeeds.length} feed${allFeeds.length !== 1 ? "s" : ""}...`, |
| 407 | ); |
| 408 | let totalNewPosts = 0; |
| 409 | |
| 410 | try { |
| 411 | for (const feed of allFeeds) { |
| 412 | try { |
| 413 | if (!feed.feedUrl) continue; |
| 414 | |
| 415 | const xmlData = await fetchFeedWithFallback(feed.feedUrl); |
| 416 | const { feedData, posts, isAtom } = parseFeedXml(xmlData); |
| 417 | |
| 418 | // Get existing posts for this feed to avoid duplicates |
| 419 | const existingPosts = allPosts.filter((p) => p.feedId === feed.id); |
| 420 | const existingLinks = new Set(existingPosts.map((p) => p.link)); |
| 421 | |
| 422 | // Process new posts/entries |
| 423 | let newPostsCount = 0; |
| 424 | for (const post of posts) { |
| 425 | const postLink = extractPostLink(post, isAtom); |
| 426 | |
| 427 | // Skip if we already have this post |
| 428 | if (existingLinks.has(postLink as any)) { |
| 429 | continue; |
| 430 | } |
| 431 | |
| 432 | const postResult = evolu.insert("rssPost", { |
| 433 | title: post.title, |
| 434 | author: extractPostAuthor(post, isAtom, feedData.title), |
| 435 | feedTitle: feed.title, |
| 436 | publishedDate: extractPostDate(post), |
| 437 | link: postLink, |
| 438 | feedId: feed.id, |
| 439 | content: extractPostContent(post, postLink), |
| 440 | }); |
| 441 | if (postResult.ok) { |
| 442 | newPostsCount++; |
| 443 | } |
| 444 | } |
| 445 | |
| 446 | totalNewPosts += newPostsCount; |
| 447 | |
| 448 | // Update feed's dateUpdated |
| 449 | const updateResult = evolu.update("rssFeed", { |
| 450 | id: feed.id as any, |
| 451 | dateUpdated: new Date().toISOString(), |
| 452 | }); |
| 453 | if (!updateResult.ok) { |
| 454 | console.warn( |
| 455 | "Failed to update feed dateUpdated:", |
| 456 | formatTypeError(updateResult.error), |
| 457 | ); |
| 458 | } |
| 459 | } catch (error) { |
| 460 | console.error(`Error refreshing feed "${feed.title}":`, error); |
| 461 | // Continue with other feeds even if one fails |
| 462 | } |
| 463 | } |
| 464 | |
| 465 | if (totalNewPosts > 0) { |
| 466 | toast.success( |
| 467 | `Refreshed feeds and found ${totalNewPosts} new post${totalNewPosts !== 1 ? "s" : ""}`, |
| 468 | ); |
| 469 | } else { |
| 470 | toast.success("All feeds up to date"); |
| 471 | } |
| 472 | } catch (error) { |
| 473 | console.error("Error refreshing feeds:", error); |
| 474 | toast.error("Failed to refresh feeds"); |
| 475 | } |
| 476 | }, [allFeeds, allPosts, evolu]); |
| 477 | |
| 478 | // Run refresh on component mount (only once, even in strict mode) |
| 479 | React.useEffect(() => { |
| 480 | if (!hasRefreshedOnMount.current) { |
| 481 | hasRefreshedOnMount.current = true; |
| 482 | refreshFeeds(); |
| 483 | } |
| 484 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 485 | }, []); // Only run once on mount |
| 486 | |
| 487 | const selectedFeed = |
| 488 | selectedFeedId && selectedFeedId !== "unread" |
| 489 | ? allFeeds.find((f) => f.id === selectedFeedId) |
| 490 | : null; |
| 491 | const selectedFeedTitle = |
| 492 | selectedFeedId === "unread" ? "Unread" : selectedFeed?.title || "All Posts"; |
| 493 | const selectedFeedCategory = selectedFeed?.category || null; |
| 494 | |
| 495 | // Get list of existing categories for the change category dialog |
| 496 | const existingCategories = React.useMemo(() => { |
| 497 | const categories = new Set<string>(); |
| 498 | allFeeds.forEach((feed) => { |
| 499 | if (feed.category) { |
| 500 | categories.add(feed.category); |
| 501 | } |
| 502 | }); |
| 503 | return Array.from(categories).sort(); |
| 504 | }, [allFeeds]); |
| 505 | |
| 506 | return ( |
| 507 | <> |
| 508 | <AddFeedDialog open={dialogOpen} onOpenChange={setDialogOpen} /> |
| 509 | <CategoryEditDialog |
| 510 | open={categoryEditDialogOpen} |
| 511 | onOpenChange={setCategoryEditDialogOpen} |
| 512 | currentCategory={categoryToEdit} |
| 513 | onRename={handleCategoryRename} |
| 514 | /> |
| 515 | <ChangeCategoryDialog |
| 516 | open={changeCategoryDialogOpen} |
| 517 | onOpenChange={setChangeCategoryDialogOpen} |
| 518 | currentCategory={categoryToEdit} |
| 519 | existingCategories={existingCategories} |
| 520 | onChangeCategory={handleFeedCategoryChange} |
| 521 | /> |
| 522 | |
| 523 | <Sidebar collapsible="offcanvas" {...props}> |
| 524 | <SidebarHeader> |
| 525 | <SidebarMenu> |
| 526 | <SidebarMenuItem> |
| 527 | {isMobile && mobileView === "posts" ? ( |
| 528 | <MobilePostsHeader |
| 529 | feedTitle={selectedFeedTitle} |
| 530 | onBack={handleBackToFeeds} |
| 531 | /> |
| 532 | ) : ( |
| 533 | <a href="#"> |
| 534 | <div className="grid flex-1 text-left text-xl px-2 pt-2"> |
| 535 | <span className="truncate font-bold">Alcove</span> |
| 536 | </div> |
| 537 | </a> |
| 538 | )} |
| 539 | </SidebarMenuItem> |
| 540 | </SidebarMenu> |
| 541 | </SidebarHeader> |
| 542 | <SidebarContent> |
| 543 | {isMobile && mobileView === "posts" ? ( |
| 544 | // Mobile posts view |
| 545 | <div className="flex flex-col h-full"> |
| 546 | <PostsList |
| 547 | posts={filteredPosts} |
| 548 | selectedPostId={selectedPostId} |
| 549 | onPostSelect={handlePostSelect} |
| 550 | searchQuery={searchQuery} |
| 551 | onSearchChange={setSearchQuery} |
| 552 | feedTitle={`${filteredPosts.length} post${filteredPosts.length !== 1 ? "s" : ""}`} |
| 553 | isPostRead={isPostRead} |
| 554 | onMarkAllAsRead={handleMarkAllAsRead} |
| 555 | onMarkAllAsUnread={handleMarkAllAsUnread} |
| 556 | onDeleteFeed={handleDeleteFeed} |
| 557 | onDeleteCategory={handleDeleteCategory} |
| 558 | onChangeCategory={handleChangeCategory} |
| 559 | selectedFeedId={selectedFeedId} |
| 560 | selectedFeedCategory={selectedFeedCategory} |
| 561 | className="border-0" |
| 562 | /> |
| 563 | </div> |
| 564 | ) : ( |
| 565 | // Feeds view (desktop and mobile default) |
| 566 | <> |
| 567 | <FeedActions |
| 568 | onAddFeed={() => setDialogOpen(true)} |
| 569 | onRefresh={refreshFeeds} |
| 570 | /> |
| 571 | <NavFeeds |
| 572 | feeds={allFeeds} |
| 573 | selectedFeedId={selectedFeedId} |
| 574 | onFeedSelect={handleFeedSelect} |
| 575 | onCategoryEdit={handleCategoryEdit} |
| 576 | onCategoryDelete={handleCategoryDelete} |
| 577 | /> |
| 578 | </> |
| 579 | )} |
| 580 | </SidebarContent> |
| 581 | <SidebarFooter> |
| 582 | {!isMobile || mobileView === "feeds" ? <NavUser /> : null} |
| 583 | </SidebarFooter> |
| 584 | </Sidebar> |
| 585 | |
| 586 | {/* Posts List Panel - Separate from main sidebar (Desktop only) */} |
| 587 | <PostsList |
| 588 | posts={filteredPosts} |
| 589 | selectedPostId={selectedPostId} |
| 590 | onPostSelect={handlePostSelect} |
| 591 | searchQuery={searchQuery} |
| 592 | onSearchChange={setSearchQuery} |
| 593 | feedTitle={selectedFeedTitle} |
| 594 | isPostRead={isPostRead} |
| 595 | onMarkAllAsRead={handleMarkAllAsRead} |
| 596 | onMarkAllAsUnread={handleMarkAllAsUnread} |
| 597 | onDeleteFeed={handleDeleteFeed} |
| 598 | onDeleteCategory={handleDeleteCategory} |
| 599 | onChangeCategory={handleChangeCategory} |
| 600 | selectedFeedId={selectedFeedId} |
| 601 | selectedFeedCategory={selectedFeedCategory} |
| 602 | className={`bg-sidebar text-sidebar-foreground hidden md:flex ${hidden ? "w-0 min-w-0 border-0 overflow-hidden" : "w-[320px] overflow-y-auto"}`} |
| 603 | /> |
| 604 | </> |
| 605 | ); |
| 606 | } |