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:
@@ -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
|
// 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.
|
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
|
||||||
const { scope, show, filters } = usePlex();
|
const { scope, show, filters } = usePlex();
|
||||||
const { setFilters, setPlaylistOpen: onOpenPlaylist } = usePlexActions();
|
const { setFilters } = usePlexActions();
|
||||||
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
|
|
||||||
const [newPlaylist, setNewPlaylist] = useState("");
|
|
||||||
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
|
// 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.
|
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||||
const source: MediaSourceKind = "plex";
|
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 { collectionTitle: _ct, ...facetKey } = filters;
|
||||||
const lang = plexLang(); // HU/EN genre vocabulary — refetch facets on a language switch
|
const lang = plexLang(); // HU/EN genre vocabulary — refetch facets on a language switch
|
||||||
const facetsQ = useQuery({
|
const facetsQ = useQuery({
|
||||||
@@ -111,44 +100,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
|||||||
{
|
{
|
||||||
id: "playlists",
|
id: "playlists",
|
||||||
title: t("plex.playlist.section"),
|
title: t("plex.playlist.section"),
|
||||||
body: (
|
body: <PlaylistPicker />,
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
...(peopleActive
|
...(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
|
// 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
|
// 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
|
// generic <Combobox> owns the field/keyboard/ARIA; this wrapper only owns the query→fetch (gated on
|
||||||
|
|||||||
@@ -243,6 +243,8 @@
|
|||||||
"playlist": {
|
"playlist": {
|
||||||
"section": "Playlists",
|
"section": "Playlists",
|
||||||
"newPlaceholder": "New playlist…",
|
"newPlaceholder": "New playlist…",
|
||||||
|
"pickerPlaceholder": "Find or create a playlist…",
|
||||||
|
"createNamed": "Create \"{{name}}\"",
|
||||||
"create": "Create playlist",
|
"create": "Create playlist",
|
||||||
"none": "No playlists yet.",
|
"none": "No playlists yet.",
|
||||||
"deleteConfirm": "Delete the playlist \"{{title}}\"?",
|
"deleteConfirm": "Delete the playlist \"{{title}}\"?",
|
||||||
|
|||||||
@@ -243,6 +243,8 @@
|
|||||||
"playlist": {
|
"playlist": {
|
||||||
"section": "Lejátszási listák",
|
"section": "Lejátszási listák",
|
||||||
"newPlaceholder": "Új lista…",
|
"newPlaceholder": "Új lista…",
|
||||||
|
"pickerPlaceholder": "Lista keresése vagy létrehozása…",
|
||||||
|
"createNamed": "„{{name}}” létrehozása",
|
||||||
"create": "Lista létrehozása",
|
"create": "Lista létrehozása",
|
||||||
"none": "Még nincs lista.",
|
"none": "Még nincs lista.",
|
||||||
"deleteConfirm": "Törlöd a(z) „{{title}}” listát?",
|
"deleteConfirm": "Törlöd a(z) „{{title}}” listát?",
|
||||||
|
|||||||
Reference in New Issue
Block a user