From 225e8de78ea5505a5fd6b5953f1836ae6dc08ac5 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 26 Sep 2026 19:10:48 +0200 Subject: [PATCH] feat(settings): one lock entry point per rail plus a right-click Lock/Unlock - Stalker's dedicated lock button becomes the same "Manage categories" (tune) button the Xtream rail has; it opens the lock-only dialog, so every portal type shares one entry point and the rail header keeps three actions. - Right-clicking a category (Xtream, Stalker) or an M3U group offers a single-row Lock / Unlock through the shared CategoryLockMenuComponent, behind the same PIN gate and lock store as the dialog. - The settings hint explains where locks are set; group lock strings added to all locales (ru/de translated). Co-Authored-By: Claude Fable 5.1 --- apps/web/src/assets/i18n/ar.json | 4 +- apps/web/src/assets/i18n/ary.json | 4 +- apps/web/src/assets/i18n/by.json | 4 +- apps/web/src/assets/i18n/de.json | 4 +- apps/web/src/assets/i18n/el.json | 4 +- apps/web/src/assets/i18n/en.json | 4 +- apps/web/src/assets/i18n/es.json | 4 +- apps/web/src/assets/i18n/fr.json | 4 +- apps/web/src/assets/i18n/hu.json | 4 +- apps/web/src/assets/i18n/it.json | 4 +- apps/web/src/assets/i18n/ja.json | 4 +- apps/web/src/assets/i18n/ko.json | 4 +- apps/web/src/assets/i18n/nl.json | 4 +- apps/web/src/assets/i18n/pl.json | 4 +- apps/web/src/assets/i18n/pt.json | 4 +- apps/web/src/assets/i18n/ru.json | 4 +- apps/web/src/assets/i18n/tr.json | 4 +- apps/web/src/assets/i18n/zh.json | 4 +- apps/web/src/assets/i18n/zhtw.json | 4 +- docs/architecture/category-management.md | 5 +- docs/architecture/parental-lock.md | 81 +++++------ libs/ui/components/src/index.ts | 1 + .../category-lock-menu.component.spec.ts | 49 +++++++ .../category-lock-menu.component.ts | 85 +++++++++++ .../groups-view/groups-view.component.html | 10 ++ .../groups-view/groups-view.component.spec.ts | 31 ++++ .../groups-view/groups-view.component.ts | 37 +++++ ...space-context-category-view.component.html | 1 + ...rkspace-context-category-view.component.ts | 15 +- ...space-category-lock-action.service.spec.ts | 122 ++++++++++++++++ .../workspace-category-lock-action.service.ts | 132 ++++++++++++++++++ .../workspace-context-panel.component.html | 25 +++- .../workspace-context-panel.component.spec.ts | 20 +-- .../workspace-context-panel.component.ts | 68 ++++++++- 34 files changed, 677 insertions(+), 81 deletions(-) create mode 100644 libs/ui/components/src/lib/category-lock-menu/category-lock-menu.component.spec.ts create mode 100644 libs/ui/components/src/lib/category-lock-menu/category-lock-menu.component.ts create mode 100644 libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.spec.ts create mode 100644 libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.ts diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 17814fd53..35d8f4088 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Change PIN", "CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.", "HOW_TO_LOCK": "Choosing what to lock", - "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.", + "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.", "DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.", "ENABLED_TOAST": "Parental lock enabled", "DISABLED_TOAST": "Parental lock disabled", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", "UNLOCK_CATEGORY": "Unlock category", + "LOCK_GROUP": "Lock group", + "UNLOCK_GROUP": "Unlock group", "MANAGE_LOCKS": "Lock categories", "MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.", "LOCK_CENSORED": "Lock adult (18+)", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 9caf09696..ef0e13638 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Change PIN", "CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.", "HOW_TO_LOCK": "Choosing what to lock", - "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.", + "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.", "DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.", "ENABLED_TOAST": "Parental lock enabled", "DISABLED_TOAST": "Parental lock disabled", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", "UNLOCK_CATEGORY": "Unlock category", + "LOCK_GROUP": "Lock group", + "UNLOCK_GROUP": "Unlock group", "MANAGE_LOCKS": "Lock categories", "MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.", "LOCK_CENSORED": "Lock adult (18+)", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 29e3e5009..0e743de81 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Change PIN", "CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.", "HOW_TO_LOCK": "Choosing what to lock", - "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.", + "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.", "DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.", "ENABLED_TOAST": "Parental lock enabled", "DISABLED_TOAST": "Parental lock disabled", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", "UNLOCK_CATEGORY": "Unlock category", + "LOCK_GROUP": "Lock group", + "UNLOCK_GROUP": "Unlock group", "MANAGE_LOCKS": "Lock categories", "MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.", "LOCK_CENSORED": "Lock adult (18+)", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 9062e06fd..33b864548 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "PIN ändern", "CHANGE_PIN_DESCRIPTION": "Fragt zuerst die aktuelle PIN ab.", "HOW_TO_LOCK": "Was gesperrt wird", - "HOW_TO_LOCK_DESCRIPTION": "Öffne eine Quelle und nutze die Schloss-Symbole im Verwaltungsdialog der Kategorienliste (Xtream, M3U-Gruppen) oder die Schloss-Schaltfläche über den Stalker-Kategorien.", + "HOW_TO_LOCK_DESCRIPTION": "Öffne eine Quelle und wähle „Kategorien verwalten“ (das Einstellungssymbol über der Kategorienliste; in einer M3U-Playlist „Gruppen verwalten“): jede Zeile hat einen Schloss-Schalter, und Stalker bietet „Erwachsene (18+) sperren“ für vom Portal markierte Genres. Rechtsklick auf eine Kategorie oder Gruppe für schnelles Sperren / Entsperren.", "DISCLAIMER": "Das ist eine Kindersicherung, kein Sicherheitsmerkmal: Die Datendateien der App sind für jeden mit Zugriff auf diesen Computer lesbar.", "ENABLED_TOAST": "Kindersicherung aktiviert", "DISABLED_TOAST": "Kindersicherung deaktiviert", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Gesperrt", "LOCK_CATEGORY": "Kategorie sperren", "UNLOCK_CATEGORY": "Kategorie entsperren", + "LOCK_GROUP": "Gruppe sperren", + "UNLOCK_GROUP": "Gruppe entsperren", "MANAGE_LOCKS": "Kategorien sperren", "MANAGE_LOCKS_DESCRIPTION": "Gesperrte Kategorien und ihre Sender verschwinden aus der gesamten App, bis die Kindersicherungs-PIN eingegeben wird.", "LOCK_CENSORED": "18+ sperren", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index fbc81610c..2825af325 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Change PIN", "CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.", "HOW_TO_LOCK": "Choosing what to lock", - "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.", + "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.", "DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.", "ENABLED_TOAST": "Parental lock enabled", "DISABLED_TOAST": "Parental lock disabled", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", "UNLOCK_CATEGORY": "Unlock category", + "LOCK_GROUP": "Lock group", + "UNLOCK_GROUP": "Unlock group", "MANAGE_LOCKS": "Lock categories", "MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.", "LOCK_CENSORED": "Lock adult (18+)", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 48c41a1c1..048380dc5 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Change PIN", "CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.", "HOW_TO_LOCK": "Choosing what to lock", - "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.", + "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.", "DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.", "ENABLED_TOAST": "Parental lock enabled", "DISABLED_TOAST": "Parental lock disabled", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", "UNLOCK_CATEGORY": "Unlock category", + "LOCK_GROUP": "Lock group", + "UNLOCK_GROUP": "Unlock group", "MANAGE_LOCKS": "Lock categories", "MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.", "LOCK_CENSORED": "Lock adult (18+)", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 841fe3066..bf61af682 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Change PIN", "CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.", "HOW_TO_LOCK": "Choosing what to lock", - "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.", + "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.", "DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.", "ENABLED_TOAST": "Parental lock enabled", "DISABLED_TOAST": "Parental lock disabled", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", "UNLOCK_CATEGORY": "Unlock category", + "LOCK_GROUP": "Lock group", + "UNLOCK_GROUP": "Unlock group", "MANAGE_LOCKS": "Lock categories", "MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.", "LOCK_CENSORED": "Lock adult (18+)", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 31a58083b..156ff7303 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Change PIN", "CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.", "HOW_TO_LOCK": "Choosing what to lock", - "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.", + "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.", "DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.", "ENABLED_TOAST": "Parental lock enabled", "DISABLED_TOAST": "Parental lock disabled", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", "UNLOCK_CATEGORY": "Unlock category", + "LOCK_GROUP": "Lock group", + "UNLOCK_GROUP": "Unlock group", "MANAGE_LOCKS": "Lock categories", "MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.", "LOCK_CENSORED": "Lock adult (18+)", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 6ac3de2df..47062710c 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Change PIN", "CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.", "HOW_TO_LOCK": "Choosing what to lock", - "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.", + "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.", "DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.", "ENABLED_TOAST": "Parental lock enabled", "DISABLED_TOAST": "Parental lock disabled", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", "UNLOCK_CATEGORY": "Unlock category", + "LOCK_GROUP": "Lock group", + "UNLOCK_GROUP": "Unlock group", "MANAGE_LOCKS": "Lock categories", "MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.", "LOCK_CENSORED": "Lock adult (18+)", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 39ad57933..1cc0dc94c 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Change PIN", "CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.", "HOW_TO_LOCK": "Choosing what to lock", - "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.", + "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.", "DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.", "ENABLED_TOAST": "Parental lock enabled", "DISABLED_TOAST": "Parental lock disabled", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", "UNLOCK_CATEGORY": "Unlock category", + "LOCK_GROUP": "Lock group", + "UNLOCK_GROUP": "Unlock group", "MANAGE_LOCKS": "Lock categories", "MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.", "LOCK_CENSORED": "Lock adult (18+)", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 5e9c9b29a..07a83653c 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Change PIN", "CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.", "HOW_TO_LOCK": "Choosing what to lock", - "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.", + "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.", "DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.", "ENABLED_TOAST": "Parental lock enabled", "DISABLED_TOAST": "Parental lock disabled", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", "UNLOCK_CATEGORY": "Unlock category", + "LOCK_GROUP": "Lock group", + "UNLOCK_GROUP": "Unlock group", "MANAGE_LOCKS": "Lock categories", "MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.", "LOCK_CENSORED": "Lock adult (18+)", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index bbb1fe503..36434beb2 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Change PIN", "CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.", "HOW_TO_LOCK": "Choosing what to lock", - "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.", + "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.", "DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.", "ENABLED_TOAST": "Parental lock enabled", "DISABLED_TOAST": "Parental lock disabled", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", "UNLOCK_CATEGORY": "Unlock category", + "LOCK_GROUP": "Lock group", + "UNLOCK_GROUP": "Unlock group", "MANAGE_LOCKS": "Lock categories", "MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.", "LOCK_CENSORED": "Lock adult (18+)", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 65a400af7..573844467 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Change PIN", "CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.", "HOW_TO_LOCK": "Choosing what to lock", - "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.", + "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.", "DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.", "ENABLED_TOAST": "Parental lock enabled", "DISABLED_TOAST": "Parental lock disabled", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", "UNLOCK_CATEGORY": "Unlock category", + "LOCK_GROUP": "Lock group", + "UNLOCK_GROUP": "Unlock group", "MANAGE_LOCKS": "Lock categories", "MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.", "LOCK_CENSORED": "Lock adult (18+)", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index fea3cda97..50cb40e0e 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Change PIN", "CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.", "HOW_TO_LOCK": "Choosing what to lock", - "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.", + "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.", "DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.", "ENABLED_TOAST": "Parental lock enabled", "DISABLED_TOAST": "Parental lock disabled", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", "UNLOCK_CATEGORY": "Unlock category", + "LOCK_GROUP": "Lock group", + "UNLOCK_GROUP": "Unlock group", "MANAGE_LOCKS": "Lock categories", "MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.", "LOCK_CENSORED": "Lock adult (18+)", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 1d30d7e82..b9af24bb1 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Change PIN", "CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.", "HOW_TO_LOCK": "Choosing what to lock", - "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.", + "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.", "DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.", "ENABLED_TOAST": "Parental lock enabled", "DISABLED_TOAST": "Parental lock disabled", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", "UNLOCK_CATEGORY": "Unlock category", + "LOCK_GROUP": "Lock group", + "UNLOCK_GROUP": "Unlock group", "MANAGE_LOCKS": "Lock categories", "MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.", "LOCK_CENSORED": "Lock adult (18+)", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 988a7f428..05830c28e 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Сменить PIN", "CHANGE_PIN_DESCRIPTION": "Сначала запрашивается текущий PIN.", "HOW_TO_LOCK": "Что блокировать", - "HOW_TO_LOCK_DESCRIPTION": "Откройте источник и используйте значки замка в диалоге управления категориями (Xtream, группы M3U) или кнопку замка над категориями Stalker.", + "HOW_TO_LOCK_DESCRIPTION": "Откройте источник и нажмите «Управлять категориями» (иконка настройки над списком категорий; в плейлисте M3U — «Управление группами»): у каждой строки есть переключатель замка, а для Stalker доступно «Заблокировать 18+» для жанров, помеченных порталом. Правый клик по категории или группе — быстрое «Заблокировать / Разблокировать».", "DISCLAIMER": "Это детская блокировка, а не защита: файлы данных приложения доступны любому, у кого есть доступ к этому компьютеру.", "ENABLED_TOAST": "Родительский контроль включён", "DISABLED_TOAST": "Родительский контроль выключен", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Заблокировано", "LOCK_CATEGORY": "Заблокировать категорию", "UNLOCK_CATEGORY": "Разблокировать категорию", + "LOCK_GROUP": "Заблокировать группу", + "UNLOCK_GROUP": "Разблокировать группу", "MANAGE_LOCKS": "Заблокировать категории", "MANAGE_LOCKS_DESCRIPTION": "Заблокированные категории и их каналы исчезают из всего приложения, пока не введён родительский PIN.", "LOCK_CENSORED": "Заблокировать 18+", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 56dd522a6..313bca312 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Change PIN", "CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.", "HOW_TO_LOCK": "Choosing what to lock", - "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.", + "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.", "DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.", "ENABLED_TOAST": "Parental lock enabled", "DISABLED_TOAST": "Parental lock disabled", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", "UNLOCK_CATEGORY": "Unlock category", + "LOCK_GROUP": "Lock group", + "UNLOCK_GROUP": "Unlock group", "MANAGE_LOCKS": "Lock categories", "MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.", "LOCK_CENSORED": "Lock adult (18+)", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 727b5203c..f57e882d0 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Change PIN", "CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.", "HOW_TO_LOCK": "Choosing what to lock", - "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.", + "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.", "DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.", "ENABLED_TOAST": "Parental lock enabled", "DISABLED_TOAST": "Parental lock disabled", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", "UNLOCK_CATEGORY": "Unlock category", + "LOCK_GROUP": "Lock group", + "UNLOCK_GROUP": "Unlock group", "MANAGE_LOCKS": "Lock categories", "MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.", "LOCK_CENSORED": "Lock adult (18+)", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index cce2242dd..b99a23e93 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -567,7 +567,7 @@ "CHANGE_PIN": "Change PIN", "CHANGE_PIN_DESCRIPTION": "Asks for the current PIN first.", "HOW_TO_LOCK": "Choosing what to lock", - "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use the lock icons in the category list's manage dialog (Xtream, M3U groups) or the lock button above Stalker categories.", + "HOW_TO_LOCK_DESCRIPTION": "Open a source, then use “Manage categories” (the tune icon above the category list; “Manage groups” in an M3U playlist): every row has a lock toggle, and Stalker offers “Lock adult (18+)” for genres the portal flags. Right-click a category or group for a quick Lock / Unlock.", "DISCLAIMER": "This is a child lock, not a security feature: the app's data files are readable by anyone with access to this computer.", "ENABLED_TOAST": "Parental lock enabled", "DISABLED_TOAST": "Parental lock disabled", @@ -1918,6 +1918,8 @@ "LOCKED_COUNT": "Locked", "LOCK_CATEGORY": "Lock category", "UNLOCK_CATEGORY": "Unlock category", + "LOCK_GROUP": "Lock group", + "UNLOCK_GROUP": "Unlock group", "MANAGE_LOCKS": "Lock categories", "MANAGE_LOCKS_DESCRIPTION": "Locked categories and their channels disappear from the whole app until the parental PIN is entered.", "LOCK_CENSORED": "Lock adult (18+)", diff --git a/docs/architecture/category-management.md b/docs/architecture/category-management.md index 2e75cdf95..aeb76b096 100644 --- a/docs/architecture/category-management.md +++ b/docs/architecture/category-management.md @@ -151,7 +151,10 @@ dialog renders a lock toggle per row and the whole dialog opens only after the PIN. Locks are a separate concept from `hidden`: they live in the renderer's lock store keyed by provider category id and are mirrored into `categories.locked` through `DB_SET_CATEGORY_LOCKS`, so a refresh keeps them -the same way it keeps `hidden`. Contract: [parental lock](parental-lock.md). +the same way it keeps `hidden`. The dialog is the one entry point on every +portal type (Stalker's "Manage categories" button opens its lock-only +dialog); a right-click on a category row offers a single-row Lock/Unlock +through the same lock store. Contract: [parental lock](parental-lock.md). ## Files Changed diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index c86bc4570..6a8c05b11 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -284,46 +284,47 @@ on either side. disclaimer. Everything applies immediately (it gates on the PIN), outside the shared dirty form; `createSettingsFromFormValue` carries both values from the current settings so Save cannot undo them. -- Lock toggles in the Xtream category-management dialog and the M3U group - dialog (rendered only while the feature is on), plus a new Stalker - `StalkerCategoryLockDialogComponent` reached from a lock button above the - categories rail; it offers "Lock adult (18+)" for genres the portal flags - `censored`. All three list the locked names and can rewrite the locks, so - each opens only after `requestUnlock()` succeeds — and closes itself, - discarding the draft, the moment `active` becomes true again (idle relock, - Lock now), since the PIN gate covers only the opening. The M3U group - dialog does this only while it carries lock toggles; the plain hide/show - editor is not behind the PIN. The Xtream dialog loads - its candidates through the capability-selected data source - (`IXtreamDataSource.getAllCategories`, which the PWA source answers from - its session cache or the API), so PWA users can set locks too; the - hide/show checkboxes remain Electron-only. The relock timeout persists - through the same undo-on-failure pattern as the switch - (`setRelockMinutes` reverts the in-memory value and the facade shows the - settings save-failure snackbar). The M3U dialog hands its lock - list back to `ChannelListContainerComponent`, which awaits the write and - reports a failed save in a snackbar (the dialog has closed by then). On - Electron the `categories.locked` re-stamp (`setCategoryLocks`) clears and - re-locks one playlist/type inside ONE transaction, so a failed restamp - keeps the previous index instead of leaving every category unlocked. The - store commits BEFORE that re-stamp, so a failed re-stamp rolls the store - back to the previous locks AND re-stamps every touched type from them (a - backup restore stamps three types, and the ones before the failing type - already carry the new locks; a category must not be recorded and shown - as locked while Electron reads, which filter by the index alone, still - serve it), and the store revision consumers reload on is published only - once every touched type is stamped, so a reload cannot read a later type - through its old stamps; if the rollback write or its re-stamp fails too, the playlist is - re-stamped on the next store access, and every launch re-derives the - index from the store for each playlist that has locks — which is why a - write that removes a playlist's LAST lock clears the index first and - drops the key afterwards (the reconcile finds playlists only through - their key; an interruption then leaves store-with-lock and index-without, - which the next reconcile repairs toward locked; a store recovered by a - later successful read marks its playlists stale the same way) — awaited inside - the store's `load()`, so `readable` (and with it every catalog read the - renderer gates) stays false until the index agrees with the store; a - re-stamp that keeps failing keeps the session fail-closed. +- One entry point per rail, the same on every portal type: the "Manage + categories" button (`tune` icon; "Manage groups" on an M3U playlist). + Xtream and M3U render lock toggles inside their existing hide/show + dialogs while the feature is on; Stalker has no hide/show dialog, so its + button — shown only while the feature is on — opens the lock-only + `StalkerCategoryLockDialogComponent`, which also offers "Lock adult + (18+)" for genres the portal flags `censored`. All three list the locked + names and can rewrite the locks, so each opens only after + `requestUnlock()` succeeds — and closes itself, discarding the draft, the + moment `active` becomes true again (idle relock, Lock now), since the + PIN gate covers only the opening. The M3U group dialog does this only + while it carries lock toggles; the plain hide/show editor is not behind + the PIN. The Xtream dialog loads its candidates through the + capability-selected data source (`IXtreamDataSource.getAllCategories`, + which the PWA source answers from its session cache or the API), so PWA + users can set locks too; the hide/show checkboxes remain Electron-only. + A deliberate non-choice: no dedicated lock button in the rail header + (the header already carries search, sort and manage, and the lock is a + category-management concern) and no keyword-based "adult" recognition + for Xtream/M3U (provider naming is arbitrary; the only automation is the + portal's own `censored` flag on Stalker). +- Right-click accelerator: a `contextmenu` on a category row (Xtream and + Stalker rail, `WorkspaceContextCategoryViewComponent`) or an M3U group + row opens the shared `CategoryLockMenuComponent` (`libs/ui/components`) + with one "Lock / Unlock category (group)" item, only while the feature + is on. The portal rail persists through + `WorkspaceCategoryLockActionService` (PIN gate, then one-id edit of the + lock store, failure snackbar); the M3U rail emits the edited list to + `ChannelListContainerComponent`, which persists it like the dialog's + result. Bulk actions stay in the dialog, and while the session is locked + a locked category is not in the rail, so unlocking always goes through + the "N locked · Enter PIN to show" row or the dialog. The M3U dialog + hands its lock list back to `ChannelListContainerComponent`, which + awaits the write and reports a failed save in a snackbar (the dialog has + closed by then). On Electron the `categories.locked` re-stamp + (`setCategoryLocks`) clears and re-locks one playlist/type inside ONE + transaction, so a failed restamp keeps the previous index instead of + leaving every category unlocked. The relock timeout persists through the + same undo-on-failure pattern as the switch (`setRelockMinutes` reverts + the in-memory value and the facade shows the settings save-failure + snackbar). - Header lock/unlock button and the `parental-lock-now` / `parental-unlock` palette commands. diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index d31faf1d9..07e6ca0b3 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -32,3 +32,4 @@ export * from './lib/watched-badge/watched-badge.component'; export * from './lib/window-controls/window-controls.component'; export * from './lib/channel-scroll-focus/channel-scroll-focus.directive'; +export * from './lib/category-lock-menu/category-lock-menu.component'; diff --git a/libs/ui/components/src/lib/category-lock-menu/category-lock-menu.component.spec.ts b/libs/ui/components/src/lib/category-lock-menu/category-lock-menu.component.spec.ts new file mode 100644 index 000000000..b9dbbf226 --- /dev/null +++ b/libs/ui/components/src/lib/category-lock-menu/category-lock-menu.component.spec.ts @@ -0,0 +1,49 @@ +import { Component, viewChild } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { CategoryLockMenuComponent } from './category-lock-menu.component'; + +@Component({ + imports: [CategoryLockMenuComponent], + template: ``, +}) +class HostComponent { + readonly menu = viewChild.required(CategoryLockMenuComponent); + readonly requested: boolean[] = []; +} + +describe('CategoryLockMenuComponent', () => { + it('opens at the pointer and requests the opposite lock state', async () => { + await TestBed.configureTestingModule({ + imports: [ + HostComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + }).compileComponents(); + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + const host = fixture.componentInstance; + const event = new MouseEvent('contextmenu', { + clientX: 40, + clientY: 60, + cancelable: true, + }); + + host.menu().open(event, true); + await Promise.resolve(); + fixture.detectChanges(); + + expect(event.defaultPrevented).toBe(true); + expect(host.menu().position()).toEqual({ x: '40px', y: '60px' }); + const item = document.querySelector( + '[data-test-id="category-lock-menu-toggle"]' + ); + expect(item?.textContent).toContain('PARENTAL_LOCK.UNLOCK_CATEGORY'); + item?.click(); + expect(host.requested).toEqual([false]); + }); +}); diff --git a/libs/ui/components/src/lib/category-lock-menu/category-lock-menu.component.ts b/libs/ui/components/src/lib/category-lock-menu/category-lock-menu.component.ts new file mode 100644 index 000000000..ee49c7e80 --- /dev/null +++ b/libs/ui/components/src/lib/category-lock-menu/category-lock-menu.component.ts @@ -0,0 +1,85 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, + signal, + viewChild, +} from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; +import { TranslatePipe } from '@ngx-translate/core'; + +/** + * Right-click accelerator of the parental lock: one positioned context menu + * with a single "Lock … / Unlock …" item. Hosts (the portal category rail, + * the M3U groups rail) call `open()` from a `contextmenu` event and persist + * the emitted target state themselves — the menu knows nothing about + * playlists or lock stores. Bulk actions and the full list stay in the + * "Manage categories" dialog; this only shortens the single-category case. + */ +@Component({ + selector: 'app-category-lock-menu', + imports: [MatIconModule, MatMenuModule, TranslatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + + `, + styles: ` + :host { + display: contents; + } + .category-lock-menu__anchor { + position: fixed; + width: 0; + height: 0; + pointer-events: none; + } + `, +}) +export class CategoryLockMenuComponent { + readonly lockLabelKey = input('PARENTAL_LOCK.LOCK_CATEGORY'); + readonly unlockLabelKey = input('PARENTAL_LOCK.UNLOCK_CATEGORY'); + /** The desired state: `true` = lock the row, `false` = unlock it. */ + readonly toggleRequested = output(); + + readonly locked = signal(false); + readonly position = signal({ x: '0px', y: '0px' }); + private readonly trigger = viewChild.required('trigger'); + + /** Opens the menu at the pointer for a row whose lock state is `locked`. */ + open(event: MouseEvent, locked: boolean): void { + event.preventDefault(); + event.stopPropagation(); + this.locked.set(locked); + this.position.set({ + x: `${event.clientX}px`, + y: `${event.clientY}px`, + }); + const trigger = this.trigger(); + if (trigger.menuOpen) { + trigger.closeMenu(); + } + queueMicrotask(() => this.trigger().openMenu()); + } +} diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html index 94947c878..0311f79f8 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html @@ -127,6 +127,9 @@ : null " (click)="selectGroup(group.key)" + (contextmenu)=" + onGroupContextMenu(group.key, $event) + " > @if (group.key) { @@ -354,3 +357,10 @@ {{ 'CHANNELS.SHOW_DETAILS' | translate }} + + diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts index c23f1bed1..e929dc5d2 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts @@ -537,6 +537,37 @@ describe('GroupsViewComponent', () => { ]); }); + it('locks and unlocks one group from the right-click menu behind the PIN gate', async () => { + const lockedGroupTitlesChanged = jest.fn(); + component.lockedGroupTitlesChanged.subscribe(lockedGroupTitlesChanged); + fixture.componentRef.setInput('lockedGroupTitles', ['News']); + fixture.detectChanges(); + const event = new MouseEvent('contextmenu', { cancelable: true }); + + component.onGroupContextMenu('Sports', event); + expect(event.defaultPrevented).toBe(true); + await component.onGroupLockToggle(true); + expect(lockedGroupTitlesChanged).toHaveBeenLastCalledWith([ + 'News', + 'Sports', + ]); + + component.onGroupContextMenu('News', event); + await component.onGroupLockToggle(false); + expect(lockedGroupTitlesChanged).toHaveBeenLastCalledWith([]); + + parentalLock.requestUnlock.mockResolvedValueOnce(false); + component.onGroupContextMenu('Movies', event); + await component.onGroupLockToggle(true); + expect(lockedGroupTitlesChanged).toHaveBeenCalledTimes(2); + }); + + it('offers no group lock menu while the feature is off', () => { + const event = new MouseEvent('contextmenu', { cancelable: true }); + component.onGroupContextMenu('Sports', event); + expect(event.defaultPrevented).toBe(false); + }); + it('keeps the manage-groups dialog closed when the parental PIN is refused', async () => { // The dialog names every locked group and can rewrite the locks. parentalLock.requestUnlock.mockResolvedValueOnce(false); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index cf1f2ecb9..cf507d525 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -41,6 +41,7 @@ import { resolveChannelLogo } from '../channel-logo-fallback.util'; import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component'; import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component'; import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component'; +import { CategoryLockMenuComponent } from '../../category-lock-menu/category-lock-menu.component'; import { ResizableDirective } from '../../resizable/resizable.directive'; import { GroupManagementDialogComponent, @@ -70,6 +71,7 @@ interface FilteredGroupView { styleUrls: ['./groups-view.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ + CategoryLockMenuComponent, ChannelScrollFocusDirective, ChannelListItemComponent, MatButtonModule, @@ -188,6 +190,10 @@ export class GroupsViewComponent { */ readonly selectedGroupChange = output(); + private readonly groupLockMenu = + viewChild('groupLockMenu'); + private groupLockKey: string | null = null; + readonly isGroupSearchOpen = signal(false); readonly localGroupSearchTerm = signal(''); readonly selectedGroupKey = signal(null); @@ -489,6 +495,37 @@ export class GroupsViewComponent { this.localGroupSearchTerm.set(value); } + /** + * Right-click accelerator of the parental lock for one group; the host + * persists the emitted list exactly as it does for the dialog. No menu + * while the feature is off (`lockedGroupTitles` is null then). + */ + onGroupContextMenu(groupKey: string, event: MouseEvent): void { + const locked = this.lockedGroupTitles(); + if (locked === null) { + return; + } + this.groupLockKey = groupKey; + this.groupLockMenu()?.open(event, locked.includes(groupKey)); + } + + async onGroupLockToggle(lock: boolean): Promise { + const groupKey = this.groupLockKey; + this.groupLockKey = null; + const current = this.lockedGroupTitles(); + if (groupKey === null || current === null) { + return; + } + if (!(await this.parentalLock.requestUnlock())) { + return; + } + this.lockedGroupTitlesChanged.emit( + lock + ? [...new Set([...current, groupKey])] + : current.filter((title) => title !== groupKey) + ); + } + async openGroupManagement(): Promise { // The dialog lists every group by name, locked ones included, and can // rewrite the locks — so it sits behind the PIN like the portal diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html index 96d1dcf0d..2d5df8b0b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html @@ -23,6 +23,7 @@ [attr.aria-disabled]="!interactionEnabled()" [disabled]="!interactionEnabled()" (click)="onCategoryClick(item)" + (contextmenu)="onCategoryContextMenu(item, $event)" > {{ diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts index 72e1b2ccd..858e15495 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts @@ -12,7 +12,7 @@ import { MatListModule } from '@angular/material/list'; import { TranslatePipe } from '@ngx-translate/core'; import { WorkspaceContextErrorViewComponent } from './workspace-context-error-view.component'; -interface WorkspaceCategoryViewItem { +export interface WorkspaceCategoryViewItem { readonly category_id?: string | number; readonly category_name?: string; readonly count?: number; @@ -28,7 +28,6 @@ interface WorkspaceCategoryViewItem { styleUrl: './workspace-context-category-view.component.scss', }) export class WorkspaceContextCategoryViewComponent { - readonly items = input>([]); readonly selectedCategoryId = input(); readonly itemCounts = input>(new Map()); @@ -64,6 +63,11 @@ export class WorkspaceContextCategoryViewComponent { private readonly hostEl = inject(ElementRef); readonly categoryClicked = output(); + /** Right-click on a row; the host decides whether a menu applies. */ + readonly categoryContextMenuRequested = output<{ + item: WorkspaceCategoryViewItem; + event: MouseEvent; + }>(); constructor() { effect(() => { @@ -128,6 +132,13 @@ export class WorkspaceContextCategoryViewComponent { return this.itemCounts().has(Number(item.id ?? item.category_id)); } + onCategoryContextMenu( + item: WorkspaceCategoryViewItem, + event: MouseEvent + ): void { + this.categoryContextMenuRequested.emit({ item, event }); + } + onCategoryClick(item: WorkspaceCategoryViewItem): void { if (!this.interactionEnabled()) { return; diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.spec.ts new file mode 100644 index 000000000..e18289585 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.spec.ts @@ -0,0 +1,122 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { ParentalLockService } from '@iptvnator/services'; +import { WorkspaceCategoryLockActionService } from './workspace-category-lock-action.service'; + +describe('WorkspaceCategoryLockActionService', () => { + const snackBar = { open: jest.fn() }; + const parentalLock = { + enabled: signal(true), + requestUnlock: jest.fn(async () => true), + lockedXtreamIds: jest.fn(() => [7]), + lockedStalkerIds: jest.fn(() => ['9']), + setXtreamLocks: jest.fn(async () => true), + setStalkerLocks: jest.fn(async () => true), + }; + let service: WorkspaceCategoryLockActionService; + + beforeEach(() => { + jest.clearAllMocks(); + parentalLock.requestUnlock.mockResolvedValue(true); + parentalLock.setXtreamLocks.mockResolvedValue(true); + TestBed.configureTestingModule({ + providers: [ + { provide: ParentalLockService, useValue: parentalLock }, + { provide: MatSnackBar, useValue: snackBar }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, + ], + }); + service = TestBed.inject(WorkspaceCategoryLockActionService); + }); + + it('reads the Xtream lock state through the provider id', () => { + const base = { + provider: 'xtreams' as const, + playlistId: 'p', + section: 'vod', + }; + expect( + service.isLocked({ ...base, item: { id: 1, xtream_id: 7 } }) + ).toBe(true); + expect(service.isLocked({ ...base, item: { category_id: '7' } })).toBe( + true + ); + expect( + service.isLocked({ ...base, item: { id: 2, xtream_id: 8 } }) + ).toBe(false); + expect(parentalLock.lockedXtreamIds).toHaveBeenCalledWith( + 'p', + 'movies' + ); + }); + + it('adds and removes one Xtream category behind the PIN gate', async () => { + const target = { + provider: 'xtreams' as const, + playlistId: 'p', + section: 'live', + item: { id: 2, xtream_id: 8 }, + }; + await expect(service.setLocked(target, true)).resolves.toBe(true); + expect(parentalLock.setXtreamLocks).toHaveBeenCalledWith( + 'p', + 'live', + [7, 8] + ); + + await service.setLocked( + { ...target, item: { id: 1, xtream_id: 7 } }, + false + ); + expect(parentalLock.setXtreamLocks).toHaveBeenLastCalledWith( + 'p', + 'live', + [] + ); + }); + + it('toggles a Stalker genre and never the All pseudo-category', async () => { + const target = { + provider: 'stalker' as const, + playlistId: 'p', + section: 'itv', + item: { category_id: '5' }, + }; + expect( + service.isLocked({ ...target, item: { category_id: '9' } }) + ).toBe(true); + await expect(service.setLocked(target, true)).resolves.toBe(true); + expect(parentalLock.setStalkerLocks).toHaveBeenCalledWith('p', 'itv', [ + '9', + '5', + ]); + await expect( + service.setLocked({ ...target, item: { category_id: '*' } }, true) + ).resolves.toBe(false); + }); + + it('does nothing when the PIN is refused and reports a failed write', async () => { + const target = { + provider: 'xtreams' as const, + playlistId: 'p', + section: 'live', + item: { xtream_id: 8 }, + }; + parentalLock.requestUnlock.mockResolvedValueOnce(false); + await expect(service.setLocked(target, true)).resolves.toBe(false); + expect(parentalLock.setXtreamLocks).not.toHaveBeenCalled(); + + parentalLock.setXtreamLocks.mockResolvedValueOnce(false); + await expect(service.setLocked(target, true)).resolves.toBe(false); + expect(snackBar.open).toHaveBeenCalledWith( + 'PARENTAL_LOCK.SAVE_FAILED', + 'CLOSE', + expect.any(Object) + ); + }); +}); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.ts new file mode 100644 index 000000000..2975fa063 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-category-lock-action.service.ts @@ -0,0 +1,132 @@ +import { inject, Injectable } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { ParentalLockService } from '@iptvnator/services'; +import { + toParentalLockStalkerCategoryType, + toParentalLockXtreamCategoryType, +} from '@iptvnator/shared/interfaces'; + +export interface CategoryLockTarget { + readonly provider: 'xtreams' | 'stalker'; + readonly playlistId: string; + readonly section: string; + readonly item: { + readonly category_id?: string | number; + readonly xtream_id?: number; + readonly id?: string | number; + }; +} + +/** + * The single-category lock toggle behind the category rail's right-click + * menu. Same persistence as the "Manage categories" dialog (the lock store + * is the source of truth), same PIN gate, same failure snackbar — only the + * entry point differs. + */ +@Injectable({ providedIn: 'root' }) +export class WorkspaceCategoryLockActionService { + private readonly parentalLock = inject(ParentalLockService); + private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); + + /** The menu exists only while the feature is on. */ + readonly enabled = this.parentalLock.enabled; + + /** + * Xtream rows carry the provider id as `xtream_id` (Electron) or as + * `category_id` (PWA); Stalker genres are keyed by `category_id`. + */ + isLocked(target: CategoryLockTarget): boolean { + if (target.provider === 'xtreams') { + const type = toParentalLockXtreamCategoryType(target.section); + const id = this.xtreamProviderId(target); + return ( + !!type && + id !== null && + this.parentalLock + .lockedXtreamIds(target.playlistId, type) + .includes(id) + ); + } + const type = toParentalLockStalkerCategoryType(target.section); + const id = this.stalkerId(target); + return ( + !!type && + id !== null && + this.parentalLock + .lockedStalkerIds(target.playlistId, type) + .includes(id) + ); + } + + /** Locks or unlocks one category; false when refused or failed. */ + async setLocked( + target: CategoryLockTarget, + locked: boolean + ): Promise { + if (!(await this.parentalLock.requestUnlock())) { + return false; + } + const saved = await this.persist(target, locked); + if (!saved) { + this.snackBar.open( + this.translate.instant('PARENTAL_LOCK.SAVE_FAILED'), + this.translate.instant('CLOSE'), + { duration: 5000 } + ); + } + return saved; + } + + private persist( + target: CategoryLockTarget, + locked: boolean + ): Promise { + if (target.provider === 'xtreams') { + const type = toParentalLockXtreamCategoryType(target.section); + const id = this.xtreamProviderId(target); + if (!type || id === null) { + return Promise.resolve(false); + } + const current = this.parentalLock.lockedXtreamIds( + target.playlistId, + type + ); + return this.parentalLock.setXtreamLocks( + target.playlistId, + type, + locked + ? [...current, id] + : current.filter((entry) => entry !== id) + ); + } + const type = toParentalLockStalkerCategoryType(target.section); + const id = this.stalkerId(target); + if (!type || id === null) { + return Promise.resolve(false); + } + const current = this.parentalLock.lockedStalkerIds( + target.playlistId, + type + ); + return this.parentalLock.setStalkerLocks( + target.playlistId, + type, + locked ? [...current, id] : current.filter((entry) => entry !== id) + ); + } + + private xtreamProviderId(target: CategoryLockTarget): number | null { + const id = Number(target.item.xtream_id ?? target.item.category_id); + return Number.isFinite(id) ? id : null; + } + + private stalkerId(target: CategoryLockTarget): string | null { + const raw = target.item.category_id ?? target.item.id; + if (raw === undefined || raw === null || String(raw) === '*') { + return null; + } + return String(raw); + } +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html index 80c04ecb7..81293079d 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html @@ -78,19 +78,21 @@ tune } - @if (canLockStalkerCategories()) { + @if (canManageStalkerCategories()) { } @if (canHideCategories()) { @@ -170,6 +172,9 @@ [interactionEnabled]="isXtreamCategoryInteractionEnabled()" [statusText]="xtreamStatusText()" (categoryClicked)="onXtreamCategoryClicked($event)" + (categoryContextMenuRequested)=" + onCategoryContextMenu('xtreams', $event) + " /> } } @else if (isStalkerCategories()) { @@ -202,6 +207,9 @@ [countDisplayMode]="stalkerCountDisplayMode()" [omitMissingCounts]="true" (categoryClicked)="onStalkerCategoryClicked($any($event))" + (categoryContextMenuRequested)=" + onCategoryContextMenu('stalker', $event) + " /> } } @@ -227,3 +235,8 @@ } + + diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts index 936fb565c..975a54bfe 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts @@ -473,9 +473,9 @@ describe('WorkspaceContextPanelComponent', () => { el.textContent?.includes('For adults') ); - expect(documentary?.querySelector('.item-count')?.textContent).toContain( - '190' - ); + expect( + documentary?.querySelector('.item-count')?.textContent + ).toContain('190'); expect(adults?.querySelector('.item-count')).toBeNull(); }); @@ -527,7 +527,9 @@ describe('WorkspaceContextPanelComponent', () => { button?.click(); - expect(liveSidebarService.stateOf('portal')()).toBe('categories-hidden'); + expect(liveSidebarService.stateOf('portal')()).toBe( + 'categories-hidden' + ); }); it('offers it for Stalker itv and radio too', () => { @@ -826,7 +828,9 @@ describe('WorkspaceContextPanelComponent', () => { ) as never ); - expect(fixture.componentInstance.stalkerCategoryErrorDescription()).toBe( + expect( + fixture.componentInstance.stalkerCategoryErrorDescription() + ).toBe( 'PORTALS.ERROR_VIEW.STALKER_DEVICE_CONFLICT device conflict - device_id mismatch — Your STB is damaged.' ); }); @@ -836,9 +840,9 @@ describe('WorkspaceContextPanelComponent', () => { new StalkerPortalError('blocked', 'Account disabled') as never ); - expect(fixture.componentInstance.stalkerCategoryErrorDescription()).toBe( - 'Account disabled' - ); + expect( + fixture.componentInstance.stalkerCategoryErrorDescription() + ).toBe('Account disabled'); }); }); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index 8683b6f35..e40c751f8 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -35,7 +35,15 @@ import { toParentalLockStalkerCategoryType, toParentalLockXtreamCategoryType, } from '@iptvnator/shared/interfaces'; -import { WorkspaceContextCategoryViewComponent } from './components/workspace-context-category-view.component'; +import { CategoryLockMenuComponent } from '@iptvnator/ui/components'; +import { + WorkspaceCategoryViewItem, + WorkspaceContextCategoryViewComponent, +} from './components/workspace-context-category-view.component'; +import { + CategoryLockTarget, + WorkspaceCategoryLockActionService, +} from './workspace-category-lock-action.service'; import { WorkspaceContextErrorViewComponent } from './components/workspace-context-error-view.component'; import { hasActiveLiveCategoryRoute } from './workspace-context-panel-route.utils'; import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util'; @@ -63,6 +71,7 @@ interface WorkspaceCategoryLike { @Component({ selector: 'app-workspace-context-panel', imports: [ + CategoryLockMenuComponent, MatIconButton, MatIcon, MatMenuModule, @@ -94,6 +103,12 @@ export class WorkspaceContextPanelComponent { ); private readonly liveSidebarState = inject(LiveLayoutSidebarStateService); private readonly parentalLock = inject(ParentalLockService); + private readonly categoryLockAction = inject( + WorkspaceCategoryLockActionService + ); + private readonly categoryLockMenu = + viewChild('categoryLockMenu'); + private categoryLockTarget: CategoryLockTarget | null = null; readonly context = input.required(); readonly section = input.required(); @@ -180,8 +195,12 @@ export class WorkspaceContextPanelComponent { readonly canManageXtreamCategories = computed( () => this.isXtreamCategories() && this.xtreamSelectedTypeCountsReady() ); - /** Stalker has no hide/show dialog; its lock dialog exists only with the lock on. */ - readonly canLockStalkerCategories = computed( + /** + * Stalker has no hide/show dialog, so its "Manage categories" is the + * lock dialog alone and exists only while the lock is on — same button, + * same icon as the Xtream rail, so the entry point is one pattern. + */ + readonly canManageStalkerCategories = computed( () => this.isStalkerCategories() && this.parentalLock.enabled() && @@ -460,8 +479,47 @@ export class WorkspaceContextPanelComponent { await this.parentalLock.requestUnlock(); } - async openLockStalkerCategories(): Promise { - if (!this.canLockStalkerCategories()) { + /** + * Right-click accelerator: one row's lock toggled without the dialog. + * Silently no menu while the feature is off — the row keeps its + * ordinary behaviour. + */ + onCategoryContextMenu( + provider: 'xtreams' | 'stalker', + request: { item: WorkspaceCategoryViewItem; event: MouseEvent } + ): void { + if (!this.categoryLockAction.enabled()) { + return; + } + const target: CategoryLockTarget = { + provider, + playlistId: this.context().playlistId, + section: this.section(), + item: request.item, + }; + if ( + provider === 'stalker' && + String(request.item.category_id ?? request.item.id) === '*' + ) { + return; + } + this.categoryLockTarget = target; + this.categoryLockMenu()?.open( + request.event, + this.categoryLockAction.isLocked(target) + ); + } + + async onCategoryLockToggle(locked: boolean): Promise { + const target = this.categoryLockTarget; + this.categoryLockTarget = null; + if (target) { + await this.categoryLockAction.setLocked(target, locked); + } + } + + async openManageStalkerCategories(): Promise { + if (!this.canManageStalkerCategories()) { return; } if (!(await this.parentalLock.requestUnlock())) {