summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-12 01:28:18 -0800
committerFuwn <[email protected]>2026-02-12 01:52:32 -0800
commit6d8f7ea8b30e229cc0662db8dc3438828feb6880 (patch)
tree9e9270f6f7d6d3cb9c11fb5393eb44e1a44ad418
parentfix: invalidate unread counts when toggling individual entry read state (diff)
downloadasa.news-6d8f7ea8b30e229cc0662db8dc3438828feb6880.tar.xz
asa.news-6d8f7ea8b30e229cc0662db8dc3438828feb6880.zip
feat: add drag-and-drop reordering for feeds, folders, and custom feeds
-rw-r--r--.gitignore2
-rw-r--r--apps/web/app/reader/_components/sidebar-content.tsx1030
-rw-r--r--apps/web/lib/queries/use-custom-feed-mutations.ts12
-rw-r--r--apps/web/lib/queries/use-folder-mutations.ts12
-rw-r--r--apps/web/lib/queries/use-reorder-mutations.ts167
-rw-r--r--apps/web/lib/queries/use-subscription-mutations.ts17
-rw-r--r--apps/web/lib/queries/use-subscriptions.ts1
-rw-r--r--apps/web/lib/reorder-positions.ts34
-rw-r--r--apps/web/lib/types/subscription.ts1
-rw-r--r--apps/web/package.json3
-rw-r--r--pnpm-lock.yaml56
-rw-r--r--supabase/schema.sql53
12 files changed, 1089 insertions, 299 deletions
diff --git a/.gitignore b/.gitignore
index 2c1d781..dc06b32 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,5 +21,7 @@ services/worker/worker
kaze/
+docs/
+
# Supabase CLI temp files
supabase/.temp/
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"
+ >
+ &#128274;
+ </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={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">&#128274;</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
- 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">&#128274;</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>
+ 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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 56fa644..26df00d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,15 @@ importers:
'@asa-news/shared':
specifier: workspace:*
version: link:../../packages/shared
+ '@dnd-kit/core':
+ specifier: ^6.3.1
+ '@dnd-kit/sortable':
+ specifier: ^10.0.0
+ '@dnd-kit/utilities':
+ specifier: ^3.2.2
+ version: 3.2.2([email protected])
'@serwist/next':
specifier: ^9.5.4
@@ -203,6 +212,28 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
+ '@dnd-kit/[email protected]':
+ resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
+ peerDependencies:
+ react: '>=16.8.0'
+
+ '@dnd-kit/[email protected]':
+ resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@dnd-kit/[email protected]':
+ resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
+ peerDependencies:
+ '@dnd-kit/core': ^6.3.0
+ react: '>=16.8.0'
+
+ '@dnd-kit/[email protected]':
+ resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
+ peerDependencies:
+ react: '>=16.8.0'
+
'@emnapi/[email protected]':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -3315,6 +3346,31 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
+ dependencies:
+ react: 19.2.3
+ tslib: 2.8.1
+
+ dependencies:
+ '@dnd-kit/accessibility': 3.1.1([email protected])
+ '@dnd-kit/utilities': 3.2.2([email protected])
+ react: 19.2.3
+ react-dom: 19.2.3([email protected])
+ tslib: 2.8.1
+
+ dependencies:
+ '@dnd-kit/utilities': 3.2.2([email protected])
+ react: 19.2.3
+ tslib: 2.8.1
+
+ dependencies:
+ react: 19.2.3
+ tslib: 2.8.1
+
'@emnapi/[email protected]':
dependencies:
'@emnapi/wasi-threads': 1.1.0
diff --git a/supabase/schema.sql b/supabase/schema.sql
index 3f91444..fde7232 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -1030,6 +1030,51 @@ $$;
ALTER FUNCTION "public"."search_entries"("p_query" "text", "p_result_limit" integer) OWNER TO "postgres";
--
+-- Name: reorder_items("text", "uuid"[], integer[]); Type: FUNCTION; Schema: public; Owner: postgres
+--
+
+CREATE OR REPLACE FUNCTION "public"."reorder_items"("target_table" "text", "item_ids" "uuid"[], "new_positions" integer[])
+RETURNS void
+LANGUAGE "plpgsql" SECURITY DEFINER
+SET "search_path" TO ''
+AS $$
+DECLARE
+ current_user_id uuid := auth.uid();
+ i integer;
+BEGIN
+ IF current_user_id IS NULL THEN
+ RAISE EXCEPTION 'Not authenticated';
+ END IF;
+
+ IF array_length(item_ids, 1) IS DISTINCT FROM array_length(new_positions, 1) THEN
+ RAISE EXCEPTION 'item_ids and new_positions must have the same length';
+ END IF;
+
+ IF target_table NOT IN ('subscriptions', 'folders', 'custom_feeds') THEN
+ RAISE EXCEPTION 'Invalid table: %', target_table;
+ END IF;
+
+ FOR i IN 1..array_length(item_ids, 1) LOOP
+ IF target_table = 'subscriptions' THEN
+ UPDATE public.subscriptions
+ SET position = new_positions[i]
+ WHERE id = item_ids[i] AND user_id = current_user_id;
+ ELSIF target_table = 'folders' THEN
+ UPDATE public.folders
+ SET position = new_positions[i]
+ WHERE id = item_ids[i] AND user_id = current_user_id;
+ ELSIF target_table = 'custom_feeds' THEN
+ UPDATE public.custom_feeds
+ SET position = new_positions[i]
+ WHERE id = item_ids[i] AND user_id = current_user_id;
+ END IF;
+ END LOOP;
+END;
+$$;
+
+ALTER FUNCTION "public"."reorder_items"("target_table" "text", "item_ids" "uuid"[], "new_positions" integer[]) OWNER TO "postgres";
+
+--
-- Name: subscribe_to_feed("text", "uuid", "text", "text", "text"); Type: FUNCTION; Schema: public; Owner: postgres
--
@@ -3402,6 +3447,14 @@ GRANT ALL ON FUNCTION "public"."search_entries"("p_query" "text", "p_result_limi
--
+-- Name: FUNCTION "reorder_items"("target_table" "text", "item_ids" "uuid"[], "new_positions" integer[]); Type: ACL; Schema: public; Owner: postgres
+--
+
+GRANT ALL ON FUNCTION "public"."reorder_items"("target_table" "text", "item_ids" "uuid"[], "new_positions" integer[]) TO "authenticated";
+GRANT ALL ON FUNCTION "public"."reorder_items"("target_table" "text", "item_ids" "uuid"[], "new_positions" integer[]) TO "service_role";
+
+
+--
-- Name: FUNCTION "subscribe_to_feed"("feed_url" "text", "target_folder_id" "uuid", "feed_custom_title" "text", "feed_credential" "text", "feed_auth_type" "text"); Type: ACL; Schema: public; Owner: postgres
--