Merge remote-tracking branch 'origin/fix/213-logs-trash' into integrate/review-gates
# Conflicts: # docs/blazor-route-parity.md
This commit is contained in:
@@ -42,7 +42,7 @@ public class LogsControllerTests
|
|||||||
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
|
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(new PagedLogEntriesViewModel(0, []));
|
.Returns(new PagedLogEntriesViewModel(0, []));
|
||||||
|
|
||||||
await _controller.GetLogs(-1, 500, "boom", CancellationToken.None);
|
await _controller.GetLogs(-1, 500, "boom", cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
await _mediator.Received(1).Send(
|
await _mediator.Received(1).Send(
|
||||||
Arg.Is<GetRecentLogEntries>(q =>
|
Arg.Is<GetRecentLogEntries>(q =>
|
||||||
@@ -52,6 +52,74 @@ public class LogsControllerTests
|
|||||||
Arg.Any<CancellationToken>());
|
Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetLogs_Should_Default_To_Timestamp_Descending()
|
||||||
|
{
|
||||||
|
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new PagedLogEntriesViewModel(0, []));
|
||||||
|
|
||||||
|
await _controller.GetLogs(cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
await _mediator.Received(1).Send(
|
||||||
|
Arg.Is<GetRecentLogEntries>(q =>
|
||||||
|
q.SortDescending == true &&
|
||||||
|
SelectsTimestamp(q.SortExpression)),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetLogs_Should_Sort_By_Level_Ascending_When_Requested()
|
||||||
|
{
|
||||||
|
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new PagedLogEntriesViewModel(0, []));
|
||||||
|
|
||||||
|
await _controller.GetLogs(sortField: "level", sortDirection: "asc", cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
await _mediator.Received(1).Send(
|
||||||
|
Arg.Is<GetRecentLogEntries>(q =>
|
||||||
|
q.SortDescending == false &&
|
||||||
|
SelectsLevel(q.SortExpression)),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetLogs_Should_Reject_Unknown_Sort_Field_And_Fall_Back_To_Timestamp()
|
||||||
|
{
|
||||||
|
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new PagedLogEntriesViewModel(0, []));
|
||||||
|
|
||||||
|
await _controller.GetLogs(sortField: "message; DROP TABLE", cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
await _mediator.Received(1).Send(
|
||||||
|
Arg.Is<GetRecentLogEntries>(q => SelectsTimestamp(q.SortExpression)),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetLogs_Should_Fall_Back_To_Descending_For_Unknown_Direction()
|
||||||
|
{
|
||||||
|
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new PagedLogEntriesViewModel(0, []));
|
||||||
|
|
||||||
|
await _controller.GetLogs(sortDirection: "sideways", cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
await _mediator.Received(1).Send(
|
||||||
|
Arg.Is<GetRecentLogEntries>(q => q.SortDescending == true),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SelectsTimestamp(System.Linq.Expressions.Expression<Func<LogEntryViewModel, object>> expr)
|
||||||
|
{
|
||||||
|
var sample = new LogEntryViewModel(DateTimeOffset.UnixEpoch.AddDays(1), LogEventLevel.Error, "m");
|
||||||
|
return Equals(expr.Compile()(sample), sample.Timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SelectsLevel(System.Linq.Expressions.Expression<Func<LogEntryViewModel, object>> expr)
|
||||||
|
{
|
||||||
|
var sample = new LogEntryViewModel(DateTimeOffset.UnixEpoch.AddDays(1), LogEventLevel.Error, "m");
|
||||||
|
return Equals(expr.Compile()(sample), sample.Level);
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task GetLogs_Should_Map_Entries_To_Response_Model()
|
public async Task GetLogs_Should_Map_Entries_To_Response_Model()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
using ErsatzTV.Application.Logs;
|
using ErsatzTV.Application.Logs;
|
||||||
using ErsatzTV.Core.Api.Logs;
|
using ErsatzTV.Core.Api.Logs;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -11,22 +12,48 @@ public class LogsController(IMediator mediator) : ControllerBase
|
|||||||
{
|
{
|
||||||
private const int MaxPageSize = 100;
|
private const int MaxPageSize = 100;
|
||||||
|
|
||||||
|
// Mirrors the sortable columns from the legacy Blazor Logs.razor (MudTableSortLabel on
|
||||||
|
// Timestamp/Level; Message was never sortable there either).
|
||||||
|
private static readonly System.Collections.Generic.HashSet<string> AllowedSortFields =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase) { "timestamp", "level" };
|
||||||
|
|
||||||
[HttpGet("/api/logs", Name = "GetLogs")]
|
[HttpGet("/api/logs", Name = "GetLogs")]
|
||||||
[Tags("Logs")]
|
[Tags("Logs")]
|
||||||
[EndpointSummary("Get recent log entries")]
|
[EndpointSummary("Get recent log entries")]
|
||||||
|
[EndpointDescription(
|
||||||
|
"sortField is validated against an allow-list (timestamp, level); an unrecognized value " +
|
||||||
|
"falls back to timestamp. sortDirection accepts asc/desc and falls back to desc (the " +
|
||||||
|
"pre-existing default, newest first).")]
|
||||||
[EndpointGroupName("general")]
|
[EndpointGroupName("general")]
|
||||||
[ProducesResponseType(typeof(PagedLogEntriesResponseModel), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedLogEntriesResponseModel), StatusCodes.Status200OK)]
|
||||||
public async Task<PagedLogEntriesResponseModel> GetLogs(
|
public async Task<PagedLogEntriesResponseModel> GetLogs(
|
||||||
[FromQuery] int pageNum = 0,
|
[FromQuery] int pageNum = 0,
|
||||||
[FromQuery] int pageSize = 100,
|
[FromQuery] int pageSize = 100,
|
||||||
[FromQuery] string filter = "",
|
[FromQuery] string filter = "",
|
||||||
|
[FromQuery] string sortField = "timestamp",
|
||||||
|
[FromQuery] string sortDirection = "desc",
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
int clampedPageNum = Math.Max(0, pageNum);
|
int clampedPageNum = Math.Max(0, pageNum);
|
||||||
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
|
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
|
||||||
|
|
||||||
|
string normalizedSortField = AllowedSortFields.Contains(sortField ?? string.Empty)
|
||||||
|
? sortField!.ToLowerInvariant()
|
||||||
|
: "timestamp";
|
||||||
|
bool descending = !string.Equals(sortDirection, "asc", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
Expression<Func<LogEntryViewModel, object>> sortExpression = normalizedSortField switch
|
||||||
|
{
|
||||||
|
"level" => le => le.Level,
|
||||||
|
_ => le => le.Timestamp
|
||||||
|
};
|
||||||
|
|
||||||
PagedLogEntriesViewModel result = await mediator.Send(
|
PagedLogEntriesViewModel result = await mediator.Send(
|
||||||
new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty),
|
new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty)
|
||||||
|
{
|
||||||
|
SortExpression = sortExpression,
|
||||||
|
SortDescending = descending
|
||||||
|
},
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return new PagedLogEntriesResponseModel(
|
return new PagedLogEntriesResponseModel(
|
||||||
|
|||||||
@@ -5470,6 +5470,7 @@
|
|||||||
"Logs"
|
"Logs"
|
||||||
],
|
],
|
||||||
"summary": "Get recent log entries",
|
"summary": "Get recent log entries",
|
||||||
|
"description": "sortField is validated against an allow-list (timestamp, level); an unrecognized value falls back to timestamp. sortDirection accepts asc/desc and falls back to desc (the pre-existing default, newest first).",
|
||||||
"operationId": "GetLogs",
|
"operationId": "GetLogs",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -5497,6 +5498,22 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"default": ""
|
"default": ""
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sortField",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "timestamp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sortDirection",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "desc"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ Exemplars:
|
|||||||
`pageNum` clamped via `Math.Max(0, pageNum)`, `pageSize` via `Math.Clamp(pageSize, 1, MaxPageSize)`
|
`pageNum` clamped via `Math.Max(0, pageNum)`, `pageSize` via `Math.Clamp(pageSize, 1, MaxPageSize)`
|
||||||
(`MaxPageSize = 100`). Any new paged endpoint should clamp the same way — don't trust client
|
(`MaxPageSize = 100`). Any new paged endpoint should clamp the same way — don't trust client
|
||||||
input for page math.
|
input for page math.
|
||||||
|
- **Sortable GET with allow-listed sort params**: same file — `sortField`/`sortDirection` are
|
||||||
|
normalized against a fixed allow-list (`AllowedSortFields`) rather than trusted or rejected with
|
||||||
|
a 422: an unrecognized `sortField` silently falls back to the default field, an unrecognized
|
||||||
|
`sortDirection` falls back to the default direction. Copy this pattern (normalize, don't 422) for
|
||||||
|
any new sortable endpoint — it matches the pageNum/pageSize clamp precedent above and keeps a bad
|
||||||
|
query string from ever producing an error response for a read-only listing.
|
||||||
|
|
||||||
## 2. DTOs: where they live and their nullable context
|
## 2. DTOs: where they live and their nullable context
|
||||||
|
|
||||||
|
|||||||
+13
-10
@@ -60,10 +60,10 @@ redirect).
|
|||||||
> | Watermarks | PARITY-OK (copy via `/add?from=` prefill, 2026-07-09) | — |
|
> | Watermarks | PARITY-OK (copy via `/add?from=` prefill, 2026-07-09) | — |
|
||||||
> | Playout creation + alternate-schedules | PARITY-OK | — |
|
> | Playout creation + alternate-schedules | PARITY-OK | — |
|
||||||
> | Playout kind-editors + list actions | PARITY-OK (delete/reset/erase/scheduling-context wired + templates preview calendar, 2026-07-09; EntityLocker build-lock gating enforced server-side via 409 + mirrored in SPA `IsLocked`, 2026-07-10 #215) | — |
|
> | Playout kind-editors + list actions | PARITY-OK (delete/reset/erase/scheduling-context wired + templates preview calendar, 2026-07-09; EntityLocker build-lock gating enforced server-side via 409 + mirrored in SPA `IsLocked`, 2026-07-10 #215) | — |
|
||||||
> | Multi/rerun collections, playlists, trash | PARITY-OK (trash select-all/clear added; 100/kind cap → decisions.md) | — |
|
> | Multi/rerun collections, playlists, trash | PARITY-OK (trash select-all/clear + per-kind "see all" paging past 100, 2026-07-11) | — |
|
||||||
> | Collections | PARITY-OK (custom-order endpoint + reorder UI, all-10-kind add picker, 2026-07-09) | — |
|
> | Collections | PARITY-OK (custom-order endpoint + reorder UI, all-10-kind add picker, 2026-07-09) | — |
|
||||||
> | Channel editor | GAPS (external logo URL, bare create, pickers) | #212 |
|
> | Channel editor | GAPS (external logo URL, bare create, pickers) | #212 |
|
||||||
> | Channels-numbers / Logs | PARITY-OK / minors | #213 |
|
> | Channels-numbers / Logs | PARITY-OK (logs sort + page-size persistence added, 2026-07-11) | — |
|
||||||
> | Search | PARITY-OK (card nav incl. episode cards — #220; per-card/multi-select add-to, add-all, save-as-smart-collection; mutation controls + Add-all gated during refetch — #221; 2026-07-11) | — |
|
> | Search | PARITY-OK (card nav incl. episode cards — #220; per-card/multi-select add-to, add-all, save-as-smart-collection; mutation controls + Add-all gated during refetch — #221; 2026-07-11) | — |
|
||||||
> | Media browse/detail (read paths + image browser) | PARITY-OK | — |
|
> | Media browse/detail (read paths + image browser) | PARITY-OK | — |
|
||||||
> | Media browse/detail (mutations, per-show scan, episode info/troubleshoot) | PARITY-OK (shared Add-to layer + scan/info/troubleshoot wired, 2026-07-10; mutation controls gated on kind/query/page refetch — #221) | — |
|
> | Media browse/detail (mutations, per-show scan, episode info/troubleshoot) | PARITY-OK (shared Add-to layer + scan/info/troubleshoot wired, 2026-07-10; mutation controls gated on kind/query/page refetch — #221) | — |
|
||||||
@@ -181,14 +181,11 @@ REST endpoints exist and are unused; only item delete/reorder and the schedule s
|
|||||||
### Remaining mutation-depth gaps inside Section 2 rows
|
### Remaining mutation-depth gaps inside Section 2 rows
|
||||||
|
|
||||||
Tracked as #91 phase (b) gates without moving whole rows — SHOULD-FIX: #212 (channel editor),
|
Tracked as #91 phase (b) gates without moving whole rows — SHOULD-FIX: #212 (channel editor),
|
||||||
#213 (remaining nits: logs sort + page-size persistence, block-history page-size/gating,
|
#213 CLOSED 2026-07-11 — full remainder landed across two branches: block-history page-size
|
||||||
blocks/templates list filter — all read-only conveniences).
|
persistence (`localStorage` key `ctv-block-history-page-size`, same `ctv-` namespace as
|
||||||
2026-07-11 (branch `fix/213-spa-nits`): block-history page-size persistence (`localStorage`,
|
`ctv-theme`) + History action gated on `block.id >= 0` + client-side name/group filter boxes on
|
||||||
key `ctv-block-history-page-size`, same `ctv-` namespace as `ctv-theme`) + gating the History
|
the Blocks and Templates lists (`fix/213-spa-nits`); logs column sorting + page-size persistence
|
||||||
action on `block.id >= 0` (`BlockPlayoutTroubleshootingScreen.tsx`), and client-side name/group
|
and trash per-kind "see all" paging (`fix/213-logs-trash`).
|
||||||
search/filter boxes on the Blocks and Templates list screens, all landed. Still open under #213:
|
|
||||||
logs sort + page-size persistence, trash paging (tracked on a separate concurrent branch,
|
|
||||||
`fix/213-logs-trash`).
|
|
||||||
CLOSED 2026-07-09: **#210** (playout delete/reset/erase/scheduling-context + preview calendar)
|
CLOSED 2026-07-09: **#210** (playout delete/reset/erase/scheduling-context + preview calendar)
|
||||||
and **#211** (collection custom order + all-kind add picker); the block/watermark copy, trash
|
and **#211** (collection custom order + all-kind add picker); the block/watermark copy, trash
|
||||||
select-all, and Trakt-note items of #213 landed in the same PR.
|
select-all, and Trakt-note items of #213 landed in the same PR.
|
||||||
@@ -214,6 +211,12 @@ id-keyed `PlayoutController` mutation + `ChannelController.ResetPlayout` returns
|
|||||||
DTO (disables the buttons + shows a "Building…" cue). The safety invariant no longer depends on
|
DTO (disables the buttons + shows a "Building…" cue). The safety invariant no longer depends on
|
||||||
Blazor, so its removal can't silently drop it. See `docs/api-conventions.md` §3a + `decisions.md`.
|
Blazor, so its removal can't silently drop it. See `docs/api-conventions.md` §3a + `decisions.md`.
|
||||||
|
|
||||||
|
2026-07-11 (#213, remaining scope): logs sort (`GET /api/logs` `sortField`/`sortDirection`,
|
||||||
|
clickable column headers) and page-size persistence (client-local `localStorage`, not a server
|
||||||
|
`ConfigElement`) landed; trash "see all" now pages past the 100/kind cap via
|
||||||
|
`GET /api/library/browse` (no new API surface — see `docs/decisions.md`). The sibling branch landed the rest
|
||||||
|
(block-history page-size/gating, blocks/templates list filters) — #213 fully closed.
|
||||||
|
|
||||||
## Section 4 — Blazor home / escape hatch
|
## Section 4 — Blazor home / escape hatch
|
||||||
|
|
||||||
Not gated on an issue — kept separate from Section 3 because it isn't blocked on anything, just the
|
Not gated on an issue — kept separate from Section 3 because it isn't blocked on anything, just the
|
||||||
|
|||||||
@@ -181,6 +181,42 @@ that per-kind cap); this mirrors the legacy Blazor Trash page, which had the sam
|
|||||||
behavior and cap. True paging is deferred until the search API grows a page param — not attempted
|
behavior and cap. True paging is deferred until the search API grows a page param — not attempted
|
||||||
here, since it would mean adding a paging contract server-side, out of scope for this pass.
|
here, since it would mean adding a paging contract server-side, out of scope for this pass.
|
||||||
|
|
||||||
|
**Superseded 2026-07-11 (#213)**: the cap is lifted via a per-kind "See all N …" button, without
|
||||||
|
adding any new API surface. `GET /api/library/browse` (`LibraryBrowseController` /
|
||||||
|
`GetLibraryBrowseItems`) already accepts `mediaType` + `pageNum` + `pageSize` and runs the same
|
||||||
|
underlying query as `GetSearchResults` (which itself fans out to `GetLibraryBrowseItems` per kind,
|
||||||
|
just always at `pageNum=0`) — so `TrashScreen.tsx` pages `pageNum=1, 2, …` through
|
||||||
|
`/api/library/browse?query=state:FileNotFound&mediaType={kind}&pageSize=100` for a kind once the
|
||||||
|
user asks to see past the first 100, and appends the results client-side. The 100/kind **first
|
||||||
|
page** still comes from `/api/search` (unchanged, cheapest for the common case where a kind has
|
||||||
|
few matches); only kinds that exceed the cap ever issue the follow-up `/api/library/browse` calls.
|
||||||
|
|
||||||
|
## 2026-07-11 — Logs page-size is a client-local preference, not a server ConfigElement
|
||||||
|
|
||||||
|
The legacy Blazor Logs page persisted the user's chosen rows-per-page via
|
||||||
|
`ConfigElementKey.LogsPageSize` (`SaveConfigElementByKey`/`GetConfigElementByKey`), a
|
||||||
|
per-server-instance setting stored in the DB. `LogsScreen.tsx` instead persists it to
|
||||||
|
`window.localStorage` under `ctv-logs-page-size` (same wrapped-`Storage` pattern as
|
||||||
|
`designSystem.ts`'s theme preference: try/catch getter, validated against the known option set,
|
||||||
|
falls back to a default) and restores it on mount. Deliberate deviation: this is a per-browser UI
|
||||||
|
preference, not server/business state — no other client should see or be affected by it, so there
|
||||||
|
is no reason to round-trip it through the API and grow a new `/api/*` surface (or reuse the
|
||||||
|
generic config-element endpoints) just to store a page-size number. Follows the existing SPA
|
||||||
|
localStorage convention (`designSystem.ts` theme, `auth.ts` token) rather than introducing a new
|
||||||
|
persistence mechanism.
|
||||||
|
|
||||||
|
## 2026-07-11 — Logs column sorting: allow-listed `sortField`/`sortDirection` on `GET /api/logs`
|
||||||
|
|
||||||
|
Parity for `Logs.razor`'s `MudTableSortLabel` columns (Timestamp, Level — Message was never
|
||||||
|
sortable in Blazor either). `LogsController.GetLogs` adds `sortField` (`timestamp` | `level`,
|
||||||
|
default `timestamp`) and `sortDirection` (`asc` | `desc`, default `desc`) query params, normalized
|
||||||
|
server-side the same way `pageNum`/`pageSize` are clamped rather than rejected with a 422: an
|
||||||
|
unrecognized `sortField` silently falls back to `timestamp`, an unrecognized `sortDirection` falls
|
||||||
|
back to `desc` — the pre-existing default behavior (newest-first) is unreachable to break via a bad
|
||||||
|
query string. `LogsScreen.tsx` renders the two sortable headers as buttons with a chevron
|
||||||
|
indicating the active field/direction; clicking the active column toggles direction, clicking the
|
||||||
|
other column switches to it ascending.
|
||||||
|
|
||||||
## 2026-07-09 — Per-playout "Schedule reset" button dropped; Reset uses the server-default build mode
|
## 2026-07-09 — Per-playout "Schedule reset" button dropped; Reset uses the server-default build mode
|
||||||
|
|
||||||
Blazor's playouts page had both a per-playout **Reset** and a separate **Schedule Reset** control
|
Blazor's playouts page had both a per-playout **Reset** and a separate **Schedule Reset** control
|
||||||
|
|||||||
@@ -159,6 +159,20 @@ page's action row) and the `AddToCollectionDialog` / `AddToPlaylistDialog` / `Ad
|
|||||||
on grid screens is an explicit "Select" toggle (see `docs/decisions.md` 2026-07-10 for the rationale
|
on grid screens is an explicit "Select" toggle (see `docs/decisions.md` 2026-07-10 for the rationale
|
||||||
and the accepted deviations from Blazor).
|
and the accepted deviations from Blazor).
|
||||||
|
|
||||||
|
## 5d. Client-local preferences: `localStorage`, namespaced `ctv-*` keys
|
||||||
|
|
||||||
|
Per-browser UI preferences (theme, an auth token, a screen's remembered page size) live in
|
||||||
|
`window.localStorage` under a namespaced `ctv-` key, **not** a round-trip through the API — the
|
||||||
|
established pattern is `designSystem.ts`'s `getStoredDesignSystemTheme`/`applyDesignSystemTheme`
|
||||||
|
(`ctv-theme`): a small `getStorage()` helper that returns `window.localStorage` wrapped in a
|
||||||
|
try/catch (so a disabled/unavailable storage API degrades to the default instead of throwing), a
|
||||||
|
getter that validates the stored value against the known option set before trusting it, and a
|
||||||
|
setter that writes straight through. `LogsScreen.tsx`'s page-size persistence (`ctv-logs-page-size`,
|
||||||
|
#213) follows the same shape. Reserve this for state that's genuinely local to the browser/user
|
||||||
|
session — if a preference needs to be shared across devices or is really server/business state
|
||||||
|
(e.g. Blazor's `ConfigElement`-backed settings), it belongs behind an API endpoint instead; see
|
||||||
|
`docs/decisions.md` 2026-07-11 for the specific reasoning on logs page-size.
|
||||||
|
|
||||||
## 6. Tests
|
## 6. Tests
|
||||||
|
|
||||||
- **vitest**, colocated `*.test.ts` / `*.test.tsx` next to the source file.
|
- **vitest**, colocated `*.test.ts` / `*.test.tsx` next to the source file.
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ describe('getLogs', () => {
|
|||||||
expect(url).toBe('/api/logs?filter=boom&pageNum=2&pageSize=50');
|
expect(url).toBe('/api/logs?filter=boom&pageNum=2&pageSize=50');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds the query string from sortField and sortDirection', async () => {
|
||||||
|
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||||
|
new Response(JSON.stringify(samplePage), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
status: 200
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await getLogs({ sortDirection: 'asc', sortField: 'level' });
|
||||||
|
|
||||||
|
const [url] = fetchSpy.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/logs?sortField=level&sortDirection=asc');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects with the ApiError status on failure', async () => {
|
it('rejects with the ApiError status on failure', async () => {
|
||||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||||
new Response(JSON.stringify({ status: 500, title: 'Server Error' }), {
|
new Response(JSON.stringify({ status: 500, title: 'Server Error' }), {
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import type { components } from './generated/v1';
|
|||||||
export type LogEntry = components['schemas']['LogEntryResponseModel'];
|
export type LogEntry = components['schemas']['LogEntryResponseModel'];
|
||||||
export type PagedLogEntries = components['schemas']['PagedLogEntriesResponseModel'];
|
export type PagedLogEntries = components['schemas']['PagedLogEntriesResponseModel'];
|
||||||
|
|
||||||
|
export type LogsSortField = 'timestamp' | 'level';
|
||||||
|
export type LogsSortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
export interface GetLogsParams {
|
export interface GetLogsParams {
|
||||||
filter?: string;
|
filter?: string;
|
||||||
pageNum?: number;
|
pageNum?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
sortDirection?: LogsSortDirection;
|
||||||
|
sortField?: LogsSortField;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLogs(params: GetLogsParams = {}): Promise<PagedLogEntries> {
|
export function getLogs(params: GetLogsParams = {}): Promise<PagedLogEntries> {
|
||||||
@@ -25,6 +30,14 @@ export function getLogs(params: GetLogsParams = {}): Promise<PagedLogEntries> {
|
|||||||
searchParams.set('pageSize', String(params.pageSize));
|
searchParams.set('pageSize', String(params.pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.sortField) {
|
||||||
|
searchParams.set('sortField', params.sortField);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.sortDirection) {
|
||||||
|
searchParams.set('sortDirection', params.sortDirection);
|
||||||
|
}
|
||||||
|
|
||||||
const queryString = searchParams.toString();
|
const queryString = searchParams.toString();
|
||||||
|
|
||||||
return request<PagedLogEntries>(`/api/logs${queryString ? `?${queryString}` : ''}`);
|
return request<PagedLogEntries>(`/api/logs${queryString ? `?${queryString}` : ''}`);
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { LogsScreen } from './LogsScreen';
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const samplePage = {
|
||||||
|
totalCount: 2,
|
||||||
|
page: [
|
||||||
|
{ timestamp: '2026-07-07T00:00:00Z', level: 'Warning', message: 'uh oh' },
|
||||||
|
{ timestamp: '2026-07-07T00:01:00Z', level: 'Information', message: 'all good' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockApi() {
|
||||||
|
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||||
|
const url = input.toString();
|
||||||
|
if (url.startsWith('/api/logs')) {
|
||||||
|
return Promise.resolve(jsonResponse(samplePage));
|
||||||
|
}
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastLogsUrl(fetchMock: ReturnType<typeof mockApi>): string {
|
||||||
|
const call = [...fetchMock.mock.calls].reverse().find(([u]) => u.toString().startsWith('/api/logs'));
|
||||||
|
return call ? call[0].toString() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LogsScreen', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads with the default timestamp/desc sort', async () => {
|
||||||
|
const fetchMock = mockApi();
|
||||||
|
render(<LogsScreen />);
|
||||||
|
|
||||||
|
await screen.findByText('uh oh');
|
||||||
|
|
||||||
|
expect(lastLogsUrl(fetchMock)).toContain('sortField=timestamp');
|
||||||
|
expect(lastLogsUrl(fetchMock)).toContain('sortDirection=desc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles sort direction when clicking the active column', async () => {
|
||||||
|
const fetchMock = mockApi();
|
||||||
|
render(<LogsScreen />);
|
||||||
|
await screen.findByText('uh oh');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Timestamp/ }));
|
||||||
|
|
||||||
|
await screen.findByText('uh oh');
|
||||||
|
expect(lastLogsUrl(fetchMock)).toContain('sortField=timestamp');
|
||||||
|
expect(lastLogsUrl(fetchMock)).toContain('sortDirection=asc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches sort field to ascending when clicking a new column', async () => {
|
||||||
|
const fetchMock = mockApi();
|
||||||
|
render(<LogsScreen />);
|
||||||
|
await screen.findByText('uh oh');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Level/ }));
|
||||||
|
|
||||||
|
await screen.findByText('uh oh');
|
||||||
|
expect(lastLogsUrl(fetchMock)).toContain('sortField=level');
|
||||||
|
expect(lastLogsUrl(fetchMock)).toContain('sortDirection=asc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists page size to localStorage and restores it on mount', async () => {
|
||||||
|
mockApi();
|
||||||
|
render(<LogsScreen />);
|
||||||
|
await screen.findByText('uh oh');
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByDisplayValue('50'), { target: { value: '100' } });
|
||||||
|
expect(await screen.findByDisplayValue('100')).toBeInTheDocument();
|
||||||
|
expect(window.localStorage.getItem('ctv-logs-page-size')).toBe('100');
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
const fetchMock = mockApi();
|
||||||
|
render(<LogsScreen />);
|
||||||
|
await screen.findByText('uh oh');
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('100')).toBeInTheDocument();
|
||||||
|
expect(lastLogsUrl(fetchMock)).toContain('pageSize=100');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, Info, RefreshCw, Search, TriangleAlert } from 'lucide-react';
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronUp,
|
||||||
|
Info,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
TriangleAlert
|
||||||
|
} from 'lucide-react';
|
||||||
import { Badge, Button, Card, IconButton, Input, Select, Spinner } from '../components';
|
import { Badge, Button, Card, IconButton, Input, Select, Spinner } from '../components';
|
||||||
import { getLogs, messageFromLogsError, type LogEntry } from '../api';
|
import { getLogs, messageFromLogsError, type LogEntry, type LogsSortDirection, type LogsSortField } from '../api';
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = ['25', '50', '100'];
|
const PAGE_SIZE_OPTIONS = ['25', '50', '100'];
|
||||||
|
const DEFAULT_PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
// Client-local UI preference (not a Blazor-style server ConfigElement) — see docs/decisions.md
|
||||||
|
// 2026-07-11 "Logs page-size is a client-local preference".
|
||||||
|
const LOGS_PAGE_SIZE_STORAGE_KEY = 'ctv-logs-page-size';
|
||||||
|
|
||||||
const LEVEL_TONE: Record<string, 'neutral' | 'accent' | 'ok' | 'warn' | 'error'> = {
|
const LEVEL_TONE: Record<string, 'neutral' | 'accent' | 'ok' | 'warn' | 'error'> = {
|
||||||
Debug: 'neutral',
|
Debug: 'neutral',
|
||||||
@@ -24,11 +38,44 @@ function formatTimestamp(value: string): string {
|
|||||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStorage(): Storage | undefined {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return window.localStorage;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredPageSize(): number {
|
||||||
|
const stored = getStorage()?.getItem(LOGS_PAGE_SIZE_STORAGE_KEY) ?? null;
|
||||||
|
return stored != null && PAGE_SIZE_OPTIONS.includes(stored) ? Number(stored) : DEFAULT_PAGE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function storePageSize(pageSize: number): void {
|
||||||
|
getStorage()?.setItem(LOGS_PAGE_SIZE_STORAGE_KEY, String(pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortableColumn {
|
||||||
|
field: LogsSortField;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS: SortableColumn[] = [
|
||||||
|
{ field: 'timestamp', label: 'Timestamp' },
|
||||||
|
{ field: 'level', label: 'Level' }
|
||||||
|
];
|
||||||
|
|
||||||
export function LogsScreen() {
|
export function LogsScreen() {
|
||||||
const [filterInput, setFilterInput] = useState('');
|
const [filterInput, setFilterInput] = useState('');
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
const [pageNum, setPageNum] = useState(0);
|
const [pageNum, setPageNum] = useState(0);
|
||||||
const [pageSize, setPageSize] = useState(50);
|
const [pageSize, setPageSize] = useState(() => getStoredPageSize());
|
||||||
|
const [sortField, setSortField] = useState<LogsSortField>('timestamp');
|
||||||
|
const [sortDirection, setSortDirection] = useState<LogsSortDirection>('desc');
|
||||||
const [state, setState] = useState<LogsState>({ entries: [], error: null, status: 'loading', totalCount: 0 });
|
const [state, setState] = useState<LogsState>({ entries: [], error: null, status: 'loading', totalCount: 0 });
|
||||||
const activeRef = useRef(true);
|
const activeRef = useRef(true);
|
||||||
const seqRef = useRef(0);
|
const seqRef = useRef(0);
|
||||||
@@ -56,7 +103,7 @@ export function LogsScreen() {
|
|||||||
// a loading spinner on every filter keystroke or page change.
|
// a loading spinner on every filter keystroke or page change.
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
const id = ++seqRef.current;
|
const id = ++seqRef.current;
|
||||||
getLogs({ filter, pageNum, pageSize })
|
getLogs({ filter, pageNum, pageSize, sortDirection, sortField })
|
||||||
.then((paged) => {
|
.then((paged) => {
|
||||||
if (activeRef.current && id === seqRef.current) {
|
if (activeRef.current && id === seqRef.current) {
|
||||||
setState({ entries: paged.page ?? [], error: null, status: 'success', totalCount: paged.totalCount ?? 0 });
|
setState({ entries: paged.page ?? [], error: null, status: 'success', totalCount: paged.totalCount ?? 0 });
|
||||||
@@ -67,7 +114,7 @@ export function LogsScreen() {
|
|||||||
setState({ entries: [], error: messageFromLogsError(error), status: 'error', totalCount: 0 });
|
setState({ entries: [], error: messageFromLogsError(error), status: 'error', totalCount: 0 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [filter, pageNum, pageSize]);
|
}, [filter, pageNum, pageSize, sortDirection, sortField]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -78,6 +125,22 @@ export function LogsScreen() {
|
|||||||
load();
|
load();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changePageSize = (nextPageSize: number) => {
|
||||||
|
setPageNum(0);
|
||||||
|
setPageSize(nextPageSize);
|
||||||
|
storePageSize(nextPageSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSort = (field: LogsSortField) => {
|
||||||
|
setPageNum(0);
|
||||||
|
if (field === sortField) {
|
||||||
|
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const totalPages = state.status === 'success' ? Math.max(1, Math.ceil(state.totalCount / pageSize)) : 1;
|
const totalPages = state.status === 'success' ? Math.max(1, Math.ceil(state.totalCount / pageSize)) : 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -91,10 +154,7 @@ export function LogsScreen() {
|
|||||||
/>
|
/>
|
||||||
<span className="ctv-channels-spacer" />
|
<span className="ctv-channels-spacer" />
|
||||||
<Select
|
<Select
|
||||||
onChange={(event) => {
|
onChange={(event) => changePageSize(Number(event.target.value))}
|
||||||
setPageNum(0);
|
|
||||||
setPageSize(Number(event.target.value));
|
|
||||||
}}
|
|
||||||
options={PAGE_SIZE_OPTIONS}
|
options={PAGE_SIZE_OPTIONS}
|
||||||
style={{ width: 96 }}
|
style={{ width: 96 }}
|
||||||
value={String(pageSize)}
|
value={String(pageSize)}
|
||||||
@@ -136,8 +196,26 @@ export function LogsScreen() {
|
|||||||
<table aria-label="Recent log entries" className="ctv-channels-table">
|
<table aria-label="Recent log entries" className="ctv-channels-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Timestamp</th>
|
{COLUMNS.map((column) => {
|
||||||
<th>Level</th>
|
const active = column.field === sortField;
|
||||||
|
return (
|
||||||
|
<th aria-sort={active ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'} key={column.field}>
|
||||||
|
<button
|
||||||
|
className="ctv-logs-sort-button"
|
||||||
|
onClick={() => toggleSort(column.field)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
{active &&
|
||||||
|
(sortDirection === 'asc' ? (
|
||||||
|
<ChevronUp aria-hidden="true" size={13} />
|
||||||
|
) : (
|
||||||
|
<ChevronDown aria-hidden="true" size={13} />
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<th>Message</th>
|
<th>Message</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ const searchResults = {
|
|||||||
songs: emptyGroup()
|
songs: emptyGroup()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Movies group has more matches than the /api/search first-page cap (100) to exercise "See all".
|
||||||
|
function searchResultsWithMoreMovies() {
|
||||||
|
const movieItems = Array.from({ length: 100 }, (_, i) => browseItem(i + 1, 'Movie', 200 + i));
|
||||||
|
return { ...searchResults, movies: { items: movieItems, totalCount: 130 }, shows: emptyGroup() };
|
||||||
|
}
|
||||||
|
|
||||||
interface MockOptions {
|
interface MockOptions {
|
||||||
onRequest?: (url: string, method: string, body: unknown) => Response | null;
|
onRequest?: (url: string, method: string, body: unknown) => Response | null;
|
||||||
}
|
}
|
||||||
@@ -129,4 +135,37 @@ describe('TrashScreen', () => {
|
|||||||
expect([...sentBody.ids].sort()).toEqual([101, 102, 103]);
|
expect([...sentBody.ids].sort()).toEqual([101, 102, 103]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('pages past the 100/kind cap via "See all" using GET /api/library/browse', async () => {
|
||||||
|
const moreMovies = searchResultsWithMoreMovies();
|
||||||
|
const fetchMock = mockApi({
|
||||||
|
onRequest: (url, method) => {
|
||||||
|
if (url.startsWith('/api/search') && method === 'GET') {
|
||||||
|
return jsonResponse(moreMovies);
|
||||||
|
}
|
||||||
|
if (url.startsWith('/api/library/browse') && method === 'GET') {
|
||||||
|
expect(url).toContain('mediaType=Movie');
|
||||||
|
expect(url).toContain('pageNum=1');
|
||||||
|
expect(url).toContain('query=state%3AFileNotFound');
|
||||||
|
const extraMovies = Array.from({ length: 30 }, (_, i) => browseItem(i + 500, 'Movie', 900 + i));
|
||||||
|
return jsonResponse({ page: extraMovies, totalCount: 130 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<TrashScreen />);
|
||||||
|
await screen.findByText('130 missing');
|
||||||
|
|
||||||
|
const seeAllButton = screen.getByRole('button', { name: 'See all 130 movies' });
|
||||||
|
fireEvent.click(seeAllButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock.mock.calls.some(([u]) => u.toString().startsWith('/api/library/browse'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('button', { name: /See all/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,15 +4,20 @@ import { Button, Card, ConfirmDialog, Spinner } from '../components';
|
|||||||
import {
|
import {
|
||||||
deleteMediaItems,
|
deleteMediaItems,
|
||||||
emptyTrash,
|
emptyTrash,
|
||||||
|
getLibraryBrowseItems,
|
||||||
getSearchResults,
|
getSearchResults,
|
||||||
|
messageFromLibraryBrowseError,
|
||||||
messageFromSearchError,
|
messageFromSearchError,
|
||||||
type LibraryBrowseItem,
|
type LibraryBrowseItem,
|
||||||
|
type LibraryBrowseMediaType,
|
||||||
type SearchResults
|
type SearchResults
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import { MediaPosterCard } from '../media/MediaPosterCard';
|
import { MediaPosterCard } from '../media/MediaPosterCard';
|
||||||
|
|
||||||
// The search API's SearchController clamps pageSize to MaxPageSize=100 and has no page-number
|
// The initial per-kind fetch uses /api/search (GetSearchResults), whose SearchController clamps
|
||||||
// param, so this is the largest single request possible — see docs/decisions.md ("Trash see all").
|
// pageSize to MaxPageSize=100 — see docs/decisions.md ("Trash see all"). "See all" beyond that
|
||||||
|
// first page pages through the same underlying data via GET /api/library/browse (mediaType +
|
||||||
|
// pageNum), which already supports paging — no new API surface was needed to lift the cap.
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
// Lucene state filter for items whose files have gone missing (matches the legacy Blazor Trash page).
|
// Lucene state filter for items whose files have gone missing (matches the legacy Blazor Trash page).
|
||||||
const TRASH_QUERY = 'state:FileNotFound';
|
const TRASH_QUERY = 'state:FileNotFound';
|
||||||
@@ -20,21 +25,30 @@ const TRASH_QUERY = 'state:FileNotFound';
|
|||||||
interface GroupDef {
|
interface GroupDef {
|
||||||
key: keyof SearchResults;
|
key: keyof SearchResults;
|
||||||
label: string;
|
label: string;
|
||||||
|
mediaType: LibraryBrowseMediaType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUPS: GroupDef[] = [
|
const GROUPS: GroupDef[] = [
|
||||||
{ key: 'movies', label: 'Movies' },
|
{ key: 'movies', label: 'Movies', mediaType: 'Movie' },
|
||||||
{ key: 'shows', label: 'TV Shows' },
|
{ key: 'shows', label: 'TV Shows', mediaType: 'TelevisionShow' },
|
||||||
{ key: 'seasons', label: 'Seasons' },
|
{ key: 'seasons', label: 'Seasons', mediaType: 'TelevisionSeason' },
|
||||||
{ key: 'episodes', label: 'Episodes' },
|
{ key: 'episodes', label: 'Episodes', mediaType: 'Episode' },
|
||||||
{ key: 'artists', label: 'Artists' },
|
{ key: 'artists', label: 'Artists', mediaType: 'Artist' },
|
||||||
{ key: 'musicVideos', label: 'Music Videos' },
|
{ key: 'musicVideos', label: 'Music Videos', mediaType: 'MusicVideo' },
|
||||||
{ key: 'songs', label: 'Songs' },
|
{ key: 'songs', label: 'Songs', mediaType: 'Song' },
|
||||||
{ key: 'otherVideos', label: 'Other Videos' },
|
{ key: 'otherVideos', label: 'Other Videos', mediaType: 'OtherVideo' },
|
||||||
{ key: 'images', label: 'Images' },
|
{ key: 'images', label: 'Images', mediaType: 'Image' },
|
||||||
{ key: 'remoteStreams', label: 'Remote Streams' }
|
{ key: 'remoteStreams', label: 'Remote Streams', mediaType: 'RemoteStream' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface SeeAllState {
|
||||||
|
error: string | null;
|
||||||
|
items: LibraryBrowseItem[];
|
||||||
|
loading: boolean;
|
||||||
|
// Next page to request; page 0 was already loaded by the initial /api/search call.
|
||||||
|
nextPageNum: number;
|
||||||
|
}
|
||||||
|
|
||||||
type TrashState =
|
type TrashState =
|
||||||
| { results: SearchResults; error: null; status: 'success' }
|
| { results: SearchResults; error: null; status: 'success' }
|
||||||
| { results: null; error: string; status: 'error' }
|
| { results: null; error: string; status: 'error' }
|
||||||
@@ -46,6 +60,7 @@ function mediaItemIdOf(item: LibraryBrowseItem): number | null {
|
|||||||
|
|
||||||
export function TrashScreen() {
|
export function TrashScreen() {
|
||||||
const [state, setState] = useState<TrashState>({ results: null, error: null, status: 'loading' });
|
const [state, setState] = useState<TrashState>({ results: null, error: null, status: 'loading' });
|
||||||
|
const [seeAll, setSeeAll] = useState<Partial<Record<keyof SearchResults, SeeAllState>>>({});
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
const [confirm, setConfirm] = useState<null | 'selected' | 'all'>(null);
|
const [confirm, setConfirm] = useState<null | 'selected' | 'all'>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -83,9 +98,45 @@ export function TrashScreen() {
|
|||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
setState({ results: null, error: null, status: 'loading' });
|
setState({ results: null, error: null, status: 'loading' });
|
||||||
|
setSeeAll({});
|
||||||
load();
|
load();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadMore = (group: GroupDef) => {
|
||||||
|
const current = seeAll[group.key];
|
||||||
|
const nextPageNum = current?.nextPageNum ?? 1; // page 0 was already loaded by /api/search above
|
||||||
|
setSeeAll((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[group.key]: { error: null, items: current?.items ?? [], loading: true, nextPageNum }
|
||||||
|
}));
|
||||||
|
|
||||||
|
getLibraryBrowseItems({ mediaType: group.mediaType, pageNum: nextPageNum, pageSize: PAGE_SIZE, query: TRASH_QUERY })
|
||||||
|
.then((page) => {
|
||||||
|
if (!activeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSeeAll((prev) => {
|
||||||
|
const existing = prev[group.key];
|
||||||
|
const items = [...(existing?.items ?? []), ...(page.page ?? [])];
|
||||||
|
return { ...prev, [group.key]: { error: null, items, loading: false, nextPageNum: nextPageNum + 1 } };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (!activeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSeeAll((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[group.key]: {
|
||||||
|
error: messageFromLibraryBrowseError(error, 'Unable to load more items'),
|
||||||
|
items: current?.items ?? [],
|
||||||
|
loading: false,
|
||||||
|
nextPageNum
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const toggle = (item: LibraryBrowseItem) => {
|
const toggle = (item: LibraryBrowseItem) => {
|
||||||
const mediaItemId = mediaItemIdOf(item);
|
const mediaItemId = mediaItemIdOf(item);
|
||||||
if (mediaItemId == null) {
|
if (mediaItemId == null) {
|
||||||
@@ -113,7 +164,8 @@ export function TrashScreen() {
|
|||||||
}
|
}
|
||||||
const ids = new Set<number>();
|
const ids = new Set<number>();
|
||||||
for (const group of GROUPS) {
|
for (const group of GROUPS) {
|
||||||
for (const item of state.results[group.key].items) {
|
const items = [...state.results[group.key].items, ...(seeAll[group.key]?.items ?? [])];
|
||||||
|
for (const item of items) {
|
||||||
const mediaItemId = mediaItemIdOf(item);
|
const mediaItemId = mediaItemIdOf(item);
|
||||||
if (mediaItemId != null) {
|
if (mediaItemId != null) {
|
||||||
ids.add(mediaItemId);
|
ids.add(mediaItemId);
|
||||||
@@ -219,9 +271,12 @@ export function TrashScreen() {
|
|||||||
state.results &&
|
state.results &&
|
||||||
GROUPS.map((group) => {
|
GROUPS.map((group) => {
|
||||||
const data = state.results![group.key];
|
const data = state.results![group.key];
|
||||||
if (data.totalCount === 0 || data.items.length === 0) {
|
const more = seeAll[group.key];
|
||||||
|
const items = [...data.items, ...(more?.items ?? [])];
|
||||||
|
if (data.totalCount === 0 || items.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const hasMore = items.length < data.totalCount;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section key={group.key}>
|
<section key={group.key}>
|
||||||
@@ -232,7 +287,7 @@ export function TrashScreen() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="ctv-media-grid">
|
<div className="ctv-media-grid">
|
||||||
{data.items.map((item) => {
|
{items.map((item) => {
|
||||||
const mediaItemId = mediaItemIdOf(item);
|
const mediaItemId = mediaItemIdOf(item);
|
||||||
return (
|
return (
|
||||||
<MediaPosterCard
|
<MediaPosterCard
|
||||||
@@ -244,6 +299,19 @@ export function TrashScreen() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{more?.error && (
|
||||||
|
<div className="ctv-channels-error" role="alert">
|
||||||
|
<TriangleAlert aria-hidden="true" size={15} />
|
||||||
|
<span>{more.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasMore && (
|
||||||
|
<div className="ctv-media-section-footer">
|
||||||
|
<Button disabled={more?.loading === true} onClick={() => loadMore(group)} size="sm" variant="secondary">
|
||||||
|
{more?.loading ? 'Loading…' : `See all ${data.totalCount} ${group.label.toLowerCase()}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -841,6 +841,26 @@ body {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ctv-logs-sort-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
text-transform: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctv-logs-sort-button:hover,
|
||||||
|
.ctv-logs-sort-button:focus-visible {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.ctv-channel-check {
|
.ctv-channel-check {
|
||||||
width: 38px;
|
width: 38px;
|
||||||
padding-left: 14px !important;
|
padding-left: 14px !important;
|
||||||
@@ -2924,6 +2944,12 @@ body {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ctv-media-section-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: var(--space-5, 10px) 0 var(--space-7, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
/* lineup */
|
/* lineup */
|
||||||
.ctv-builder-lineup-list {
|
.ctv-builder-lineup-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user