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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 19:10:48 +02:00
1 parent 1d945e91b1
commit 225e8de78e
34 files changed
+677 -81

No files matched your search

@@ -23,6 +23,7 @@
[attr.aria-disabled]="!interactionEnabled()"
[disabled]="!interactionEnabled()"
(click)="onCategoryClick(item)"
(contextmenu)="onCategoryContextMenu(item, $event)"
>
<span class="nav-item-label">
{{
@@ -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<ReadonlyArray<WorkspaceCategoryViewItem>>([]);
readonly selectedCategoryId = input<string | number | null | undefined>();
readonly itemCounts = input<Map<number, number>>(new Map());
@@ -64,6 +63,11 @@ export class WorkspaceContextCategoryViewComponent {
private readonly hostEl = inject(ElementRef<HTMLElement>);
readonly categoryClicked = output<WorkspaceCategoryViewItem>();
/** 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;
@@ -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)
);
});
});
@@ -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<boolean> {
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<boolean> {
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);
}
}
@@ -78,19 +78,21 @@
<mat-icon>tune</mat-icon>
</button>
}
@if (canLockStalkerCategories()) {
@if (canManageStalkerCategories()) {
<button
type="button"
mat-icon-button
class="context-header__action"
data-test-id="context-lock-categories"
(click)="openLockStalkerCategories()"
[matTooltip]="'PARENTAL_LOCK.MANAGE_LOCKS' | translate"
data-test-id="context-manage-categories"
(click)="openManageStalkerCategories()"
[matTooltip]="
'WORKSPACE.CONTEXT.MANAGE_CATEGORIES' | translate
"
[attr.aria-label]="
'PARENTAL_LOCK.MANAGE_LOCKS' | translate
'WORKSPACE.CONTEXT.MANAGE_CATEGORIES' | translate
"
>
<mat-icon>lock</mat-icon>
<mat-icon>tune</mat-icon>
</button>
}
@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 @@
</button>
}
</div>
<app-category-lock-menu
#categoryLockMenu
(toggleRequested)="onCategoryLockToggle($event)"
/>
@@ -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');
});
});
});
@@ -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<CategoryLockMenuComponent>('categoryLockMenu');
private categoryLockTarget: CategoryLockTarget | null = null;
readonly context = input.required<WorkspaceContextRoute>();
readonly section = input.required<string>();
@@ -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<void> {
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<void> {
const target = this.categoryLockTarget;
this.categoryLockTarget = null;
if (target) {
await this.categoryLockAction.setLocked(target, locked);
}
}
async openManageStalkerCategories(): Promise<void> {
if (!this.canManageStalkerCategories()) {
return;
}
if (!(await this.parentalLock.requestUnlock())) {