From 8232a86a1eab1e7f4ef0093afd173b4181d276ce Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 15 May 2026 00:28:32 +0200 Subject: [PATCH] feat(portal): add category sorting controls (#940) * feat(portal): add category sort modes * fix(portal): address category sort review feedback --- .../operations/category.operations.spec.ts | 17 +- .../operations/category.operations.ts | 10 +- apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + docs/architecture/category-management.md | 12 +- docs/architecture/workspace-shell.md | 6 + libs/portal/shared/util/src/index.ts | 1 + .../shared/util/src/lib/category-sort.spec.ts | 108 +++++++++++++ .../shared/util/src/lib/category-sort.ts | 57 +++++++ .../with-stalker-content.feature.spec.ts | 25 +++ .../features/with-stalker-content.feature.ts | 25 ++- libs/workspace/shell/feature/project.json | 8 + ...rkspace-context-category-view.component.ts | 2 +- .../workspace-context-panel.component.html | 41 +++++ .../workspace-context-panel.component.scss | 4 + .../workspace-context-panel.component.spec.ts | 147 +++++++++++++++++- .../workspace-context-panel.component.ts | 126 ++++++++++++--- .../services/workspace-shell.facade.ts | 5 +- 34 files changed, 579 insertions(+), 51 deletions(-) create mode 100644 libs/portal/shared/util/src/lib/category-sort.spec.ts create mode 100644 libs/portal/shared/util/src/lib/category-sort.ts diff --git a/apps/electron-backend/src/app/database/operations/category.operations.spec.ts b/apps/electron-backend/src/app/database/operations/category.operations.spec.ts index 6992f28f2..ba79ca834 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.spec.ts @@ -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(); diff --git a/apps/electron-backend/src/app/database/operations/category.operations.ts b/apps/electron-backend/src/app/database/operations/category.operations.ts index bc9d1828f..d1263b00c 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.ts @@ -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( diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 723daab4f..ebf0adda4 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -792,6 +792,8 @@ "SORT_NAME_ASC": "الاسم (أ-ي)", "SORT_NAME_DESC": "الاسم (ي-أ)", "SORT_LABEL": "ترتيب: ", + "SORT_SERVER": "ترتيب الخادم", + "CATEGORY_SORT_ARIA": "ترتيب الفئات", "DASHBOARD": { "CONTINUE_WATCHING": "متابعة المشاهدة", "UNTITLED_SOURCE": "مصدر بدون عنوان", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index e21c67da6..e3ff4dd52 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -792,6 +792,8 @@ "SORT_NAME_ASC": "الاسم أ-ي", "SORT_NAME_DESC": "الاسم ي-أ", "SORT_LABEL": "ترتيب: ", + "SORT_SERVER": "ترتيب السيرفر", + "CATEGORY_SORT_ARIA": "ترتيب التصنيفات", "DASHBOARD": { "CONTINUE_WATCHING": "كمل المشاهدة", "UNTITLED_SOURCE": "مصدر بلا عنوان", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 38a14c918..30fa9963a 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -792,6 +792,8 @@ "SORT_NAME_ASC": "Назва А-Я", "SORT_NAME_DESC": "Назва Я-А", "SORT_LABEL": "Сартаваць: ", + "SORT_SERVER": "Парадак сервера", + "CATEGORY_SORT_ARIA": "Сартаваць катэгорыі", "DASHBOARD": { "CONTINUE_WATCHING": "Працягнуць прагляд", "UNTITLED_SOURCE": "Крыніца без назвы", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 6cbf84c63..71f1b91b9 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 169dd5d95..00dfb314f 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Πηγή χωρίς τίτλο", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index faf73197b..5670dbdbe 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 7ccfed620..8586fe1e2 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 35e451955..a0afad51c 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 81b47e2ec..382246bd3 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 653ae142e..3cd55a128 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "無題のソース", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 73b698b3f..27a42a643 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "제목 없는 소스", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index e4a4f28e2..baa6d432d 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index c0293fa21..78d897df3 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 6cada2740..2d9f91a0f 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index af1480a04..f840edb02 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -796,6 +796,8 @@ "SORT_NAME_ASC": "Имя (А-Я)", "SORT_NAME_DESC": "Имя (Я-А)", "SORT_LABEL": "Сортировка: ", + "SORT_SERVER": "Порядок сервера", + "CATEGORY_SORT_ARIA": "Сортировать категории", "DASHBOARD": { "CONTINUE_WATCHING": "Продолжить просмотр", "UNTITLED_SOURCE": "Источник без названия", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 059d257f1..20a1f36f1 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index a2096f120..02da1e7a5 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "未命名源", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 747260097..9115b80c1 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "未命名的來源", diff --git a/docs/architecture/category-management.md b/docs/architecture/category-management.md index 9bc35bd82..c9aaeaddb 100644 --- a/docs/architecture/category-management.md +++ b/docs/architecture/category-management.md @@ -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 diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 1f5783b0c..b3e37bd75 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -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: diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index fa636d2e8..6ce6a72c9 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -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'; diff --git a/libs/portal/shared/util/src/lib/category-sort.spec.ts b/libs/portal/shared/util/src/lib/category-sort.spec.ts new file mode 100644 index 000000000..dbdebfdf8 --- /dev/null +++ b/libs/portal/shared/util/src/lib/category-sort.spec.ts @@ -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', + ]); + }); +}); diff --git a/libs/portal/shared/util/src/lib/category-sort.ts b/libs/portal/shared/util/src/lib/category-sort.ts new file mode 100644 index 000000000..d32adc144 --- /dev/null +++ b/libs/portal/shared/util/src/lib/category-sort.ts @@ -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( + 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; + }) + ); +} diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index b9771a8e9..9b638d703 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -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') diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index c4d2d6f7b..61e0929a0 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -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, diff --git a/libs/workspace/shell/feature/project.json b/libs/workspace/shell/feature/project.json index 4f6493d4c..b35d3333d 100644 --- a/libs/workspace/shell/feature/project.json +++ b/libs/workspace/shell/feature/project.json @@ -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" } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts index c07502568..61fe42a58 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts @@ -27,7 +27,7 @@ interface WorkspaceCategoryViewItem { styleUrl: './workspace-context-category-view.component.scss', }) export class WorkspaceContextCategoryViewComponent { - readonly items = input([]); + readonly items = input>([]); readonly selectedCategoryId = input(); readonly itemCounts = input>(new Map()); readonly showCounts = input(false); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html index 8f0ae7f57..c812c82d5 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html @@ -18,6 +18,47 @@ search } + @if (canSearchCategories()) { + + + @for ( + option of categorySortOptions; + track option.mode + ) { + + } + + } @if (isXtreamCategories()) {