feat(portals): back button on the in-portal search page (#1142)

* feat(portals): back button on the in-portal search page

Opening a "grey" (unmatched) film from an actor page navigates to the
portal search prefilled with ?q=, but the search page had no way back —
users had to re-navigate through the sidebar to reach the actor's
filmography again.

SearchLayoutComponent gains a showBackButton input + backClick output;
both the Xtream in-portal search (SearchResultsComponent) and the Stalker
search wire it to Location.back(). Shown only on the nested in-portal
search — the top-level global search (sidebar destination) and the dialog
mode (own close button) are excluded. location.back() unwinds the full
history, so search → actor → movie detail → … all chain correctly.

Tests: search-layout spec covers the conditional button + backClick.
Docs: CLAUDE.md actor-pages note updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(portals): address search back-button review (dialog guard, RTL-safe spacing)

- showBackButton now also checks !dialogRef, so a hypothetical dialog
  opened with isGlobalSearch:false from a workspace route can't show a
  back arrow that navigates the page behind the dialog (greptile).
- Replaced the fragile negative margin with a header-title-group flex
  wrapper (gap: 4px) that keeps the arrow and title together regardless
  of the outer gap or Material padding, and is RTL-safe (greptile).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-07 08:20:34 +02:00
1 parent 64839e3495
commit 09764e24d4
9 files changed
+85 -2

No files matched your search

+1 -1
View File
@@ -648,7 +648,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (`CrossPortalSimilarService` in `libs/services`, batched `DB_MATCH_TITLES`, Electron only); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation
- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`)
- Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per session; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint
- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); shared UI in `libs/ui/shared-portals` (`ActorViewComponent`)
- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`)
- Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts`
- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`)
- Match confidence: provider `tmdb_id` trusted fully; otherwise normalized-title + year (±1) search with a strict gate — no confident match means no enrichment
@@ -7,7 +7,19 @@
<div class="header sticky-header">
<div class="header-top">
<div class="header-left">
<h2>{{ title() | translate }}</h2>
<div class="header-title-group">
@if (showBackButton()) {
<button
mat-icon-button
class="header-back"
[attr.aria-label]="'BACK' | translate"
(click)="onBackClick()"
>
<mat-icon>arrow_back</mat-icon>
</button>
}
<h2>{{ title() | translate }}</h2>
</div>
<!-- Filters slot -->
<div class="type-filters">
@@ -52,6 +52,18 @@
flex: 1;
flex-wrap: wrap;
// Keep the back arrow and title tightly grouped, independent of the
// wide gap between the title group and the filters (RTL-safe)
.header-title-group {
display: flex;
align-items: center;
gap: 4px;
}
.header-back {
flex-shrink: 0;
}
h2 {
margin: 0;
white-space: nowrap;
@@ -58,6 +58,26 @@ describe('SearchLayoutComponent', () => {
});
}
it('shows the back button only when showBackButton is set and emits backClick', () => {
fixture.detectChanges();
expect(
fixture.debugElement.query(By.css('.header-back'))
).toBeNull();
fixture.componentRef.setInput('showBackButton', true);
fixture.detectChanges();
const backButton = fixture.debugElement.query(By.css('.header-back'));
expect(backButton).not.toBeNull();
const emitted: unknown[] = [];
fixture.componentInstance.backClick.subscribe((value) =>
emitted.push(value)
);
(backButton.nativeElement as HTMLButtonElement).click();
expect(emitted).toHaveLength(1);
});
it('emits nearEnd when the results container is scrolled near the bottom', () => {
const nearEndSpy = jest.fn();
fixture.componentInstance.nearEnd.subscribe(nearEndSpy);
@@ -45,6 +45,9 @@ export class SearchLayoutComponent {
/** Whether to show the close button (for dialog mode) */
readonly showCloseButton = input<boolean>(false);
/** Whether to show the back button (nested search reached via navigation) */
readonly showBackButton = input<boolean>(false);
/** Whether to show results count */
readonly showResultsCount = input<boolean>(true);
@@ -68,6 +71,9 @@ export class SearchLayoutComponent {
/** Emitted when close button is clicked */
readonly closeClick = output<void>();
/** Emitted when the back button is clicked */
readonly backClick = output<void>();
/** Emitted when the scroll container is close to the bottom */
readonly nearEnd = output<void>();
@@ -84,6 +90,10 @@ export class SearchLayoutComponent {
this.closeClick.emit();
}
onBackClick(): void {
this.backClick.emit();
}
onSearchContentScroll(event: Event): void {
const target = event.target as HTMLElement | null;
if (!target) {
@@ -4,8 +4,10 @@
[isLoading]="searchResultsResource.isLoading()"
[showResultsCount]="true"
[showSearchInput]="!isWorkspaceLayout"
[showBackButton]="isWorkspaceLayout"
[showDetails]="showingDetails"
(searchTermChange)="updateSearchTerm($event)"
(backClick)="goBack()"
>
@if (showingDetails) {
<app-stalker-inline-detail
@@ -7,6 +7,7 @@ import {
resource,
signal,
} from '@angular/core';
import { Location } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatSnackBar } from '@angular/material/snack-bar';
@@ -87,6 +88,7 @@ interface StalkerSearchResponse {
})
export class StalkerSearchComponent {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly location = inject(Location);
private readonly dataService = inject(DataService);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly playlistService = inject(PlaylistsService);
@@ -385,6 +387,11 @@ export class StalkerSearchComponent {
});
}
/** Leave the search page (e.g. back to the actor page that opened it) */
goBack(): void {
this.location.back();
}
onVodBack(): void {
const cleared = clearStalkerDetailViewState();
this.itemDetails.set(cleared.itemDetails);
@@ -3,12 +3,14 @@
[resultsCount]="xtreamStore.searchResults().length"
[isLoading]="xtreamStore.isSearching()"
[showCloseButton]="false"
[showBackButton]="showBackButton"
[showResultsCount]="!isGlobalSearch || !groupByPlaylist()"
[showSearchInput]="showInlineSearchInput"
[minSearchLength]="minSearchLength"
[initialDescriptionKey]="initialDescriptionKey"
(searchTermChange)="updateSearchTerm($event)"
(closeClick)="onCloseDialog()"
(backClick)="goBack()"
(nearEnd)="loadMoreGlobalResults()"
>
<!-- Type filters -->
@@ -10,6 +10,7 @@ import {
signal,
viewChild,
} from '@angular/core';
import { Location } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -99,6 +100,7 @@ export class SearchResultsComponent implements AfterViewInit {
readonly router = inject(Router);
readonly activatedRoute = inject(ActivatedRoute);
readonly databaseService = inject(DatabaseService);
private readonly location = inject(Location);
private readonly logger = createLogger('XtreamSearchResults');
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
readonly routeSearchTerm = queryParamSignal(
@@ -456,6 +458,10 @@ export class SearchResultsComponent implements AfterViewInit {
this.dialogRef?.close();
}
goBack(): void {
this.location.back();
}
toggleGroupByPlaylist(value: boolean) {
this.groupByPlaylist.set(value);
localStorage.setItem(
@@ -487,6 +493,18 @@ export class SearchResultsComponent implements AfterViewInit {
return this.isGlobalSearch && !this.isWorkspaceLayout;
}
/**
* The in-portal search is a nested view (reached from the toolbar
* search box or an actor page); the global search is a top-level
* sidebar destination, and the dialog has its own close button — a
* back arrow there would navigate the page behind the open dialog.
*/
get showBackButton(): boolean {
return (
this.isWorkspaceLayout && !this.isGlobalSearch && !this.dialogRef
);
}
get minSearchLength(): number {
return this.isGlobalSearch ? 2 : 3;
}