summaryrefslogtreecommitdiff
path: root/apps/web/app/reader/_components/sidebar-content.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/app/reader/_components/sidebar-content.tsx')
-rw-r--r--apps/web/app/reader/_components/sidebar-content.tsx356
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">&#9835;</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">&#9835;</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>
+ )
+}