From daff96d90bf23b7657e1e98a109159affaade06a Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 14 May 2026 23:55:15 +0200 Subject: [PATCH] feat(portal): add category sort modes --- .../operations/category.operations.spec.ts | 17 ++- .../operations/category.operations.ts | 6 +- 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 | 113 ++++++++++++++++++ .../shared/util/src/lib/category-sort.ts | 71 +++++++++++ .../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 | 34 ++++++ .../workspace-context-panel.component.scss | 4 + .../workspace-context-panel.component.spec.ts | 93 +++++++++++++- .../workspace-context-panel.component.ts | 109 +++++++++++++---- .../services/workspace-shell.facade.ts | 5 +- 16 files changed, 480 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..a2cc9b49e 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; @@ -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( 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..8b0deda16 --- /dev/null +++ b/libs/portal/shared/util/src/lib/category-sort.spec.ts @@ -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', + ]); + }); +}); 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..e5a2ec9f8 --- /dev/null +++ b/libs/portal/shared/util/src/lib/category-sort.ts @@ -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( + 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..ca038c241 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,40 @@ search } + @if (canSearchCategories()) { + + + @for ( + option of categorySortOptions; + track option.mode + ) { + + } + + } @if (isXtreamCategories()) {