"use client" import { useState, useMemo } from "react" import Link from "next/link" import { usePathname, useSearchParams } from "next/navigation" import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, closestCenter, type DragStartEvent, type DragEndEvent, } from "@dnd-kit/core" import { SortableContext, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" import { useSubscriptions } from "@/lib/queries/use-subscriptions" import { useUnreadCounts } from "@/lib/queries/use-unread-counts" import { useCustomFeeds } from "@/lib/queries/use-custom-feeds" import { useMoveSubscriptionToFolder } from "@/lib/queries/use-subscription-mutations" import { useReorderSubscriptions, useReorderFolders, useReorderCustomFeeds, } from "@/lib/queries/use-reorder-mutations" import { computeReorderPositions } from "@/lib/reorder-positions" import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" import { classNames } from "@/lib/utilities" import type { Subscription, Folder } from "@/lib/types/subscription" import type { CustomFeed } from "@/lib/types/custom-feed" const NAVIGATION_LINK_CLASS = "block whitespace-nowrap 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 ( ) } 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 ( {count > 999 ? "999+" : count} ) } function sidebarFocusClass( focusedPanel: string, focusedSidebarIndex: number, navigationIndex: number ): string { return focusedPanel === "sidebar" && focusedSidebarIndex === navigationIndex ? "bg-background-tertiary text-text-primary" : "" } function SortableFeedItem({ subscription, activeFeedIdentifier, showFeedFavicons, unreadCount, focusedPanel, focusedSidebarIndex, navigationIndex, indentClass, closeSidebarOnMobile, }: { subscription: Subscription activeFeedIdentifier: string | null showFeedFavicons: boolean unreadCount: number focusedPanel: string focusedSidebarIndex: number navigationIndex: number indentClass: string closeSidebarOnMobile: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: subscription.subscriptionIdentifier }) const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : undefined, } return ( 0 ? { "data-has-unreads": "" } : {})} onClick={closeSidebarOnMobile} className={classNames( NAVIGATION_LINK_CLASS, `flex items-center truncate ${indentClass} text-[0.85em]`, activeFeedIdentifier === subscription.feedIdentifier && ACTIVE_LINK_CLASS, subscription.hiddenFromTimeline && "opacity-50", sidebarFocusClass(focusedPanel, focusedSidebarIndex, navigationIndex) )} > {showFeedFavicons && } {displayNameForSubscription(subscription)} {subscription.feedVisibility === "authenticated" && ( 🔒 )} {subscription.feedType === "podcast" && ( )} {subscription.consecutiveFailures > 0 && ( [!] )} ) } function SortableFolderItem({ folder, isExpanded, folderSubscriptions, folderUnreadCount, activeFeedIdentifier, activeFolderIdentifier, showFeedFavicons, unreadCounts, focusedPanel, focusedSidebarIndex, folderNavigationIndex, feedNavigationIndexStart, toggleFolderExpansion, closeSidebarOnMobile, }: { folder: Folder isExpanded: boolean folderSubscriptions: Subscription[] folderUnreadCount: number activeFeedIdentifier: string | null activeFolderIdentifier: string | null showFeedFavicons: boolean unreadCounts: Record | undefined focusedPanel: string focusedSidebarIndex: number folderNavigationIndex: number feedNavigationIndexStart: number toggleFolderExpansion: (identifier: string) => void closeSidebarOnMobile: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: folder.folderIdentifier }) const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : undefined, } const feedIdentifiers = useMemo( () => folderSubscriptions.map((subscription) => subscription.subscriptionIdentifier), [folderSubscriptions] ) return (
0 ? { "data-has-unreads": "" } : {})} {...attributes} {...listeners} className={classNames( "flex w-full items-center gap-1 px-2 py-1", sidebarFocusClass( focusedPanel, focusedSidebarIndex, folderNavigationIndex ) )} > {folder.iconUrl && ( )} {folder.name}
{isExpanded && (
{folderSubscriptions.map((subscription, subscriptionIndex) => ( ))}
)}
) } function SortableCustomFeedItem({ customFeed, activeCustomFeedIdentifier, focusedPanel, focusedSidebarIndex, navigationIndex, closeSidebarOnMobile, }: { customFeed: CustomFeed activeCustomFeedIdentifier: string | null focusedPanel: string focusedSidebarIndex: number navigationIndex: number closeSidebarOnMobile: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: customFeed.identifier }) const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : undefined, } return ( {customFeed.iconUrl && ( )} {customFeed.name} ) } function FeedDragOverlay({ subscription, showFeedFavicons, }: { subscription: Subscription showFeedFavicons: boolean }) { return (
{showFeedFavicons && } {displayNameForSubscription(subscription)}
) } function FolderDragOverlay({ folder }: { folder: Folder }) { return (
{"\u25B8"} {folder.name}
) } function CustomFeedDragOverlay({ customFeed }: { customFeed: CustomFeed }) { return (
{customFeed.iconUrl && ( )} {customFeed.name}
) } type DragItemType = "feed" | "folder" | "customFeed" 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 ) const showFoldersAboveFeeds = useUserInterfaceStore( (state) => state.showFoldersAboveFeeds ) const moveToFolder = useMoveSubscriptionToFolder() const reorderSubscriptions = useReorderSubscriptions() const reorderFolders = useReorderFolders() const reorderCustomFeeds = useReorderCustomFeeds() const [activeDragIdentifier, setActiveDragIdentifier] = useState(null) const [activeDragType, setActiveDragType] = useState( null ) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 }, }) ) function closeSidebarOnMobile() { if (typeof window !== "undefined" && window.innerWidth < 768) { toggleSidebar() } } const folders = data?.folders ?? [] const subscriptions = data?.subscriptions ?? [] const customFeeds = customFeedsData ?? [] const ungroupedSubscriptions = subscriptions.filter( (subscription) => !subscription.folderIdentifier ) const totalUnreadCount = subscriptions .filter((subscription) => !subscription.hiddenFromTimeline) .reduce( (sum, subscription) => sum + (unreadCounts?.[subscription.feedIdentifier] ?? 0), 0 ) function getFolderUnreadCount(folderIdentifier: string): number { return subscriptions .filter( (subscription) => subscription.folderIdentifier === folderIdentifier && !subscription.hiddenFromTimeline ) .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") const subscriptionMap = useMemo( () => new Map(subscriptions.map((subscription) => [subscription.subscriptionIdentifier, subscription])), [subscriptions] ) const folderMap = useMemo( () => new Map(folders.map((folder) => [folder.folderIdentifier, folder])), [folders] ) const customFeedMap = useMemo( () => new Map(customFeeds.map((customFeed) => [customFeed.identifier, customFeed])), [customFeeds] ) const folderIdentifiers = useMemo( () => folders.map((folder) => folder.folderIdentifier), [folders] ) const ungroupedFeedIdentifiers = useMemo( () => ungroupedSubscriptions.map((subscription) => subscription.subscriptionIdentifier), [ungroupedSubscriptions] ) const customFeedIdentifiers = useMemo( () => customFeeds.map((customFeed) => customFeed.identifier), [customFeeds] ) function identifyDragType(identifier: string): DragItemType | null { if (subscriptionMap.has(identifier)) return "feed" if (folderMap.has(identifier)) return "folder" if (customFeedMap.has(identifier)) return "customFeed" return null } function handleDragStart(event: DragStartEvent) { const identifier = String(event.active.id) setActiveDragIdentifier(identifier) setActiveDragType(identifyDragType(identifier)) } function handleDragEnd(event: DragEndEvent) { setActiveDragIdentifier(null) setActiveDragType(null) const { active, over } = event if (!over || active.id === over.id) return const activeIdentifier = String(active.id) const overIdentifier = String(over.id) const dragType = identifyDragType(activeIdentifier) if (dragType === "folder") { const items = folders.map((folder) => ({ identifier: folder.folderIdentifier, position: folder.position, })) const result = computeReorderPositions(items, activeIdentifier, overIdentifier) if (result) reorderFolders.mutate(result) return } if (dragType === "customFeed") { const items = customFeeds.map((customFeed) => ({ identifier: customFeed.identifier, position: customFeed.position, })) const result = computeReorderPositions(items, activeIdentifier, overIdentifier) if (result) reorderCustomFeeds.mutate(result) return } if (dragType === "feed") { const activeSubscription = subscriptionMap.get(activeIdentifier) const overSubscription = subscriptionMap.get(overIdentifier) if (!activeSubscription) return if (overSubscription) { if (activeSubscription.folderIdentifier === overSubscription.folderIdentifier) { const containerSubscriptions = subscriptions .filter( (subscription) => subscription.folderIdentifier === activeSubscription.folderIdentifier ) .map((subscription) => ({ identifier: subscription.subscriptionIdentifier, position: subscription.position, })) const result = computeReorderPositions( containerSubscriptions, activeIdentifier, overIdentifier ) if (result) reorderSubscriptions.mutate(result) } else { const targetFolderIdentifier = overSubscription.folderIdentifier const targetFolderName = targetFolderIdentifier ? folderMap.get(targetFolderIdentifier)?.name : undefined moveToFolder.mutate({ subscriptionIdentifier: activeIdentifier, folderIdentifier: targetFolderIdentifier, feedTitle: activeSubscription.customTitle ?? activeSubscription.feedTitle ?? undefined, folderName: targetFolderName, }) } } } } function handleDragCancel() { setActiveDragIdentifier(null) setActiveDragType(null) } function renderDragOverlay() { if (!activeDragIdentifier || !activeDragType) return null if (activeDragType === "feed") { const subscription = subscriptionMap.get(activeDragIdentifier) if (!subscription) return null return ( ) } if (activeDragType === "folder") { const folder = folderMap.get(activeDragIdentifier) if (!folder) return null return } if (activeDragType === "customFeed") { const customFeed = customFeedMap.get(activeDragIdentifier) if (!customFeed) return null return } return null } const folderData = folders.map((folder) => { const isExpanded = expandedFolderIdentifiers.includes( folder.folderIdentifier ) const folderSubscriptions = subscriptions.filter( (subscription) => subscription.folderIdentifier === folder.folderIdentifier ) const folderUnreadCount = getFolderUnreadCount(folder.folderIdentifier) return { folder, isExpanded, folderSubscriptions, folderUnreadCount, } }) let navigationIndex = 0 const staticNavigationStart = navigationIndex navigationIndex += 4 const customFeedNavigationStart = navigationIndex navigationIndex += customFeeds.length const sections = showFoldersAboveFeeds ? (["folders", "ungrouped"] as const) : (["ungrouped", "folders"] as const) const folderNavigationIndices: { folderNavigationIndex: number; feedNavigationIndexStart: number }[] = [] let ungroupedNavigationStart = 0 for (const section of sections) { if (section === "folders") { for (const folderDatum of folderData) { folderNavigationIndices.push({ folderNavigationIndex: navigationIndex++, feedNavigationIndexStart: navigationIndex, }) if (folderDatum.isExpanded) navigationIndex += folderDatum.folderSubscriptions.length } } else { ungroupedNavigationStart = navigationIndex navigationIndex += ungroupedSubscriptions.length } } const addFeedNavigationIndex = navigationIndex return ( {renderDragOverlay()} ) }