diff options
Diffstat (limited to 'apps/web/app/reader/_components/sidebar-content.tsx')
| -rw-r--r-- | apps/web/app/reader/_components/sidebar-content.tsx | 356 |
1 files changed, 356 insertions, 0 deletions
diff --git a/apps/web/app/reader/_components/sidebar-content.tsx b/apps/web/app/reader/_components/sidebar-content.tsx new file mode 100644 index 0000000..ee5c873 --- /dev/null +++ b/apps/web/app/reader/_components/sidebar-content.tsx @@ -0,0 +1,356 @@ +"use client" + +import Link from "next/link" +import { usePathname, useSearchParams } from "next/navigation" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useUnreadCounts } from "@/lib/queries/use-unread-counts" +import { useCustomFeeds } from "@/lib/queries/use-custom-feeds" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { classNames } from "@/lib/utilities" + +const NAVIGATION_LINK_CLASS = + "block px-2 py-1 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + +const ACTIVE_LINK_CLASS = "bg-background-tertiary text-text-primary" + +function getFaviconUrl(feedUrl: string): string | null { + try { + const hostname = new URL(feedUrl).hostname + return `https://www.google.com/s2/favicons?domain=${hostname}&sz=16` + } catch { + return null + } +} + +function FeedFavicon({ feedUrl }: { feedUrl: string }) { + const faviconUrl = getFaviconUrl(feedUrl) + if (!faviconUrl) return null + + return ( + <img + src={faviconUrl} + alt="" + width={16} + height={16} + className="shrink-0" + loading="lazy" + /> + ) +} + +function displayNameForSubscription(subscription: { + customTitle: string | null + feedTitle: string + feedUrl: string +}): string { + if (subscription.customTitle) return subscription.customTitle + if (subscription.feedTitle) return subscription.feedTitle + + try { + return new URL(subscription.feedUrl).hostname + } catch { + return subscription.feedUrl || "untitled feed" + } +} + +function UnreadBadge({ count }: { count: number }) { + if (count === 0) return null + + return ( + <span className="ml-auto shrink-0 text-[0.625rem] tabular-nums text-text-dim"> + {count > 999 ? "999+" : count} + </span> + ) +} + +function sidebarFocusClass( + focusedPanel: string, + focusedSidebarIndex: number, + navIndex: number +): string { + return focusedPanel === "sidebar" && focusedSidebarIndex === navIndex + ? "bg-background-tertiary text-text-primary" + : "" +} + +export function SidebarContent() { + const pathname = usePathname() + const searchParameters = useSearchParams() + const { data } = useSubscriptions() + const { data: unreadCounts } = useUnreadCounts() + const { data: customFeedsData } = useCustomFeeds() + const setAddFeedDialogOpen = useUserInterfaceStore( + (state) => state.setAddFeedDialogOpen + ) + const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) + const showFeedFavicons = useUserInterfaceStore( + (state) => state.showFeedFavicons + ) + const expandedFolderIdentifiers = useUserInterfaceStore( + (state) => state.expandedFolderIdentifiers + ) + const toggleFolderExpansion = useUserInterfaceStore( + (state) => state.toggleFolderExpansion + ) + const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) + const focusedSidebarIndex = useUserInterfaceStore( + (state) => state.focusedSidebarIndex + ) + + function closeSidebarOnMobile() { + if (typeof window !== "undefined" && window.innerWidth < 768) { + toggleSidebar() + } + } + + const folders = data?.folders ?? [] + const subscriptions = data?.subscriptions ?? [] + const ungroupedSubscriptions = subscriptions.filter( + (subscription) => !subscription.folderIdentifier + ) + + const totalUnreadCount = Object.values(unreadCounts ?? {}).reduce( + (sum, count) => sum + count, + 0 + ) + + function getFolderUnreadCount(folderIdentifier: string): number { + return subscriptions + .filter( + (subscription) => + subscription.folderIdentifier === folderIdentifier + ) + .reduce( + (sum, subscription) => + sum + (unreadCounts?.[subscription.feedIdentifier] ?? 0), + 0 + ) + } + + const activeFeedIdentifier = searchParameters.get("feed") + const activeFolderIdentifier = searchParameters.get("folder") + const activeCustomFeedIdentifier = searchParameters.get("custom_feed") + + let navIndex = 0 + + return ( + <nav className="flex-1 space-y-1 overflow-auto px-2"> + <Link + href="/reader" + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + "flex items-center", + pathname === "/reader" && + !activeFeedIdentifier && + !activeFolderIdentifier && + !activeCustomFeedIdentifier && + ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + <span>all entries</span> + <UnreadBadge count={totalUnreadCount} /> + </Link> + <Link + href="/reader/saved" + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + pathname === "/reader/saved" && ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + saved + </Link> + <Link + href="/reader/highlights" + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + pathname === "/reader/highlights" && ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + highlights + </Link> + <Link + href="/reader/shares" + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + pathname === "/reader/shares" && ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + shares + </Link> + + {customFeedsData && customFeedsData.length > 0 && ( + <div className="mt-3 space-y-0.5"> + {customFeedsData.map((customFeed) => ( + <Link + key={customFeed.identifier} + href={`/reader?custom_feed=${customFeed.identifier}`} + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + "truncate pl-4 text-[0.85em]", + activeCustomFeedIdentifier === customFeed.identifier && + ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + {customFeed.name} + </Link> + ))} + </div> + )} + + {ungroupedSubscriptions.length > 0 && ( + <div className="mt-3 space-y-0.5"> + {ungroupedSubscriptions.map((subscription) => ( + <Link + key={subscription.subscriptionIdentifier} + href={`/reader?feed=${subscription.feedIdentifier}`} + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + "flex items-center truncate pl-4 text-[0.85em]", + activeFeedIdentifier === subscription.feedIdentifier && + ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + {showFeedFavicons && ( + <FeedFavicon feedUrl={subscription.feedUrl} /> + )} + <span className={classNames("truncate", showFeedFavicons && "ml-2")}> + {displayNameForSubscription(subscription)} + </span> + {subscription.feedType === "podcast" && ( + <span className="ml-1 shrink-0 text-text-dim" title="podcast">♫</span> + )} + {subscription.consecutiveFailures > 0 && ( + <span className="ml-1 shrink-0 text-status-warning" title={subscription.lastFetchError ?? "feed error"}> + [!] + </span> + )} + <UnreadBadge + count={unreadCounts?.[subscription.feedIdentifier] ?? 0} + /> + </Link> + ))} + </div> + )} + + {folders.map((folder) => { + const isExpanded = expandedFolderIdentifiers.includes( + folder.folderIdentifier + ) + const folderSubscriptions = subscriptions.filter( + (subscription) => + subscription.folderIdentifier === folder.folderIdentifier + ) + const folderUnreadCount = getFolderUnreadCount( + folder.folderIdentifier + ) + + const folderNavIndex = navIndex++ + + return ( + <div key={folder.folderIdentifier} className="mt-2"> + <div + data-sidebar-nav-item + className={classNames( + "flex w-full items-center gap-1 px-2 py-1", + sidebarFocusClass(focusedPanel, focusedSidebarIndex, folderNavIndex) + )} + > + <button + type="button" + onClick={() => + toggleFolderExpansion(folder.folderIdentifier) + } + className="shrink-0 px-0.5 text-text-secondary transition-colors hover:text-text-primary" + > + {isExpanded ? "\u25BE" : "\u25B8"} + </button> + <Link + href={`/reader?folder=${folder.folderIdentifier}`} + onClick={closeSidebarOnMobile} + className={classNames( + "flex-1 truncate text-text-secondary transition-colors hover:text-text-primary", + activeFolderIdentifier === folder.folderIdentifier && + "text-text-primary" + )} + > + {folder.name} + </Link> + <UnreadBadge count={folderUnreadCount} /> + </div> + {isExpanded && ( + <div className="space-y-0.5"> + {folderSubscriptions.map((subscription) => ( + <Link + key={subscription.subscriptionIdentifier} + href={`/reader?feed=${subscription.feedIdentifier}`} + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + "flex items-center truncate pl-6 text-[0.85em]", + activeFeedIdentifier === + subscription.feedIdentifier && ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + {showFeedFavicons && ( + <FeedFavicon feedUrl={subscription.feedUrl} /> + )} + <span className={classNames("truncate", showFeedFavicons && "ml-2")}> + {displayNameForSubscription(subscription)} + </span> + {subscription.feedType === "podcast" && ( + <span className="ml-1 shrink-0 text-text-dim" title="podcast">♫</span> + )} + {subscription.consecutiveFailures > 0 && ( + <span className="ml-1 shrink-0 text-status-warning" title={subscription.lastFetchError ?? "feed error"}> + [!] + </span> + )} + <UnreadBadge + count={ + unreadCounts?.[subscription.feedIdentifier] ?? 0 + } + /> + </Link> + ))} + </div> + )} + </div> + ) + })} + + <div className="mt-3"> + <button + type="button" + data-sidebar-nav-item + onClick={() => setAddFeedDialogOpen(true)} + className={classNames( + "w-full px-2 py-1 text-left text-text-dim transition-colors hover:bg-background-tertiary hover:text-text-secondary", + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) + )} + > + + add feed + </button> + </div> + </nav> + ) +} |