feat(managers): actions-first discovery table, tabs in the back chain, pager scrolls to top
UAT round 4: - Discovery table: move Actions to the first column, mirroring the Subscriptions table (where it sits right after Prio) so both tabs' actions line up. - The manager's tabs now ride in history.state (`_chTab`), the same way the channel page (`_chan`) and the live search (`_yt`) already do — so Back steps back through the tabs you visited before it leaves the module. Arriving from elsewhere stamps the entry setPage pushed (one navigation never costs two Back presses). - Turning a page now scrolls the list back to its top (both the tables and the card grid, since they share Pager) — landing mid-list in an unseen page is disorienting.
This commit is contained in:
@@ -97,6 +97,34 @@ export default function ChannelDiscovery({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const columns: Column<DiscoveredChannel>[] = [
|
const columns: Column<DiscoveredChannel>[] = [
|
||||||
|
// Actions first, mirroring the Subscriptions table (where it sits right after Prio) so the two
|
||||||
|
// tabs' action clusters line up in the same place.
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: t("channels.cols.actions"),
|
||||||
|
align: "left",
|
||||||
|
nowrap: true,
|
||||||
|
width: "120px",
|
||||||
|
cardLabel: false,
|
||||||
|
render: (c) => (
|
||||||
|
<Tooltip
|
||||||
|
hint={
|
||||||
|
canWrite
|
||||||
|
? t("channels.discovery.subscribeHint")
|
||||||
|
: t("channels.discovery.needWriteHint")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => onSubscribe(c)}
|
||||||
|
disabled={!canWrite || subscribe.isPending}
|
||||||
|
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-3.5 h-3.5" />
|
||||||
|
{t("channels.discovery.subscribe")}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "channel",
|
key: "channel",
|
||||||
header: t("channels.cols.channel"),
|
header: t("channels.cols.channel"),
|
||||||
@@ -156,32 +184,6 @@ export default function ChannelDiscovery({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "actions",
|
|
||||||
header: t("channels.cols.actions"),
|
|
||||||
align: "right",
|
|
||||||
nowrap: true,
|
|
||||||
width: "120px",
|
|
||||||
cardLabel: false,
|
|
||||||
render: (c) => (
|
|
||||||
<Tooltip
|
|
||||||
hint={
|
|
||||||
canWrite
|
|
||||||
? t("channels.discovery.subscribeHint")
|
|
||||||
: t("channels.discovery.needWriteHint")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => onSubscribe(c)}
|
|
||||||
disabled={!canWrite || subscribe.isPending}
|
|
||||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 transition"
|
|
||||||
>
|
|
||||||
<UserPlus className="w-3.5 h-3.5" />
|
|
||||||
{t("channels.discovery.subscribe")}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Card layout paginates like the table (slice the sorted rows; the Pager reports changes).
|
// Card layout paginates like the table (slice the sorted rows; the Pager reports changes).
|
||||||
|
|||||||
@@ -120,9 +120,19 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
setStatusFilterState(f);
|
setStatusFilterState(f);
|
||||||
setAccountRaw(LS.channelFilter, f);
|
setAccountRaw(LS.channelFilter, f);
|
||||||
}, []);
|
}, []);
|
||||||
|
// The active tab rides in history.state (`_chTab`), the same way the channel page (`_chan`) and the
|
||||||
|
// live YouTube search (`_yt`) do — so Back steps back through the tabs you actually visited instead
|
||||||
|
// of jumping straight out to the previous module.
|
||||||
const setView = useCallback((v: ChannelsView) => {
|
const setView = useCallback((v: ChannelsView) => {
|
||||||
setViewState(v);
|
setViewState(v);
|
||||||
setAccountRaw(LS.channelsView, v);
|
setAccountRaw(LS.channelsView, v);
|
||||||
|
const st = window.history.state || {};
|
||||||
|
// Only own an entry while the manager is already on screen. Arriving from elsewhere
|
||||||
|
// (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so
|
||||||
|
// one navigation never costs two Back presses.
|
||||||
|
if (st.sfPage === "channels") {
|
||||||
|
window.history.pushState({ ...st, sfPage: "channels", _chTab: v }, "");
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
const setLayout = useCallback((l: ChannelLayout) => {
|
const setLayout = useCallback((l: ChannelLayout) => {
|
||||||
setLayoutState(l);
|
setLayoutState(l);
|
||||||
@@ -154,6 +164,27 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
|||||||
if (page !== "channels") setFocusName(null);
|
if (page !== "channels") setFocusName(null);
|
||||||
}, [page]);
|
}, [page]);
|
||||||
|
|
||||||
|
// Label the entry the manager was REACHED on (setPage pushes a bare { sfPage }), so stepping back
|
||||||
|
// onto it later restores the tab it belonged to rather than leaving whatever tab is current.
|
||||||
|
useEffect(() => {
|
||||||
|
if (page !== "channels") return;
|
||||||
|
const st = window.history.state || {};
|
||||||
|
if (st._chTab === undefined) window.history.replaceState({ ...st, _chTab: view }, "");
|
||||||
|
}, [page, view]);
|
||||||
|
|
||||||
|
// Back/Forward across tab entries: restore the tab the entry carries.
|
||||||
|
useEffect(() => {
|
||||||
|
function onPop(e: PopStateEvent) {
|
||||||
|
const tab = e.state?._chTab;
|
||||||
|
if (!tab) return; // an entry outside the manager — leave the tab alone
|
||||||
|
const next = clampView(tab as string);
|
||||||
|
setViewState(next);
|
||||||
|
setAccountRaw(LS.channelsView, next);
|
||||||
|
}
|
||||||
|
window.addEventListener("popstate", onPop);
|
||||||
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const state = useMemo<ChannelsState>(
|
const state = useMemo<ChannelsState>(
|
||||||
() => ({ search, statusFilter, view, layout, sort, filtersResetToken, focusName, focusToken }),
|
() => ({ search, statusFilter, view, layout, sort, filtersResetToken, focusName, focusToken }),
|
||||||
[search, statusFilter, view, layout, sort, filtersResetToken, focusName, focusToken],
|
[search, statusFilter, view, layout, sort, filtersResetToken, focusName, focusToken],
|
||||||
|
|||||||
@@ -34,9 +34,17 @@ export default function Pager({
|
|||||||
// Local text for the editable "jump to page" box (committed on Enter/blur).
|
// Local text for the editable "jump to page" box (committed on Enter/blur).
|
||||||
const [pageInput, setPageInput] = useState(String(safePage + 1));
|
const [pageInput, setPageInput] = useState(String(safePage + 1));
|
||||||
useEffect(() => setPageInput(String(safePage + 1)), [safePage]);
|
useEffect(() => setPageInput(String(safePage + 1)), [safePage]);
|
||||||
|
|
||||||
|
// Turning the page swaps the whole list, so put the reader at its top — landing mid-list in a page
|
||||||
|
// you haven't seen is disorienting. The page scroller is the app's <main> (the same implicit
|
||||||
|
// contract BackToTop and VirtualGrid's getScrollParent rely on).
|
||||||
|
function goToPage(p: number) {
|
||||||
|
onPageChange(p);
|
||||||
|
document.querySelector("main")?.scrollTo({ top: 0 });
|
||||||
|
}
|
||||||
function commitPageInput() {
|
function commitPageInput() {
|
||||||
const n = parseInt(pageInput, 10);
|
const n = parseInt(pageInput, 10);
|
||||||
if (!isNaN(n)) onPageChange(Math.min(totalPages - 1, Math.max(0, n - 1)));
|
if (!isNaN(n)) goToPage(Math.min(totalPages - 1, Math.max(0, n - 1)));
|
||||||
else setPageInput(String(safePage + 1));
|
else setPageInput(String(safePage + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +54,7 @@ export default function Pager({
|
|||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => onPageChange(Math.max(0, safePage - 1))}
|
onClick={() => goToPage(Math.max(0, safePage - 1))}
|
||||||
disabled={safePage === 0}
|
disabled={safePage === 0}
|
||||||
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
||||||
>
|
>
|
||||||
@@ -69,7 +77,7 @@ export default function Pager({
|
|||||||
{t("datatable.pager.ofTotal", { total: totalPages })}
|
{t("datatable.pager.ofTotal", { total: totalPages })}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => onPageChange(Math.min(totalPages - 1, safePage + 1))}
|
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
|
||||||
disabled={safePage >= totalPages - 1}
|
disabled={safePage >= totalPages - 1}
|
||||||
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user