diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index a63ec5a1..d3183567 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -1,9 +1,11 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { UserPlus } from "lucide-react"; import { api, type DiscoveredChannel } from "../lib/api"; -import { accountKey, LS } from "../lib/storage"; +import { accountKey, LS, readAccount, writeAccount } from "../lib/storage"; import { formatCountOrDash } from "../lib/format"; +import { sortRows, type SortState } from "../lib/columnSort"; import { subsColumn } from "./channelColumns"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; @@ -12,6 +14,7 @@ import ChannelLink from "./ChannelLink"; import AboutCell from "./AboutCell"; import DataTable, { type Column } from "./DataTable"; import ChannelLayoutGrid from "./ChannelLayoutGrid"; +import ColumnSortControl from "./ColumnSortControl"; import { PageToolbar } from "./PageShell"; import { useConfirm } from "./ConfirmProvider"; import { useChannels } from "./ChannelsProvider"; @@ -34,8 +37,16 @@ export default function ChannelDiscovery({ const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - // The table/cards/rows density is shared with the Subscriptions tab (ChannelsProvider). + // The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is + // this tab's own (its columns differ), lifted out of the table so the card layout shares it. const { layout } = useChannels(); + const [sort, setSortState] = useState(() => + readAccount(LS.channelDiscoverySort, null), + ); + const setSort = (s: SortState) => { + setSortState(s); + writeAccount(LS.channelDiscoverySort, s); + }; const query = useQuery({ queryKey: ["discovered-channels"], @@ -173,8 +184,20 @@ export default function ChannelDiscovery({ <> {/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */} -
+

{t("channels.discovery.intro")}

+ {/* Cards has no column headers, so a dropdown drives the sort (table uses the headers). */} + {layout === "cards" && ( + + )}
@@ -189,11 +212,13 @@ export default function ChannelDiscovery({ controlsPosition="both" controlsInBand controlsBandClassName="px-4 max-w-[96rem] mx-auto" + sort={sort} + onSortChange={setSort} emptyText={t("channels.discovery.empty")} /> ) : ( c.id} aboutKey="about" diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index a02894ca..f2277382 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -29,7 +29,9 @@ import Tooltip from "./Tooltip"; import DataTable, { type Column } from "./DataTable"; import ChannelLayoutGrid from "./ChannelLayoutGrid"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; +import ColumnSortControl from "./ColumnSortControl"; import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout"; +import { sortRows } from "../lib/columnSort"; import { PageToolbar } from "./PageShell"; import ChannelLink from "./ChannelLink"; import AboutCell from "./AboutCell"; @@ -72,11 +74,12 @@ export default function Channels() { statusFilter, view, layout, + sort, filtersResetToken, focusName: focusChannelName, focusToken: focusChannelToken, } = useChannels(); - const { setSearch, setStatusFilter, setView, setLayout, focusChannel: onFocusChannel } = + const { setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel: onFocusChannel } = useChannelsActions(); const { openChannel: onViewChannel, setPage } = useNavigationActions(); const feedFilters = useFeedFilters(); @@ -497,6 +500,19 @@ export default function Channels() { /> ); const hasLayout = view === "subscribed" || view === "discovery"; + // In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state + // the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks. + const sortControl = ( + + ); const tabs = (
@@ -517,6 +533,7 @@ export default function Channels() {
{view === "subscribed" && statsBlock} + {view === "subscribed" && layout === "cards" && sortControl} {hasLayout && layoutSwitcher}
@@ -651,12 +668,14 @@ export default function Channels() { controlsLeading={statusChips} controlsInBand controlsBandClassName="px-4 max-w-[96rem] mx-auto" + sort={sort} + onSortChange={setSort} rowClassName={(c) => (c.hidden ? "opacity-60" : "")} emptyText={t("channels.empty")} /> ) : ( c.id} leadKey="priority" diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx index 136334bc..c7989d5d 100644 --- a/frontend/src/components/ChannelsProvider.tsx +++ b/frontend/src/components/ChannelsProvider.tsx @@ -7,8 +7,9 @@ import { useState, type ReactNode, } from "react"; -import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; +import { getAccountRaw, LS, readAccount, setAccountRaw, writeAccount } from "../lib/storage"; import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout"; +import { type SortState } from "../lib/columnSort"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; import type { ChannelStatusFilter, ChannelsView } from "./Channels"; @@ -42,8 +43,10 @@ type ChannelsState = { search: string; statusFilter: ChannelStatusFilter; view: ChannelsView; - // The table/cards/rows density (shared by the subscribed + discovery tabs). + // The table/cards density (shared by the subscribed + discovery tabs). layout: ChannelLayout; + // The subscribed table's sort, lifted out of DataTable so the card layout shares it. + sort: SortState; // Bumped to tell the manager to drop a stale column filter when an intent sends the user to a // specific set (the header's "without full history" link). filtersResetToken: number; @@ -58,6 +61,7 @@ interface ChannelsActions { setStatusFilter: (f: ChannelStatusFilter) => void; setView: (v: ChannelsView) => void; setLayout: (l: ChannelLayout) => void; + setSort: (s: SortState) => void; // "Focus this channel in the manager": jump to Channels (subscriptions tab) and seed its name // filter so the channel is isolated. Called from the feed sidebar and the notifications inbox. focusChannel: (name: string) => void; @@ -70,6 +74,7 @@ const StateContext = createContext({ statusFilter: "all", view: "subscribed", layout: "table", + sort: null, filtersResetToken: 0, focusName: null, focusToken: 0, @@ -79,6 +84,7 @@ const ActionsContext = createContext({ setStatusFilter: () => {}, setView: () => {}, setLayout: () => {}, + setSort: () => {}, focusChannel: () => {}, goToFullHistory: () => {}, }); @@ -104,6 +110,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { const [layout, setLayoutState] = useState(() => parseChannelLayout(getAccountRaw(LS.channelLayout)), ); + const [sort, setSortState] = useState(() => readAccount(LS.channelSort, null)); const [filtersResetToken, setFiltersResetToken] = useState(0); const [focusName, setFocusName] = useState(null); const [focusToken, setFocusToken] = useState(0); @@ -121,6 +128,10 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { setLayoutState(l); setAccountRaw(LS.channelLayout, l); }, []); + const setSort = useCallback((s: SortState) => { + setSortState(s); + writeAccount(LS.channelSort, s); + }, []); const focusChannel = useCallback( (name: string) => { @@ -144,12 +155,12 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { }, [page]); const state = useMemo( - () => ({ search, statusFilter, view, layout, filtersResetToken, focusName, focusToken }), - [search, statusFilter, view, layout, filtersResetToken, focusName, focusToken], + () => ({ search, statusFilter, view, layout, sort, filtersResetToken, focusName, focusToken }), + [search, statusFilter, view, layout, sort, filtersResetToken, focusName, focusToken], ); const actions = useMemo( - () => ({ setSearch, setStatusFilter, setView, setLayout, focusChannel, goToFullHistory }), - [setSearch, setStatusFilter, setView, setLayout, focusChannel, goToFullHistory], + () => ({ setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory }), + [setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory], ); return ( diff --git a/frontend/src/components/ColumnSortControl.tsx b/frontend/src/components/ColumnSortControl.tsx new file mode 100644 index 00000000..78f30294 --- /dev/null +++ b/frontend/src/components/ColumnSortControl.tsx @@ -0,0 +1,60 @@ +import { ArrowDown, ArrowUp } from "lucide-react"; +import { type Column } from "./DataTable"; +import { type SortState } from "../lib/columnSort"; + +// A sort control for views without clickable column headers (the channel manager's card layout): +// a dropdown of the sortable columns + an asc/desc toggle, mirroring the feed toolbar's sort. It's +// driven by the SAME `SortState` the DataTable header uses, so table and cards stay in sync. The +// dropdown's options come straight from the columns' `sortable`/`sortValue` metadata — no new list. +export default function ColumnSortControl({ + columns, + sort, + onChange, + label, + defaultLabel, + ascLabel, + descLabel, +}: { + columns: Column[]; + sort: SortState; + onChange: (next: SortState) => void; + /** Accessible name for the dropdown (e.g. "Sort"). */ + label: string; + /** The "no sort / original order" option's text. */ + defaultLabel: string; + ascLabel: string; + descLabel: string; +}) { + const sortable = columns.filter((c) => c.sortable && c.sortValue); + return ( +
+ + {sort && ( + + )} +
+ ); +} diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index e9140334..40130214 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react"; import { useDismiss } from "../lib/useDismiss"; import { useScrollFade } from "../lib/useScrollFade"; +import { sortRows, cycleSort, type SortState } from "../lib/columnSort"; import { PageToolbar } from "./PageShell"; // A reusable, client-side data table: per-column sort + filter (in the header) + pagination, @@ -50,7 +51,6 @@ export interface Column { cardLabel?: boolean; } -type SortState = { key: string; dir: "asc" | "desc" } | null; type FilterMap = Record; interface Persisted { sort: SortState; @@ -89,6 +89,8 @@ export default function DataTable({ controlsBandClassName, externalFilter, resetFiltersToken, + sort: sortProp, + onSortChange, }: { rows: T[]; columns: Column[]; @@ -112,10 +114,20 @@ export default function DataTable({ // set" (e.g. the header's "without full history" link), so a stale persisted filter can't // hide the rows the user was just sent to see. resetFiltersToken?: number; + // CONTROLLED sort (optional): when `onSortChange` is given, the caller owns the sort — the table + // reflects `sort` and reports header clicks instead of keeping its own — so another view of the + // same rows (the channel manager's cards) can share one sort. Omit both for the default internal, + // persistKey-backed sort. + sort?: SortState; + onSortChange?: (next: SortState) => void; }) { const { t } = useTranslation(); const initial = loadPersist(persistKey); - const [sort, setSort] = useState(initial.sort); + // Sort is controlled when the caller passes onSortChange (it then owns/persists it); otherwise + // the table keeps its own, seeded from and saved to persistKey. + const controlledSort = onSortChange !== undefined; + const [internalSort, setInternalSort] = useState(initial.sort); + const sort = controlledSort ? sortProp ?? null : internalSort; const [filters, setFilters] = useState(initial.filters); const [page, setPage] = useState(initial.page); const [size, setSize] = useState(initial.size ?? pageSize); @@ -161,19 +173,7 @@ export default function DataTable({ }) ); - const sortCol = sort ? columns.find((c) => c.key === sort.key) : undefined; - const sorted = - sort && sortCol?.sortValue - ? [...filtered].sort((a, b) => { - const av = sortCol.sortValue!(a); - const bv = sortCol.sortValue!(b); - const cmp = - typeof av === "number" && typeof bv === "number" - ? av - bv - : String(av).localeCompare(String(bv)); - return sort.dir === "asc" ? cmp : -cmp; - }) - : filtered; + const sorted = sortRows(filtered, columns, sort); // size <= 0 means "All" — one page with every row. const allRows = size <= 0; @@ -189,9 +189,9 @@ export default function DataTable({ } function toggleSort(key: string) { - setSort((s) => - !s || s.key !== key ? { key, dir: "asc" } : s.dir === "asc" ? { key, dir: "desc" } : null - ); + const next = cycleSort(sort, key); + if (controlledSort) onSortChange!(next); + else setInternalSort(next); setPage(0); } function applyFilter(key: string, value: string | string[]) { diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index b0fcb777..fb6cb8fe 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -13,6 +13,12 @@ "table": "Table", "cards": "Cards" }, + "sort": { + "label": "Sort", + "default": "Default order", + "asc": "Ascending", + "desc": "Descending" + }, "discovery": { "intro": "Channels that appear in your playlists but that you don't subscribe to. Subscribe to follow them — their new uploads will start showing in your feed (subscribing costs a little YouTube quota; existing videos aren't re-fetched).", "loading": "Finding channels…", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 41939889..6e4cca52 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -13,6 +13,12 @@ "table": "Táblázat", "cards": "Kártya" }, + "sort": { + "label": "Rendezés", + "default": "Alap sorrend", + "asc": "Növekvő", + "desc": "Csökkenő" + }, "discovery": { "intro": "Csatornák, amelyek megjelennek a lejátszási listáidban, de még nem iratkoztál fel rájuk. Iratkozz fel, hogy kövesd őket — az új feltöltéseik megjelennek a hírfolyamodban (a feliratkozás kevés YouTube-kvótát használ; a meglévő videókat nem tölti le újra).", "loading": "Csatornák keresése…", diff --git a/frontend/src/lib/columnSort.ts b/frontend/src/lib/columnSort.ts new file mode 100644 index 00000000..283e6e12 --- /dev/null +++ b/frontend/src/lib/columnSort.ts @@ -0,0 +1,34 @@ +// Shared column-sort primitives, lifted out of DataTable so a NON-table renderer (the channel +// manager's card layout) can sort the same rows the same way — one comparator, one source of truth. +// Kept structural (no `Column` import) so it has zero coupling to DataTable. + +export type SortState = { key: string; dir: "asc" | "desc" } | null; + +interface Sortable { + key: string; + sortValue?: (row: T) => string | number; +} + +/** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff or locale compare). + * Returns `rows` unchanged when there's no sort or the column has no `sortValue`. */ +export function sortRows(rows: T[], columns: Sortable[], sort: SortState): T[] { + if (!sort) return rows; + const get = columns.find((c) => c.key === sort.key)?.sortValue; + if (!get) return rows; + return [...rows].sort((a, b) => { + const av = get(a); + const bv = get(b); + const cmp = + typeof av === "number" && typeof bv === "number" + ? av - bv + : String(av).localeCompare(String(bv)); + return sort.dir === "asc" ? cmp : -cmp; + }); +} + +/** The header-click cycle: unsorted → ascending → descending → unsorted. */ +export function cycleSort(current: SortState, key: string): SortState { + if (!current || current.key !== key) return { key, dir: "asc" }; + if (current.dir === "asc") return { key, dir: "desc" }; + return null; +} diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 4c98082c..c8f8e2c9 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -24,8 +24,11 @@ export const LS = { feedView: "siftlode.feedView", channelFilter: "siftlode.channelFilter", channelsView: "siftlode.channelsView", - // The channel manager's layout (table / cards / rows — see lib/channelLayout.ts), per-account. + // The channel manager's layout (table / cards — see lib/channelLayout.ts), per-account. channelLayout: "siftlode.channelLayout", + // The manager's sort, lifted out of the table so the card layout shares it (per-account, per-tab). + channelSort: "siftlode.channelSort", + channelDiscoverySort: "siftlode.channelDiscoverySort", channelsTable: "siftlode.channelsTable", channelDiscoveryTable: "siftlode.channelDiscoveryTable", settingsTab: "siftlode.settingsTab",