Proyecto App Windows

This commit is contained in:
2026-05-03 23:36:48 +02:00
commit 96793b2e1e
1248 changed files with 189750 additions and 0 deletions
@@ -0,0 +1,21 @@
export default {
displayName: 'portal-catalog-feature',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/portal/catalog/feature',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};
+21
View File
@@ -0,0 +1,21 @@
{
"name": "portal-catalog-feature",
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/portal/catalog/feature/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:portal", "domain:portal-shared", "type:feature"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/portal/catalog/feature/jest.config.ts",
"tsConfig": "libs/portal/catalog/feature/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+1
View File
@@ -0,0 +1 @@
export * from './lib/category-content-view/category-content-view.component';
@@ -0,0 +1,106 @@
@if (selectedCategory()) {
@if (selectedDetailComponent(); as detailComponentRef) {
<ng-container *ngComponentOutlet="detailComponentRef" />
} @else {
<div class="category-content-layout">
<div class="category-content-header">
<div class="category-meta">
<h2 class="category-title">
{{ selectedCategoryTitle() }}
</h2>
<div class="category-subtitle">
{{ categoryItemSubtitle() }}
</div>
</div>
@if (canSortContent()) {
<button
type="button"
mat-icon-button
class="sort-action"
[matMenuTriggerFor]="sortContentMenu"
matTooltip="Sort content"
aria-label="Sort content"
>
<mat-icon>sort</mat-icon>
</button>
<mat-menu #sortContentMenu="matMenu">
<button
mat-menu-item
(click)="setContentSortMode('date-desc')"
>
<span>{{
'WORKSPACE.SORT_DATE_DESC' | translate
}}</span>
@if (contentSortMode() === 'date-desc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('date-asc')"
>
<span>{{
'WORKSPACE.SORT_DATE_ASC' | translate
}}</span>
@if (contentSortMode() === 'date-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('name-asc')"
>
<span>{{
'WORKSPACE.SORT_NAME_ASC' | translate
}}</span>
@if (contentSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('name-desc')"
>
<span>{{
'WORKSPACE.SORT_NAME_DESC' | translate
}}</span>
@if (contentSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
}
@if (categoryItemCount() > 0) {
<mat-paginator
[pageIndex]="pageIndex()"
[length]="categoryItemCount()"
[pageSize]="limit()"
[pageSizeOptions]="pageSizeOptions"
(page)="onPageChange($event)"
aria-label="Select page"
/>
}
</div>
<app-grid-list
[isLoading]="isPaginatedContentLoading()"
[items]="contentWithProgress()"
(itemClicked)="onItemClick($event)"
[pageIndex]="pageIndex()"
[totalPages]="totalPages()"
[limit]="limit()"
[pageSizeOptions]="pageSizeOptions"
[showPaginator]="false"
(pageChange)="onPageChange($event)"
/>
</div>
}
} @else {
<app-playlist-error-view
[title]="'PORTALS.ERROR_VIEW.NO_CATEGORY_SELECTED.TITLE' | translate"
[description]="
'PORTALS.ERROR_VIEW.NO_CATEGORY_SELECTED.DESCRIPTION' | translate
"
[showActionButtons]="false"
[viewType]="'EMPTY_CATEGORY'"
/>
}
@@ -0,0 +1,75 @@
@use '../../../../../../ui/styles/panel-header' as panel;
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.category-content-layout {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
// ─── Sticky header bar — styled like a widget header ─────────────────────────
.category-content-header {
@include panel.standard-panel-header($sticky: true);
}
// ─── Category meta (title + subtitle) ────────────────────────────────────────
.category-meta {
@include panel.standard-panel-meta();
}
.category-title {
@include panel.standard-panel-title();
}
.category-subtitle {
@include panel.standard-panel-subtitle();
}
// ─── Paginator — flush right, no ghost background ────────────────────────────
mat-paginator {
background: transparent !important;
flex-shrink: 0;
// Shrink paginator text to fit header height comfortably
--mat-paginator-container-size: 40px;
--mat-paginator-enabled-icon-color: var(--app-body-color, var(--mat-sys-on-surface-variant));
--mat-paginator-disabled-icon-color: rgba(255, 255, 255, 0.18);
}
// ─── Grid scroll area ─────────────────────────────────────────────────────────
app-grid-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 20px 20px;
display: block;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
}
@media (max-width: 768px) {
.category-content-header {
flex-direction: column;
align-items: flex-start;
padding: 10px 16px;
min-height: unset;
}
.category-meta {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
app-grid-list {
padding: 12px 16px 16px;
}
}
@@ -0,0 +1,307 @@
import { Component, input, output, signal } from '@angular/core';
import { NgComponentOutlet } from '@angular/common';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ReplaySubject, of } from 'rxjs';
import {
PORTAL_CATALOG_DETAIL_COMPONENT,
PORTAL_CATALOG_FACADE,
PortalCatalogSortMode,
} from '@iptvnator/portal/shared/util';
import { CategoryContentViewComponent } from './category-content-view.component';
@Component({
selector: 'app-grid-list',
standalone: true,
template: '',
})
class MockGridListComponent {
readonly isLoading = input<boolean>();
readonly items = input<unknown[]>();
readonly pageIndex = input<number>();
readonly totalPages = input<number>();
readonly limit = input<number>();
readonly pageSizeOptions = input<number[]>();
readonly showPaginator = input(true);
readonly itemClicked = output<unknown>();
readonly pageChange = output<unknown>();
}
@Component({
selector: 'app-playlist-error-view',
standalone: true,
template: '',
})
class MockPlaylistErrorViewComponent {
readonly title = input('');
readonly description = input('');
readonly showActionButtons = input(true);
readonly viewType = input('');
}
@Component({
standalone: true,
template: '',
})
class MockDetailComponent {}
describe('CategoryContentViewComponent', () => {
let fixture: ComponentFixture<CategoryContentViewComponent>;
let router: { navigate: jest.Mock };
const paramMap$ = new ReplaySubject(1);
const queryParamMap$ = new ReplaySubject(1);
const isPaginatedContentLoading = signal(true);
const categoryItemCount = signal(0);
const contentSortMode = signal<PortalCatalogSortMode | null>(null);
const catalog = {
provider: 'xtream' as const,
pageSizeOptions: [10, 25, 50],
contentType: signal('vod'),
limit: signal(25),
pageIndex: signal(0),
selectedCategory: signal({ id: 1 }),
paginatedContent: signal<unknown[]>([]),
selectedCategoryTitle: signal('Movies'),
categoryItemCount,
selectedItem: signal(null),
totalPages: signal(0),
contentSortMode,
playlist: signal(null),
isPaginatedContentLoading,
initialize: jest.fn(),
setSearchQuery: jest.fn(),
clearSelectedItem: jest.fn(),
setPage: jest.fn(),
setLimit: jest.fn(),
setContentSortMode: jest.fn(),
selectItem: jest.fn().mockReturnValue(null),
getItemProgress: jest.fn().mockReturnValue({}),
};
beforeEach(async () => {
isPaginatedContentLoading.set(true);
categoryItemCount.set(0);
contentSortMode.set(null);
catalog.initialize.mockClear();
catalog.setSearchQuery.mockClear();
catalog.setPage.mockClear();
catalog.setLimit.mockClear();
catalog.selectItem.mockClear();
catalog.selectItem.mockReturnValue(null);
router = {
navigate: jest.fn(),
};
paramMap$.next(convertToParamMap({}));
queryParamMap$.next(convertToParamMap({}));
await TestBed.configureTestingModule({
imports: [CategoryContentViewComponent, NoopAnimationsModule],
providers: [
{
provide: TranslateService,
useValue: {
instant: (key: string) =>
key === 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
? 'Fetching playlist data from source...'
: key,
get: (key: string) =>
of(
key === 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
? 'Fetching playlist data from source...'
: key
),
stream: (key: string) =>
of(
key === 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
? 'Fetching playlist data from source...'
: key
),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
{
provide: PORTAL_CATALOG_FACADE,
useValue: catalog,
},
{
provide: PORTAL_CATALOG_DETAIL_COMPONENT,
useValue: MockDetailComponent,
},
{
provide: ActivatedRoute,
useValue: {
paramMap: paramMap$.asObservable(),
queryParamMap: queryParamMap$.asObservable(),
snapshot: {
params: {},
},
},
},
{
provide: Router,
useValue: router,
},
],
})
.overrideComponent(CategoryContentViewComponent, {
set: {
imports: [
NgComponentOutlet,
MockGridListComponent,
MockPlaylistErrorViewComponent,
MatIcon,
MatIconButton,
MatMenuModule,
MatPaginatorModule,
MatTooltip,
TranslatePipe,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(CategoryContentViewComponent);
});
it('shows loading copy in the subtitle instead of 0 items while xtream content is still warming up', () => {
fixture.detectChanges();
const subtitle = fixture.nativeElement.querySelector(
'.category-subtitle'
) as HTMLElement | null;
expect(catalog.initialize).toHaveBeenCalledWith(null);
expect(subtitle?.textContent?.trim()).toBe(
'Fetching playlist data from source...'
);
});
it('forwards query-param search updates to the catalog facade when supported', () => {
fixture.detectChanges();
catalog.setSearchQuery.mockClear();
queryParamMap$.next(
convertToParamMap({
q: 'matrix',
})
);
expect(catalog.setSearchQuery).toHaveBeenCalledWith('matrix');
});
it('restores the zero-based catalog page from the one-based page query param', () => {
fixture.detectChanges();
catalog.setPage.mockClear();
queryParamMap$.next(
convertToParamMap({
page: '3',
})
);
expect(catalog.setPage).toHaveBeenCalledWith(2);
});
it('falls back to the first catalog page when the page query param is absent or invalid', () => {
fixture.detectChanges();
catalog.setPage.mockClear();
queryParamMap$.next(convertToParamMap({}));
queryParamMap$.next(
convertToParamMap({
page: 'not-a-page',
})
);
expect(catalog.setPage).toHaveBeenNthCalledWith(1, 0);
expect(catalog.setPage).toHaveBeenNthCalledWith(2, 0);
});
it('writes one-based page query params when the paginator changes', () => {
fixture.detectChanges();
fixture.componentInstance.onPageChange({
length: 100,
pageIndex: 1,
pageSize: 25,
previousPageIndex: 0,
});
expect(catalog.setPage).toHaveBeenCalledWith(1);
expect(catalog.setLimit).toHaveBeenCalledWith(25);
expect(router.navigate).toHaveBeenCalledWith([], {
relativeTo: expect.any(Object),
queryParams: {
page: 2,
},
queryParamsHandling: 'merge',
replaceUrl: true,
});
});
it('removes the page query param when returning to the first page', () => {
fixture.detectChanges();
fixture.componentInstance.onPageChange({
length: 100,
pageIndex: 0,
pageSize: 25,
previousPageIndex: 1,
});
expect(router.navigate).toHaveBeenCalledWith([], {
relativeTo: expect.any(Object),
queryParams: {
page: null,
},
queryParamsHandling: 'merge',
replaceUrl: true,
});
});
it('scrolls the grid list host to the top when the paginator changes', () => {
fixture.detectChanges();
const gridList = fixture.nativeElement.querySelector(
'app-grid-list'
) as HTMLElement;
const scrollTo = jest.fn();
Object.defineProperty(gridList, 'scrollTo', {
configurable: true,
value: scrollTo,
});
fixture.componentInstance.onPageChange({
length: 100,
pageIndex: 1,
pageSize: 25,
previousPageIndex: 0,
});
expect(scrollTo).toHaveBeenCalledWith({ top: 0 });
});
it('preserves query params when navigating from an item to Xtream details', () => {
catalog.selectItem.mockReturnValue(['42']);
fixture.detectChanges();
fixture.componentInstance.onItemClick({
xtream_id: 42,
});
expect(router.navigate).toHaveBeenCalledWith(['42'], {
relativeTo: expect.any(Object),
queryParamsHandling: 'preserve',
});
});
});
@@ -0,0 +1,185 @@
import { NgComponentOutlet } from '@angular/common';
import {
Component,
computed,
DestroyRef,
ElementRef,
inject,
OnInit,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
GridListComponent,
PlaylistErrorViewComponent,
} from '@iptvnator/portal/shared/ui';
import {
clearNavigationStateKeys,
getOpenStalkerItemState,
PortalCatalogFacade,
OPEN_STALKER_ITEM_STATE_KEY,
PORTAL_CATALOG_DETAIL_COMPONENT,
PORTAL_CATALOG_FACADE,
PortalCatalogSortMode,
} from '@iptvnator/portal/shared/util';
interface CategoryContentItem {
id?: number | string;
is_series?: number | string | boolean;
xtream_id?: number | string;
series_id?: number | string;
stream_id?: number | string;
category_id?: number | string;
[key: string]: unknown;
}
@Component({
selector: 'app-category-content-view',
templateUrl: './category-content-view.component.html',
styleUrls: ['./category-content-view.component.scss'],
imports: [
GridListComponent,
MatIcon,
MatIconButton,
MatMenuModule,
MatPaginatorModule,
MatTooltip,
NgComponentOutlet,
PlaylistErrorViewComponent,
TranslatePipe,
],
})
export class CategoryContentViewComponent implements OnInit {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
private readonly hostElement = inject(ElementRef<HTMLElement>);
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
private readonly catalog = inject(
PORTAL_CATALOG_FACADE
) as PortalCatalogFacade<
CategoryContentItem,
CategoryContentItem,
CategoryContentItem
>;
readonly detailComponent = inject(PORTAL_CATALOG_DETAIL_COMPONENT);
readonly contentType = this.catalog.contentType;
readonly limit = this.catalog.limit;
readonly pageIndex = this.catalog.pageIndex;
readonly pageSizeOptions = Array.from(this.catalog.pageSizeOptions);
readonly selectedCategory = this.catalog.selectedCategory;
readonly paginatedContent = this.catalog.paginatedContent;
readonly selectedCategoryTitle = this.catalog.selectedCategoryTitle;
readonly categoryItemCount = this.catalog.categoryItemCount;
readonly selectedItem = this.catalog.selectedItem;
readonly totalPages = this.catalog.totalPages;
readonly contentSortMode = this.catalog.contentSortMode;
readonly isPaginatedContentLoading = this.catalog.isPaginatedContentLoading;
readonly isXtreamLoadingSubtitle = computed(
() =>
this.catalog.provider === 'xtream' &&
this.isPaginatedContentLoading()
);
readonly categoryItemSubtitle = computed(() => {
if (this.isXtreamLoadingSubtitle()) {
return this.translate.instant(
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
);
}
const itemCount = this.categoryItemCount();
return `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`;
});
readonly canSortContent = computed(() => this.contentSortMode() !== null);
readonly selectedDetailComponent = computed(() =>
this.selectedItem() ? this.detailComponent : null
);
readonly contentWithProgress = computed(() =>
(this.paginatedContent() ?? []).map((item: CategoryContentItem) => ({
...item,
...this.catalog.getItemProgress(item),
}))
);
setContentSortMode(mode: PortalCatalogSortMode): void {
this.catalog.setContentSortMode(mode);
}
ngOnInit(): void {
this.activatedRoute.paramMap
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
this.catalog.initialize(params.get('categoryId'));
this.openStalkerItemFromNavigationState();
});
this.activatedRoute.queryParamMap
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
this.catalog.setSearchQuery?.(params.get('q') ?? '');
this.catalog.setPage(this.toPageIndex(params.get('page')));
});
}
onPageChange(event: PageEvent): void {
this.catalog.setPage(event.pageIndex);
this.catalog.setLimit(event.pageSize);
this.scrollGridToTop();
void this.router.navigate([], {
relativeTo: this.activatedRoute,
queryParams: {
page: event.pageIndex > 0 ? event.pageIndex + 1 : null,
},
queryParamsHandling: 'merge',
replaceUrl: true,
});
}
onItemClick(item: CategoryContentItem): void {
const navigation = this.catalog.selectItem(item);
if (navigation?.length) {
this.router.navigate(navigation, {
relativeTo: this.activatedRoute,
queryParamsHandling: 'preserve',
});
}
}
private toPageIndex(value: string | null): number {
const page = Number(value);
return Number.isInteger(page) && page > 0 ? page - 1 : 0;
}
private scrollGridToTop(): void {
const gridList = this.hostElement.nativeElement.querySelector(
'app-grid-list'
) as HTMLElement | null;
gridList?.scrollTo?.({ top: 0 });
}
private openStalkerItemFromNavigationState(): void {
if (this.catalog.provider !== 'stalker') {
return;
}
const item = getOpenStalkerItemState(window.history.state);
if (!item) {
return;
}
this.catalog.selectItem(item as CategoryContentItem);
clearNavigationStateKeys([
OPEN_STALKER_ITEM_STATE_KEY,
'openFavoriteItem',
'openRecentItem',
]);
}
}
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
+28
View File
@@ -0,0 +1,28 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}