mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
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:
1 parent
1d945e91b1
commit
225e8de78e
34 files changed
+677
-81
No files matched your search
+1
@@ -23,6 +23,7 @@
|
||||
[attr.aria-disabled]="!interactionEnabled()"
|
||||
[disabled]="!interactionEnabled()"
|
||||
(click)="onCategoryClick(item)"
|
||||
(contextmenu)="onCategoryContextMenu(item, $event)"
|
||||
>
|
||||
<span class="nav-item-label">
|
||||
{{
|
||||
|
||||
+13
-2
@@ -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;
|
||||
|
||||
+122
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
+132
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
-6
@@ -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)"
|
||||
/>
|
||||
+12
-8
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+63
-5
@@ -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())) {
|
||||
|
||||
Reference in new issue
Block a user