feat(managers): shared sort across the table and card layouts
Cards have no column headers to sort by, so lift the sort out of DataTable into one shared state that BOTH views drive and reflect. New lib/columnSort.ts holds the comparator + click-cycle (one source of truth); DataTable gains an optional controlled sort (sort + onSortChange) and falls back to its internal persistKey sort when they're omitted, so every other table is untouched. The subscribed sort lives in ChannelsProvider, discovery keeps its own (its columns differ); a new ColumnSortControl dropdown (options derived from the columns' sortable metadata, feed-style + asc/desc toggle) drives it in cards mode. Per-account, persisted, per-tab.
This commit is contained in:
@@ -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<SortState>(() =>
|
||||
readAccount<SortState>(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). */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 max-w-[96rem] mx-auto">
|
||||
<div className="px-4 pt-3 max-w-[96rem] mx-auto flex items-start justify-between gap-4">
|
||||
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
|
||||
{/* Cards has no column headers, so a dropdown drives the sort (table uses the headers). */}
|
||||
{layout === "cards" && (
|
||||
<ColumnSortControl
|
||||
columns={columns}
|
||||
sort={sort}
|
||||
onChange={setSort}
|
||||
label={t("channels.sort.label")}
|
||||
defaultLabel={t("channels.sort.default")}
|
||||
ascLabel={t("channels.sort.asc")}
|
||||
descLabel={t("channels.sort.desc")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageToolbar>
|
||||
<div className="px-4 pb-4 max-w-[96rem] mx-auto">
|
||||
@@ -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")}
|
||||
/>
|
||||
) : (
|
||||
<ChannelLayoutGrid
|
||||
rows={rows}
|
||||
rows={sortRows(rows, columns, sort)}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
aboutKey="about"
|
||||
|
||||
@@ -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 = (
|
||||
<ColumnSortControl
|
||||
columns={columns}
|
||||
sort={sort}
|
||||
onChange={setSort}
|
||||
label={t("channels.sort.label")}
|
||||
defaultLabel={t("channels.sort.default")}
|
||||
ascLabel={t("channels.sort.asc")}
|
||||
descLabel={t("channels.sort.desc")}
|
||||
/>
|
||||
);
|
||||
|
||||
const tabs = (
|
||||
<div className="px-4 pt-4 max-w-[96rem] mx-auto flex items-center justify-between gap-4 flex-wrap">
|
||||
@@ -517,6 +533,7 @@ export default function Channels() {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{view === "subscribed" && statsBlock}
|
||||
{view === "subscribed" && layout === "cards" && sortControl}
|
||||
{hasLayout && layoutSwitcher}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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")}
|
||||
/>
|
||||
) : (
|
||||
<ChannelLayoutGrid
|
||||
rows={visibleChannels}
|
||||
rows={sortRows(visibleChannels, columns, sort)}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
leadKey="priority"
|
||||
|
||||
@@ -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<ChannelsState>({
|
||||
statusFilter: "all",
|
||||
view: "subscribed",
|
||||
layout: "table",
|
||||
sort: null,
|
||||
filtersResetToken: 0,
|
||||
focusName: null,
|
||||
focusToken: 0,
|
||||
@@ -79,6 +84,7 @@ const ActionsContext = createContext<ChannelsActions>({
|
||||
setStatusFilter: () => {},
|
||||
setView: () => {},
|
||||
setLayout: () => {},
|
||||
setSort: () => {},
|
||||
focusChannel: () => {},
|
||||
goToFullHistory: () => {},
|
||||
});
|
||||
@@ -104,6 +110,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
const [layout, setLayoutState] = useState<ChannelLayout>(() =>
|
||||
parseChannelLayout(getAccountRaw(LS.channelLayout)),
|
||||
);
|
||||
const [sort, setSortState] = useState<SortState>(() => readAccount<SortState>(LS.channelSort, null));
|
||||
const [filtersResetToken, setFiltersResetToken] = useState(0);
|
||||
const [focusName, setFocusName] = useState<string | null>(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<ChannelsState>(
|
||||
() => ({ 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<ChannelsActions>(
|
||||
() => ({ 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 (
|
||||
|
||||
@@ -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<T>({
|
||||
columns,
|
||||
sort,
|
||||
onChange,
|
||||
label,
|
||||
defaultLabel,
|
||||
ascLabel,
|
||||
descLabel,
|
||||
}: {
|
||||
columns: Column<T>[];
|
||||
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 (
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={sort?.key ?? ""}
|
||||
onChange={(e) => {
|
||||
const key = e.target.value;
|
||||
// Keep the direction when re-picking the same column; a new column starts ascending.
|
||||
onChange(key ? { key, dir: sort?.key === key ? sort.dir : "asc" } : null);
|
||||
}}
|
||||
aria-label={label}
|
||||
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">{defaultLabel}</option>
|
||||
{sortable.map((c) => (
|
||||
<option key={c.key} value={c.key}>
|
||||
{c.header}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{sort && (
|
||||
<button
|
||||
onClick={() => onChange({ key: sort.key, dir: sort.dir === "asc" ? "desc" : "asc" })}
|
||||
title={sort.dir === "asc" ? ascLabel : descLabel}
|
||||
aria-label={sort.dir === "asc" ? ascLabel : descLabel}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||
>
|
||||
{sort.dir === "asc" ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<T> {
|
||||
cardLabel?: boolean;
|
||||
}
|
||||
|
||||
type SortState = { key: string; dir: "asc" | "desc" } | null;
|
||||
type FilterMap = Record<string, string | string[]>;
|
||||
interface Persisted {
|
||||
sort: SortState;
|
||||
@@ -89,6 +89,8 @@ export default function DataTable<T>({
|
||||
controlsBandClassName,
|
||||
externalFilter,
|
||||
resetFiltersToken,
|
||||
sort: sortProp,
|
||||
onSortChange,
|
||||
}: {
|
||||
rows: T[];
|
||||
columns: Column<T>[];
|
||||
@@ -112,10 +114,20 @@ export default function DataTable<T>({
|
||||
// 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<SortState>(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<SortState>(initial.sort);
|
||||
const sort = controlledSort ? sortProp ?? null : internalSort;
|
||||
const [filters, setFilters] = useState<FilterMap>(initial.filters);
|
||||
const [page, setPage] = useState(initial.page);
|
||||
const [size, setSize] = useState(initial.size ?? pageSize);
|
||||
@@ -161,19 +173,7 @@ export default function DataTable<T>({
|
||||
})
|
||||
);
|
||||
|
||||
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<T>({
|
||||
}
|
||||
|
||||
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[]) {
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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<T> {
|
||||
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<T>(rows: T[], columns: Sortable<T>[], 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user