diff options
| author | Fuwn <[email protected]> | 2026-02-12 01:28:18 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-12 01:52:32 -0800 |
| commit | 6d8f7ea8b30e229cc0662db8dc3438828feb6880 (patch) | |
| tree | 9e9270f6f7d6d3cb9c11fb5393eb44e1a44ad418 /apps/web | |
| parent | fix: invalidate unread counts when toggling individual entry read state (diff) | |
| download | asa.news-6d8f7ea8b30e229cc0662db8dc3438828feb6880.tar.xz asa.news-6d8f7ea8b30e229cc0662db8dc3438828feb6880.zip | |
feat: add drag-and-drop reordering for feeds, folders, and custom feeds
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/app/reader/_components/sidebar-content.tsx | 1030 | ||||
| -rw-r--r-- | apps/web/lib/queries/use-custom-feed-mutations.ts | 12 | ||||
| -rw-r--r-- | apps/web/lib/queries/use-folder-mutations.ts | 12 | ||||
| -rw-r--r-- | apps/web/lib/queries/use-reorder-mutations.ts | 167 | ||||
| -rw-r--r-- | apps/web/lib/queries/use-subscription-mutations.ts | 17 | ||||
| -rw-r--r-- | apps/web/lib/queries/use-subscriptions.ts | 1 | ||||
| -rw-r--r-- | apps/web/lib/reorder-positions.ts | 34 | ||||
| -rw-r--r-- | apps/web/lib/types/subscription.ts | 1 | ||||
| -rw-r--r-- | apps/web/package.json | 3 |
9 files changed, 978 insertions, 299 deletions
diff --git a/apps/web/app/reader/_components/sidebar-content.tsx b/apps/web/app/reader/_components/sidebar-content.tsx index c490cf4..8b1362e 100644 --- a/apps/web/app/reader/_components/sidebar-content.tsx +++ b/apps/web/app/reader/_components/sidebar-content.tsx @@ -1,14 +1,38 @@ "use client" -import { useState } from "react" +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" @@ -68,13 +92,351 @@ function UnreadBadge({ count }: { count: number }) { function sidebarFocusClass( focusedPanel: string, focusedSidebarIndex: number, - navIndex: number + navigationIndex: number ): string { - return focusedPanel === "sidebar" && focusedSidebarIndex === navIndex + 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 ( + <Link + ref={setNodeRef} + style={style} + {...attributes} + {...listeners} + href={`/reader?feed=${subscription.feedIdentifier}`} + data-sidebar-nav-item + {...(unreadCount > 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 && <FeedFavicon feedUrl={subscription.feedUrl} />} + <span + className={classNames("truncate", showFeedFavicons && "ml-2")} + > + {displayNameForSubscription(subscription)} + </span> + {subscription.feedVisibility === "authenticated" && ( + <span + className="ml-1 shrink-0 text-text-dim" + title="authenticated feed" + > + 🔒 + </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={unreadCount} /> + </Link> + ) +} + +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<string, number> | 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 ( + <div + ref={setNodeRef} + style={style} + className="mt-2" + > + <div + data-sidebar-nav-item + {...(folderUnreadCount > 0 ? { "data-has-unreads": "" } : {})} + {...attributes} + {...listeners} + className={classNames( + "flex w-full items-center gap-1 px-2 py-1", + sidebarFocusClass( + focusedPanel, + focusedSidebarIndex, + folderNavigationIndex + ) + )} + > + <button + type="button" + aria-expanded={isExpanded} + onClick={(event) => { + event.stopPropagation() + 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 flex-1 items-center gap-2 truncate text-text-secondary transition-colors hover:text-text-primary", + activeFolderIdentifier === folder.folderIdentifier && + "text-text-primary" + )} + > + {folder.iconUrl && ( + <img + src={folder.iconUrl} + alt="" + width={16} + height={16} + className="shrink-0" + loading="lazy" + /> + )} + <span className="truncate">{folder.name}</span> + </Link> + <UnreadBadge count={folderUnreadCount} /> + </div> + {isExpanded && ( + <SortableContext + items={feedIdentifiers} + strategy={verticalListSortingStrategy} + > + <div className="space-y-0.5"> + {folderSubscriptions.map((subscription, subscriptionIndex) => ( + <SortableFeedItem + key={subscription.subscriptionIdentifier} + subscription={subscription} + activeFeedIdentifier={activeFeedIdentifier} + showFeedFavicons={showFeedFavicons} + unreadCount={ + unreadCounts?.[subscription.feedIdentifier] ?? 0 + } + focusedPanel={focusedPanel} + focusedSidebarIndex={focusedSidebarIndex} + navigationIndex={feedNavigationIndexStart + subscriptionIndex} + indentClass="pl-6" + closeSidebarOnMobile={closeSidebarOnMobile} + /> + ))} + </div> + </SortableContext> + )} + </div> + ) +} + +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 ( + <Link + ref={setNodeRef} + style={style} + {...attributes} + {...listeners} + href={`/reader?custom_feed=${customFeed.identifier}`} + data-sidebar-nav-item + onClick={closeSidebarOnMobile} + className={classNames( + NAVIGATION_LINK_CLASS, + "flex items-center gap-2 truncate pl-4 text-[0.85em]", + activeCustomFeedIdentifier === customFeed.identifier && + ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, navigationIndex) + )} + > + {customFeed.iconUrl && ( + <img + src={customFeed.iconUrl} + alt="" + width={16} + height={16} + className="shrink-0" + loading="lazy" + /> + )} + <span className="truncate">{customFeed.name}</span> + </Link> + ) +} + +function FeedDragOverlay({ + subscription, + showFeedFavicons, +}: { + subscription: Subscription + showFeedFavicons: boolean +}) { + return ( + <div + className={classNames( + NAVIGATION_LINK_CLASS, + "flex items-center truncate text-[0.85em] bg-background-secondary shadow-lg outline outline-1 outline-border" + )} + > + {showFeedFavicons && <FeedFavicon feedUrl={subscription.feedUrl} />} + <span className={classNames("truncate", showFeedFavicons && "ml-2")}> + {displayNameForSubscription(subscription)} + </span> + </div> + ) +} + +function FolderDragOverlay({ folder }: { folder: Folder }) { + return ( + <div className="flex items-center gap-1 px-2 py-1 bg-background-secondary shadow-lg outline outline-1 outline-border"> + <span className="shrink-0 px-0.5 text-text-secondary">{"\u25B8"}</span> + <span className="truncate text-text-secondary">{folder.name}</span> + </div> + ) +} + +function CustomFeedDragOverlay({ customFeed }: { customFeed: CustomFeed }) { + return ( + <div + className={classNames( + NAVIGATION_LINK_CLASS, + "flex items-center gap-2 truncate text-[0.85em] bg-background-secondary shadow-lg outline outline-1 outline-border" + )} + > + {customFeed.iconUrl && ( + <img + src={customFeed.iconUrl} + alt="" + width={16} + height={16} + className="shrink-0" + loading="lazy" + /> + )} + <span className="truncate">{customFeed.name}</span> + </div> + ) +} + +type DragItemType = "feed" | "folder" | "customFeed" + export function SidebarContent() { const pathname = usePathname() const searchParameters = useSearchParams() @@ -101,42 +463,22 @@ export function SidebarContent() { const showFoldersAboveFeeds = useUserInterfaceStore( (state) => state.showFoldersAboveFeeds ) + const moveToFolder = useMoveSubscriptionToFolder() - const [dropTargetFolderIdentifier, setDropTargetFolderIdentifier] = useState<string | null>(null) - const [isDraggingOverUngrouped, setIsDraggingOverUngrouped] = useState(false) - - function handleFeedDragStart( - event: React.DragEvent, - subscriptionIdentifier: string - ) { - event.dataTransfer.setData("text/plain", subscriptionIdentifier) - event.dataTransfer.effectAllowed = "move" - } + const reorderSubscriptions = useReorderSubscriptions() + const reorderFolders = useReorderFolders() + const reorderCustomFeeds = useReorderCustomFeeds() - function handleFolderDrop( - event: React.DragEvent, - targetFolderIdentifier: string | null, - targetFolderName?: string - ) { - event.preventDefault() - setDropTargetFolderIdentifier(null) - setIsDraggingOverUngrouped(false) - const subscriptionIdentifier = event.dataTransfer.getData("text/plain") - if (!subscriptionIdentifier) return - - const subscription = subscriptions.find( - (s) => s.subscriptionIdentifier === subscriptionIdentifier - ) - if (!subscription) return - if (subscription.folderIdentifier === targetFolderIdentifier) return - - moveToFolder.mutate({ - subscriptionIdentifier, - folderIdentifier: targetFolderIdentifier, - feedTitle: subscription.customTitle ?? subscription.feedTitle ?? undefined, - folderName: targetFolderName, + const [activeDragIdentifier, setActiveDragIdentifier] = useState<string | null>(null) + const [activeDragType, setActiveDragType] = useState<DragItemType | null>( + null + ) + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, }) - } + ) function closeSidebarOnMobile() { if (typeof window !== "undefined" && window.innerWidth < 768) { @@ -146,6 +488,7 @@ export function SidebarContent() { const folders = data?.folders ?? [] const subscriptions = data?.subscriptions ?? [] + const customFeeds = customFeedsData ?? [] const ungroupedSubscriptions = subscriptions.filter( (subscription) => !subscription.folderIdentifier ) @@ -176,274 +519,369 @@ export function SidebarContent() { const activeFolderIdentifier = searchParameters.get("folder") const activeCustomFeedIdentifier = searchParameters.get("custom_feed") - let navIndex = 0 + 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] + ) - return ( - <nav data-sidebar-nav className="flex-1 space-y-1 overflow-auto px-2"> - <Link - href="/reader" - data-sidebar-nav-item - {...(totalUnreadCount > 0 ? { "data-has-unreads": "" } : {})} - 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, - "flex items-center gap-2 truncate pl-4 text-[0.85em]", - activeCustomFeedIdentifier === customFeed.identifier && - ACTIVE_LINK_CLASS, - sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) - )} - > - {customFeed.iconUrl && ( - <img src={customFeed.iconUrl} alt="" width={16} height={16} className="shrink-0" loading="lazy" /> - )} - <span className="truncate">{customFeed.name}</span> - </Link> - ))} - </div> - )} + 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] + ) - {(showFoldersAboveFeeds - ? ["folders", "ungrouped"] - : ["ungrouped", "folders"] - ).map((section) => - section === "folders" - ? folders.map((folder) => { - const isExpanded = expandedFolderIdentifiers.includes( - folder.folderIdentifier - ) - const folderSubscriptions = subscriptions.filter( - (subscription) => - subscription.folderIdentifier === folder.folderIdentifier - ) - const folderUnreadCount = getFolderUnreadCount( - folder.folderIdentifier - ) + function identifyDragType(identifier: string): DragItemType | null { + if (subscriptionMap.has(identifier)) return "feed" + if (folderMap.has(identifier)) return "folder" + if (customFeedMap.has(identifier)) return "customFeed" - const folderNavIndex = navIndex++ - - return ( - <div key={folder.folderIdentifier} className="mt-2"> - <div - data-sidebar-nav-item - {...(folderUnreadCount > 0 ? { "data-has-unreads": "" } : {})} - onDragOver={(event) => { - event.preventDefault() - event.dataTransfer.dropEffect = "move" - setDropTargetFolderIdentifier(folder.folderIdentifier) - }} - onDragLeave={() => setDropTargetFolderIdentifier(null)} - onDrop={(event) => handleFolderDrop(event, folder.folderIdentifier, folder.name)} - className={classNames( - "flex w-full items-center gap-1 px-2 py-1", - sidebarFocusClass(focusedPanel, focusedSidebarIndex, folderNavIndex), - dropTargetFolderIdentifier === folder.folderIdentifier && "bg-background-tertiary outline outline-1 outline-text-dim" - )} - > - <button - type="button" - aria-expanded={isExpanded} - 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 flex-1 items-center gap-2 truncate text-text-secondary transition-colors hover:text-text-primary", - activeFolderIdentifier === folder.folderIdentifier && - "text-text-primary" - )} - > - {folder.iconUrl && ( - <img src={folder.iconUrl} alt="" width={16} height={16} className="shrink-0" loading="lazy" /> - )} - <span className="truncate">{folder.name}</span> - </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 - draggable - onDragStart={(event) => handleFeedDragStart(event, subscription.subscriptionIdentifier)} - {...((unreadCounts?.[subscription.feedIdentifier] ?? 0) > 0 ? { "data-has-unreads": "" } : {})} - onClick={closeSidebarOnMobile} - className={classNames( - NAVIGATION_LINK_CLASS, - "flex items-center truncate pl-6 text-[0.85em]", - activeFeedIdentifier === - subscription.feedIdentifier && ACTIVE_LINK_CLASS, - subscription.hiddenFromTimeline && "opacity-50", - sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) - )} - > - {showFeedFavicons && ( - <FeedFavicon feedUrl={subscription.feedUrl} /> - )} - <span className={classNames("truncate", showFeedFavicons && "ml-2")}> - {displayNameForSubscription(subscription)} - </span> - {subscription.feedVisibility === "authenticated" && ( - <span className="ml-1 shrink-0 text-text-dim" title="authenticated feed">🔒</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 - key="ungrouped" - className={classNames( - "mt-3 space-y-0.5", - isDraggingOverUngrouped && "outline outline-1 outline-text-dim" - )} - onDragOver={(event) => { - event.preventDefault() - event.dataTransfer.dropEffect = "move" - setIsDraggingOverUngrouped(true) - }} - onDragLeave={() => setIsDraggingOverUngrouped(false)} - onDrop={(event) => handleFolderDrop(event, null)} - > - {ungroupedSubscriptions.map((subscription) => ( - <Link - key={subscription.subscriptionIdentifier} - href={`/reader?feed=${subscription.feedIdentifier}`} - data-sidebar-nav-item - draggable - onDragStart={(event) => handleFeedDragStart(event, subscription.subscriptionIdentifier)} - {...((unreadCounts?.[subscription.feedIdentifier] ?? 0) > 0 ? { "data-has-unreads": "" } : {})} - onClick={closeSidebarOnMobile} - className={classNames( - NAVIGATION_LINK_CLASS, - "flex items-center truncate pl-4 text-[0.85em]", - activeFeedIdentifier === subscription.feedIdentifier && - ACTIVE_LINK_CLASS, - subscription.hiddenFromTimeline && "opacity-50", - sidebarFocusClass(focusedPanel, focusedSidebarIndex, navIndex++) - )} - > - {showFeedFavicons && ( - <FeedFavicon feedUrl={subscription.feedUrl} /> - )} - <span className={classNames("truncate", showFeedFavicons && "ml-2")}> - {displayNameForSubscription(subscription)} - </span> - {subscription.feedVisibility === "authenticated" && ( - <span className="ml-1 shrink-0 text-text-dim" title="authenticated feed">🔒</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> + 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, + })) - <div className="mt-3 space-y-0.5"> - <button - type="button" + 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 ( + <FeedDragOverlay + subscription={subscription} + showFeedFavicons={showFeedFavicons} + /> + ) + } + + if (activeDragType === "folder") { + const folder = folderMap.get(activeDragIdentifier) + if (!folder) return null + + return <FolderDragOverlay folder={folder} /> + } + + if (activeDragType === "customFeed") { + const customFeed = customFeedMap.get(activeDragIdentifier) + if (!customFeed) return null + + return <CustomFeedDragOverlay customFeed={customFeed} /> + } + + 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 ( + <DndContext + sensors={sensors} + collisionDetection={closestCenter} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onDragCancel={handleDragCancel} + > + <nav data-sidebar-nav className="flex-1 space-y-1 overflow-auto px-2"> + <Link + href="/reader" data-sidebar-nav-item - onClick={() => setAddFeedDialogOpen(true)} + {...(totalUnreadCount > 0 ? { "data-has-unreads": "" } : {})} + onClick={closeSidebarOnMobile} 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++) + NAVIGATION_LINK_CLASS, + "flex items-center", + pathname === "/reader" && + !activeFeedIdentifier && + !activeFolderIdentifier && + !activeCustomFeedIdentifier && + ACTIVE_LINK_CLASS, + sidebarFocusClass(focusedPanel, focusedSidebarIndex, staticNavigationStart) )} > - + add feed - </button> - </div> - </nav> + <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, staticNavigationStart + 1) + )} + > + 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, staticNavigationStart + 2) + )} + > + 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, staticNavigationStart + 3) + )} + > + shares + </Link> + + {customFeeds.length > 0 && ( + <SortableContext + items={customFeedIdentifiers} + strategy={verticalListSortingStrategy} + > + <div className="mt-3 space-y-0.5"> + {customFeeds.map((customFeed, customFeedIndex) => ( + <SortableCustomFeedItem + key={customFeed.identifier} + customFeed={customFeed} + activeCustomFeedIdentifier={activeCustomFeedIdentifier} + focusedPanel={focusedPanel} + focusedSidebarIndex={focusedSidebarIndex} + navigationIndex={customFeedNavigationStart + customFeedIndex} + closeSidebarOnMobile={closeSidebarOnMobile} + /> + ))} + </div> + </SortableContext> + )} + + {sections.map((section) => + section === "folders" ? ( + <SortableContext + key="folders" + items={folderIdentifiers} + strategy={verticalListSortingStrategy} + > + {folderData.map((folderDatum, folderIndex) => ( + <SortableFolderItem + key={folderDatum.folder.folderIdentifier} + folder={folderDatum.folder} + isExpanded={folderDatum.isExpanded} + folderSubscriptions={folderDatum.folderSubscriptions} + folderUnreadCount={folderDatum.folderUnreadCount} + activeFeedIdentifier={activeFeedIdentifier} + activeFolderIdentifier={activeFolderIdentifier} + showFeedFavicons={showFeedFavicons} + unreadCounts={unreadCounts} + focusedPanel={focusedPanel} + focusedSidebarIndex={focusedSidebarIndex} + folderNavigationIndex={folderNavigationIndices[folderIndex].folderNavigationIndex} + feedNavigationIndexStart={folderNavigationIndices[folderIndex].feedNavigationIndexStart} + toggleFolderExpansion={toggleFolderExpansion} + closeSidebarOnMobile={closeSidebarOnMobile} + /> + ))} + </SortableContext> + ) : ( + <SortableContext + key="ungrouped" + items={ungroupedFeedIdentifiers} + strategy={verticalListSortingStrategy} + > + <div className="mt-3 space-y-0.5"> + {ungroupedSubscriptions.map((subscription, subscriptionIndex) => ( + <SortableFeedItem + key={subscription.subscriptionIdentifier} + subscription={subscription} + activeFeedIdentifier={activeFeedIdentifier} + showFeedFavicons={showFeedFavicons} + unreadCount={ + unreadCounts?.[subscription.feedIdentifier] ?? 0 + } + focusedPanel={focusedPanel} + focusedSidebarIndex={focusedSidebarIndex} + navigationIndex={ungroupedNavigationStart + subscriptionIndex} + indentClass="pl-4" + closeSidebarOnMobile={closeSidebarOnMobile} + /> + ))} + </div> + </SortableContext> + ) + )} + + <div className="mt-3 space-y-0.5"> + <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, + addFeedNavigationIndex + ) + )} + > + + add feed + </button> + </div> + </nav> + + <DragOverlay dropAnimation={null}>{renderDragOverlay()}</DragOverlay> + </DndContext> ) } diff --git a/apps/web/lib/queries/use-custom-feed-mutations.ts b/apps/web/lib/queries/use-custom-feed-mutations.ts index 66c2a19..34120db 100644 --- a/apps/web/lib/queries/use-custom-feed-mutations.ts +++ b/apps/web/lib/queries/use-custom-feed-mutations.ts @@ -27,13 +27,23 @@ export function useCreateCustomFeed() { if (!user) throw new Error("not authenticated") + const { data: maxRow } = await supabaseClient + .from("custom_feeds") + .select("position") + .eq("user_id", user.id) + .order("position", { ascending: false }) + .limit(1) + .maybeSingle() + + const nextPosition = (maxRow?.position ?? 0) + 1000 + const { error } = await supabaseClient.from("custom_feeds").insert({ user_id: user.id, name, query, match_mode: matchMode, source_folder_id: sourceFolderIdentifier, - position: 0, + position: nextPosition, }) if (error) throw error diff --git a/apps/web/lib/queries/use-folder-mutations.ts b/apps/web/lib/queries/use-folder-mutations.ts index 4bc1247..cc84580 100644 --- a/apps/web/lib/queries/use-folder-mutations.ts +++ b/apps/web/lib/queries/use-folder-mutations.ts @@ -17,10 +17,20 @@ export function useCreateFolder() { if (!user) throw new Error("not authenticated") + const { data: maxRow } = await supabaseClient + .from("folders") + .select("position") + .eq("user_id", user.id) + .order("position", { ascending: false }) + .limit(1) + .maybeSingle() + + const nextPosition = (maxRow?.position ?? 0) + 1000 + const { error } = await supabaseClient.from("folders").insert({ user_id: user.id, name, - position: 0, + position: nextPosition, }) if (error) throw error diff --git a/apps/web/lib/queries/use-reorder-mutations.ts b/apps/web/lib/queries/use-reorder-mutations.ts new file mode 100644 index 0000000..8ed32e9 --- /dev/null +++ b/apps/web/lib/queries/use-reorder-mutations.ts @@ -0,0 +1,167 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { Subscription, Folder } from "@/lib/types/subscription" +import type { CustomFeed } from "@/lib/types/custom-feed" + +interface ReorderInput { + itemIdentifiers: string[] + newPositions: number[] +} + +export function useReorderSubscriptions() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ itemIdentifiers, newPositions }: ReorderInput) => { + const { error } = await supabaseClient.rpc("reorder_items", { + target_table: "subscriptions", + item_ids: itemIdentifiers, + new_positions: newPositions, + }) + + if (error) throw error + }, + onMutate: async ({ itemIdentifiers, newPositions }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.subscriptions.all }) + + const previous = queryClient.getQueryData<{ + subscriptions: Subscription[] + folders: Folder[] + }>(queryKeys.subscriptions.all) + + if (previous) { + const positionMap = new Map( + itemIdentifiers.map((identifier, positionIndex) => [identifier, newPositions[positionIndex]]) + ) + + queryClient.setQueryData(queryKeys.subscriptions.all, { + ...previous, + subscriptions: previous.subscriptions + .map((subscription) => ({ + ...subscription, + position: + positionMap.get(subscription.subscriptionIdentifier) ?? subscription.position, + })) + .sort((first, second) => first.position - second.position), + }) + } + + return { previous } + }, + onError: (_error, _variables, context) => { + if (context?.previous) { + queryClient.setQueryData(queryKeys.subscriptions.all, context.previous) + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + }, + }) +} + +export function useReorderFolders() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ itemIdentifiers, newPositions }: ReorderInput) => { + const { error } = await supabaseClient.rpc("reorder_items", { + target_table: "folders", + item_ids: itemIdentifiers, + new_positions: newPositions, + }) + + if (error) throw error + }, + onMutate: async ({ itemIdentifiers, newPositions }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.subscriptions.all }) + + const previous = queryClient.getQueryData<{ + subscriptions: Subscription[] + folders: Folder[] + }>(queryKeys.subscriptions.all) + + if (previous) { + const positionMap = new Map( + itemIdentifiers.map((identifier, positionIndex) => [identifier, newPositions[positionIndex]]) + ) + + queryClient.setQueryData(queryKeys.subscriptions.all, { + ...previous, + folders: previous.folders + .map((folder) => ({ + ...folder, + position: + positionMap.get(folder.folderIdentifier) ?? folder.position, + })) + .sort((first, second) => first.position - second.position), + }) + } + + return { previous } + }, + onError: (_error, _variables, context) => { + if (context?.previous) { + queryClient.setQueryData(queryKeys.subscriptions.all, context.previous) + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + }, + }) +} + +export function useReorderCustomFeeds() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ itemIdentifiers, newPositions }: ReorderInput) => { + const { error } = await supabaseClient.rpc("reorder_items", { + target_table: "custom_feeds", + item_ids: itemIdentifiers, + new_positions: newPositions, + }) + + if (error) throw error + }, + onMutate: async ({ itemIdentifiers, newPositions }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.customFeeds.all }) + + const previous = queryClient.getQueryData<CustomFeed[]>( + queryKeys.customFeeds.all + ) + + if (previous) { + const positionMap = new Map( + itemIdentifiers.map((identifier, positionIndex) => [identifier, newPositions[positionIndex]]) + ) + + queryClient.setQueryData( + queryKeys.customFeeds.all, + previous + .map((feed) => ({ + ...feed, + position: + positionMap.get(feed.identifier) ?? feed.position, + })) + .sort((first, second) => first.position - second.position) + ) + } + + return { previous } + }, + onError: (_error, _variables, context) => { + if (context?.previous) { + queryClient.setQueryData(queryKeys.customFeeds.all, context.previous) + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.customFeeds.all }) + }, + }) +} diff --git a/apps/web/lib/queries/use-subscription-mutations.ts b/apps/web/lib/queries/use-subscription-mutations.ts index 1570167..df4fe32 100644 --- a/apps/web/lib/queries/use-subscription-mutations.ts +++ b/apps/web/lib/queries/use-subscription-mutations.ts @@ -80,9 +80,24 @@ export function useMoveSubscriptionToFolder() { sourceFolderName?: string folderName?: string }) => { + let query = supabaseClient + .from("subscriptions") + .select("position") + .order("position", { ascending: false }) + .limit(1) + + if (folderIdentifier) { + query = query.eq("folder_id", folderIdentifier) + } else { + query = query.is("folder_id", null) + } + + const { data: maxRow } = await query.maybeSingle() + const nextPosition = (maxRow?.position ?? 0) + 1000 + const { error } = await supabaseClient .from("subscriptions") - .update({ folder_id: folderIdentifier }) + .update({ folder_id: folderIdentifier, position: nextPosition }) .eq("id", subscriptionIdentifier) if (error) throw error diff --git a/apps/web/lib/queries/use-subscriptions.ts b/apps/web/lib/queries/use-subscriptions.ts index 5dc6076..4185638 100644 --- a/apps/web/lib/queries/use-subscriptions.ts +++ b/apps/web/lib/queries/use-subscriptions.ts @@ -58,6 +58,7 @@ export function useSubscriptions() { feedIdentifier: row.feed_id, folderIdentifier: row.folder_id, customTitle: row.custom_title, + position: row.position, feedTitle: row.feeds?.title ?? "", feedUrl: row.feeds?.url ?? "", consecutiveFailures: row.feeds?.consecutive_failures ?? 0, diff --git a/apps/web/lib/reorder-positions.ts b/apps/web/lib/reorder-positions.ts new file mode 100644 index 0000000..928a00b --- /dev/null +++ b/apps/web/lib/reorder-positions.ts @@ -0,0 +1,34 @@ +const POSITION_GAP = 1000 + +export function computeReorderPositions< + T extends { identifier: string; position: number }, +>( + items: T[], + activeIdentifier: string, + overIdentifier: string +): { itemIdentifiers: string[]; newPositions: number[] } | null { + const oldIndex = items.findIndex((item) => item.identifier === activeIdentifier) + const newIndex = items.findIndex((item) => item.identifier === overIdentifier) + + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return null + + const reordered = [...items] + const [moved] = reordered.splice(oldIndex, 1) + reordered.splice(newIndex, 0, moved) + + const itemIdentifiers: string[] = [] + const newPositions: number[] = [] + + for (let index = 0; index < reordered.length; index++) { + const newPosition = (index + 1) * POSITION_GAP + + if (reordered[index].position !== newPosition) { + itemIdentifiers.push(reordered[index].identifier) + newPositions.push(newPosition) + } + } + + if (itemIdentifiers.length === 0) return null + + return { itemIdentifiers, newPositions } +} diff --git a/apps/web/lib/types/subscription.ts b/apps/web/lib/types/subscription.ts index 96f314d..d59556a 100644 --- a/apps/web/lib/types/subscription.ts +++ b/apps/web/lib/types/subscription.ts @@ -10,6 +10,7 @@ export interface Subscription { feedIdentifier: string folderIdentifier: string | null customTitle: string | null + position: number feedTitle: string feedUrl: string consecutiveFailures: number diff --git a/apps/web/package.json b/apps/web/package.json index 0b5aa4c..80d6f25 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,9 @@ }, "dependencies": { "@asa-news/shared": "workspace:*", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@serwist/next": "^9.5.4", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.95.2", |