feat(portal): add category sorting controls (#940)

* feat(portal): add category sort modes

* fix(portal): address category sort review feedback
This commit is contained in:
4gray authored and GitHub committed 2026-05-15 00:28:32 +02:00
1 parent 2a5b1d1da7
commit 8232a86a1e
34 files changed
+579 -51

No files matched your search

@@ -1,5 +1,6 @@
import type { AppDatabase } from '../database.types';
import { saveCategories } from './category.operations';
import * as schema from 'database-schema';
import { getCategories, saveCategories } from './category.operations';
function createDbMock(existingCount = 0) {
const where = jest.fn().mockResolvedValue([{ count: existingCount }]);
@@ -24,6 +25,20 @@ function createDbMock(existingCount = 0) {
}
describe('category.operations', () => {
it('reads visible categories in insertion order to preserve server sorting', async () => {
const orderBy = jest.fn().mockResolvedValue([]);
const where = jest.fn().mockReturnValue({ orderBy });
const from = jest.fn().mockReturnValue({ where });
const select = jest.fn().mockReturnValue({ from });
const db = {
select,
} as unknown as AppDatabase;
await getCategories(db, 'playlist-1', 'live');
expect(orderBy).toHaveBeenCalledWith(schema.categories.id);
});
it('restores hidden categories when Xtream API category IDs are strings', async () => {
const { db, values, insert } = createDbMock();
@@ -7,7 +7,9 @@ type XtreamCategoryInput = {
category_id: string | number;
};
function normalizeXtreamCategoryId(rawCategoryId: string | number): number | null {
function normalizeXtreamCategoryId(
rawCategoryId: string | number
): number | null {
const xtreamId = Number.parseInt(String(rawCategoryId), 10);
return Number.isNaN(xtreamId) ? null : xtreamId;
@@ -36,6 +38,10 @@ export async function getCategories(
playlistId: string,
type: 'live' | 'movies' | 'series'
) {
// Xtream categories are inserted once in provider order and existing
// xtream IDs are preserved, so row id order represents server order.
// If partial category re-inserts are added later, persist a provider
// sort index instead of relying on the insertion id.
return db
.select()
.from(schema.categories)
@@ -46,7 +52,7 @@ export async function getCategories(
eq(schema.categories.hidden, false)
)
)
.orderBy(sql`name COLLATE NOCASE`);
.orderBy(schema.categories.id);
}
export async function saveCategories(
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "الاسم (أ-ي)",
"SORT_NAME_DESC": "الاسم (ي-أ)",
"SORT_LABEL": "ترتيب: ",
"SORT_SERVER": "ترتيب الخادم",
"CATEGORY_SORT_ARIA": "ترتيب الفئات",
"DASHBOARD": {
"CONTINUE_WATCHING": "متابعة المشاهدة",
"UNTITLED_SOURCE": "مصدر بدون عنوان",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "الاسم أ-ي",
"SORT_NAME_DESC": "الاسم ي-أ",
"SORT_LABEL": "ترتيب: ",
"SORT_SERVER": "ترتيب السيرفر",
"CATEGORY_SORT_ARIA": "ترتيب التصنيفات",
"DASHBOARD": {
"CONTINUE_WATCHING": "كمل المشاهدة",
"UNTITLED_SOURCE": "مصدر بلا عنوان",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Назва А-Я",
"SORT_NAME_DESC": "Назва Я-А",
"SORT_LABEL": "Сартаваць: ",
"SORT_SERVER": "Парадак сервера",
"CATEGORY_SORT_ARIA": "Сартаваць катэгорыі",
"DASHBOARD": {
"CONTINUE_WATCHING": "Працягнуць прагляд",
"UNTITLED_SOURCE": "Крыніца без назвы",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Name A-Z",
"SORT_NAME_DESC": "Name Z-A",
"SORT_LABEL": "Sortierung: ",
"SORT_SERVER": "Serverreihenfolge",
"CATEGORY_SORT_ARIA": "Kategorien sortieren",
"DASHBOARD": {
"CONTINUE_WATCHING": "Weiter schauen",
"UNTITLED_SOURCE": "Unbenannte Quelle",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Όνομα A-Z",
"SORT_NAME_DESC": "Όνομα Z-A",
"SORT_LABEL": "Ταξινόμηση: ",
"SORT_SERVER": "Σειρά διακομιστή",
"CATEGORY_SORT_ARIA": "Ταξινόμηση κατηγοριών",
"DASHBOARD": {
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
"UNTITLED_SOURCE": "Πηγή χωρίς τίτλο",
+2
View File
@@ -798,6 +798,8 @@
"SORT_NAME_ASC": "Name A-Z",
"SORT_NAME_DESC": "Name Z-A",
"SORT_LABEL": "Sort: ",
"SORT_SERVER": "Server sorting",
"CATEGORY_SORT_ARIA": "Sort categories",
"DASHBOARD": {
"CONTINUE_WATCHING": "Continue Watching",
"UNTITLED_SOURCE": "Untitled source",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Nombre A-Z",
"SORT_NAME_DESC": "Nombre Z-A",
"SORT_LABEL": "Ordenar: ",
"SORT_SERVER": "Orden del servidor",
"CATEGORY_SORT_ARIA": "Ordenar categorías",
"DASHBOARD": {
"CONTINUE_WATCHING": "Continuar viendo",
"UNTITLED_SOURCE": "Fuente sin título",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Nom A-Z",
"SORT_NAME_DESC": "Nom Z-A",
"SORT_LABEL": "Trier : ",
"SORT_SERVER": "Ordre du serveur",
"CATEGORY_SORT_ARIA": "Trier les catégories",
"DASHBOARD": {
"CONTINUE_WATCHING": "Reprendre la lecture",
"UNTITLED_SOURCE": "Source sans titre",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Nome A-Z",
"SORT_NAME_DESC": "Nome Z-A",
"SORT_LABEL": "Ordina: ",
"SORT_SERVER": "Ordine del server",
"CATEGORY_SORT_ARIA": "Ordina categorie",
"DASHBOARD": {
"CONTINUE_WATCHING": "Continua a guardare",
"UNTITLED_SOURCE": "Sorgente senza titolo",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "名前 A-Z",
"SORT_NAME_DESC": "名前 Z-A",
"SORT_LABEL": "並び替え:",
"SORT_SERVER": "サーバー順",
"CATEGORY_SORT_ARIA": "カテゴリを並び替え",
"DASHBOARD": {
"CONTINUE_WATCHING": "視聴を再開",
"UNTITLED_SOURCE": "無題のソース",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "이름 A-Z",
"SORT_NAME_DESC": "이름 Z-A",
"SORT_LABEL": "정렬: ",
"SORT_SERVER": "서버 순서",
"CATEGORY_SORT_ARIA": "카테고리 정렬",
"DASHBOARD": {
"CONTINUE_WATCHING": "이어서 시청",
"UNTITLED_SOURCE": "제목 없는 소스",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Naam A-Z",
"SORT_NAME_DESC": "Naam Z-A",
"SORT_LABEL": "Sorteren: ",
"SORT_SERVER": "Servervolgorde",
"CATEGORY_SORT_ARIA": "Categorieën sorteren",
"DASHBOARD": {
"CONTINUE_WATCHING": "Verder kijken",
"UNTITLED_SOURCE": "Naamloze bron",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Nazwa A-Z",
"SORT_NAME_DESC": "Nazwa Z-A",
"SORT_LABEL": "Sortuj: ",
"SORT_SERVER": "Kolejność serwera",
"CATEGORY_SORT_ARIA": "Sortuj kategorie",
"DASHBOARD": {
"CONTINUE_WATCHING": "Kontynuuj oglądanie",
"UNTITLED_SOURCE": "Źródło bez tytułu",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "Nome A-Z",
"SORT_NAME_DESC": "Nome Z-A",
"SORT_LABEL": "Ordenar: ",
"SORT_SERVER": "Ordem do servidor",
"CATEGORY_SORT_ARIA": "Ordenar categorias",
"DASHBOARD": {
"CONTINUE_WATCHING": "Continuar assistindo",
"UNTITLED_SOURCE": "Fonte sem título",
+2
View File
@@ -796,6 +796,8 @@
"SORT_NAME_ASC": "Имя (А-Я)",
"SORT_NAME_DESC": "Имя (Я-А)",
"SORT_LABEL": "Сортировка: ",
"SORT_SERVER": "Порядок сервера",
"CATEGORY_SORT_ARIA": "Сортировать категории",
"DASHBOARD": {
"CONTINUE_WATCHING": "Продолжить просмотр",
"UNTITLED_SOURCE": "Источник без названия",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "İsim A-Z",
"SORT_NAME_DESC": "İsim Z-A",
"SORT_LABEL": "Sırala: ",
"SORT_SERVER": "Sunucu sıralaması",
"CATEGORY_SORT_ARIA": "Kategorileri sırala",
"DASHBOARD": {
"CONTINUE_WATCHING": "İzlemeye Devam Et",
"UNTITLED_SOURCE": "Adsız kaynak",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "名称 A-Z",
"SORT_NAME_DESC": "名称 Z-A",
"SORT_LABEL": "排序:",
"SORT_SERVER": "服务器排序",
"CATEGORY_SORT_ARIA": "排序分类",
"DASHBOARD": {
"CONTINUE_WATCHING": "继续观看",
"UNTITLED_SOURCE": "未命名源",
+2
View File
@@ -792,6 +792,8 @@
"SORT_NAME_ASC": "名稱 A-Z",
"SORT_NAME_DESC": "名稱 Z-A",
"SORT_LABEL": "排序:",
"SORT_SERVER": "伺服器排序",
"CATEGORY_SORT_ARIA": "排序分類",
"DASHBOARD": {
"CONTINUE_WATCHING": "繼續觀看",
"UNTITLED_SOURCE": "未命名的來源",