feat(portal): add category sorting controls (#940)

* feat(portal): add category sort modes

* fix(portal): address category sort review feedback
This commit is contained in:
4gray authored and GitHub committed 2026-05-15 00:28:32 +02:00
1 parent 2a5b1d1da7
commit 8232a86a1e
34 files changed
+579 -51

No files matched your search

@@ -1,5 +1,6 @@
import type { AppDatabase } from '../database.types';
import { saveCategories } from './category.operations';
import * as schema from 'database-schema';
import { getCategories, saveCategories } from './category.operations';
function createDbMock(existingCount = 0) {
const where = jest.fn().mockResolvedValue([{ count: existingCount }]);
@@ -24,6 +25,20 @@ function createDbMock(existingCount = 0) {
}
describe('category.operations', () => {
it('reads visible categories in insertion order to preserve server sorting', async () => {
const orderBy = jest.fn().mockResolvedValue([]);
const where = jest.fn().mockReturnValue({ orderBy });
const from = jest.fn().mockReturnValue({ where });
const select = jest.fn().mockReturnValue({ from });
const db = {
select,
} as unknown as AppDatabase;
await getCategories(db, 'playlist-1', 'live');
expect(orderBy).toHaveBeenCalledWith(schema.categories.id);
});
it('restores hidden categories when Xtream API category IDs are strings', async () => {
const { db, values, insert } = createDbMock();
@@ -7,7 +7,9 @@ type XtreamCategoryInput = {
category_id: string | number;
};
function normalizeXtreamCategoryId(rawCategoryId: string | number): number | null {
function normalizeXtreamCategoryId(
rawCategoryId: string | number
): number | null {
const xtreamId = Number.parseInt(String(rawCategoryId), 10);
return Number.isNaN(xtreamId) ? null : xtreamId;
@@ -36,6 +38,10 @@ export async function getCategories(
playlistId: string,
type: 'live' | 'movies' | 'series'
) {
// Xtream categories are inserted once in provider order and existing
// xtream IDs are preserved, so row id order represents server order.
// If partial category re-inserts are added later, persist a provider
// sort index instead of relying on the insertion id.
return db
.select()
.from(schema.categories)
@@ -46,7 +52,7 @@ export async function getCategories(
eq(schema.categories.hidden, false)
)
)
.orderBy(sql`name COLLATE NOCASE`);
.orderBy(schema.categories.id);
}
export async function saveCategories(
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "الاسم (أ-ي)",
"SORT_NAME_DESC": "الاسم (ي-أ)",
"SORT_LABEL": "ترتيب: ",
"SORT_SERVER": "ترتيب الخادم",
"CATEGORY_SORT_ARIA": "ترتيب الفئات",
"DASHBOARD": {
"CONTINUE_WATCHING": "متابعة المشاهدة",
"UNTITLED_SOURCE": "مصدر بدون عنوان",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "الاسم أ-ي",
"SORT_NAME_DESC": "الاسم ي-أ",
"SORT_LABEL": "ترتيب: ",
"SORT_SERVER": "ترتيب السيرفر",
"CATEGORY_SORT_ARIA": "ترتيب التصنيفات",
"DASHBOARD": {
"CONTINUE_WATCHING": "كمل المشاهدة",
"UNTITLED_SOURCE": "مصدر بلا عنوان",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Назва А-Я",
"SORT_NAME_DESC": "Назва Я-А",
"SORT_LABEL": "Сартаваць: ",
"SORT_SERVER": "Парадак сервера",
"CATEGORY_SORT_ARIA": "Сартаваць катэгорыі",
"DASHBOARD": {
"CONTINUE_WATCHING": "Працягнуць прагляд",
"UNTITLED_SOURCE": "Крыніца без назвы",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Name A-Z",
"SORT_NAME_DESC": "Name Z-A",
"SORT_LABEL": "Sortierung: ",
"SORT_SERVER": "Serverreihenfolge",
"CATEGORY_SORT_ARIA": "Kategorien sortieren",
"DASHBOARD": {
"CONTINUE_WATCHING": "Weiter schauen",
"UNTITLED_SOURCE": "Unbenannte Quelle",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Όνομα A-Z",
"SORT_NAME_DESC": "Όνομα Z-A",
"SORT_LABEL": "Ταξινόμηση: ",
"SORT_SERVER": "Σειρά διακομιστή",
"CATEGORY_SORT_ARIA": "Ταξινόμηση κατηγοριών",
"DASHBOARD": {
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
"UNTITLED_SOURCE": "Πηγή χωρίς τίτλο",
+2
View File
@@ -798,6 +798,8 @@
"SORT_NAME_ASC": "Name A-Z",
"SORT_NAME_DESC": "Name Z-A",
"SORT_LABEL": "Sort: ",
"SORT_SERVER": "Server sorting",
"CATEGORY_SORT_ARIA": "Sort categories",
"DASHBOARD": {
"CONTINUE_WATCHING": "Continue Watching",
"UNTITLED_SOURCE": "Untitled source",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Nombre A-Z",
"SORT_NAME_DESC": "Nombre Z-A",
"SORT_LABEL": "Ordenar: ",
"SORT_SERVER": "Orden del servidor",
"CATEGORY_SORT_ARIA": "Ordenar categorías",
"DASHBOARD": {
"CONTINUE_WATCHING": "Continuar viendo",
"UNTITLED_SOURCE": "Fuente sin título",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Nom A-Z",
"SORT_NAME_DESC": "Nom Z-A",
"SORT_LABEL": "Trier : ",
"SORT_SERVER": "Ordre du serveur",
"CATEGORY_SORT_ARIA": "Trier les catégories",
"DASHBOARD": {
"CONTINUE_WATCHING": "Reprendre la lecture",
"UNTITLED_SOURCE": "Source sans titre",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Nome A-Z",
"SORT_NAME_DESC": "Nome Z-A",
"SORT_LABEL": "Ordina: ",
"SORT_SERVER": "Ordine del server",
"CATEGORY_SORT_ARIA": "Ordina categorie",
"DASHBOARD": {
"CONTINUE_WATCHING": "Continua a guardare",
"UNTITLED_SOURCE": "Sorgente senza titolo",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "名前 A-Z",
"SORT_NAME_DESC": "名前 Z-A",
"SORT_LABEL": "並び替え:",
"SORT_SERVER": "サーバー順",
"CATEGORY_SORT_ARIA": "カテゴリを並び替え",
"DASHBOARD": {
"CONTINUE_WATCHING": "視聴を再開",
"UNTITLED_SOURCE": "無題のソース",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "이름 A-Z",
"SORT_NAME_DESC": "이름 Z-A",
"SORT_LABEL": "정렬: ",
"SORT_SERVER": "서버 순서",
"CATEGORY_SORT_ARIA": "카테고리 정렬",
"DASHBOARD": {
"CONTINUE_WATCHING": "이어서 시청",
"UNTITLED_SOURCE": "제목 없는 소스",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Naam A-Z",
"SORT_NAME_DESC": "Naam Z-A",
"SORT_LABEL": "Sorteren: ",
"SORT_SERVER": "Servervolgorde",
"CATEGORY_SORT_ARIA": "Categorieën sorteren",
"DASHBOARD": {
"CONTINUE_WATCHING": "Verder kijken",
"UNTITLED_SOURCE": "Naamloze bron",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Nazwa A-Z",
"SORT_NAME_DESC": "Nazwa Z-A",
"SORT_LABEL": "Sortuj: ",
"SORT_SERVER": "Kolejność serwera",
"CATEGORY_SORT_ARIA": "Sortuj kategorie",
"DASHBOARD": {
"CONTINUE_WATCHING": "Kontynuuj oglądanie",
"UNTITLED_SOURCE": "Źródło bez tytułu",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Nome A-Z",
"SORT_NAME_DESC": "Nome Z-A",
"SORT_LABEL": "Ordenar: ",
"SORT_SERVER": "Ordem do servidor",
"CATEGORY_SORT_ARIA": "Ordenar categorias",
"DASHBOARD": {
"CONTINUE_WATCHING": "Continuar assistindo",
"UNTITLED_SOURCE": "Fonte sem título",
+2
View File
@@ -796,6 +796,8 @@
"SORT_NAME_ASC": "Имя (А-Я)",
"SORT_NAME_DESC": "Имя (Я-А)",
"SORT_LABEL": "Сортировка: ",
"SORT_SERVER": "Порядок сервера",
"CATEGORY_SORT_ARIA": "Сортировать категории",
"DASHBOARD": {
"CONTINUE_WATCHING": "Продолжить просмотр",
"UNTITLED_SOURCE": "Источник без названия",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "İsim A-Z",
"SORT_NAME_DESC": "İsim Z-A",
"SORT_LABEL": "Sırala: ",
"SORT_SERVER": "Sunucu sıralaması",
"CATEGORY_SORT_ARIA": "Kategorileri sırala",
"DASHBOARD": {
"CONTINUE_WATCHING": "İzlemeye Devam Et",
"UNTITLED_SOURCE": "Adsız kaynak",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "名称 A-Z",
"SORT_NAME_DESC": "名称 Z-A",
"SORT_LABEL": "排序:",
"SORT_SERVER": "服务器排序",
"CATEGORY_SORT_ARIA": "排序分类",
"DASHBOARD": {
"CONTINUE_WATCHING": "继续观看",
"UNTITLED_SOURCE": "未命名源",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "名稱 A-Z",
"SORT_NAME_DESC": "名稱 Z-A",
"SORT_LABEL": "排序:",
"SORT_SERVER": "伺服器排序",
"CATEGORY_SORT_ARIA": "排序分類",
"DASHBOARD": {
"CONTINUE_WATCHING": "繼續觀看",
"UNTITLED_SOURCE": "未命名的來源",
+7 -5
View File
@@ -36,11 +36,11 @@ ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0
**File**: `apps/electron-backend/src/app/events/database/category.events.ts`
| IPC Handler | Purpose |
| ------------------------------- | -------------------------------------------------- |
| `DB_GET_CATEGORIES` | Returns visible categories only (`hidden = false`) |
| `DB_GET_ALL_CATEGORIES` | Returns all categories (for management dialog) |
| `DB_UPDATE_CATEGORY_VISIBILITY` | Batch updates `hidden` status for category IDs |
| IPC Handler | Purpose |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `DB_GET_CATEGORIES` | Returns visible categories only (`hidden = false`) in SQLite insertion order, preserving the Xtream server order by default |
| `DB_GET_ALL_CATEGORIES` | Returns all categories (for management dialog) |
| `DB_UPDATE_CATEGORY_VISIBILITY` | Batch updates `hidden` status for category IDs |
### Frontend Services
@@ -85,6 +85,8 @@ Added `reloadCategories()` method to refresh categories from database after visi
- **Persistence**: Visibility settings survive playlist refresh (see below)
- **Per-playlist, per-type**: Categories are managed per playlist and per content type (live/movies/series)
- **No content deletion**: Hiding a category only affects sidebar visibility; the category and its content remain in the database
- **Display order**: The sidebar defaults to server order. Users can switch the
category panel to `A-Z` or `Z-A` from the sort menu next to category search.
- **All-hidden recovery**: Once the selected Xtream type is loaded, the manage
categories button remains available even if every visible category has been
hidden. The sidebar category list is filtered, but the dialog reads all
+6
View File
@@ -101,6 +101,12 @@ The context panel is part of the shell contract. New workspace-level routes
should explicitly decide whether they need one rather than adding local
sidebars inside feature pages.
Xtream and Stalker category panels preserve provider/server category order by
default. The panel header exposes a sort menu next to category search with
`Server sorting`, `A-Z`, and `Z-A`; when alphabetical sorting is active,
synthetic "all categories" entries stay pinned before sorted provider
categories.
## Search And Navigation Rules
Search is shell-owned and route-aware:
+1
View File
@@ -1,4 +1,5 @@
export * from './lib/logger';
export * from './lib/category-sort';
export * from './lib/channel-sort';
export * from './lib/favorites-channel-sort';
export * from './lib/portal-catalog-detail';
@@ -0,0 +1,108 @@
import {
DEFAULT_PORTAL_CATEGORY_SORT_MODE,
WORKSPACE_CATEGORY_SORT_STORAGE_KEY,
isPortalCategorySortMode,
persistPortalCategorySortMode,
restorePortalCategorySortMode,
sortPortalCategoryItems,
} from './category-sort';
describe('portal category sort', () => {
beforeEach(() => {
localStorage.removeItem(WORKSPACE_CATEGORY_SORT_STORAGE_KEY);
});
it('defaults to server sorting and ignores invalid stored values', () => {
expect(DEFAULT_PORTAL_CATEGORY_SORT_MODE).toBe('server');
expect(restorePortalCategorySortMode()).toBe('server');
localStorage.setItem(WORKSPACE_CATEGORY_SORT_STORAGE_KEY, 'random');
expect(restorePortalCategorySortMode()).toBe('server');
});
it('persists and restores valid category sort modes', () => {
persistPortalCategorySortMode('name-desc');
expect(localStorage.getItem(WORKSPACE_CATEGORY_SORT_STORAGE_KEY)).toBe(
'name-desc'
);
expect(restorePortalCategorySortMode()).toBe('name-desc');
});
it('recognizes valid category sort modes', () => {
expect(isPortalCategorySortMode('server')).toBe(true);
expect(isPortalCategorySortMode('name-asc')).toBe(true);
expect(isPortalCategorySortMode('name-desc')).toBe(true);
expect(isPortalCategorySortMode('date-desc')).toBe(false);
});
it('preserves input order for server sorting and supports A-Z/Z-A sorting', () => {
const categories = [
{ category_name: 'Zulu' },
{ category_name: 'Alpha' },
{ name: 'Movies' },
];
expect(
sortPortalCategoryItems(
categories,
'server',
(category) => category.category_name ?? category.name
)
).toBe(categories);
expect(
sortPortalCategoryItems(
categories,
'name-asc',
(category) => category.category_name ?? category.name
).map((category) => category.category_name ?? category.name)
).toEqual(['Alpha', 'Movies', 'Zulu']);
expect(
sortPortalCategoryItems(
categories,
'name-desc',
(category) => category.category_name ?? category.name
).map((category) => category.category_name ?? category.name)
).toEqual(['Zulu', 'Movies', 'Alpha']);
});
it('keeps pinned entries first when name sorting is active', () => {
const categories = [
{ category_id: '*', category_name: 'All Categories' },
{ category_id: 'z', category_name: 'Zulu' },
{ category_id: 'a', category_name: 'Alpha' },
];
expect(
sortPortalCategoryItems(
categories,
'name-desc',
(category) => category.category_name,
(category) => category.category_id === '*'
).map((category) => category.category_name)
).toEqual(['All Categories', 'Zulu', 'Alpha']);
});
it('ignores accidental provider whitespace around names when sorting', () => {
const categories = [
{ category_name: ' DENMARK' },
{ category_name: ' SPORTS | INDIA' },
{ category_name: '24/7 PAK DRAMA' },
{ category_name: 'AFGHANISTAN' },
];
expect(
sortPortalCategoryItems(
categories,
'name-asc',
(category) => category.category_name
).map((category) => category.category_name)
).toEqual([
'24/7 PAK DRAMA',
'AFGHANISTAN',
' DENMARK',
' SPORTS | INDIA',
]);
});
});
@@ -0,0 +1,57 @@
const CATEGORY_SORT_COLLATOR = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
export type PortalCategorySortMode = 'server' | 'name-asc' | 'name-desc';
export const DEFAULT_PORTAL_CATEGORY_SORT_MODE: PortalCategorySortMode =
'server';
export const WORKSPACE_CATEGORY_SORT_STORAGE_KEY =
'workspace-category-sort-mode';
export function isPortalCategorySortMode(
value: unknown
): value is PortalCategorySortMode {
return value === 'server' || value === 'name-asc' || value === 'name-desc';
}
export function restorePortalCategorySortMode(
storageKey: string = WORKSPACE_CATEGORY_SORT_STORAGE_KEY,
fallback: PortalCategorySortMode = DEFAULT_PORTAL_CATEGORY_SORT_MODE
): PortalCategorySortMode {
const storedValue = localStorage.getItem(storageKey);
return isPortalCategorySortMode(storedValue) ? storedValue : fallback;
}
export function persistPortalCategorySortMode(
mode: PortalCategorySortMode,
storageKey: string = WORKSPACE_CATEGORY_SORT_STORAGE_KEY
): void {
localStorage.setItem(storageKey, mode);
}
export function sortPortalCategoryItems<T>(
items: readonly T[],
mode: PortalCategorySortMode,
getDisplayName: (item: T) => string | null | undefined,
isPinnedFirst: (item: T) => boolean = () => false
): readonly T[] {
if (mode === 'server') {
return items;
}
const pinnedItems = items.filter(isPinnedFirst);
const sortableItems = items.filter((item) => !isPinnedFirst(item));
return pinnedItems.concat(
sortableItems.sort((a, b) => {
const result = CATEGORY_SORT_COLLATOR.compare(
(getDisplayName(a) ?? '').trim(),
(getDisplayName(b) ?? '').trim()
);
return mode === 'name-asc' ? result : -result;
})
);
}
@@ -183,6 +183,31 @@ describe('withStalkerContent failure states', () => {
});
});
it('preserves server category order while keeping the all category first', async () => {
dataService.sendIpcEvent.mockResolvedValue({
js: [
{ id: 'z', title: 'Zulu' },
{ id: 'a', title: 'Alpha' },
{ id: 'm', title: 'Movies' },
],
});
store.setSelectedContentType('vod');
store.setCurrentPlaylist(PLAYLIST);
void store.isCategoryResourceLoading();
await waitForCondition(
() => dataService.sendIpcEvent.mock.calls.length > 0
);
await flushResources();
expect(
store
.getCategoryResource()
.map((category) => category.category_name)
).toEqual(['PORTALS.ALL_CATEGORIES', 'Zulu', 'Alpha', 'Movies']);
});
it('normalizes content failures into empty collections instead of undefined state', async () => {
dataService.sendIpcEvent.mockRejectedValue(
new Error('get_ordered_list failed')
@@ -265,18 +265,12 @@ export function withStalkerContent() {
return [];
}
const normalizedCategories = response.js
.map(
(item): StalkerCategoryItem => ({
category_name: item.title ?? '',
category_id: String(item.id),
})
)
.sort((left, right) =>
left.category_name.localeCompare(
right.category_name
)
);
const normalizedCategories = response.js.map(
(item): StalkerCategoryItem => ({
category_name: item.title ?? '',
category_id: String(item.id),
})
);
const categories = prependAllCategory(
params.contentType,
params.contentType === 'radio' &&
@@ -303,9 +297,10 @@ export function withStalkerContent() {
error,
});
if (params.contentType === 'radio') {
const fallback = fallbackRadioCategories(
translateService
);
const fallback =
fallbackRadioCategories(
translateService
);
patchState(store, {
radioCategories: fallback,
categoryError: null,
@@ -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"
}
@@ -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);
@@ -18,6 +18,47 @@
<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]="
('WORKSPACE.SORT_LABEL' | translate) +
(categorySortLabelKey() | translate)
"
[attr.aria-label]="
'WORKSPACE.CATEGORY_SORT_ARIA' | translate
"
>
<mat-icon>{{ categorySortIcon() }}</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.translationKey | translate
}}</span>
@if (categorySortMode() === option.mode) {
<mat-icon class="context-sort-check"
>check</mat-icon
>
}
</button>
}
</mat-menu>
}
@if (isXtreamCategories()) {
<button
type="button"
@@ -80,6 +80,10 @@
text-transform: uppercase;
}
.context-sort-check {
margin-left: auto;
}
.context-header p {
margin: 4px 0 0;
font-size: 0.78rem;
@@ -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,
@@ -17,6 +18,11 @@ const translations: Record<string, string> = {
'WORKSPACE.CONTEXT.RADIO_CATEGORIES': 'Radio Categories',
'WORKSPACE.CONTEXT.XTREAM_SYNCING_LIVE': 'Syncing live categories...',
'WORKSPACE.CONTEXT.XTREAM_SYNCING_MOVIES': 'Syncing movie categories...',
'WORKSPACE.CATEGORY_SORT_ARIA': 'Sort categories',
'WORKSPACE.SORT_LABEL': 'Sort: ',
'WORKSPACE.SORT_NAME_ASC': 'Name A-Z',
'WORKSPACE.SORT_NAME_DESC': 'Name Z-A',
'WORKSPACE.SORT_SERVER': 'Server sorting',
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING':
'Fetching playlist data from source...',
};
@@ -35,13 +41,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 +111,7 @@ describe('WorkspaceContextPanelComponent', () => {
};
beforeEach(async () => {
localStorage.removeItem(WORKSPACE_CATEGORY_SORT_STORAGE_KEY);
xtreamCategories.set([
{ id: 1, name: 'News' },
{ id: 2, name: 'Sports' },
@@ -255,6 +269,135 @@ 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('uses translated category sort labels and distinct mode icons', () => {
fixture.componentRef.setInput('section', 'vod');
xtreamSelectedTypeContentState.set('ready');
fixture.detectChanges();
const sortButton = Array.from(
fixture.nativeElement.querySelectorAll('.context-header__action')
).at(1) as HTMLButtonElement | undefined;
expect(sortButton?.getAttribute('aria-label')).toBe('Sort categories');
expect(fixture.componentInstance.categorySortLabelKey()).toBe(
'WORKSPACE.SORT_SERVER'
);
expect(fixture.componentInstance.categorySortIcon()).toBe('dns');
expect(
fixture.componentInstance.categorySortOptions.map((option) => ({
mode: option.mode,
translationKey: option.translationKey,
icon: option.icon,
}))
).toEqual([
{
mode: 'server',
translationKey: 'WORKSPACE.SORT_SERVER',
icon: 'dns',
},
{
mode: 'name-asc',
translationKey: 'WORKSPACE.SORT_NAME_ASC',
icon: 'sort_by_alpha',
},
{
mode: 'name-desc',
translationKey: 'WORKSPACE.SORT_NAME_DESC',
icon: 'arrow_downward',
},
]);
fixture.componentInstance.setCategorySortMode('name-desc');
fixture.detectChanges();
expect(fixture.componentInstance.categorySortLabelKey()).toBe(
'WORKSPACE.SORT_NAME_DESC'
);
expect(fixture.componentInstance.categorySortIcon()).toBe(
'arrow_downward'
);
});
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');
@@ -13,10 +13,17 @@ 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,
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 +41,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 +115,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 +153,78 @@ export class WorkspaceContextPanelComponent {
viewChild<ElementRef<HTMLInputElement>>('searchInput');
readonly isSearchOpen = signal(false);
readonly categorySearchTerm = signal('');
readonly categorySortMode = signal<PortalCategorySortMode>(
restorePortalCategorySortMode()
);
readonly categorySortLabelKey = computed(() =>
this.getCategorySortLabelKey(this.categorySortMode())
);
readonly categorySortOptions: ReadonlyArray<{
mode: PortalCategorySortMode;
translationKey: string;
icon: string;
}> = [
{
mode: 'server',
translationKey: 'WORKSPACE.SORT_SERVER',
icon: 'dns',
},
{
mode: 'name-asc',
translationKey: 'WORKSPACE.SORT_NAME_ASC',
icon: 'sort_by_alpha',
},
{
mode: 'name-desc',
translationKey: 'WORKSPACE.SORT_NAME_DESC',
icon: 'arrow_downward',
},
];
readonly categorySortIcon = computed(
() =>
this.categorySortOptions.find(
(option) => option.mode === this.categorySortMode()
)?.icon ?? 'dns'
);
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 +292,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 +319,8 @@ export class WorkspaceContextPanelComponent {
data: {
playlistId: context.playlistId,
contentType,
itemCounts: this.xtreamStore.getCategoryItemCounts(),
itemCounts:
this.xtreamStore.getCategoryItemCounts(),
},
width: '500px',
maxHeight: '90vh',
@@ -396,4 +454,24 @@ export class WorkspaceContextPanelComponent {
return '';
}
}
private getCategorySortLabelKey(mode: PortalCategorySortMode): string {
switch (mode) {
case 'name-asc':
return 'WORKSPACE.SORT_NAME_ASC';
case 'name-desc':
return 'WORKSPACE.SORT_NAME_DESC';
case 'server':
default:
return 'WORKSPACE.SORT_SERVER';
}
}
private getCategoryLabel(category: WorkspaceCategoryLike): string {
return category.category_name ?? category.name ?? '';
}
private isAllCategory(category: WorkspaceCategoryLike): boolean {
return String(category.category_id ?? category.id) === '*';
}
}
@@ -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;
}