fix(portal): address category sort review feedback

This commit is contained in:
4gray committed 2026-05-15 00:06:55 +02:00
1 parent daff96d90b
commit c5815650c2
24 files changed
+131 -32

No files matched your search

@@ -38,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)
+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": "未命名的來源",
@@ -1,7 +1,6 @@
import {
DEFAULT_PORTAL_CATEGORY_SORT_MODE,
WORKSPACE_CATEGORY_SORT_STORAGE_KEY,
getPortalCategorySortModeLabel,
isPortalCategorySortMode,
persistPortalCategorySortMode,
restorePortalCategorySortMode,
@@ -31,15 +30,11 @@ describe('portal category sort', () => {
expect(restorePortalCategorySortMode()).toBe('name-desc');
});
it('recognizes valid modes and labels them for the category menu', () => {
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);
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', () => {
@@ -32,20 +32,6 @@ export function persistPortalCategorySortMode(
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,
@@ -27,10 +27,15 @@
categorySortMode() !== 'server'
"
[matMenuTriggerFor]="categorySortMenu"
[matTooltip]="'Sort: ' + categorySortLabel()"
aria-label="Sort categories"
[matTooltip]="
('WORKSPACE.SORT_LABEL' | translate) +
(categorySortLabelKey() | translate)
"
[attr.aria-label]="
'WORKSPACE.CATEGORY_SORT_ARIA' | translate
"
>
<mat-icon>sort_by_alpha</mat-icon>
<mat-icon>{{ categorySortIcon() }}</mat-icon>
</button>
<mat-menu #categorySortMenu="matMenu">
@for (
@@ -42,7 +47,9 @@
(click)="setCategorySortMode(option.mode)"
>
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{ option.label }}</span>
<span>{{
option.translationKey | translate
}}</span>
@if (categorySortMode() === option.mode) {
<mat-icon class="context-sort-check"
>check</mat-icon
@@ -18,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...',
};
@@ -302,6 +307,55 @@ describe('WorkspaceContextPanelComponent', () => {
);
});
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',
@@ -20,7 +20,6 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
PortalCategorySortMode,
getPortalCategorySortModeLabel,
persistPortalCategorySortMode,
restorePortalCategorySortMode,
sortPortalCategoryItems,
@@ -157,30 +156,36 @@ export class WorkspaceContextPanelComponent {
readonly categorySortMode = signal<PortalCategorySortMode>(
restorePortalCategorySortMode()
);
readonly categorySortLabel = computed(() =>
getPortalCategorySortModeLabel(this.categorySortMode())
readonly categorySortLabelKey = computed(() =>
this.getCategorySortLabelKey(this.categorySortMode())
);
readonly categorySortOptions: ReadonlyArray<{
mode: PortalCategorySortMode;
label: string;
translationKey: string;
icon: string;
}> = [
{
mode: 'server',
label: 'Server sorting',
translationKey: 'WORKSPACE.SORT_SERVER',
icon: 'dns',
},
{
mode: 'name-asc',
label: 'A-Z',
translationKey: 'WORKSPACE.SORT_NAME_ASC',
icon: 'sort_by_alpha',
},
{
mode: 'name-desc',
label: 'Z-A',
icon: 'sort_by_alpha',
translationKey: 'WORKSPACE.SORT_NAME_DESC',
icon: 'arrow_downward',
},
];
readonly categorySortIcon = computed(
() =>
this.categorySortOptions.find(
(option) => option.mode === this.categorySortMode()
)?.icon ?? 'dns'
);
readonly canSearchCategories = computed(
() =>
@@ -450,6 +455,18 @@ export class WorkspaceContextPanelComponent {
}
}
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 ?? '';
}