mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
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(
|
||||
|
||||
@@ -792,6 +792,8 @@
|
||||
"SORT_NAME_ASC": "الاسم (أ-ي)",
|
||||
"SORT_NAME_DESC": "الاسم (ي-أ)",
|
||||
"SORT_LABEL": "ترتيب: ",
|
||||
"SORT_SERVER": "ترتيب الخادم",
|
||||
"CATEGORY_SORT_ARIA": "ترتيب الفئات",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "متابعة المشاهدة",
|
||||
"UNTITLED_SOURCE": "مصدر بدون عنوان",
|
||||
|
||||
@@ -792,6 +792,8 @@
|
||||
"SORT_NAME_ASC": "الاسم أ-ي",
|
||||
"SORT_NAME_DESC": "الاسم ي-أ",
|
||||
"SORT_LABEL": "ترتيب: ",
|
||||
"SORT_SERVER": "ترتيب السيرفر",
|
||||
"CATEGORY_SORT_ARIA": "ترتيب التصنيفات",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "كمل المشاهدة",
|
||||
"UNTITLED_SOURCE": "مصدر بلا عنوان",
|
||||
|
||||
@@ -792,6 +792,8 @@
|
||||
"SORT_NAME_ASC": "Назва А-Я",
|
||||
"SORT_NAME_DESC": "Назва Я-А",
|
||||
"SORT_LABEL": "Сартаваць: ",
|
||||
"SORT_SERVER": "Парадак сервера",
|
||||
"CATEGORY_SORT_ARIA": "Сартаваць катэгорыі",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Працягнуць прагляд",
|
||||
"UNTITLED_SOURCE": "Крыніца без назвы",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Πηγή χωρίς τίτλο",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "無題のソース",
|
||||
|
||||
@@ -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": "제목 없는 소스",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -796,6 +796,8 @@
|
||||
"SORT_NAME_ASC": "Имя (А-Я)",
|
||||
"SORT_NAME_DESC": "Имя (Я-А)",
|
||||
"SORT_LABEL": "Сортировка: ",
|
||||
"SORT_SERVER": "Порядок сервера",
|
||||
"CATEGORY_SORT_ARIA": "Сортировать категории",
|
||||
"DASHBOARD": {
|
||||
"CONTINUE_WATCHING": "Продолжить просмотр",
|
||||
"UNTITLED_SOURCE": "Источник без названия",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "未命名源",
|
||||
|
||||
@@ -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": "未命名的來源",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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;
|
||||
})
|
||||
);
|
||||
}
|
||||
+25
@@ -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')
|
||||
|
||||
+10
-15
@@ -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"
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+41
@@ -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"
|
||||
|
||||
+4
@@ -80,6 +80,10 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.context-sort-check {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.context-header p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.78rem;
|
||||
|
||||
+145
-2
@@ -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');
|
||||
|
||||
+102
-24
@@ -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) === '*';
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user