feat(plex): make the playlists rail widget a compact combobox

The playlists section used to render an always-open list plus a dedicated
create input, growing the rail taller with every playlist. Replace it with a
single Combobox field (reusing the shared component, like the feed's "Your
tags"): the list of playlists only appears when the dropdown is opened, and
typing a name that doesn't exist offers a "Create …" row. Picking a playlist
opens it (the existing playlistOpen hand-off); the create row creates then opens
the new one. `selected` stays empty — this picks an action, not a persisted value.
This commit is contained in:
2026-08-22 07:59:41 +02:00
parent f8e144a658
commit 6c7038cf1f
3 changed files with 75 additions and 50 deletions
+71 -50
View File
@@ -65,21 +65,10 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
// the header search box); this rail is the primary editor of them. Opening a playlist is a
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
const { scope, show, filters } = usePlex();
const { setFilters, setPlaylistOpen: onOpenPlaylist } = usePlexActions();
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
const [newPlaylist, setNewPlaylist] = useState("");
const { setFilters } = usePlexActions();
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
const source: MediaSourceKind = "plex";
const qc = useQueryClient();
async function createPlaylist() {
const title = newPlaylist.trim();
if (!title) return;
const pl = await api.plexCreatePlaylist(title);
setNewPlaylist("");
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
onOpenPlaylist(pl.id);
}
const { collectionTitle: _ct, ...facetKey } = filters;
const lang = plexLang(); // HU/EN genre vocabulary — refetch facets on a language switch
const facetsQ = useQuery({
@@ -111,44 +100,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
{
id: "playlists",
title: t("plex.playlist.section"),
body: (
<>
<div className="mb-1.5 flex gap-1.5">
<input
value={newPlaylist}
onChange={(e) => setNewPlaylist(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createPlaylist()}
placeholder={t("plex.playlist.newPlaceholder")}
className="glass-card min-w-0 flex-1 rounded-lg px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
onClick={createPlaylist}
disabled={!newPlaylist.trim()}
title={t("plex.playlist.create")}
className="glass-card glass-hover shrink-0 rounded-lg p-1.5 disabled:opacity-50"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{(playlistsQ.data?.playlists ?? []).length === 0 ? (
<p className="text-xs text-muted">{t("plex.playlist.none")}</p>
) : (
<div className="space-y-0.5">
{playlistsQ.data!.playlists.map((p) => (
<button
key={p.id}
onClick={() => onOpenPlaylist(p.id)}
className="glass-hover flex w-full items-center gap-2 rounded-sm px-1.5 py-1 text-left text-sm"
>
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="min-w-0 flex-1 truncate">{p.title}</span>
<span className="shrink-0 text-xs text-muted">{p.item_count}</span>
</button>
))}
</div>
)}
</>
),
body: <PlaylistPicker />,
},
...(peopleActive
? [
@@ -352,6 +304,75 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
);
}
// The playlists section: a combobox that stays compact (just a field + chevron) until opened, instead
// of an always-expanded list that grew the rail with every playlist. Picking a playlist opens it (the
// PlexBrowse `playlistOpen` hand-off); typing a name that doesn't exist yet offers a "Create …" row so
// you can still add one — the same reuse-the-Combobox idea as the feed's "Your tags" widget. `selected`
// stays empty: this picks an action (open / create), it doesn't hold a persistent selection.
const CREATE_PLAYLIST = "__create__";
function PlaylistPicker() {
const { t } = useTranslation();
const { setPlaylistOpen: onOpenPlaylist } = usePlexActions();
const qc = useQueryClient();
const [query, setQuery] = useState("");
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
const playlists = playlistsQ.data?.playlists ?? [];
const q = query.trim();
const ql = q.toLowerCase();
const matches = ql ? playlists.filter((p) => p.title.toLowerCase().includes(ql)) : playlists;
const exact = playlists.some((p) => p.title.toLowerCase() === ql);
const options: ComboboxOption[] = [
...matches.map((p) => ({ value: `pl:${p.id}`, label: p.title, count: p.item_count })),
// Offer to create only when the typed name isn't already a playlist.
...(q && !exact ? [{ value: CREATE_PLAYLIST, label: q }] : []),
];
async function createPlaylist(title: string) {
const pl = await api.plexCreatePlaylist(title);
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
onOpenPlaylist(pl.id);
}
return (
<Combobox
label={t("plex.playlist.section")}
multiple={false}
clientFilter={false} // we build `options` (incl. the create row) against the query ourselves
options={options}
selected={[]}
query={query}
onQueryChange={setQuery}
placeholder={t("plex.playlist.pickerPlaceholder")}
emptyText={t("plex.playlist.none")}
renderOption={(o) =>
o.value === CREATE_PLAYLIST ? (
<>
<Plus className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="flex-1 truncate">
{t("plex.playlist.createNamed", { name: o.label })}
</span>
</>
) : (
<>
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="flex-1 truncate">{o.label}</span>
{o.count != null && (
<span className="shrink-0 text-2xs tabular-nums text-muted">{o.count}</span>
)}
</>
)
}
onChange={(next) => {
const v = next[0];
if (!v) return;
if (v === CREATE_PLAYLIST) createPlaylist(q);
else onOpenPlaylist(Number(v.slice(3)));
}}
/>
);
}
// The collection filter (E5 S7 → combobox rework): a single-select combi-input matching the
// genre/studio pickers, its vocabulary searched SERVER-side (the union can run to hundreds). The
// generic <Combobox> owns the field/keyboard/ARIA; this wrapper only owns the query→fetch (gated on
+2
View File
@@ -243,6 +243,8 @@
"playlist": {
"section": "Playlists",
"newPlaceholder": "New playlist…",
"pickerPlaceholder": "Find or create a playlist…",
"createNamed": "Create \"{{name}}\"",
"create": "Create playlist",
"none": "No playlists yet.",
"deleteConfirm": "Delete the playlist \"{{title}}\"?",
+2
View File
@@ -243,6 +243,8 @@
"playlist": {
"section": "Lejátszási listák",
"newPlaceholder": "Új lista…",
"pickerPlaceholder": "Lista keresése vagy létrehozása…",
"createNamed": "„{{name}}” létrehozása",
"create": "Lista létrehozása",
"none": "Még nincs lista.",
"deleteConfirm": "Törlöd a(z) „{{title}}” listát?",