diff --git a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts index be2db1331..6f32acbd7 100644 --- a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts @@ -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 { - 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(); } diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index a635153a3..1e6938040 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "ترتيب الفئات", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index a617c041e..9f3119fea 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "ترتيب التصنيفات", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 7c39d2209..2fc65c058 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "Сартаваць катэгорыі", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index f1a83f3f8..f770eea44 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 775bf208d..53ccdaf5e 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Ταξινόμηση κατηγοριών", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 8fadf88f0..d9f25ee14 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index a36753a51..719d1f969 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index ab0150f91..508bf4da4 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 0099b270f..7ffc98f9f 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index e7b5bfcbb..3eccbc95f 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "カテゴリを並び替え", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index f00557a87..bfa21a99c 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "카테고리 정렬", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 62f211028..95bda8344 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index e2c93f1a4..7e71c4be1 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index e49fbfc48..634bb6b16 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 1d6495e75..17facbdc7 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Сортировать категории", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 126c30ca6..409764bd9 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 49c2e475d..2c1062b83 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "排序分类", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 4bfcbde42..ff794a039 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "排序分類", diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html index 9315abad2..96ad8756c 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html @@ -12,62 +12,182 @@ {{ categoryItemSubtitle() }} - @if (canSortContent()) { + @if (hasRefineControls()) { +
+ @if (canSortContent()) { +
+ sort + + {{ 'WORKSPACE.SORT_LABEL' | translate + }}{{ activeSortLabelKey() | translate }} + + + {{ activeSortLabelKey() | translate }} + +
+ } + @if (minRating(); as rating) { + + } +
- - + + + + @if (supportsRatingSort()) { + + } - - - + @for (threshold of ratingThresholds; track threshold) { + } - - + } } @if (categoryItemCount() > 0) { diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss index 290c92f68..36340c715 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss @@ -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; } diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts index c38cbd2cb..a5acd656a 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts @@ -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(null); + const minRating = signal(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(); diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts index 389b92bf7..173d0db49 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts @@ -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)) diff --git a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts index ccd9e3e0d..d379cb597 100644 --- a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts +++ b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts @@ -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; readonly contentSortMode: Signal; readonly playlist: Signal; + /** + * 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; 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; } diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection-rating.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection-rating.spec.ts new file mode 100644 index 000000000..2c05487a8 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection-rating.spec.ts @@ -0,0 +1,60 @@ +import { filterByMinRating, getNumericRating } from './with-selection.feature'; + +type RatingItem = Parameters[0]; + +const item = (fields: Record): 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); + }); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts index ff5268eba..fc15c84f7 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts @@ -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); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts index 7157306ee..7f4804b94 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts @@ -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) + : null; + const record = item as Record; + 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 */ diff --git a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts index 5492aaba8..e7045538c 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.spec.ts @@ -47,6 +47,7 @@ describe('XtreamCatalogFacadeService', () => { const totalPages = signal(1); const isPaginatedContentLoading = signal(false); const contentSortMode = signal('date-desc'); + const minRating = signal(null); const currentPlaylist = signal(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(); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts index 79f7c40ba..551248497 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts @@ -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, @@ -37,7 +50,20 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade< readonly categoryItemCount = computed( () => this.xtreamStore.selectItemsFromSelectedCategory().length ); - readonly contentSortMode = this.xtreamStore.contentSortMode; + readonly contentSortMode = computed(() => { + 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(() => { 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[] | null { const xtreamId = item['xtream_id']; if (xtreamId === undefined || xtreamId === null) {