feat(portal): add category sort modes

This commit is contained in:
4gray committed 2026-05-14 23:55:15 +02:00
1 parent 2a5b1d1da7
commit daff96d90b
16 files changed
+480 -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;
@@ -46,7 +48,7 @@ export async function getCategories(
eq(schema.categories.hidden, false)
)
)
.orderBy(sql`name COLLATE NOCASE`);
.orderBy(schema.categories.id);
}
export async function saveCategories(
+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,113 @@
import {
DEFAULT_PORTAL_CATEGORY_SORT_MODE,
WORKSPACE_CATEGORY_SORT_STORAGE_KEY,
getPortalCategorySortModeLabel,
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 modes and labels them for the category menu', () => {
expect(isPortalCategorySortMode('server')).toBe(true);
expect(isPortalCategorySortMode('name-asc')).toBe(true);
expect(isPortalCategorySortMode('name-desc')).toBe(true);
expect(isPortalCategorySortMode('date-desc')).toBe(false);
expect(getPortalCategorySortModeLabel('server')).toBe('Server sorting');
expect(getPortalCategorySortModeLabel('name-asc')).toBe('A-Z');
expect(getPortalCategorySortModeLabel('name-desc')).toBe('Z-A');
});
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,71 @@
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 getPortalCategorySortModeLabel(
mode: PortalCategorySortMode
): string {
if (mode === 'name-asc') {
return 'A-Z';
}
if (mode === 'name-desc') {
return 'Z-A';
}
return 'Server sorting';
}
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,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"
@@ -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,
@@ -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');
@@ -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) === '*';
}
}
@@ -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;
}