mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(xtream): sort and filter the VOD/series catalog by IMDb rating
Adds IMDb rating sort and minimum-rating filtering for Xtream VOD/series catalogs, consolidates refinement controls, and guards rating controls away from Live TV.
This commit is contained in:
1 parent
2f27895e6b
commit
ffc07146fe
29 files changed
+1004
-76
No files matched your search
@@ -193,7 +193,10 @@ test.describe('Electron Catalog Sorting', () => {
|
||||
await waitForStalkerCatalog(app.mainWindow);
|
||||
|
||||
await expect(
|
||||
app.mainWindow.getByRole('button', { name: 'Sort content' })
|
||||
app.mainWindow.getByRole('button', {
|
||||
name: 'Refine',
|
||||
exact: true,
|
||||
})
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
app.mainWindow.getByRole('button', { name: 'Sort channels' })
|
||||
@@ -201,12 +204,18 @@ test.describe('Electron Catalog Sorting', () => {
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Movies');
|
||||
await expect(
|
||||
app.mainWindow.getByRole('button', { name: 'Sort content' })
|
||||
app.mainWindow.getByRole('button', {
|
||||
name: 'Refine',
|
||||
exact: true,
|
||||
})
|
||||
).toHaveCount(0);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'Series');
|
||||
await expect(
|
||||
app.mainWindow.getByRole('button', { name: 'Sort content' })
|
||||
app.mainWindow.getByRole('button', {
|
||||
name: 'Refine',
|
||||
exact: true,
|
||||
})
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
@@ -371,7 +380,7 @@ async function setContentSortMode(
|
||||
| 'Name A-Z'
|
||||
| 'Name Z-A'
|
||||
): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Sort content' }).click();
|
||||
await page.getByRole('button', { name: 'Refine', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: label, exact: true }).click();
|
||||
}
|
||||
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "تاريخ الإضافة (الأقدم أولاً)",
|
||||
"SORT_NAME_ASC": "الاسم (أ-ي)",
|
||||
"SORT_NAME_DESC": "الاسم (ي-أ)",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "ترتيب: ",
|
||||
"SORT_SERVER": "ترتيب الخادم",
|
||||
"CATEGORY_SORT_ARIA": "ترتيب الفئات",
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "تاريخ الإضافة (الأقدم أولاً)",
|
||||
"SORT_NAME_ASC": "الاسم أ-ي",
|
||||
"SORT_NAME_DESC": "الاسم ي-أ",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "ترتيب: ",
|
||||
"SORT_SERVER": "ترتيب السيرفر",
|
||||
"CATEGORY_SORT_ARIA": "ترتيب التصنيفات",
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "Дата дадання (спачатку старыя)",
|
||||
"SORT_NAME_ASC": "Назва А-Я",
|
||||
"SORT_NAME_DESC": "Назва Я-А",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "Сартаваць: ",
|
||||
"SORT_SERVER": "Парадак сервера",
|
||||
"CATEGORY_SORT_ARIA": "Сартаваць катэгорыі",
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "Datum (Älteste zuerst)",
|
||||
"SORT_NAME_ASC": "Name A-Z",
|
||||
"SORT_NAME_DESC": "Name Z-A",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "Sortierung: ",
|
||||
"SORT_SERVER": "Serverreihenfolge",
|
||||
"CATEGORY_SORT_ARIA": "Kategorien sortieren",
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "Ημερομηνία προσθήκης (Πρώτα τα παλαιότερα)",
|
||||
"SORT_NAME_ASC": "Όνομα A-Z",
|
||||
"SORT_NAME_DESC": "Όνομα Z-A",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "Ταξινόμηση: ",
|
||||
"SORT_SERVER": "Σειρά διακομιστή",
|
||||
"CATEGORY_SORT_ARIA": "Ταξινόμηση κατηγοριών",
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "Date Added (Oldest First)",
|
||||
"SORT_NAME_ASC": "Name A-Z",
|
||||
"SORT_NAME_DESC": "Name Z-A",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "Sort: ",
|
||||
"SORT_SERVER": "Server sorting",
|
||||
"CATEGORY_SORT_ARIA": "Sort categories",
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "Fecha de adición (más antiguas primero)",
|
||||
"SORT_NAME_ASC": "Nombre A-Z",
|
||||
"SORT_NAME_DESC": "Nombre Z-A",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "Ordenar: ",
|
||||
"SORT_SERVER": "Orden del servidor",
|
||||
"CATEGORY_SORT_ARIA": "Ordenar categorías",
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "Date d'ajout (plus anciens en premier)",
|
||||
"SORT_NAME_ASC": "Nom A-Z",
|
||||
"SORT_NAME_DESC": "Nom Z-A",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "Trier : ",
|
||||
"SORT_SERVER": "Ordre du serveur",
|
||||
"CATEGORY_SORT_ARIA": "Trier les catégories",
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "Data aggiunta (prima i più vecchi)",
|
||||
"SORT_NAME_ASC": "Nome A-Z",
|
||||
"SORT_NAME_DESC": "Nome Z-A",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "Ordina: ",
|
||||
"SORT_SERVER": "Ordine del server",
|
||||
"CATEGORY_SORT_ARIA": "Ordina categorie",
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "追加日(古い順)",
|
||||
"SORT_NAME_ASC": "名前 A-Z",
|
||||
"SORT_NAME_DESC": "名前 Z-A",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "並び替え:",
|
||||
"SORT_SERVER": "サーバー順",
|
||||
"CATEGORY_SORT_ARIA": "カテゴリを並び替え",
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "추가 날짜 (오래된순)",
|
||||
"SORT_NAME_ASC": "이름 A-Z",
|
||||
"SORT_NAME_DESC": "이름 Z-A",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "정렬: ",
|
||||
"SORT_SERVER": "서버 순서",
|
||||
"CATEGORY_SORT_ARIA": "카테고리 정렬",
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "Datum toegevoegd (oudste eerst)",
|
||||
"SORT_NAME_ASC": "Naam A-Z",
|
||||
"SORT_NAME_DESC": "Naam Z-A",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "Sorteren: ",
|
||||
"SORT_SERVER": "Servervolgorde",
|
||||
"CATEGORY_SORT_ARIA": "Categorieën sorteren",
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "Data dodania (najstarsze pierwsze)",
|
||||
"SORT_NAME_ASC": "Nazwa A-Z",
|
||||
"SORT_NAME_DESC": "Nazwa Z-A",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "Sortuj: ",
|
||||
"SORT_SERVER": "Kolejność serwera",
|
||||
"CATEGORY_SORT_ARIA": "Sortuj kategorie",
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "Data de adição (mais antigo primeiro)",
|
||||
"SORT_NAME_ASC": "Nome A-Z",
|
||||
"SORT_NAME_DESC": "Nome Z-A",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "Ordenar: ",
|
||||
"SORT_SERVER": "Ordem do servidor",
|
||||
"CATEGORY_SORT_ARIA": "Ordenar categorias",
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "Дата добавления (Сначала старые)",
|
||||
"SORT_NAME_ASC": "Имя (А-Я)",
|
||||
"SORT_NAME_DESC": "Имя (Я-А)",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "Сортировка: ",
|
||||
"SORT_SERVER": "Порядок сервера",
|
||||
"CATEGORY_SORT_ARIA": "Сортировать категории",
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "Eklenme tarihi (En eski önce)",
|
||||
"SORT_NAME_ASC": "İsim A-Z",
|
||||
"SORT_NAME_DESC": "İsim Z-A",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "Sırala: ",
|
||||
"SORT_SERVER": "Sunucu sıralaması",
|
||||
"CATEGORY_SORT_ARIA": "Kategorileri sırala",
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "添加日期(最旧优先)",
|
||||
"SORT_NAME_ASC": "名称 A-Z",
|
||||
"SORT_NAME_DESC": "名称 Z-A",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "排序:",
|
||||
"SORT_SERVER": "服务器排序",
|
||||
"CATEGORY_SORT_ARIA": "排序分类",
|
||||
|
||||
@@ -875,6 +875,15 @@
|
||||
"SORT_DATE_ASC": "新增日期(最舊優先)",
|
||||
"SORT_NAME_ASC": "名稱 A-Z",
|
||||
"SORT_NAME_DESC": "名稱 Z-A",
|
||||
"SORT_TOP_RATED": "Top rated",
|
||||
"SORT_LOWEST_RATED": "Lowest rated first",
|
||||
"REFINE_ACTION": "Refine",
|
||||
"REFINE_SORT_SECTION": "Sort by",
|
||||
"REFINE_RATING_SECTION": "Minimum rating",
|
||||
"REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter",
|
||||
"FILTER_RATING": "Filter by rating",
|
||||
"FILTER_RATING_ANY": "Any rating",
|
||||
"FILTER_RATING_MIN": "{{value}}.0 and higher",
|
||||
"SORT_LABEL": "排序:",
|
||||
"SORT_SERVER": "伺服器排序",
|
||||
"CATEGORY_SORT_ARIA": "排序分類",
|
||||
|
||||
+169
-49
@@ -12,62 +12,182 @@
|
||||
{{ categoryItemSubtitle() }}
|
||||
</div>
|
||||
</div>
|
||||
@if (canSortContent()) {
|
||||
@if (hasRefineControls()) {
|
||||
<div class="active-refinements" aria-live="polite">
|
||||
@if (canSortContent()) {
|
||||
<div
|
||||
class="refinement-chip sort-refinement-chip"
|
||||
[attr.aria-label]="
|
||||
('WORKSPACE.SORT_LABEL' | translate) +
|
||||
(activeSortLabelKey() | translate)
|
||||
"
|
||||
>
|
||||
<mat-icon>sort</mat-icon>
|
||||
<span
|
||||
class="refinement-chip-label refinement-chip-label-full"
|
||||
>
|
||||
{{ 'WORKSPACE.SORT_LABEL' | translate
|
||||
}}{{ activeSortLabelKey() | translate }}
|
||||
</span>
|
||||
<span
|
||||
class="refinement-chip-label refinement-chip-label-compact"
|
||||
>
|
||||
{{ activeSortLabelKey() | translate }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (minRating(); as rating) {
|
||||
<button
|
||||
type="button"
|
||||
mat-button
|
||||
class="refinement-chip rating-refinement-chip"
|
||||
(click)="setMinRating(null)"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.REFINE_CLEAR_RATING'
|
||||
| translate: { value: rating }
|
||||
"
|
||||
>
|
||||
<mat-icon>star</mat-icon>
|
||||
<span
|
||||
class="refinement-chip-label refinement-chip-label-full"
|
||||
>
|
||||
{{ 'WORKSPACE.FILTER_RATING' | translate }}:
|
||||
{{ rating }}.0+
|
||||
</span>
|
||||
<span
|
||||
class="refinement-chip-label refinement-chip-label-compact"
|
||||
>
|
||||
{{ rating }}.0+
|
||||
</span>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="sort-action"
|
||||
[matMenuTriggerFor]="sortContentMenu"
|
||||
matTooltip="Sort content"
|
||||
aria-label="Sort content"
|
||||
mat-button
|
||||
class="refine-action"
|
||||
[class.refine-action-active]="activeRefinementCount() > 0"
|
||||
[matMenuTriggerFor]="refineMenu"
|
||||
[matTooltip]="'WORKSPACE.REFINE_ACTION' | translate"
|
||||
[attr.aria-label]="'WORKSPACE.REFINE_ACTION' | translate"
|
||||
>
|
||||
<mat-icon>sort</mat-icon>
|
||||
<mat-icon>tune</mat-icon>
|
||||
<span>{{ 'WORKSPACE.REFINE_ACTION' | translate }}</span>
|
||||
@if (activeRefinementCount() > 0) {
|
||||
<span class="refine-count">{{
|
||||
activeRefinementCount()
|
||||
}}</span>
|
||||
}
|
||||
</button>
|
||||
<mat-menu #sortContentMenu="matMenu">
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('date-desc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_DATE_DESC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'date-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
<mat-menu #refineMenu="matMenu" class="refine-menu">
|
||||
@if (canSortContent()) {
|
||||
<div class="refine-menu-section-title">
|
||||
{{ 'WORKSPACE.REFINE_SORT_SECTION' | translate }}
|
||||
</div>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('date-desc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_DATE_DESC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'date-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('date-asc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_DATE_ASC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'date-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('name-asc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_NAME_ASC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'name-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('name-desc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_NAME_DESC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'name-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
@if (supportsRatingSort()) {
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('rating-desc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_TOP_RATED' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'rating-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('rating-asc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_LOWEST_RATED'
|
||||
| translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'rating-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('date-asc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_DATE_ASC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'date-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
@if (canFilterByRating()) {
|
||||
@if (canSortContent()) {
|
||||
<div class="refine-menu-divider"></div>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('name-asc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_NAME_ASC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'name-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
<div class="refine-menu-section-title">
|
||||
{{
|
||||
'WORKSPACE.REFINE_RATING_SECTION'
|
||||
| translate
|
||||
}}
|
||||
</div>
|
||||
<button mat-menu-item (click)="setMinRating(null)">
|
||||
<span>{{
|
||||
'WORKSPACE.FILTER_RATING_ANY' | translate
|
||||
}}</span>
|
||||
@if (minRating() === null) {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
@for (threshold of ratingThresholds; track threshold) {
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setMinRating(threshold)"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.FILTER_RATING_MIN'
|
||||
| translate: { value: threshold }
|
||||
}}</span>
|
||||
@if (minRating() === threshold) {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('name-desc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_NAME_DESC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'name-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
}
|
||||
@if (categoryItemCount() > 0) {
|
||||
|
||||
+198
-8
@@ -14,16 +14,27 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
container: category-content / inline-size;
|
||||
}
|
||||
|
||||
// ─── Sticky header bar — styled like a widget header ─────────────────────────
|
||||
.category-content-header {
|
||||
@include panel.standard-panel-header($sticky: true);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, auto) auto auto;
|
||||
grid-template-areas: 'meta refinements refine paginator';
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
// ─── Category meta (title + subtitle) ────────────────────────────────────────
|
||||
.category-meta {
|
||||
@include panel.standard-panel-meta();
|
||||
grid-area: meta;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.category-title {
|
||||
@@ -32,18 +43,200 @@
|
||||
|
||||
.category-subtitle {
|
||||
@include panel.standard-panel-subtitle();
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
line-height: 1.2;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// ─── Refine controls — show current sort/filter state compactly ──────────────
|
||||
.active-refinements {
|
||||
grid-area: refinements;
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: min(430px, 36cqi);
|
||||
flex-wrap: wrap;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.refinement-chip,
|
||||
.refine-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
min-height: 32px;
|
||||
border-radius: 999px;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
|
||||
--mdc-text-button-label-text-color: currentColor;
|
||||
--mat-text-button-state-layer-color: currentColor;
|
||||
|
||||
mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.refinement-chip {
|
||||
max-width: 220px;
|
||||
padding: 0 10px;
|
||||
background: var(--mat-sys-surface-container);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.refinement-chip-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.refinement-chip-label-compact {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@container category-content (max-width: 1120px) {
|
||||
.refinement-chip-label-full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.refinement-chip-label-compact {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.sort-refinement-chip {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.rating-refinement-chip,
|
||||
.refine-action.refine-action-active {
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
.refine-action {
|
||||
grid-area: refine;
|
||||
justify-self: end;
|
||||
padding: 0 12px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.refine-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--mat-sys-on-primary);
|
||||
background: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
.refine-menu-section-title {
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.refine-menu-divider {
|
||||
height: 1px;
|
||||
margin: 6px 12px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
// ─── Paginator — flush right, no ghost background ────────────────────────────
|
||||
mat-paginator {
|
||||
grid-area: paginator;
|
||||
justify-self: end;
|
||||
max-width: 100%;
|
||||
background: transparent !important;
|
||||
flex-shrink: 0;
|
||||
// Shrink paginator text to fit header height comfortably
|
||||
--mat-paginator-container-size: 40px;
|
||||
--mat-paginator-enabled-icon-color: var(--app-body-color, var(--mat-sys-on-surface-variant));
|
||||
--mat-paginator-enabled-icon-color: var(
|
||||
--app-body-color,
|
||||
var(--mat-sys-on-surface-variant)
|
||||
);
|
||||
--mat-paginator-disabled-icon-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
@container category-content (max-width: 920px) {
|
||||
.category-content-header {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
grid-template-areas:
|
||||
'meta paginator paginator'
|
||||
'refinements refinements refine';
|
||||
padding: 8px 16px;
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.active-refinements {
|
||||
justify-self: stretch;
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-start;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.refinement-chip {
|
||||
max-width: min(220px, 100%);
|
||||
}
|
||||
|
||||
.refine-action {
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
|
||||
@container category-content (max-width: 640px) {
|
||||
.category-content-header {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
'meta meta'
|
||||
'refinements refine'
|
||||
'paginator paginator';
|
||||
}
|
||||
|
||||
.category-meta {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.active-refinements {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.refinement-chip {
|
||||
max-width: min(220px, calc(100cqi - 160px));
|
||||
}
|
||||
|
||||
.refine-action {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
mat-paginator {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Grid scroll area ─────────────────────────────────────────────────────────
|
||||
app-grid-list {
|
||||
flex: 1;
|
||||
@@ -56,19 +249,16 @@ app-grid-list {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.category-content-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 10px 16px;
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.category-meta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.active-refinements {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
app-grid-list {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
+111
-2
@@ -2,7 +2,7 @@ import { Component, input, output, signal } from '@angular/core';
|
||||
import { NgComponentOutlet } from '@angular/common';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
@@ -61,6 +61,7 @@ describe('CategoryContentViewComponent', () => {
|
||||
const isPaginatedContentLoading = signal(true);
|
||||
const categoryItemCount = signal(0);
|
||||
const contentSortMode = signal<PortalCatalogSortMode | null>(null);
|
||||
const minRating = signal<number | null>(null);
|
||||
const catalog = {
|
||||
provider: 'xtream' as const,
|
||||
pageSizeOptions: [10, 25, 50],
|
||||
@@ -74,6 +75,8 @@ describe('CategoryContentViewComponent', () => {
|
||||
selectedItem: signal(null),
|
||||
totalPages: signal(0),
|
||||
contentSortMode,
|
||||
supportsRatingSort: true,
|
||||
minRating,
|
||||
playlist: signal(null),
|
||||
isPaginatedContentLoading,
|
||||
initialize: jest.fn(),
|
||||
@@ -82,6 +85,7 @@ describe('CategoryContentViewComponent', () => {
|
||||
setPage: jest.fn(),
|
||||
setLimit: jest.fn(),
|
||||
setContentSortMode: jest.fn(),
|
||||
setMinRating: jest.fn(),
|
||||
selectItem: jest.fn().mockReturnValue(null),
|
||||
getItemProgress: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
@@ -90,10 +94,14 @@ describe('CategoryContentViewComponent', () => {
|
||||
isPaginatedContentLoading.set(true);
|
||||
categoryItemCount.set(0);
|
||||
contentSortMode.set(null);
|
||||
catalog.supportsRatingSort = true;
|
||||
minRating.set(null);
|
||||
catalog.initialize.mockClear();
|
||||
catalog.setSearchQuery.mockClear();
|
||||
catalog.setPage.mockClear();
|
||||
catalog.setLimit.mockClear();
|
||||
catalog.setContentSortMode.mockClear();
|
||||
catalog.setMinRating.mockClear();
|
||||
catalog.selectItem.mockClear();
|
||||
catalog.selectItem.mockReturnValue(null);
|
||||
router = {
|
||||
@@ -162,7 +170,7 @@ describe('CategoryContentViewComponent', () => {
|
||||
MockGridListComponent,
|
||||
MockPlaylistErrorViewComponent,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatButtonModule,
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatTooltip,
|
||||
@@ -201,6 +209,107 @@ describe('CategoryContentViewComponent', () => {
|
||||
expect(catalog.setSearchQuery).toHaveBeenCalledWith('matrix');
|
||||
});
|
||||
|
||||
it('groups catalog sort and rating filters behind one refine menu trigger', () => {
|
||||
contentSortMode.set('date-desc');
|
||||
categoryItemCount.set(12);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const refineButton = fixture.nativeElement.querySelector(
|
||||
'.refine-action'
|
||||
) as HTMLButtonElement | null;
|
||||
const sortChip = fixture.nativeElement.querySelector(
|
||||
'.sort-refinement-chip'
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(refineButton).not.toBeNull();
|
||||
expect(sortChip).not.toBeNull();
|
||||
expect(sortChip?.tagName).not.toBe('BUTTON');
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.sort-action')
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.rating-filter-action')
|
||||
).toBeNull();
|
||||
|
||||
refineButton?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const overlayText = document.body.textContent ?? '';
|
||||
expect(overlayText).toContain('WORKSPACE.REFINE_SORT_SECTION');
|
||||
expect(overlayText).toContain('WORKSPACE.SORT_DATE_DESC');
|
||||
expect(overlayText).toContain('WORKSPACE.REFINE_RATING_SECTION');
|
||||
expect(overlayText).toContain('WORKSPACE.FILTER_RATING_ANY');
|
||||
});
|
||||
|
||||
it('shows active sort and rating chips and lets the rating chip clear the threshold', () => {
|
||||
contentSortMode.set('rating-desc');
|
||||
minRating.set(8);
|
||||
categoryItemCount.set(12);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const sortChip = fixture.nativeElement.querySelector(
|
||||
'.sort-refinement-chip'
|
||||
) as HTMLElement | null;
|
||||
const ratingChip = fixture.nativeElement.querySelector(
|
||||
'.rating-refinement-chip'
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(sortChip?.textContent).toContain('WORKSPACE.SORT_TOP_RATED');
|
||||
expect(ratingChip?.textContent).toContain('8');
|
||||
|
||||
ratingChip?.click();
|
||||
|
||||
expect(catalog.setMinRating).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('hides rating refinements when the catalog facade does not support rating sorting', () => {
|
||||
catalog.supportsRatingSort = false;
|
||||
contentSortMode.set('date-desc');
|
||||
minRating.set(9);
|
||||
categoryItemCount.set(12);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.supportsRatingSort()).toBe(false);
|
||||
expect(fixture.componentInstance.canFilterByRating()).toBe(false);
|
||||
expect(fixture.componentInstance.minRating()).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.rating-refinement-chip')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders full and compact refinement chip labels for responsive layouts', () => {
|
||||
contentSortMode.set('name-asc');
|
||||
minRating.set(9);
|
||||
categoryItemCount.set(12);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const sortChip = fixture.nativeElement.querySelector(
|
||||
'.sort-refinement-chip'
|
||||
) as HTMLElement | null;
|
||||
const ratingChip = fixture.nativeElement.querySelector(
|
||||
'.rating-refinement-chip'
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(
|
||||
sortChip?.querySelector('.refinement-chip-label-full')?.textContent
|
||||
).toContain('WORKSPACE.SORT_LABEL');
|
||||
expect(
|
||||
sortChip?.querySelector('.refinement-chip-label-compact')
|
||||
?.textContent
|
||||
).toContain('WORKSPACE.SORT_NAME_ASC');
|
||||
expect(
|
||||
ratingChip?.querySelector('.refinement-chip-label-full')?.textContent
|
||||
).toContain('WORKSPACE.FILTER_RATING');
|
||||
expect(
|
||||
ratingChip?.querySelector('.refinement-chip-label-compact')
|
||||
?.textContent
|
||||
).toContain('9.0+');
|
||||
});
|
||||
|
||||
it('restores the zero-based catalog page from the one-based page query param', () => {
|
||||
fixture.detectChanges();
|
||||
catalog.setPage.mockClear();
|
||||
|
||||
+42
-2
@@ -10,7 +10,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
@@ -48,8 +48,8 @@ interface CategoryContentItem {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
GridListComponent,
|
||||
MatButtonModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatTooltip,
|
||||
@@ -103,6 +103,42 @@ export class CategoryContentViewComponent implements OnInit {
|
||||
return `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`;
|
||||
});
|
||||
readonly canSortContent = computed(() => this.contentSortMode() !== null);
|
||||
readonly supportsRatingSort = computed(
|
||||
() => this.catalog.supportsRatingSort === true
|
||||
);
|
||||
readonly canFilterByRating = computed(
|
||||
() =>
|
||||
this.supportsRatingSort() &&
|
||||
typeof this.catalog.setMinRating === 'function'
|
||||
);
|
||||
readonly minRating = computed(() =>
|
||||
this.canFilterByRating() ? (this.catalog.minRating?.() ?? null) : null
|
||||
);
|
||||
readonly ratingThresholds = [9, 8, 7, 6, 5] as const;
|
||||
readonly hasRefineControls = computed(
|
||||
() => this.canSortContent() || this.canFilterByRating()
|
||||
);
|
||||
readonly activeRefinementCount = computed(() =>
|
||||
this.minRating() !== null ? 1 : 0
|
||||
);
|
||||
readonly activeSortLabelKey = computed(() => {
|
||||
switch (this.contentSortMode()) {
|
||||
case 'date-desc':
|
||||
return 'WORKSPACE.SORT_DATE_DESC';
|
||||
case 'date-asc':
|
||||
return 'WORKSPACE.SORT_DATE_ASC';
|
||||
case 'name-asc':
|
||||
return 'WORKSPACE.SORT_NAME_ASC';
|
||||
case 'name-desc':
|
||||
return 'WORKSPACE.SORT_NAME_DESC';
|
||||
case 'rating-desc':
|
||||
return 'WORKSPACE.SORT_TOP_RATED';
|
||||
case 'rating-asc':
|
||||
return 'WORKSPACE.SORT_LOWEST_RATED';
|
||||
default:
|
||||
return 'WORKSPACE.SORT_CUSTOM';
|
||||
}
|
||||
});
|
||||
readonly searchTerm = toSignal(
|
||||
this.activatedRoute.queryParamMap.pipe(map((p) => p.get('q') ?? '')),
|
||||
{ initialValue: '' }
|
||||
@@ -121,6 +157,10 @@ export class CategoryContentViewComponent implements OnInit {
|
||||
this.catalog.setContentSortMode(mode);
|
||||
}
|
||||
|
||||
setMinRating(value: number | null): void {
|
||||
this.catalog.setMinRating?.(value);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.activatedRoute.paramMap
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
|
||||
@@ -7,7 +7,9 @@ export type PortalCatalogSortMode =
|
||||
| 'date-desc'
|
||||
| 'date-asc'
|
||||
| 'name-asc'
|
||||
| 'name-desc';
|
||||
| 'name-desc'
|
||||
| 'rating-desc'
|
||||
| 'rating-asc';
|
||||
|
||||
export interface PortalCatalogPlaylistMeta {
|
||||
id: string;
|
||||
@@ -44,6 +46,13 @@ export interface PortalCatalogFacade<
|
||||
readonly categoryItemCount: Signal<number>;
|
||||
readonly contentSortMode: Signal<PortalCatalogSortMode | null>;
|
||||
readonly playlist: Signal<PortalCatalogPlaylistMeta | null>;
|
||||
/**
|
||||
* Optional IMDb-rating capability (Xtream VOD/series). Providers without
|
||||
* structured ratings (e.g. Stalker) leave these undefined, and the rating
|
||||
* sort options + minimum-rating filter hide themselves in the UI.
|
||||
*/
|
||||
readonly supportsRatingSort?: boolean;
|
||||
readonly minRating?: Signal<number | null>;
|
||||
|
||||
initialize(categoryId?: string | null): void;
|
||||
setSearchQuery?(query: string): void;
|
||||
@@ -51,6 +60,7 @@ export interface PortalCatalogFacade<
|
||||
setPage(page: number): void;
|
||||
setLimit(limit: number): void;
|
||||
setContentSortMode(mode: PortalCatalogSortMode): void;
|
||||
setMinRating?(value: number | null): void;
|
||||
selectItem(item: TItem): string[] | null;
|
||||
getItemProgress(item: TItem): PortalCatalogItemProgress;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { filterByMinRating, getNumericRating } from './with-selection.feature';
|
||||
|
||||
type RatingItem = Parameters<typeof getNumericRating>[0];
|
||||
|
||||
const item = (fields: Record<string, unknown>): RatingItem =>
|
||||
fields as RatingItem;
|
||||
|
||||
describe('with-selection rating helpers', () => {
|
||||
describe('getNumericRating', () => {
|
||||
it('prefers rating_imdb over the generic rating', () => {
|
||||
expect(
|
||||
getNumericRating(item({ rating_imdb: '7.4', rating: '6.5' }))
|
||||
).toBe(7.4);
|
||||
});
|
||||
|
||||
it('falls back to the generic rating and parses numbers + strings', () => {
|
||||
expect(getNumericRating(item({ rating: '6.5' }))).toBe(6.5);
|
||||
expect(getNumericRating(item({ rating: 8 }))).toBe(8);
|
||||
});
|
||||
|
||||
it('reads the nested info object when the list shape lacks a rating', () => {
|
||||
expect(getNumericRating(item({ info: { rating: '5.5' } }))).toBe(
|
||||
5.5
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for missing or unparseable ratings', () => {
|
||||
expect(getNumericRating(item({ rating: 'N/A' }))).toBeNull();
|
||||
expect(getNumericRating(item({ rating: '' }))).toBeNull();
|
||||
expect(getNumericRating(item({}))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterByMinRating', () => {
|
||||
const items = [
|
||||
item({ name: 'A', rating: '8.5' }),
|
||||
item({ name: 'B', rating: '6.0' }),
|
||||
item({ name: 'C' }),
|
||||
];
|
||||
|
||||
it('keeps only items at or above the threshold and drops unrated ones', () => {
|
||||
expect(
|
||||
filterByMinRating(items, 7).map((i) => (i as { name: string }).name)
|
||||
).toEqual(['A']);
|
||||
});
|
||||
|
||||
it('is inclusive of the exact threshold value', () => {
|
||||
expect(
|
||||
filterByMinRating(items, 6).map(
|
||||
(i) => (i as { name: string }).name
|
||||
)
|
||||
).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('returns the original array when the threshold is null or non-positive', () => {
|
||||
expect(filterByMinRating(items, null)).toBe(items);
|
||||
expect(filterByMinRating(items, 0)).toBe(items);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -247,4 +247,25 @@ describe('withSelection', () => {
|
||||
store.selectItemsFromSelectedCategory().map((item) => item.title)
|
||||
).toEqual(['World News', 'World Sports']);
|
||||
});
|
||||
|
||||
it('clears the rating filter when the category changes', () => {
|
||||
store.setSelectedContentType('vod');
|
||||
store.setSelectedCategory(10);
|
||||
store.setMinRating(7);
|
||||
expect(store.minRating()).toBe(7);
|
||||
|
||||
store.setSelectedCategory(20);
|
||||
|
||||
expect(store.minRating()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the rating filter when the same category is re-selected', () => {
|
||||
store.setSelectedContentType('vod');
|
||||
store.setSelectedCategory(10);
|
||||
store.setMinRating(7);
|
||||
|
||||
store.setSelectedCategory(10);
|
||||
|
||||
expect(store.minRating()).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,9 @@ export type XtreamCategorySortMode =
|
||||
| 'date-desc'
|
||||
| 'date-asc'
|
||||
| 'name-asc'
|
||||
| 'name-desc';
|
||||
| 'name-desc'
|
||||
| 'rating-desc'
|
||||
| 'rating-asc';
|
||||
|
||||
/**
|
||||
* Selection state for managing UI selection and pagination
|
||||
@@ -34,6 +36,7 @@ export interface SelectionState {
|
||||
limit: number;
|
||||
contentSortMode: XtreamCategorySortMode;
|
||||
categorySearchTerm: string;
|
||||
minRating: number | null;
|
||||
isLoadingDetails: boolean;
|
||||
detailsError: string | null;
|
||||
}
|
||||
@@ -49,6 +52,7 @@ const initialSelectionState: SelectionState = {
|
||||
limit: Number(localStorage.getItem('xtream-page-size') ?? 25),
|
||||
contentSortMode: 'date-desc',
|
||||
categorySearchTerm: '',
|
||||
minRating: null,
|
||||
isLoadingDetails: false,
|
||||
detailsError: null,
|
||||
};
|
||||
@@ -114,6 +118,61 @@ type ParentSelectionStoreLike = {
|
||||
vodStreams?: () => XtreamSelectionItem[];
|
||||
};
|
||||
|
||||
const parseRatingValue = (value: unknown): number | null => {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseFloat(value.trim());
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Numeric IMDb-style rating used for sorting/filtering. Mirrors the grid badge
|
||||
* (prefers `rating_imdb`, falls back to the generic `rating`), checking the
|
||||
* stream-list shape first and the nested `info` object as a fallback. Returns
|
||||
* null when no parseable rating exists.
|
||||
*/
|
||||
export const getNumericRating = (
|
||||
item: XtreamSelectionItem
|
||||
): number | null => {
|
||||
const info =
|
||||
item.info && !Array.isArray(item.info)
|
||||
? (item.info as Record<string, unknown>)
|
||||
: null;
|
||||
const record = item as Record<string, unknown>;
|
||||
for (const field of ['rating_imdb', 'rating'] as const) {
|
||||
const direct = parseRatingValue(record[field]);
|
||||
if (direct !== null) {
|
||||
return direct;
|
||||
}
|
||||
const nested = info ? parseRatingValue(info[field]) : null;
|
||||
if (nested !== null) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter VOD/series items by a minimum IMDb rating. Unrated items are excluded
|
||||
* while a threshold is active.
|
||||
*/
|
||||
export const filterByMinRating = (
|
||||
items: XtreamSelectionItem[],
|
||||
minRating: number | null
|
||||
): XtreamSelectionItem[] => {
|
||||
if (minRating === null || minRating <= 0) {
|
||||
return items;
|
||||
}
|
||||
return items.filter((item) => {
|
||||
const rating = getNumericRating(item);
|
||||
return rating !== null && rating >= minRating;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Selection feature store for managing UI selection and pagination.
|
||||
* Handles:
|
||||
@@ -157,6 +216,29 @@ export function withSelection() {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
sortMode === 'rating-desc' ||
|
||||
sortMode === 'rating-asc'
|
||||
) {
|
||||
const ratingA = getNumericRating(a);
|
||||
const ratingB = getNumericRating(b);
|
||||
// Unrated items always sink to the bottom, regardless
|
||||
// of sort direction.
|
||||
if (ratingA === null || ratingB === null) {
|
||||
if (ratingA !== ratingB) {
|
||||
return ratingA === null ? 1 : -1;
|
||||
}
|
||||
} else if (ratingA !== ratingB) {
|
||||
return sortMode === 'rating-desc'
|
||||
? ratingB - ratingA
|
||||
: ratingA - ratingB;
|
||||
}
|
||||
// Equal ratings (or both unrated): alphabetical tiebreak.
|
||||
const ratingTitleA = a.title ?? a.name ?? '';
|
||||
const ratingTitleB = b.title ?? b.name ?? '';
|
||||
return COLLATOR.compare(ratingTitleA, ratingTitleB);
|
||||
}
|
||||
|
||||
const titleA = a.title ?? a.name ?? '';
|
||||
const titleB = b.title ?? b.name ?? '';
|
||||
const byName = COLLATOR.compare(titleA, titleB);
|
||||
@@ -269,6 +351,7 @@ export function withSelection() {
|
||||
: storeAny.serialStreams?.() || [];
|
||||
|
||||
if (categoryType === 'vod' || categoryType === 'series') {
|
||||
const minRating = store.minRating();
|
||||
let filtered = categoryId
|
||||
? content.filter(
|
||||
(item) => Number(item.category_id) === categoryId
|
||||
@@ -276,7 +359,8 @@ export function withSelection() {
|
||||
: sortedContent();
|
||||
|
||||
filtered = filterBySearchTerm(filtered, searchTerm);
|
||||
return categoryId || searchTerm
|
||||
filtered = filterByMinRating(filtered, minRating);
|
||||
return categoryId || searchTerm || minRating
|
||||
? sortByMode(filtered, sortMode, categoryType)
|
||||
: filtered;
|
||||
}
|
||||
@@ -436,6 +520,7 @@ export function withSelection() {
|
||||
selectedCategoryId: null,
|
||||
page: 0,
|
||||
categorySearchTerm: '',
|
||||
minRating: null,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -454,6 +539,10 @@ export function withSelection() {
|
||||
selectedCategoryId: newCategoryId,
|
||||
page: 0,
|
||||
categorySearchTerm: '',
|
||||
// Clear the rating filter on category change too, mirroring
|
||||
// categorySearchTerm and setSelectedContentType — otherwise a
|
||||
// stale threshold stays silently applied in the new category.
|
||||
minRating: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -507,6 +596,21 @@ export function withSelection() {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the minimum IMDb rating filter for VOD/series content.
|
||||
* A null or non-positive value clears the filter.
|
||||
*/
|
||||
setMinRating(value: number | null): void {
|
||||
const normalized = value && value > 0 ? value : null;
|
||||
if (store.minRating() === normalized) {
|
||||
return;
|
||||
}
|
||||
patchState(store, {
|
||||
minRating: normalized,
|
||||
page: 0,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set selected category search term
|
||||
*/
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('XtreamCatalogFacadeService', () => {
|
||||
const totalPages = signal(1);
|
||||
const isPaginatedContentLoading = signal(false);
|
||||
const contentSortMode = signal<PortalCatalogSortMode>('date-desc');
|
||||
const minRating = signal<number | null>(null);
|
||||
const currentPlaylist = signal<XtreamPlaylistData | null>(PLAYLIST_ONE);
|
||||
|
||||
const xtreamStore = {
|
||||
@@ -61,6 +62,7 @@ describe('XtreamCatalogFacadeService', () => {
|
||||
getTotalPages: totalPages,
|
||||
isPaginatedContentLoading,
|
||||
contentSortMode,
|
||||
minRating,
|
||||
currentPlaylist,
|
||||
loadAllPositions: jest.fn(),
|
||||
setCategorySearchTerm: jest.fn(),
|
||||
@@ -79,6 +81,9 @@ describe('XtreamCatalogFacadeService', () => {
|
||||
setContentSortMode: jest.fn((mode: PortalCatalogSortMode) => {
|
||||
contentSortMode.set(mode);
|
||||
}),
|
||||
setMinRating: jest.fn((value: number | null) => {
|
||||
minRating.set(value);
|
||||
}),
|
||||
hasSeriesProgress: jest.fn().mockReturnValue(false),
|
||||
getProgressPercent: jest.fn().mockReturnValue(40),
|
||||
isWatched: jest.fn().mockReturnValue(false),
|
||||
@@ -100,6 +105,7 @@ describe('XtreamCatalogFacadeService', () => {
|
||||
totalPages.set(1);
|
||||
isPaginatedContentLoading.set(false);
|
||||
contentSortMode.set('date-desc');
|
||||
minRating.set(null);
|
||||
currentPlaylist.set(PLAYLIST_ONE);
|
||||
|
||||
xtreamStore.loadAllPositions.mockClear();
|
||||
@@ -109,6 +115,7 @@ describe('XtreamCatalogFacadeService', () => {
|
||||
xtreamStore.setPage.mockClear();
|
||||
xtreamStore.setLimit.mockClear();
|
||||
xtreamStore.setContentSortMode.mockClear();
|
||||
xtreamStore.setMinRating.mockClear();
|
||||
xtreamStore.hasSeriesProgress.mockClear();
|
||||
xtreamStore.getProgressPercent.mockClear();
|
||||
xtreamStore.isWatched.mockClear();
|
||||
@@ -190,4 +197,67 @@ describe('XtreamCatalogFacadeService', () => {
|
||||
'name-desc'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps rating sort modes out of live content', () => {
|
||||
contentSortMode.set('rating-desc');
|
||||
contentType.set('live');
|
||||
|
||||
expect(service.supportsRatingSort).toBe(false);
|
||||
expect(service.contentSortMode()).toBe('date-desc');
|
||||
|
||||
service.setContentSortMode('rating-asc');
|
||||
|
||||
expect(xtreamStore.setContentSortMode).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem('xtream-category-sort-mode')).toBeNull();
|
||||
|
||||
contentType.set('vod');
|
||||
|
||||
expect(service.supportsRatingSort).toBe(true);
|
||||
expect(service.contentSortMode()).toBe('rating-desc');
|
||||
|
||||
service.setContentSortMode('rating-asc');
|
||||
|
||||
expect(xtreamStore.setContentSortMode).toHaveBeenCalledWith(
|
||||
'rating-asc'
|
||||
);
|
||||
expect(localStorage.getItem('xtream-category-sort-mode')).toBe(
|
||||
'rating-asc'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not restore saved rating sort modes for live content', () => {
|
||||
contentType.set('live');
|
||||
localStorage.setItem('xtream-category-sort-mode', 'rating-desc');
|
||||
|
||||
service.initialize('42');
|
||||
|
||||
expect(xtreamStore.setContentSortMode).not.toHaveBeenCalled();
|
||||
expect(service.contentSortMode()).toBe('date-desc');
|
||||
});
|
||||
|
||||
it('exposes rating refinements only for VOD and series content', () => {
|
||||
minRating.set(8);
|
||||
|
||||
expect(service.supportsRatingSort).toBe(true);
|
||||
expect(service.minRating?.()).toBe(8);
|
||||
|
||||
service.setMinRating(7);
|
||||
|
||||
expect(xtreamStore.setMinRating).toHaveBeenCalledWith(7);
|
||||
|
||||
contentType.set('series');
|
||||
|
||||
expect(service.supportsRatingSort).toBe(true);
|
||||
expect(service.minRating?.()).toBe(7);
|
||||
|
||||
xtreamStore.setMinRating.mockClear();
|
||||
contentType.set('live');
|
||||
|
||||
expect(service.supportsRatingSort).toBe(false);
|
||||
expect(service.minRating?.()).toBeNull();
|
||||
|
||||
service.setMinRating(9);
|
||||
|
||||
expect(xtreamStore.setMinRating).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,19 @@ import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
|
||||
const SORT_STORAGE_KEY = 'xtream-category-sort-mode';
|
||||
|
||||
const isValidSortMode = (
|
||||
mode: string | null
|
||||
): mode is PortalCatalogSortMode =>
|
||||
mode === 'date-desc' ||
|
||||
mode === 'date-asc' ||
|
||||
mode === 'name-asc' ||
|
||||
mode === 'name-desc' ||
|
||||
mode === 'rating-desc' ||
|
||||
mode === 'rating-asc';
|
||||
|
||||
const isRatingSortMode = (mode: PortalCatalogSortMode): boolean =>
|
||||
mode === 'rating-desc' || mode === 'rating-asc';
|
||||
|
||||
@Injectable()
|
||||
export class XtreamCatalogFacadeService implements PortalCatalogFacade<
|
||||
Record<string, unknown>,
|
||||
@@ -37,7 +50,20 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade<
|
||||
readonly categoryItemCount = computed(
|
||||
() => this.xtreamStore.selectItemsFromSelectedCategory().length
|
||||
);
|
||||
readonly contentSortMode = this.xtreamStore.contentSortMode;
|
||||
readonly contentSortMode = computed<PortalCatalogSortMode>(() => {
|
||||
const mode = this.xtreamStore.contentSortMode();
|
||||
|
||||
return !this.supportsRatingSort && isRatingSortMode(mode)
|
||||
? 'date-desc'
|
||||
: mode;
|
||||
});
|
||||
get supportsRatingSort(): boolean {
|
||||
const type = this.contentType();
|
||||
return type === 'vod' || type === 'series';
|
||||
}
|
||||
readonly minRating = computed(() =>
|
||||
this.supportsRatingSort ? this.xtreamStore.minRating() : null
|
||||
);
|
||||
readonly playlist = computed<PortalCatalogPlaylistMeta | null>(() => {
|
||||
const playlist = this.xtreamStore.currentPlaylist();
|
||||
if (!playlist) {
|
||||
@@ -52,13 +78,8 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade<
|
||||
|
||||
initialize(categoryId?: string | null): void {
|
||||
const savedSortMode = localStorage.getItem(SORT_STORAGE_KEY);
|
||||
if (
|
||||
savedSortMode === 'date-desc' ||
|
||||
savedSortMode === 'date-asc' ||
|
||||
savedSortMode === 'name-asc' ||
|
||||
savedSortMode === 'name-desc'
|
||||
) {
|
||||
this.xtreamStore.setContentSortMode(savedSortMode);
|
||||
if (isValidSortMode(savedSortMode)) {
|
||||
this.setContentSortMode(savedSortMode);
|
||||
}
|
||||
|
||||
const playlistId = this.xtreamStore.currentPlaylist()?.id;
|
||||
@@ -93,10 +114,22 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade<
|
||||
}
|
||||
|
||||
setContentSortMode(mode: PortalCatalogSortMode): void {
|
||||
if (!this.supportsRatingSort && isRatingSortMode(mode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.xtreamStore.setContentSortMode(mode);
|
||||
localStorage.setItem(SORT_STORAGE_KEY, mode);
|
||||
}
|
||||
|
||||
setMinRating(value: number | null): void {
|
||||
if (!this.supportsRatingSort) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.xtreamStore.setMinRating(value);
|
||||
}
|
||||
|
||||
selectItem(item: Record<string, unknown>): string[] | null {
|
||||
const xtreamId = item['xtream_id'];
|
||||
if (xtreamId === undefined || xtreamId === null) {
|
||||
|
||||
Reference in new issue
Block a user