refactor(ui): close the sort/layout reuse-gaps (R13 S2c step 5)
Two surfaces reinvented controls the app already consolidated: - PlaylistsRail sort (a bespoke <select> + hand-rolled asc/desc flip) now uses the shared SortControl; "custom" is a directionless option (no arrow), the "unsynced first" pin rides the trailing slot as a pill toggle - PlexPlaylistView Accordion/Tree layout (two hand-rolled toggle buttons) now uses the shared ViewSwitcher with a Rows3/ListTree option set Adds aria-label i18n (plex.playlist.layoutLabel; HU+EN), drops the now-unused ArrowUp/ArrowDown + ReactNode imports. Self-E2E'd the rail sort: key switch reveals the arrow, asc<->desc flips, pin toggles, custom hides the arrow. gate green (tsc/eslint-0/vitest 136/knip/prettier). Branch not merged.
This commit is contained in:
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
|
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
|
||||||
import { api, type Playlist } from "../lib/api";
|
import { api, type Playlist } from "../lib/api";
|
||||||
import { formatDuration } from "../lib/format";
|
import { formatDuration } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
@@ -11,6 +11,8 @@ import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
|||||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||||
import SidePanel from "./SidePanel";
|
import SidePanel from "./SidePanel";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||||
|
import SortControl from "./SortControl";
|
||||||
|
import { type SortOption } from "../lib/columnSort";
|
||||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../lib/useMe";
|
||||||
import { playlistName } from "../lib/playlistName";
|
import { playlistName } from "../lib/playlistName";
|
||||||
@@ -45,6 +47,12 @@ export default function PlaylistsRail({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
|
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
|
||||||
|
const plSortOptions: SortOption[] = [
|
||||||
|
{ key: "custom", label: t("playlists.railSortCustom"), directionless: true },
|
||||||
|
{ key: "name", label: t("playlists.railSortName"), defaultDir: "asc" },
|
||||||
|
{ key: "count", label: t("playlists.railSortCount"), defaultDir: "desc" },
|
||||||
|
{ key: "duration", label: t("playlists.railSortDuration"), defaultDir: "desc" },
|
||||||
|
];
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeAccount(LS.plSort, plSort);
|
writeAccount(LS.plSort, plSort);
|
||||||
}, [plSort]);
|
}, [plSort]);
|
||||||
@@ -149,43 +157,26 @@ export default function PlaylistsRail({
|
|||||||
{t("playlists.syncYoutube")}
|
{t("playlists.syncYoutube")}
|
||||||
</button>
|
</button>
|
||||||
{playlists.length > 1 && (
|
{playlists.length > 1 && (
|
||||||
<div className="flex items-center gap-1">
|
<SortControl
|
||||||
<select
|
label={t("playlists.sortLabel")}
|
||||||
value={plSort.key}
|
ascLabel={t("playlists.dirAsc")}
|
||||||
onChange={(e) => setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })}
|
descLabel={t("playlists.dirDesc")}
|
||||||
aria-label={t("playlists.sortLabel")}
|
options={plSortOptions}
|
||||||
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-1.5 py-1 text-2xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
sort={{ key: plSort.key, dir: plSort.dir }}
|
||||||
>
|
onChange={(next) =>
|
||||||
<option value="custom">{t("playlists.railSortCustom")}</option>
|
next && setPlSort({ ...plSort, key: next.key as PlSort["key"], dir: next.dir })
|
||||||
<option value="name">{t("playlists.railSortName")}</option>
|
}
|
||||||
<option value="count">{t("playlists.railSortCount")}</option>
|
trailing={
|
||||||
<option value="duration">{t("playlists.railSortDuration")}</option>
|
<button
|
||||||
</select>
|
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
|
||||||
<button
|
title={t("playlists.dirtyFirst")}
|
||||||
onClick={() => setPlSort({ ...plSort, dir: plSort.dir === "asc" ? "desc" : "asc" })}
|
aria-pressed={plSort.dirtyFirst}
|
||||||
disabled={plSort.key === "custom"}
|
className="pill shrink-0 rounded-lg border p-1.5"
|
||||||
title={plSort.dir === "asc" ? t("playlists.dirAsc") : t("playlists.dirDesc")}
|
>
|
||||||
className="p-1 rounded-md border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"
|
<Pin className="h-4 w-4" />
|
||||||
>
|
</button>
|
||||||
{plSort.dir === "asc" ? (
|
}
|
||||||
<ArrowUp className="w-3.5 h-3.5" />
|
/>
|
||||||
) : (
|
|
||||||
<ArrowDown className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
|
|
||||||
title={t("playlists.dirtyFirst")}
|
|
||||||
aria-pressed={plSort.dirtyFirst}
|
|
||||||
className={`p-1 rounded-md border transition ${
|
|
||||||
plSort.dirtyFirst
|
|
||||||
? "border-accent text-accent"
|
|
||||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Pin className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -35,6 +35,7 @@ import { api, type PlexCard } from "../lib/api";
|
|||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
|
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||||
|
|
||||||
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
|
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
|
||||||
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
|
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
|
||||||
@@ -499,18 +500,10 @@ export default function PlexPlaylistView({
|
|||||||
onRemove,
|
onRemove,
|
||||||
};
|
};
|
||||||
|
|
||||||
const LayoutBtn = ({ v, icon, label }: { v: Layout; icon: ReactNode; label: string }) => (
|
const layoutOptions: ViewOption<Layout>[] = [
|
||||||
<button
|
{ id: "accordion", label: t("plex.playlist.layoutAccordion"), icon: Rows3 },
|
||||||
onClick={() => setLayout(v)}
|
{ id: "tree", label: t("plex.playlist.layoutTree"), icon: ListTree },
|
||||||
title={label}
|
];
|
||||||
aria-label={label}
|
|
||||||
aria-pressed={layout === v}
|
|
||||||
data-soft="true"
|
|
||||||
className={`pill rounded-lg p-2 text-sm ${layout === v ? "" : "glass-card glass-hover text-muted"}`}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-[90%] max-w-[1100px] p-4">
|
<div className="mx-auto w-[90%] max-w-[1100px] p-4">
|
||||||
@@ -536,19 +529,13 @@ export default function PlexPlaylistView({
|
|||||||
) : (
|
) : (
|
||||||
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
|
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
|
||||||
)}
|
)}
|
||||||
{/* Layout preference: Accordion (A) or Tree (C). */}
|
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */}
|
||||||
<div className="flex items-center gap-1">
|
<ViewSwitcher
|
||||||
<LayoutBtn
|
value={layout}
|
||||||
v="accordion"
|
options={layoutOptions}
|
||||||
icon={<Rows3 className="h-4 w-4" />}
|
onChange={setLayout}
|
||||||
label={t("plex.playlist.layoutAccordion")}
|
label={t("plex.playlist.layoutLabel")}
|
||||||
/>
|
/>
|
||||||
<LayoutBtn
|
|
||||||
v="tree"
|
|
||||||
icon={<ListTree className="h-4 w-4" />}
|
|
||||||
label={t("plex.playlist.layoutTree")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setName(q.data?.title ?? "");
|
setName(q.data?.title ?? "");
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
"playAll": "Play all",
|
"playAll": "Play all",
|
||||||
"delete": "Delete playlist",
|
"delete": "Delete playlist",
|
||||||
"empty": "This playlist is empty. Add titles from their info page.",
|
"empty": "This playlist is empty. Add titles from their info page.",
|
||||||
|
"layoutLabel": "Layout",
|
||||||
"layoutAccordion": "Accordion view",
|
"layoutAccordion": "Accordion view",
|
||||||
"layoutTree": "Tree view",
|
"layoutTree": "Tree view",
|
||||||
"removeShow": "Remove whole show",
|
"removeShow": "Remove whole show",
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
"playAll": "Összes lejátszása",
|
"playAll": "Összes lejátszása",
|
||||||
"delete": "Lista törlése",
|
"delete": "Lista törlése",
|
||||||
"empty": "Ez a lista üres. Adj hozzá címeket az info-oldalukról.",
|
"empty": "Ez a lista üres. Adj hozzá címeket az info-oldalukról.",
|
||||||
|
"layoutLabel": "Elrendezés",
|
||||||
"layoutAccordion": "Harmonika nézet",
|
"layoutAccordion": "Harmonika nézet",
|
||||||
"layoutTree": "Fa nézet",
|
"layoutTree": "Fa nézet",
|
||||||
"removeShow": "Egész sorozat eltávolítása",
|
"removeShow": "Egész sorozat eltávolítása",
|
||||||
|
|||||||
Reference in New Issue
Block a user