mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
64839e3495
commit
09764e24d4
9 files changed
+85
-2
No files matched your search
@@ -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
|
||||
|
||||
+13
-1
@@ -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;
|
||||
|
||||
+20
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user