Two halves of the same report ("Collectionben nem működik a rendezés?"). The pasted browse URL
sorts correctly — that part was working. What did not:
The Collection INFO page had no sort control at all and a hardcoded release-year-ascending order.
It now shows the browse grid's Sort control, with the browse grid's vocabulary, because a second
sort vocabulary on a neighbouring surface is exactly the kind of drift this repo keeps paying for:
`PLEX_SORT_KEYS` is now one tuple in `plex/query.py`, and a test pins BOTH implementations to it —
browse's key→column map (extracted from the handler to be reachable) and the collection page's
key→member map. A key added to the vocabulary without teaching one of them fails the gate instead
of putting a dead option in the menu.
Ordering a collection cannot be a single ORDER BY: its two legs are different tables, so the merged
member list is sorted in Python. Members with no value for the key sink to the END in both
directions (a movie with no rating is not "the worst rated" — the browse grid's `nullslast()`), and
ties always resolve title-ascending, so flipping the direction reverses the ranking rather than the
alphabet inside it. `lastwatched` needed the show leg's `resumed_at`, so the page now takes ONE
rollup pass that yields both the badge status and the recency instead of the status alone — without
it that menu entry would have ordered every show as never-watched. The default stays release-year
ascending: a franchise reads in release order, and that is what the page did before.
The other half: a shared Plex link carries the collection filter as bare rating_keys, so a link
opened cold had no name for them — the sidebar token rendered `17045` and the chip could only say
"Collection". `GET /api/plex/collections` takes a `keys=` CSV now, and a display-only hook resolves
just the missing ids. It deliberately does NOT write them into `collectionTitles`: that map is
hashed into the Plex query keys, so storing a resolved label there would split the cache from the
identical state and refetch the grid for a piece of text.
Verified in the browser on localdev against the real Plex library: the movie collection's menu
offers Length and the show collection's does not (the same scope rule as browse); rating-ascending
reorders 8.1/7.4/7.2/6.6/6.5 correctly; `lastwatched` on a show collection brings the in-progress
shows up; the choice survives a reload (it is a server pref, on its own key so opening a collection
never re-sorts the library behind it); and the reported URL now reads "Pirates of the Caribbean
Collection" in both the sidebar and the chip.
12 pytest + 8 vitest, mutation-checked against 14 mutations (direction ignored, nulls first, no
stable tiebreak, unknown key untouched, wrong default, show recency dropped, empty `keys=` falling
through to everything, a key missing from either sort map, every id refetched, ids unsorted,
fetched titles unmerged, the chip ignoring them, the hook's result never reaching the builder) —
each red on exactly the test that names it.
526 lines
20 KiB
TypeScript
526 lines
20 KiB
TypeScript
import { useState } from "react";
|
||
import { qk } from "../lib/queryKeys";
|
||
import { usePlexCollectionTitles } from "../lib/usePlexCollectionTitles";
|
||
import { useTranslation } from "react-i18next";
|
||
import { plexLang } from "../i18n";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
||
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
|
||
import { type PanelLayout } from "../lib/panelLayout";
|
||
import { type MediaSourceKind } from "../lib/mediaSource";
|
||
import { useDebounced } from "../lib/useDebounced";
|
||
import FilterRail from "./FilterRail";
|
||
import { type PanelItem } from "./ui/PanelGroups";
|
||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||
import Combobox, { type ComboboxOption } from "./ui/Combobox";
|
||
import SavedViewsPopover from "./SavedViewsPopover";
|
||
import PersonalViewsModal from "./PersonalViewsModal";
|
||
import { useSavedViews } from "./SavedViewsProvider";
|
||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||
import { useMe } from "../lib/useMe";
|
||
|
||
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
|
||
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
|
||
// set; shows keep library + sort. State is owned by App (per-account persisted); facets come from
|
||
// the backend so the panel only offers what the library actually contains.
|
||
|
||
type Props = {
|
||
layout: PanelLayout;
|
||
setLayout: (l: PanelLayout) => void;
|
||
collapsed: boolean;
|
||
onToggleCollapse: () => void;
|
||
};
|
||
|
||
const RATING_STEPS = [5, 6, 7, 8, 9];
|
||
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
|
||
const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [
|
||
{ key: "short", min: null, max: 90 * 60 },
|
||
{ key: "mid", min: 90 * 60, max: 120 * 60 },
|
||
{ key: "long", min: 120 * 60, max: null },
|
||
];
|
||
|
||
// The any/all combine toggle for a multi-value filter (genres, actors) — a thin binding of the shared
|
||
// SegmentedToggle to the Plex match vocabulary (same look as the feed rail's tag MATCH toggle).
|
||
function MatchToggle({
|
||
mode,
|
||
onChange,
|
||
}: {
|
||
mode: "any" | "all";
|
||
onChange: (m: "any" | "all") => void;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
return (
|
||
<SegmentedToggle
|
||
label={t("plex.filter.matchLabel")}
|
||
ariaLabel={t("plex.filter.matchLabel")}
|
||
options={[
|
||
{ value: "any", label: t("plex.filter.match.any") },
|
||
{ value: "all", label: t("plex.filter.match.all") },
|
||
]}
|
||
value={mode}
|
||
onChange={onChange}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export default function PlexSidebar({ layout, setLayout, collapsed, onToggleCollapse }: Props) {
|
||
const { t } = useTranslation();
|
||
// Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and
|
||
// 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 } = usePlexActions();
|
||
const isDemo = useMe().is_demo;
|
||
// Personal ("saved") views for the Plex module: the rail header icon opens a quick-pick popover, the
|
||
// title opens the full manager modal (same shared components as the feed). Hidden for the demo account.
|
||
const { openModal, modalOpen } = useSavedViews();
|
||
// 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";
|
||
// Titles are display-only (chip labels) — keep them OUT of the facet query key so renaming/relabelling
|
||
// never refetches. collectionMode stays IN the key: any/all changes the result set.
|
||
const { collectionTitles: _cts, ...facetKey } = filters;
|
||
const lang = plexLang(); // HU/EN genre vocabulary — refetch facets on a language switch
|
||
const facetsQ = useQuery({
|
||
queryKey: qk.plexFacets(scope, show, facetKey, lang),
|
||
queryFn: () => api.plexFacets(scope, filters, show, lang),
|
||
enabled: !!scope,
|
||
placeholderData: (prev) => prev,
|
||
});
|
||
const facets = facetsQ.data;
|
||
|
||
const fCount = plexFilterCount(filters);
|
||
// Scope and sort moved to the header / above-the-grid toolbar (S3); the panel badge now counts only
|
||
// the filters that still live in this panel (expanded filters + watch-state).
|
||
const activeCount = fCount + (show !== "all" ? 1 : 0);
|
||
|
||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||
|
||
const durBucketKey = DURATION_BUCKETS.find(
|
||
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
||
)?.key;
|
||
// Directors + actors have no picker (they're added from the detail/cast surfaces) — this section
|
||
// just lists them as removable chips. Studios now have their own picker section (below).
|
||
const peopleActive = filters.directors.length + filters.actors.length > 0;
|
||
|
||
// Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed,
|
||
// so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved
|
||
// layout and lets the user reorder / hide / collapse them.
|
||
const items: PanelItem[] = [
|
||
{
|
||
id: "playlists",
|
||
title: t("plex.playlist.section"),
|
||
body: <PlaylistPicker />,
|
||
},
|
||
...(peopleActive
|
||
? [
|
||
{
|
||
id: "people",
|
||
title: t("plex.filter.active"),
|
||
body: (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{filters.directors.map((d) => (
|
||
<RemovableChip
|
||
key={`d:${d}`}
|
||
label={d}
|
||
onRemove={() => patch({ directors: filters.directors.filter((x) => x !== d) })}
|
||
/>
|
||
))}
|
||
{filters.actors.length > 1 && (
|
||
<div className="w-full">
|
||
<MatchToggle
|
||
mode={filters.actorMode ?? "any"}
|
||
onChange={(m) => patch({ actorMode: m })}
|
||
/>
|
||
</div>
|
||
)}
|
||
{filters.actors.map((a) => (
|
||
<RemovableChip
|
||
key={`a:${a}`}
|
||
label={a}
|
||
onRemove={() => patch({ actors: filters.actors.filter((x) => x !== a) })}
|
||
/>
|
||
))}
|
||
</div>
|
||
),
|
||
},
|
||
]
|
||
: []),
|
||
{
|
||
id: "collection",
|
||
title: t("plex.filter.collection"),
|
||
body: (
|
||
<CollectionPicker
|
||
values={filters.collections}
|
||
titles={filters.collectionTitles}
|
||
mode={filters.collectionMode ?? "any"}
|
||
onChange={(collections, collectionTitles) => patch({ collections, collectionTitles })}
|
||
onModeChange={(m) => patch({ collectionMode: m })}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
id: "rating",
|
||
title: t("plex.filter.rating"),
|
||
body: (
|
||
<FilterChipGroup
|
||
ariaLabel={t("plex.filter.rating")}
|
||
value={filters.ratingMin == null ? "" : String(filters.ratingMin)}
|
||
options={[
|
||
{ value: "", label: t("plex.filter.any") },
|
||
...RATING_STEPS.map((r) => ({ value: String(r), label: `${r}+` })),
|
||
]}
|
||
onChange={(v) => patch({ ratingMin: v === "" ? null : Number(v) })}
|
||
/>
|
||
),
|
||
},
|
||
...(facets && facets.genres.length > 0
|
||
? [
|
||
{
|
||
id: "genre",
|
||
title: t("plex.filter.genre"),
|
||
body: (
|
||
<>
|
||
{filters.genres.length > 1 && (
|
||
<div className="mb-1.5">
|
||
<MatchToggle
|
||
mode={filters.genreMode ?? "any"}
|
||
onChange={(m) => patch({ genreMode: m })}
|
||
/>
|
||
</div>
|
||
)}
|
||
<Combobox
|
||
label={t("plex.filter.genre")}
|
||
options={facets.genres}
|
||
selected={filters.genres}
|
||
onChange={(next) => patch({ genres: next })}
|
||
/>
|
||
</>
|
||
),
|
||
},
|
||
]
|
||
: []),
|
||
{
|
||
id: "year",
|
||
title: t("plex.filter.year"),
|
||
body: (
|
||
<div className="flex items-center gap-2">
|
||
<NumberInput
|
||
value={filters.yearMin ?? null}
|
||
placeholder={facets?.year_min ?? undefined}
|
||
onChange={(v) => patch({ yearMin: v })}
|
||
/>
|
||
<span className="text-muted text-xs">–</span>
|
||
<NumberInput
|
||
value={filters.yearMax ?? null}
|
||
placeholder={facets?.year_max ?? undefined}
|
||
onChange={(v) => patch({ yearMax: v })}
|
||
/>
|
||
</div>
|
||
),
|
||
},
|
||
...(scope === "movie"
|
||
? [
|
||
{
|
||
id: "duration",
|
||
title: t("plex.filter.duration"),
|
||
body: (
|
||
<FilterChipGroup
|
||
ariaLabel={t("plex.filter.duration")}
|
||
// "Any" is selected only when there is genuinely no duration filter; a set-but-off-bucket
|
||
// duration (e.g. stale state after a bucket-boundary change) selects nothing, not "Any".
|
||
value={
|
||
durBucketKey ??
|
||
(filters.durationMin == null && filters.durationMax == null ? "" : " ")
|
||
}
|
||
options={[
|
||
{ value: "", label: t("plex.filter.any") },
|
||
...DURATION_BUCKETS.map((b) => ({
|
||
value: b.key,
|
||
label: t(`plex.filter.durationOpt.${b.key}`),
|
||
})),
|
||
]}
|
||
onChange={(key) => {
|
||
const b = DURATION_BUCKETS.find((x) => x.key === key);
|
||
patch({ durationMin: b?.min ?? null, durationMax: b?.max ?? null });
|
||
}}
|
||
/>
|
||
),
|
||
},
|
||
]
|
||
: []),
|
||
{
|
||
id: "added",
|
||
title: t("plex.filter.added"),
|
||
body: (
|
||
<FilterChipGroup
|
||
ariaLabel={t("plex.filter.added")}
|
||
value={filters.addedWithin || ""}
|
||
options={[
|
||
{ value: "", label: t("plex.filter.any") },
|
||
...ADDED_OPTS.map((a) => ({ value: a, label: t(`plex.filter.addedOpt.${a}`) })),
|
||
]}
|
||
onChange={(v) => patch({ addedWithin: v })}
|
||
/>
|
||
),
|
||
},
|
||
...(facets && facets.content_ratings.length > 0
|
||
? [
|
||
{
|
||
id: "contentrating",
|
||
title: t("plex.filter.contentRating"),
|
||
body: (
|
||
<Combobox
|
||
label={t("plex.filter.contentRating")}
|
||
options={facets.content_ratings}
|
||
selected={filters.contentRatings}
|
||
onChange={(next) => patch({ contentRatings: next })}
|
||
/>
|
||
),
|
||
},
|
||
]
|
||
: []),
|
||
...(facets && facets.studios.length > 0
|
||
? [
|
||
{
|
||
id: "studio",
|
||
title: t("plex.filter.studio"),
|
||
body: (
|
||
<Combobox
|
||
label={t("plex.filter.studio")}
|
||
options={facets.studios}
|
||
selected={filters.studios}
|
||
onChange={(next) => patch({ studios: next })}
|
||
/>
|
||
),
|
||
},
|
||
]
|
||
: []),
|
||
// Playback class (Direct Play / Direct Stream / Transcode HW/SW) — movie-only (shows have no
|
||
// class), and dropped when the facet is empty. Labels are localized (the facet ships class keys).
|
||
...(scope === "movie" && facets && facets.playable.length > 0
|
||
? [
|
||
{
|
||
id: "playable",
|
||
title: t("plex.filter.playable"),
|
||
body: (
|
||
<Combobox
|
||
label={t("plex.filter.playable")}
|
||
options={facets.playable.map((p) => ({
|
||
value: p.value,
|
||
label: t(`plex.filter.playableOpt.${p.value}`),
|
||
count: p.count,
|
||
}))}
|
||
selected={filters.playable}
|
||
onChange={(next) => patch({ playable: next })}
|
||
/>
|
||
),
|
||
},
|
||
]
|
||
: []),
|
||
];
|
||
|
||
return (
|
||
<>
|
||
<FilterRail
|
||
kind={source}
|
||
title={t("sidebar.filters")}
|
||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||
count={activeCount}
|
||
collapsed={collapsed}
|
||
onToggleCollapse={onToggleCollapse}
|
||
layout={layout}
|
||
setLayout={setLayout}
|
||
items={items}
|
||
// The Plex widgets are simple label + control rows now, not collapsible islands (matches the
|
||
// feed rail). The rail's own "Clear all" is dropped too — the chip row above the grid owns it.
|
||
collapsible={false}
|
||
// Saved ("personal") views: header icon → quick-pick popover, title → the manager modal. Hidden
|
||
// for the demo account. Same shared components as the feed rail (SavedViewsProvider switches source).
|
||
iconExpanded={
|
||
isDemo ? undefined : (
|
||
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
|
||
)
|
||
}
|
||
onTitleClick={isDemo ? undefined : openModal}
|
||
titleActionLabel={isDemo ? undefined : t("views.manageTitle")}
|
||
/>
|
||
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
|
||
{modalOpen && <PersonalViewsModal />}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// 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; now multi-select): a 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 + the any/all match footer; this wrapper owns the query→fetch (gated on
|
||
// `open`) and maps collections to options. A title can belong to several collections, so multiple can
|
||
// be picked and combined with Any/All. Titles are carried alongside the ids for the active-filter chips.
|
||
function CollectionPicker({
|
||
values,
|
||
titles,
|
||
mode,
|
||
onChange,
|
||
onModeChange,
|
||
}: {
|
||
values: string[];
|
||
titles: Record<string, string> | undefined;
|
||
mode: "any" | "all";
|
||
onChange: (values: string[], titles: Record<string, string> | undefined) => void;
|
||
onModeChange: (mode: "any" | "all") => void;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const [open, setOpen] = useState(false);
|
||
const [search, setSearch] = useState("");
|
||
const searchDeb = useDebounced(search.trim(), 300);
|
||
const q = useQuery({
|
||
queryKey: qk.plexCollections("union", searchDeb),
|
||
queryFn: () => api.plexCollections(undefined, searchDeb || undefined),
|
||
enabled: open, // only fetch while the picker is open
|
||
});
|
||
// …but a selection restored from a LINK has ids and no titles, and the list above is only fetched
|
||
// once the picker is opened — so the token rendered the raw rating_key until then. Resolve just the
|
||
// selected ids (see usePlexCollectionTitles).
|
||
const resolved = usePlexCollectionTitles(values, titles);
|
||
const options: ComboboxOption[] = (q.data?.collections ?? []).map((c) => ({
|
||
value: c.id,
|
||
label: c.title,
|
||
count: c.child_count ?? undefined,
|
||
}));
|
||
// Combine the fetched labels with the ones already stored so the trip through onChange keeps every
|
||
// selected collection's title (the fetched list is narrowed by the current search).
|
||
const labelMap: Record<string, string> = { ...resolved };
|
||
for (const o of options) if (o.label) labelMap[o.value] = o.label;
|
||
|
||
return (
|
||
<Combobox
|
||
label={t("plex.filter.collection")}
|
||
clientFilter={false} // the server already narrows to `search`
|
||
options={options}
|
||
selected={values}
|
||
selectedLabels={resolved}
|
||
query={search}
|
||
onQueryChange={setSearch}
|
||
onOpenChange={setOpen}
|
||
loading={q.isFetching}
|
||
placeholder={t("plex.filter.collectionSearch")}
|
||
emptyText={t("plex.filter.collectionEmpty")}
|
||
matchMode={mode === "all" ? "and" : "or"}
|
||
onMatchModeChange={(m) => onModeChange(m === "and" ? "all" : "any")}
|
||
onChange={(next) => {
|
||
// Carry only the selected ids' titles forward (drop titles for de-selected ones). Collapse an
|
||
// empty map to undefined so a clear-all key-matches the pristine state (an empty {} still hashes
|
||
// differently from an absent key), mirroring the chip-row remove handler.
|
||
const nextTitles: Record<string, string> = {};
|
||
for (const id of next) nextTitles[id] = labelMap[id] ?? titles?.[id] ?? id;
|
||
onChange(next, next.length ? nextTitles : undefined);
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||
return (
|
||
<span className="chip px-2 py-1 text-xs">
|
||
<span className="truncate max-w-36">{label}</span>
|
||
<button onClick={onRemove} className="hover:opacity-70" aria-label="remove">
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function NumberInput({
|
||
value,
|
||
placeholder,
|
||
onChange,
|
||
}: {
|
||
value: number | null;
|
||
placeholder?: number;
|
||
onChange: (v: number | null) => void;
|
||
}) {
|
||
return (
|
||
<input
|
||
type="number"
|
||
inputMode="numeric"
|
||
value={value ?? ""}
|
||
placeholder={placeholder != null ? String(placeholder) : ""}
|
||
onChange={(e) => {
|
||
const n = e.target.value === "" ? null : Number(e.target.value);
|
||
onChange(n != null && Number.isFinite(n) ? n : null);
|
||
}}
|
||
className="w-20 rounded-lg border border-border bg-surface px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||
/>
|
||
);
|
||
}
|