mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
feat(portal): add category sort modes
This commit is contained in:
1 parent
2a5b1d1da7
commit
daff96d90b
16 files changed
+480
-51
No files matched your search
@@ -6,6 +6,14 @@
|
||||
"projectType": "library",
|
||||
"tags": ["scope:workspace", "domain:workspace", "type:feature"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/workspace/shell/feature/jest.config.ts",
|
||||
"tsConfig": "libs/workspace/shell/feature/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ interface WorkspaceCategoryViewItem {
|
||||
styleUrl: './workspace-context-category-view.component.scss',
|
||||
})
|
||||
export class WorkspaceContextCategoryViewComponent {
|
||||
readonly items = input<WorkspaceCategoryViewItem[]>([]);
|
||||
readonly items = input<ReadonlyArray<WorkspaceCategoryViewItem>>([]);
|
||||
readonly selectedCategoryId = input<string | number | null | undefined>();
|
||||
readonly itemCounts = input<Map<number, number>>(new Map());
|
||||
readonly showCounts = input(false);
|
||||
|
||||
+34
@@ -18,6 +18,40 @@
|
||||
<mat-icon>search</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (canSearchCategories()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="context-header__action"
|
||||
[class.context-header__action--active]="
|
||||
categorySortMode() !== 'server'
|
||||
"
|
||||
[matMenuTriggerFor]="categorySortMenu"
|
||||
[matTooltip]="'Sort: ' + categorySortLabel()"
|
||||
aria-label="Sort categories"
|
||||
>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
</button>
|
||||
<mat-menu #categorySortMenu="matMenu">
|
||||
@for (
|
||||
option of categorySortOptions;
|
||||
track option.mode
|
||||
) {
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setCategorySortMode(option.mode)"
|
||||
>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{ option.label }}</span>
|
||||
@if (categorySortMode() === option.mode) {
|
||||
<mat-icon class="context-sort-check"
|
||||
>check</mat-icon
|
||||
>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
}
|
||||
@if (isXtreamCategories()) {
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+4
@@ -80,6 +80,10 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.context-sort-check {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.context-header p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.78rem;
|
||||
|
||||
+91
-2
@@ -6,6 +6,7 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { WORKSPACE_CATEGORY_SORT_STORAGE_KEY } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
XtreamContentLoadState,
|
||||
XtreamStore,
|
||||
@@ -35,13 +36,20 @@ function createRouteSnapshot(
|
||||
return {
|
||||
routeConfig: path ? { path } : null,
|
||||
paramMap: {
|
||||
has: (param: string) =>
|
||||
hasCategoryId && param === 'categoryId',
|
||||
has: (param: string) => hasCategoryId && param === 'categoryId',
|
||||
},
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function getCategoryLabels(
|
||||
fixture: ComponentFixture<WorkspaceContextPanelComponent>
|
||||
): string[] {
|
||||
return Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.category-item .nav-item-label')
|
||||
).map((element: Element) => element.textContent?.trim() ?? '');
|
||||
}
|
||||
|
||||
describe('WorkspaceContextPanelComponent', () => {
|
||||
let fixture: ComponentFixture<WorkspaceContextPanelComponent>;
|
||||
const xtreamCategories = signal([
|
||||
@@ -98,6 +106,7 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.removeItem(WORKSPACE_CATEGORY_SORT_STORAGE_KEY);
|
||||
xtreamCategories.set([
|
||||
{ id: 1, name: 'News' },
|
||||
{ id: 2, name: 'Sports' },
|
||||
@@ -255,6 +264,86 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps xtream categories in server order by default and sorts them from the menu modes', () => {
|
||||
fixture.componentRef.setInput('section', 'vod');
|
||||
xtreamSelectedTypeContentState.set('ready');
|
||||
xtreamCategories.set([
|
||||
{ id: 1, name: 'Sports' },
|
||||
{ id: 2, name: 'Movies' },
|
||||
{ id: 3, name: 'News' },
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getCategoryLabels(fixture)).toEqual([
|
||||
'Sports',
|
||||
'Movies',
|
||||
'News',
|
||||
]);
|
||||
|
||||
fixture.componentInstance.setCategorySortMode('name-asc');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getCategoryLabels(fixture)).toEqual([
|
||||
'Movies',
|
||||
'News',
|
||||
'Sports',
|
||||
]);
|
||||
|
||||
fixture.componentInstance.setCategorySortMode('name-desc');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getCategoryLabels(fixture)).toEqual([
|
||||
'Sports',
|
||||
'News',
|
||||
'Movies',
|
||||
]);
|
||||
expect(localStorage.getItem(WORKSPACE_CATEGORY_SORT_STORAGE_KEY)).toBe(
|
||||
'name-desc'
|
||||
);
|
||||
});
|
||||
|
||||
it('applies the category sort menu modes to stalker categories', () => {
|
||||
fixture.componentRef.setInput('context', {
|
||||
provider: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
});
|
||||
fixture.componentRef.setInput('section', 'series');
|
||||
stalkerStore.getCategoryResource.set([
|
||||
{ category_id: '*', category_name: 'All Categories' },
|
||||
{ category_id: 'z', category_name: 'Zulu' },
|
||||
{ category_id: 'a', category_name: 'Alpha' },
|
||||
{ category_id: 'm', category_name: 'Movies' },
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getCategoryLabels(fixture)).toEqual([
|
||||
'All Categories',
|
||||
'Zulu',
|
||||
'Alpha',
|
||||
'Movies',
|
||||
]);
|
||||
|
||||
fixture.componentInstance.setCategorySortMode('name-asc');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getCategoryLabels(fixture)).toEqual([
|
||||
'All Categories',
|
||||
'Alpha',
|
||||
'Movies',
|
||||
'Zulu',
|
||||
]);
|
||||
|
||||
fixture.componentInstance.setCategorySortMode('name-desc');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getCategoryLabels(fixture)).toEqual([
|
||||
'All Categories',
|
||||
'Zulu',
|
||||
'Movies',
|
||||
'Alpha',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves the active xtream live item when switching live categories', () => {
|
||||
fixture.componentRef.setInput('section', 'live');
|
||||
xtreamSelectedTypeContentState.set('ready');
|
||||
|
||||
+85
-24
@@ -13,10 +13,18 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
PortalCategorySortMode,
|
||||
getPortalCategorySortModeLabel,
|
||||
persistPortalCategorySortMode,
|
||||
restorePortalCategorySortMode,
|
||||
sortPortalCategoryItems,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { WorkspaceContextCategoryViewComponent } from './components/workspace-context-category-view.component';
|
||||
import { WorkspaceContextErrorViewComponent } from './components/workspace-context-error-view.component';
|
||||
@@ -34,11 +42,19 @@ interface XtreamCategoryLike {
|
||||
category_id?: number | string;
|
||||
}
|
||||
|
||||
interface WorkspaceCategoryLike {
|
||||
readonly category_id?: string | number;
|
||||
readonly category_name?: string;
|
||||
readonly id?: string | number;
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-context-panel',
|
||||
imports: [
|
||||
MatIconButton,
|
||||
MatIcon,
|
||||
MatMenuModule,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
WorkspaceContextCategoryViewComponent,
|
||||
@@ -100,9 +116,7 @@ export class WorkspaceContextPanelComponent {
|
||||
this.isXtreamCategoryInteractionEnabled() ? 'ready' : 'loading'
|
||||
);
|
||||
readonly canManageXtreamCategories = computed(
|
||||
() =>
|
||||
this.isXtreamCategories() &&
|
||||
this.xtreamSelectedTypeCountsReady()
|
||||
() => this.isXtreamCategories() && this.xtreamSelectedTypeCountsReady()
|
||||
);
|
||||
readonly xtreamStatusText = computed(() => {
|
||||
if (
|
||||
@@ -140,39 +154,72 @@ export class WorkspaceContextPanelComponent {
|
||||
viewChild<ElementRef<HTMLInputElement>>('searchInput');
|
||||
readonly isSearchOpen = signal(false);
|
||||
readonly categorySearchTerm = signal('');
|
||||
readonly categorySortMode = signal<PortalCategorySortMode>(
|
||||
restorePortalCategorySortMode()
|
||||
);
|
||||
readonly categorySortLabel = computed(() =>
|
||||
getPortalCategorySortModeLabel(this.categorySortMode())
|
||||
);
|
||||
readonly categorySortOptions: ReadonlyArray<{
|
||||
mode: PortalCategorySortMode;
|
||||
label: string;
|
||||
icon: string;
|
||||
}> = [
|
||||
{
|
||||
mode: 'server',
|
||||
label: 'Server sorting',
|
||||
icon: 'dns',
|
||||
},
|
||||
{
|
||||
mode: 'name-asc',
|
||||
label: 'A-Z',
|
||||
icon: 'sort_by_alpha',
|
||||
},
|
||||
{
|
||||
mode: 'name-desc',
|
||||
label: 'Z-A',
|
||||
icon: 'sort_by_alpha',
|
||||
},
|
||||
];
|
||||
|
||||
readonly canSearchCategories = computed(
|
||||
() =>
|
||||
(this.isXtreamCategories() &&
|
||||
this.xtreamCategories().length > 0) ||
|
||||
(this.isStalkerCategories() &&
|
||||
this.stalkerCategories().length > 0)
|
||||
(this.isXtreamCategories() && this.xtreamCategories().length > 0) ||
|
||||
(this.isStalkerCategories() && this.stalkerCategories().length > 0)
|
||||
);
|
||||
|
||||
readonly filteredXtreamCategories = computed(() => {
|
||||
const cats = this.xtreamCategories();
|
||||
const term = this.categorySearchTerm().trim().toLowerCase();
|
||||
if (!term) return cats;
|
||||
return cats.filter((c) => {
|
||||
const label =
|
||||
(c as { category_name?: string }).category_name ??
|
||||
(c as { name?: string }).name ??
|
||||
'';
|
||||
return label.toLowerCase().includes(term);
|
||||
});
|
||||
const filtered = term
|
||||
? cats.filter((category) =>
|
||||
this.getCategoryLabel(category).toLowerCase().includes(term)
|
||||
)
|
||||
: cats;
|
||||
|
||||
return sortPortalCategoryItems(
|
||||
filtered,
|
||||
this.categorySortMode(),
|
||||
(category) => this.getCategoryLabel(category),
|
||||
(category) => this.isAllCategory(category)
|
||||
);
|
||||
});
|
||||
|
||||
readonly filteredStalkerCategories = computed(() => {
|
||||
const cats = this.stalkerCategories();
|
||||
const term = this.categorySearchTerm().trim().toLowerCase();
|
||||
if (!term) return cats;
|
||||
return cats.filter((c) => {
|
||||
const label =
|
||||
(c as { name?: string }).name ??
|
||||
(c as { category_name?: string }).category_name ??
|
||||
'';
|
||||
return label.toLowerCase().includes(term);
|
||||
});
|
||||
const filtered = term
|
||||
? cats.filter((category) =>
|
||||
this.getCategoryLabel(category).toLowerCase().includes(term)
|
||||
)
|
||||
: cats;
|
||||
|
||||
return sortPortalCategoryItems(
|
||||
filtered,
|
||||
this.categorySortMode(),
|
||||
(category) => this.getCategoryLabel(category),
|
||||
(category) => this.isAllCategory(category)
|
||||
);
|
||||
});
|
||||
|
||||
readonly title = computed(() => {
|
||||
@@ -240,6 +287,11 @@ export class WorkspaceContextPanelComponent {
|
||||
this.searchInput()?.nativeElement.focus();
|
||||
}
|
||||
|
||||
setCategorySortMode(mode: PortalCategorySortMode): void {
|
||||
this.categorySortMode.set(mode);
|
||||
persistPortalCategorySortMode(mode);
|
||||
}
|
||||
|
||||
openManageCategories(): void {
|
||||
if (!this.canManageXtreamCategories()) {
|
||||
return;
|
||||
@@ -262,7 +314,8 @@ export class WorkspaceContextPanelComponent {
|
||||
data: {
|
||||
playlistId: context.playlistId,
|
||||
contentType,
|
||||
itemCounts: this.xtreamStore.getCategoryItemCounts(),
|
||||
itemCounts:
|
||||
this.xtreamStore.getCategoryItemCounts(),
|
||||
},
|
||||
width: '500px',
|
||||
maxHeight: '90vh',
|
||||
@@ -396,4 +449,12 @@ export class WorkspaceContextPanelComponent {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private getCategoryLabel(category: WorkspaceCategoryLike): string {
|
||||
return category.category_name ?? category.name ?? '';
|
||||
}
|
||||
|
||||
private isAllCategory(category: WorkspaceCategoryLike): boolean {
|
||||
return String(category.category_id ?? category.id) === '*';
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -593,7 +593,10 @@ export class WorkspaceShellFacade {
|
||||
if (
|
||||
context?.provider !== 'stalker' ||
|
||||
!section ||
|
||||
(section !== 'vod' && section !== 'series' && section !== 'itv')
|
||||
(section !== 'vod' &&
|
||||
section !== 'series' &&
|
||||
section !== 'itv' &&
|
||||
section !== 'radio')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user