mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
feat(portals): add "View in portal" action to inline collection details
Details opened from the dashboard, global favorites/recent, or a portal's own favorites/recent tabs render full-width without the category sidebar, so there was no way back to the title's place in its portal. A shared `app-view-in-portal-action` renders a separate-row action below the main hero buttons. It is gated on the new `VIEW_IN_PORTAL_HANDOFF` token, provided only by the two collection-detail wrappers, so router-mounted portal details never show it without any URL sniffing. Targets come from `getUnifiedCollectionDetailNavigation()`, which unlike `getUnifiedCollectionNavigation` never degrades to a category- or section-only route: an Xtream item without a resolvable category and positive id keeps the action hidden rather than promising a jump to the title and landing in a list. Unlike the download handoff it does not pass `detailPresentation: 'provider-only'` — the item exists in the provider catalog, so the full detail is wanted. Stalker carries `stalkerReturnTo` so back returns to the originating collection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4bcd4bd390
commit
db1ec7cf5e
40 files changed
+716
-7
No files matched your search
@@ -13,6 +13,8 @@ export * from './lib/external-playback-dock/external-playback-dock.component';
|
||||
export * from './lib/portal-detail-shell/content-about.component';
|
||||
export * from './lib/portal-detail-shell/detail-template.directives';
|
||||
export * from './lib/portal-detail-shell/portal-detail-shell.component';
|
||||
export * from './lib/view-in-portal-action/view-in-portal-action.component';
|
||||
export * from './lib/view-in-portal-action/view-in-portal-handoff.token';
|
||||
export * from './lib/progress-capsule/progress-capsule.component';
|
||||
export * from './lib/resizable/resizable.directive';
|
||||
export * from './lib/season-container/season-container.component';
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { ViewInPortalActionComponent } from './view-in-portal-action.component';
|
||||
import {
|
||||
VIEW_IN_PORTAL_HANDOFF,
|
||||
ViewInPortalHandoff,
|
||||
} from './view-in-portal-handoff.token';
|
||||
|
||||
@Component({
|
||||
imports: [ViewInPortalActionComponent],
|
||||
template: `<app-view-in-portal-action />`,
|
||||
})
|
||||
class HostComponent {}
|
||||
|
||||
const BUTTON_SELECTOR = '[data-testid="collection-view-in-portal"]';
|
||||
|
||||
describe('ViewInPortalActionComponent', () => {
|
||||
function createHandoff(
|
||||
overrides: Partial<{
|
||||
available: boolean;
|
||||
playlistName: string | null;
|
||||
}> = {}
|
||||
): ViewInPortalHandoff & { openInPortal: jest.Mock } {
|
||||
return {
|
||||
viewInPortalAvailable: signal(overrides.available ?? true),
|
||||
viewInPortalPlaylistName: signal(
|
||||
overrides.playlistName ?? null
|
||||
),
|
||||
openInPortal: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
async function createFixture(
|
||||
handoff: ViewInPortalHandoff | null
|
||||
): Promise<ComponentFixture<HostComponent>> {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HostComponent, TranslateModule.forRoot()],
|
||||
providers: handoff
|
||||
? [{ provide: VIEW_IN_PORTAL_HANDOFF, useValue: handoff }]
|
||||
: [],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it('renders nothing and hides the host without a handoff token', async () => {
|
||||
const fixture = await createFixture(null);
|
||||
|
||||
expect(fixture.debugElement.query(By.css(BUTTON_SELECTOR))).toBeNull();
|
||||
const host = fixture.debugElement.query(
|
||||
By.directive(ViewInPortalActionComponent)
|
||||
);
|
||||
expect(
|
||||
host.nativeElement.classList.contains(
|
||||
'view-in-portal-action--hidden'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('stays hidden while the handoff reports the target unavailable', async () => {
|
||||
const fixture = await createFixture(
|
||||
createHandoff({ available: false })
|
||||
);
|
||||
|
||||
expect(fixture.debugElement.query(By.css(BUTTON_SELECTOR))).toBeNull();
|
||||
const host = fixture.debugElement.query(
|
||||
By.directive(ViewInPortalActionComponent)
|
||||
);
|
||||
expect(
|
||||
host.nativeElement.classList.contains(
|
||||
'view-in-portal-action--hidden'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the button and forwards clicks to the handoff', async () => {
|
||||
const handoff = createHandoff({ available: true });
|
||||
const fixture = await createFixture(handoff);
|
||||
|
||||
const button = fixture.debugElement.query(By.css(BUTTON_SELECTOR));
|
||||
expect(button).not.toBeNull();
|
||||
const host = fixture.debugElement.query(
|
||||
By.directive(ViewInPortalActionComponent)
|
||||
);
|
||||
expect(
|
||||
host.nativeElement.classList.contains(
|
||||
'view-in-portal-action--hidden'
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
button.nativeElement.click();
|
||||
expect(handoff.openInPortal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses the playlist name for the tooltip when provided', async () => {
|
||||
const fixture = await createFixture(
|
||||
createHandoff({ available: true, playlistName: 'My Portal' })
|
||||
);
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
PORTALS: { VIEW_IN_PORTAL_TOOLTIP: 'Open in {{name}}' },
|
||||
});
|
||||
translate.use('en');
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = fixture.debugElement.query(By.css(BUTTON_SELECTOR));
|
||||
const tooltip = button.injector.get(MatTooltip);
|
||||
expect(tooltip.message).toBe('Open in My Portal');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { VIEW_IN_PORTAL_HANDOFF } from './view-in-portal-handoff.token';
|
||||
|
||||
/**
|
||||
* Separate-row hero action that jumps from an inline collection detail to the
|
||||
* same item inside its owning portal. Renders nothing (and removes itself from
|
||||
* the flex flow) unless a host provides `VIEW_IN_PORTAL_HANDOFF` and reports
|
||||
* the target as resolvable.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-view-in-portal-action',
|
||||
imports: [MatButtonModule, MatIconModule, MatTooltipModule, TranslateModule],
|
||||
template: `
|
||||
@if (visible()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-testid="collection-view-in-portal"
|
||||
[matTooltip]="
|
||||
playlistName()
|
||||
? ('PORTALS.VIEW_IN_PORTAL_TOOLTIP'
|
||||
| translate: tooltipParams())
|
||||
: ''
|
||||
"
|
||||
(click)="handoff?.openInPortal()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">open_in_new</mat-icon>
|
||||
{{ 'PORTALS.VIEW_IN_PORTAL' | translate }}
|
||||
</button>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
/* Own row inside the hero's flex-wrap action container. */
|
||||
:host {
|
||||
display: block;
|
||||
flex: 0 0 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Without this the empty host would still claim a full flex row. */
|
||||
:host(.view-in-portal-action--hidden) {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
host: {
|
||||
'[class.view-in-portal-action--hidden]': '!visible()',
|
||||
},
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ViewInPortalActionComponent {
|
||||
protected readonly handoff = inject(VIEW_IN_PORTAL_HANDOFF, {
|
||||
optional: true,
|
||||
});
|
||||
|
||||
protected readonly visible = computed(
|
||||
() => this.handoff?.viewInPortalAvailable() ?? false
|
||||
);
|
||||
|
||||
protected readonly playlistName = computed(
|
||||
() => this.handoff?.viewInPortalPlaylistName() ?? null
|
||||
);
|
||||
|
||||
protected readonly tooltipParams = computed(() => ({
|
||||
name: this.playlistName() ?? '',
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { InjectionToken, Signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Contract a collection-detail host provides to surface the
|
||||
* "View in portal" action inside a projected detail view. The token is
|
||||
* deliberately absent in router-mounted portal details, which is what keeps
|
||||
* the action scoped to inline collection contexts (global and portal-scoped
|
||||
* favorites/recent).
|
||||
*
|
||||
* Member names carry the `viewInPortal` prefix so hosts can implement the
|
||||
* interface directly without clashing with their existing members.
|
||||
*/
|
||||
export interface ViewInPortalHandoff {
|
||||
readonly viewInPortalAvailable: Signal<boolean>;
|
||||
readonly viewInPortalPlaylistName: Signal<string | null>;
|
||||
openInPortal(): void;
|
||||
}
|
||||
|
||||
export const VIEW_IN_PORTAL_HANDOFF = new InjectionToken<ViewInPortalHandoff>(
|
||||
'VIEW_IN_PORTAL_HANDOFF'
|
||||
);
|
||||
Reference in new issue
Block a user