diff --git a/src/__tests__/e2e/rss.spec.ts b/src/__tests__/e2e/rss.spec.ts index 4ba9562..581a23a 100644 --- a/src/__tests__/e2e/rss.spec.ts +++ b/src/__tests__/e2e/rss.spec.ts @@ -92,6 +92,66 @@ test("clicking an article opens the add dialog with article context", async ({ p await expect.poll(() => addBody).toContain("magnet:?xt=v2"); }); +test("quick download adds the torrent directly without the dialog", async ({ page }) => { + let addBody = ""; + await page.route("**/api/v2/torrents/add", (route) => { + addBody = route.request().postData() ?? ""; + route.fulfill({ status: 200, body: "Ok." }); + }); + + await feedRow(page).click(); + // real right-click: a plain Event has button undefined (fixtures README) + await page.getByText("New Release v2.0").evaluate((el) => { + el.dispatchEvent(new MouseEvent("contextmenu", { button: 2, bubbles: true })); + }); + await page.getByRole("menuitem", { name: "Quick Download" }).click(); + // POSTs the magnet straight away with server-default parameters + await expect.poll(() => addBody).toContain("magnet:?xt=v2"); + // the add-torrent dialog never opens, and the flow reports success + await expect(page.getByRole("dialog")).toBeHidden(); + await expect(page.locator("[data-sonner-toast]").getByText("Added successfully")).toBeVisible(); +}); + +test("Shift+Enter on the selected article quick-downloads it", async ({ page }) => { + let addBody = ""; + await page.route("**/api/v2/torrents/add", (route) => { + addBody = route.request().postData() ?? ""; + route.fulfill({ status: 200, body: "Ok." }); + }); + + await feedRow(page).click(); + await page.keyboard.press("j"); // select the first (unread) article + await page.keyboard.press("Shift+Enter"); + await expect.poll(() => addBody).toContain("magnet:?xt=v2"); + // still no dialog — Shift+Enter is the keyboard path of Quick Download + await expect(page.getByRole("dialog")).toBeHidden(); +}); + +test("article sort survives switching pages and reloads", async ({ page }) => { + const options = () => page.locator('[role="option"]'); + await feedRow(page).click(); + await expect(options()).toHaveCount(2); + // default date-desc puts New Release first; sorting by title flips it + await expect(options().first()).toContainText("New Release v2.0"); + await page.getByRole("button", { name: "Title" }).click(); + await expect(options().first()).toContainText("Bug Fix v1.9"); + + // leave the RSS page and come back (hash navigation, no reload) + await page.evaluate(() => { + window.location.hash = "#/"; + }); + await page.evaluate(() => { + window.location.hash = "#/rss"; + }); + await expect(options()).toHaveCount(2); + await expect(options().first()).toContainText("Bug Fix v1.9"); + + // the choice also lives in the persisted ui store (survives reloads) + const stored = await page.evaluate(() => localStorage.getItem("qiubi-ui")); + expect(stored).toContain('"articleSort"'); + expect(stored).toContain('"key":"title"'); +}); + test("add feed dialog opens", async ({ page }) => { await page.getByRole("button", { name: /Add Feed/i }).click(); await expect(page.getByRole("heading", { name: /Add Feed/i })).toBeVisible(); diff --git a/src/i18n/en/rss.ts b/src/i18n/en/rss.ts index 667f5db..734a438 100644 --- a/src/i18n/en/rss.ts +++ b/src/i18n/en/rss.ts @@ -38,7 +38,7 @@ export default { "RSS is disabled on the server — enable it in Settings → RSS", "Copy Feed URL": "Copy Feed URL", "RSS hotkeys hint": - "j/k select article · Shift+J/K switch feed · Enter download · r refresh · Shift+R refresh all", + "j/k select article · Shift+J/K switch feed · Enter download · Shift+Enter quick download · r refresh · Shift+R refresh all", Title: "Title", Date: "Date", Read: "Read", @@ -46,4 +46,5 @@ export default { "Feed failed to refresh — see the server execution log", Refreshing: "Refreshing…", "{name} failed to refresh": "{name} failed to refresh", + "Quick Download": "Quick Download", } satisfies Dictionary; diff --git a/src/i18n/es/rss.ts b/src/i18n/es/rss.ts index 1bc36c4..ff5519a 100644 --- a/src/i18n/es/rss.ts +++ b/src/i18n/es/rss.ts @@ -38,7 +38,7 @@ export default { "RSS está desactivado en el servidor — actívalo en Ajustes → RSS", "Copy Feed URL": "Copiar URL del canal", "RSS hotkeys hint": - "j/k elegir artículo · Shift+J/K cambiar fuente · Enter descargar · r actualizar · Shift+R actualizar todo", + "j/k elegir artículo · Shift+J/K cambiar fuente · Enter descargar · Shift+Enter descarga rápida · r actualizar · Shift+R actualizar todo", Title: "Título", Date: "Fecha", Read: "Leído", @@ -46,4 +46,5 @@ export default { "El canal no se pudo actualizar — consulta el registro de ejecución del servidor", Refreshing: "Actualizando…", "{name} failed to refresh": "Error al actualizar {name}", + "Quick Download": "Descarga rápida", } satisfies Dictionary; diff --git a/src/i18n/ja/rss.ts b/src/i18n/ja/rss.ts index bb224b6..8e1758e 100644 --- a/src/i18n/ja/rss.ts +++ b/src/i18n/ja/rss.ts @@ -38,7 +38,7 @@ export default { "サーバーで RSS が無効です — 設定 → RSS で有効にしてください", "Copy Feed URL": "フィードURLをコピー", "RSS hotkeys hint": - "j/k 記事を選択 · Shift+J/K フィード切替 · Enter ダウンロード · r 更新 · Shift+R すべて更新", + "j/k 記事を選択 · Shift+J/K フィード切替 · Enter ダウンロード · Shift+Enter クイックダウンロード · r 更新 · Shift+R すべて更新", Title: "タイトル", Date: "日付", Read: "既読", @@ -46,4 +46,5 @@ export default { "フィードの更新に失敗しました — サーバーの実行ログを確認してください", Refreshing: "更新中…", "{name} failed to refresh": "{name} の更新に失敗しました", + "Quick Download": "クイックダウンロード", } satisfies Dictionary; diff --git a/src/i18n/ko/rss.ts b/src/i18n/ko/rss.ts index 09465f8..c2735e9 100644 --- a/src/i18n/ko/rss.ts +++ b/src/i18n/ko/rss.ts @@ -38,7 +38,7 @@ export default { "서버에서 RSS가 비활성되어 있습니다 — 설정 → RSS에서 활성화하세요", "Copy Feed URL": "피드 URL 복사", "RSS hotkeys hint": - "j/k 기사 선택 · Shift+J/K 피드 전환 · Enter 다운로드 · r 새로 고침 · Shift+R 모두 새로 고침", + "j/k 기사 선택 · Shift+J/K 피드 전환 · Enter 다운로드 · Shift+Enter 빠른 다운로드 · r 새로 고침 · Shift+R 모두 새로 고침", Title: "제목", Date: "날짜", Read: "읽음", @@ -46,4 +46,5 @@ export default { "피드 새로 고침 실패 — 서버 실행 로그를 확인하세요", Refreshing: "새로 고치는 중…", "{name} failed to refresh": "{name} 새로 고침 실패", + "Quick Download": "빠른 다운로드", } satisfies Dictionary; diff --git a/src/i18n/pt/rss.ts b/src/i18n/pt/rss.ts index 9619da3..b41ad3f 100644 --- a/src/i18n/pt/rss.ts +++ b/src/i18n/pt/rss.ts @@ -38,7 +38,7 @@ export default { "RSS está desativado no servidor — ative em Configurações → RSS", "Copy Feed URL": "Copiar URL do feed", "RSS hotkeys hint": - "j/k escolher artigo · Shift+J/K trocar feed · Enter baixar · r atualizar · Shift+R atualizar tudo", + "j/k escolher artigo · Shift+J/K trocar feed · Enter baixar · Shift+Enter download rápido · r atualizar · Shift+R atualizar tudo", Title: "Título", Date: "Data", Read: "Lido", @@ -46,4 +46,5 @@ export default { "O feed falhou ao atualizar — veja o log de execução do servidor", Refreshing: "Atualizando…", "{name} failed to refresh": "Falha ao atualizar {name}", + "Quick Download": "Download rápido", } satisfies Dictionary; diff --git a/src/i18n/ru/rss.ts b/src/i18n/ru/rss.ts index e7a3d71..668a9ac 100644 --- a/src/i18n/ru/rss.ts +++ b/src/i18n/ru/rss.ts @@ -38,7 +38,7 @@ export default { "RSS отключён на сервере — включите в Настройки → RSS", "Copy Feed URL": "Копировать ссылку на ленту", "RSS hotkeys hint": - "j/k выбор статьи · Shift+J/K смена ленты · Enter скачать · r обновить · Shift+R обновить все", + "j/k выбор статьи · Shift+J/K смена ленты · Enter скачать · Shift+Enter быстрая загрузка · r обновить · Shift+R обновить все", Title: "Заголовок", Date: "Дата", Read: "Прочитано", @@ -46,4 +46,5 @@ export default { "Не удалось обновить подписку — подробности в журнале сервера", Refreshing: "Обновление…", "{name} failed to refresh": "Не удалось обновить {name}", + "Quick Download": "Быстрая загрузка", } satisfies Dictionary; diff --git a/src/i18n/zh-TW/rss.ts b/src/i18n/zh-TW/rss.ts index 4be83d8..fd34aab 100644 --- a/src/i18n/zh-TW/rss.ts +++ b/src/i18n/zh-TW/rss.ts @@ -38,7 +38,7 @@ export default { "RSS 功能已在服務端關閉,請到 設定 → RSS 中開啟", "Copy Feed URL": "複製 RSS 來源網址", "RSS hotkeys hint": - "j/k 選擇文章 · Shift+J/K 切換訂閱源 · Enter 下載 · r 重新整理 · Shift+R 全部重新整理", + "j/k 選擇文章 · Shift+J/K 切換訂閱源 · Enter 下載 · Shift+Enter 快速下載 · r 重新整理 · Shift+R 全部重新整理", Title: "標題", Date: "日期", Read: "已讀", @@ -46,4 +46,5 @@ export default { "訂閱源重新整理失敗 — 詳情見伺服器執行記錄", Refreshing: "正在重新整理…", "{name} failed to refresh": "{name} 重新整理失敗", + "Quick Download": "快速下載", } satisfies Dictionary; diff --git a/src/i18n/zh/rss.ts b/src/i18n/zh/rss.ts index 6ba408a..4ef28f8 100644 --- a/src/i18n/zh/rss.ts +++ b/src/i18n/zh/rss.ts @@ -38,11 +38,12 @@ export default { "RSS 功能已在服务端关闭,请到 设置 → RSS 中开启", "Copy Feed URL": "复制 RSS 源地址", "RSS hotkeys hint": - "j/k 选择文章 · Shift+J/K 切换订阅源 · Enter 下载 · r 刷新 · Shift+R 全部刷新", + "j/k 选择文章 · Shift+J/K 切换订阅源 · Enter 下载 · Shift+Enter 快速下载 · r 刷新 · Shift+R 全部刷新", Title: "标题", Date: "日期", Read: "已读", "Feed failed to refresh — see the server execution log": "订阅源刷新失败 — 详情见服务端执行日志", Refreshing: "正在刷新…", "{name} failed to refresh": "{name} 刷新失败", + "Quick Download": "快速下载", } satisfies Dictionary; diff --git a/src/pages/rss/ArticleList.tsx b/src/pages/rss/ArticleList.tsx index ce8313f..3ede09a 100644 --- a/src/pages/rss/ArticleList.tsx +++ b/src/pages/rss/ArticleList.tsx @@ -1,4 +1,4 @@ -import { ChevronRight, Download, ExternalLink, RefreshCw, Rss } from "lucide-react"; +import { ChevronRight, Download, ExternalLink, RefreshCw, Rss, Zap } from "lucide-react"; import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Shortcut } from "@/components/Shortcut"; @@ -67,13 +67,16 @@ interface ArticleListProps { /** Click / "Download" menu item → open the add-torrent dialog with article * context */ onDownload: (article: RssArticle) => void; + /** "Quick Download" menu item → add with server defaults, no dialog */ + onQuickDownload: (article: RssArticle) => void; /** Refresh just the selected feed */ onRefresh: (path: string) => void; } /** Right column: article table — sortable by title / date / read status. * Click opens the add dialog (with article context); right-click / - * long-press menu offers download or opening the article's webpage. */ + * long-press menu offers download, quick download (server defaults, no + * dialog) or opening the article's webpage. */ export function ArticleList({ selectedFeed, articles, @@ -82,6 +85,7 @@ export function ArticleList({ selectedArticleId, onSelectArticle, onDownload, + onQuickDownload, onRefresh, }: ArticleListProps) { const { t } = useTranslation(); @@ -235,6 +239,10 @@ export function ArticleList({ {t("Download")} + onQuickDownload(article)}> + + {t("Quick Download")} + {article.link && ( window.open(article.link, "_blank")}> diff --git a/src/pages/rss/index.tsx b/src/pages/rss/index.tsx index e81a6b9..9b0b224 100644 --- a/src/pages/rss/index.tsx +++ b/src/pages/rss/index.tsx @@ -20,8 +20,9 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { pollWithBackoff } from "@/hooks/useMainDataSync"; +import { useUiStore } from "@/stores/ui"; import type { AppPreferences, RssArticle, RssFeed } from "@/types/qbt"; -import { ArticleList, type ArticleSort, sortArticles } from "./ArticleList"; +import { ArticleList, sortArticles } from "./ArticleList"; import { FeedTree, flattenFeeds } from "./FeedTree"; /** Apply fn to the map that CONTAINS the feed/folder at `path` — used for @@ -92,11 +93,11 @@ export function RssPage() { const selected = flat.find((x) => x.path === selectedFeed); const selectedArticles: RssArticle[] = selected?.feed.articles ?? []; /** Display order (date-desc by default; the article table headers can - * re-sort) — the exact order ArticleList renders; keyboard nav walks it */ - const [articleSort, setArticleSort] = useState({ - key: "date", - dir: "desc", - }); + * re-sort) — the exact order ArticleList renders; keyboard nav walks it. + * Lives in the persistent ui store so the choice survives page switches + * and reloads, like the torrent table's sorting. */ + const articleSort = useUiStore((s) => s.articleSort); + const setArticleSort = useUiStore((s) => s.setArticleSort); const sortedArticles = useMemo( () => sortArticles(selectedArticles, articleSort), [selectedArticles, articleSort], @@ -225,6 +226,24 @@ export function RssPage() { }); }; + /** Context-menu "Quick Download": add the article's torrent with the + * server's default parameters — no dialog round-trip. Counts as reading + * like the dialog flow does. */ + const quickDownloadArticle = async (article: RssArticle) => { + try { + await qbtClient.addTorrent({ urls: article.torrentURL || article.link }); + toast.success(t("Added successfully")); + if (selectedFeed && !article.isRead) { + qbtClient + .markRssAsRead(selectedFeed, article.id) + .then(() => qc.invalidateQueries({ queryKey: ["rss-feeds"] })) + .catch(() => {}); + } + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e)); + } + }; + /** * qBT's refreshItem is fire-and-forget — it 200s before the fetch even * starts (and silently for an unknown itemPath), so the real outcome only @@ -363,6 +382,7 @@ export function RssPage() { refreshOne, refreshAll, downloadArticle, + quickDownloadArticle, sortedArticles, feedPaths: [] as string[], }); @@ -370,6 +390,7 @@ export function RssPage() { refreshOne, refreshAll, downloadArticle, + quickDownloadArticle, sortedArticles, feedPaths: feedOnly.map((f) => f.path), }; @@ -415,7 +436,12 @@ export function RssPage() { } if (e.key === "Enter") { const article = hotkeyRef.current.sortedArticles.find((a) => a.id === selectedArticleId); - if (article) void hotkeyRef.current.downloadArticle(article); + // Enter opens the prefilled add dialog; Shift+Enter quick-downloads + // with server defaults, no dialog + if (article) + void (e.shiftKey + ? hotkeyRef.current.quickDownloadArticle(article) + : hotkeyRef.current.downloadArticle(article)); return; } if (e.key !== "r" && e.key !== "R") return; @@ -524,6 +550,7 @@ export function RssPage() { selectedArticleId={selectedArticleId} onSelectArticle={setSelectedArticleId} onDownload={downloadArticle} + onQuickDownload={quickDownloadArticle} onRefresh={refreshOne} /> diff --git a/src/stores/ui.ts b/src/stores/ui.ts index fd96862..99ac931 100644 --- a/src/stores/ui.ts +++ b/src/stores/ui.ts @@ -1,6 +1,7 @@ import type { SortingState } from "@tanstack/react-table"; import { create } from "zustand"; import { persist } from "zustand/middleware"; +import type { ArticleSort } from "@/pages/rss/ArticleList"; type UiState = { /** View menu: show/hide the filter sidebar */ @@ -15,6 +16,9 @@ type UiState = { columnOrder: string[]; /** Torrent list sorting (header click), persisted across reloads */ torrentSorting: SortingState; + /** RSS article table sorting (header click), persisted across reloads and + * page switches — the same ArticleSort the article table sorts by */ + articleSort: ArticleSort; /** Last status filter chosen in the toolbar; a fresh open without ?status= * in the URL seeds the filter from it (explicit URLs always win) */ lastTorrentStatus: string; @@ -33,6 +37,7 @@ type UiState = { setColumnVisibility: (v: Record) => void; setColumnOrder: (order: string[]) => void; setTorrentSorting: (updater: SortingState | ((prev: SortingState) => SortingState)) => void; + setArticleSort: (sort: ArticleSort) => void; setLastTorrentStatus: (status: string) => void; setDetailPanelOpen: (open: boolean) => void; setDetailHash: (hash: string | null) => void; @@ -47,6 +52,7 @@ export const useUiStore = create()( columnVisibility: {}, columnOrder: [], torrentSorting: [], + articleSort: { key: "date", dir: "desc" }, lastTorrentStatus: "all", detailPanelOpen: false, detailHash: null, @@ -67,6 +73,7 @@ export const useUiStore = create()( set((s) => ({ torrentSorting: typeof updater === "function" ? updater(s.torrentSorting) : updater, })), + setArticleSort: (articleSort) => set({ articleSort }), setLastTorrentStatus: (lastTorrentStatus) => set({ lastTorrentStatus }), setDetailPanelOpen: (detailPanelOpen) => set({ detailPanelOpen }), setDetailHash: (detailHash) => set({ detailHash }), @@ -85,6 +92,7 @@ export const useUiStore = create()( columnVisibility: s.columnVisibility, columnOrder: s.columnOrder, torrentSorting: s.torrentSorting, + articleSort: s.articleSort, lastTorrentStatus: s.lastTorrentStatus, detailPanelOpen: s.detailPanelOpen, }),