style(ui): every remaining dialog onto the shared dialog shell (#1957)

* style(ui): every remaining dialog onto the shared dialog shell

Sixteen dialogs move onto app-dialog-shell, so every dialog shares one
frame: the header with its icon tile, heading, subtitle and close button,
the body, and a footer with the dismiss first and the main action last.
Settings unsaved changes and delete all, the server migration prompt, the
external player notice, source cleanup, the group, category, lock and EPG
mapping pickers, playlist details, both account info dialogs, channel
details, downloaded episodes, episode info, the trailer, release notes and
the parental lock PIN.

A hero dialog keeps its stats band as the body's first block, edge to
edge, with its title in the shell's header and its status pills in the
header's end slot; a media dialog does the same with its frame or still.
Widths come from dialogSize() at every opener; the account dialogs and the
trailer keep their wider explicit widths. The command palette, the
programme dialog and the EPG import panel stay as they are, by design.

Specs that pinned the old literal widths at the openers now expect the
preset values; the delete-all spec counts the footer's buttons, since the
shell adds its header close.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ui): let a picker's list own the scrolling; keep cleanup's lock on close

A child of the dialog shell's body cannot size itself from the body's
height: a flex item's height is indefinite for percentages, so the list
wrappers' height: 100% never resolved. With eighty groups the wrapper grew
to its content, the whole body scrolled and the search box and bulk actions
scrolled away. The shell gains bodyLayout="column", which lays the body out
as a flex column; the three list pickers set it, their wrapper flexes to
the height left under the cap and only the list scrolls. An Electron E2E
imports eighty groups and checks that the body holds still, the list
scrolls and the search box stays in view; it fails without the fix.

The source cleanup dialog hides the shell's header close while it deletes:
mat-dialog-close ignores disableClose, so the header close would have
ended the batch and hidden its results. Stop stays the one way out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ui): close the shell with no result; hide its close while a save runs

A bare mat-dialog-close attribute closes with an empty string, not with
undefined as Escape and the backdrop do. The parental lock services take
any string for an entered PIN, so the shell's header close would have
opened the lock on a bare dismissal, and the groups view would have taken
the empty string for a saved result. The header close now binds undefined.
The shell's spec asserts it, and the PIN dialog's spec that its header
close cancels.

The Xtream category and Stalker lock pickers hide the header close while
they save: mat-dialog-close ignores disableClose, and a dismissal mid-save
would have skipped the caller's reload while the writes still finished.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ui): wrap an unbreakable heading in the dialog shell

A shell heading often carries provider text: an episode's file name, a
channel name with no spaces. Such a string has no break opportunity, and
the episode title lost the overflow-wrap it had before the shell, so it
ran under the header's close button. The shell's heading and subtitle now
wrap anywhere. An Electron E2E opens channel details for an underscore-only
channel name in a narrow window and checks the title wraps and stays clear
of the close button; it fails without the rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(ui): one opener for the channel details dialog

Five channel lists opened the channel details dialog with the same size
preset repeated at each site. The dialog now owns its settings in a static
open(dialog, channel), as the EPG mapping and PIN dialogs do, so a later
size change cannot miss a caller.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ui): keep the hero bands' grid stretched in a narrow window

Removing the padding rules the shell made redundant left the hero selector
grouped with the status pills' left-alignment rule in both hero
stylesheets. On a grid, justify-content: flex-start stops the implicit
column from stretching, so under 720px the account stats shrank to their
content, 157px in a 355px band. Only the status pills re-align now. An
Electron E2E opens the account dialog in a narrow window and checks the
stats fill the band; it fails without the fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(settings): one opener for the release notes dialog

The notification card and the settings facade opened the release notes
dialog with the same size, autofocus and restore settings repeated at each
site. The dialog now owns them in a static open(dialog, data); a caller
picks only which release to show.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(ui): measure the hero band against the body's client width

The narrow-window hero test compared the band to the body's bounding
width, which on Linux includes the 15px scrollbar that macOS overlays, so
it failed on the ubuntu runners while passing on a Mac. The client width
leaves the scrollbar out on both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(ui): keep the fraction when measuring the hero band's body

clientWidth is an integer; against the band's fractional bounding width it
left a 1.4px difference on a Mac. The body's bounding width minus its
integer scrollbar width keeps the fraction and still leaves out the
classic scrollbar the CI runners draw.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ui): keep the dialog shell's header close reachable when the dialog scrolls

Under 600px of height the shell let Material's surface scroll every
dialog whole, so the header close scrolled away; a dialog without a
footer (account info, downloaded episodes, keyboard shortcuts) then had
no in-dialog dismissal left in view. Such a dialog now keeps its body as
the scroll owner, with the header in place; the cap still comes off for
a dialog with a footer, whose stacked actions would squeeze the body.

An opener's `maxHeight` tighter than the shell's own cap (the account
dialogs' 90vh) also left the surface scrolling: the dialog component
between the surface and the shell is a block that never shrinks below
its content. Those ancestors are now shrinkable flex columns, so the
shell sizes from the surface and only the body scrolls.

The dialog-shell E2E opens the account dialog in a 520px-tall window,
scrolls both candidate owners and checks that the body took the scroll
and the close button stayed inside the surface; it fails without either
rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ui): hide the PIN dialog's header close while a verification is pending

The dialog disables its field, Cancel and Submit while the caller's
`verify` runs, but the shell's header close stayed enabled: a dismissal
then handed the caller a cancel, and an accepted PIN could no longer be
delivered. The close now follows Cancel, as the category and lock
pickers' does while they save. The spec holds a verification open and
checks that the close is gone and the accepted PIN still arrives.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-10-16 15:24:25 +02:00
1 parent b64b766f0a
commit e6df866787
76 files changed
+2920 -2409

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
---
type: fix
area: ui
---
Every dialog now shares one frame: the same header with a close button,
body and footer, with the dismiss first and the main action last. This
covers settings, playlist details, account info, channel details, EPG
mapping, group and category management, parental lock, downloads, trailer,
episode info, release notes and the server migration prompt.
@@ -0,0 +1,246 @@
import type { Locator } from '@playwright/test';
import {
addXtreamPortal,
buildM3uContent,
channelItemByTitle,
closeElectronApp,
createMutableTextServer,
expect,
importM3uPlaylistFromUrl,
launchElectronApp,
openWorkspaceSection,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
// ---------------------------------------------------------------------------
// The dialog shell's heading often carries provider text: a channel name, an
// episode's file name. Such a string may have no break opportunity at all,
// and a heading that cannot wrap runs under the close button at the header's
// end. The shell wraps its heading anywhere instead.
// ---------------------------------------------------------------------------
const UNBREAKABLE_NAME =
'Channel_name_with_no_spaces_at_all_as_some_providers_write_them_1080p';
/**
* Imports a one-channel playlist and opens that channel's details in a
* narrow window, where the heading has no room beside the tile and the
* close button and the hero band's stats stack.
*/
async function openChannelDetailsNarrow(
app: Awaited<ReturnType<typeof launchElectronApp>>,
resourceUrl: string
): Promise<Locator> {
const page = app.mainWindow;
await page.route('https://example.com/**', (route) => route.abort());
await importM3uPlaylistFromUrl(page, resourceUrl);
await page.setViewportSize({ width: 560, height: 760 });
await openWorkspaceSection(page, 'All channels');
await channelItemByTitle(page, UNBREAKABLE_NAME).click({
button: 'right',
});
await page.getByRole('menuitem', { name: 'Show channel details' }).click();
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog).toBeVisible();
return dialog;
}
/**
* Opens the Xtream account dialog through the command palette and waits for
* its stats band; the dialog has no footer, so its header close is its only
* in-dialog dismissal.
*/
async function openAccountInfoDialog(
page: Awaited<ReturnType<typeof launchElectronApp>>['mainWindow']
): Promise<Locator> {
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control';
await page.locator('body').focus();
await page.keyboard.press(`${modifier}+K`);
const palette = page.locator(
'mat-dialog-container app-workspace-command-palette'
);
await expect(palette).toBeVisible();
await palette.locator('input[type="search"]').fill('account');
await palette.getByRole('button', { name: /Account info/i }).click();
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog.locator('.account-stat').first()).toBeVisible({
timeout: 20_000,
});
return dialog;
}
async function serveUnbreakableChannel() {
return createMutableTextServer(
buildM3uContent([
{
name: UNBREAKABLE_NAME,
url: 'https://example.com/live/unbreakable.m3u8',
},
]),
{
contentType: 'application/x-mpegurl; charset=utf-8',
resourcePath: '/unbreakable.m3u',
}
);
}
test.describe('Dialog shell', () => {
test('wraps an unbreakable heading before it reaches the close button', async ({
dataDir,
}) => {
const m3u = await serveUnbreakableChannel();
let app: Awaited<ReturnType<typeof launchElectronApp>> | undefined;
try {
app = await launchElectronApp(dataDir);
const dialog = await openChannelDetailsNarrow(app, m3u.resourceUrl);
const geometry = await dialog.evaluate((container) => {
const title = container.querySelector(
'.app-dialog__title'
) as HTMLElement;
const close = container.querySelector(
'.app-dialog__close'
) as HTMLElement;
const header = container.querySelector(
'.app-dialog__header'
) as HTMLElement;
const titleBox = title.getBoundingClientRect();
return {
lines: Math.round(
titleBox.height /
parseFloat(getComputedStyle(title).lineHeight)
),
titleRight: titleBox.right,
closeLeft: close.getBoundingClientRect().left,
headerRight: header.getBoundingClientRect().right,
overflows: title.scrollWidth > title.clientWidth + 1,
};
});
expect(geometry.lines).toBeGreaterThan(1);
expect(geometry.overflows).toBe(false);
expect(geometry.titleRight).toBeLessThanOrEqual(geometry.closeLeft);
expect(geometry.titleRight).toBeLessThanOrEqual(
geometry.headerRight
);
} finally {
if (app) await closeElectronApp(app);
await m3u.close();
}
});
test('keeps a hero band stretched to the dialog in a narrow window', async ({
dataDir,
request,
}) => {
// The band is a grid; a narrow-window rule that re-aligns its status
// pills must not touch the grid itself, or its column stops
// stretching and the stats shrink to their content. The account
// dialog shows it: its stats stack in one column there, with short
// values that are far narrower than the band.
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await page.setViewportSize({ width: 560, height: 760 });
const dialog = await openAccountInfoDialog(page);
const geometry = await dialog.evaluate((container) => {
const hero = container.querySelector(
'.account-dialog__hero'
) as HTMLElement;
const stats = container.querySelector(
'.account-dialog__hero-stats'
) as HTMLElement;
const body = container.querySelector(
'mat-dialog-content'
) as HTMLElement;
const heroStyle = getComputedStyle(hero);
return {
heroWidth: hero.getBoundingClientRect().width,
// The body's width without its scrollbar: the CI runners
// draw a classic 15px one inside the box, a local Mac
// overlays it. The bounding width keeps the fraction the
// integer clientWidth would round away.
bodyWidth:
body.getBoundingClientRect().width -
(body.offsetWidth - body.clientWidth),
statsWidth: stats.getBoundingClientRect().width,
heroInner:
hero.getBoundingClientRect().width -
parseFloat(heroStyle.paddingLeft) -
parseFloat(heroStyle.paddingRight),
};
});
// Edge to edge under the header, and the stats fill the band.
expect(
Math.abs(geometry.heroWidth - geometry.bodyWidth)
).toBeLessThanOrEqual(1);
expect(geometry.statsWidth).toBeGreaterThanOrEqual(
geometry.heroInner - 1
);
} finally {
await closeElectronApp(app);
}
});
test('keeps the header close in place in a short window when the dialog has no footer', async ({
dataDir,
request,
}) => {
// Under 600px of height the shell lets Material's surface scroll a
// dialog whole, so a stacked footer never squeezes the body. A
// dialog without a footer has only the header close to dismiss it
// with the mouse, and a scrolling surface would carry it away: such
// a dialog keeps the body as the scroll owner instead.
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await page.setViewportSize({ width: 1024, height: 520 });
const dialog = await openAccountInfoDialog(page);
await expect(dialog.locator('.account-panel').first()).toBeVisible({
timeout: 20_000,
});
// Scroll both candidate owners to their end: a box with nothing
// to scroll stays at 0, so the two scrollTop values name the
// owner (a wheel event does not reach the surface under
// Playwright's Electron driver, so this is set directly).
const state = await dialog.evaluate((container) => {
const body = container.querySelector(
'mat-dialog-content'
) as HTMLElement;
const surface = container.querySelector(
'.mat-mdc-dialog-surface'
) as HTMLElement;
for (const owner of [body, surface]) {
owner.scrollTop = owner.scrollHeight;
}
const close = container
.querySelector('.app-dialog__close')
?.getBoundingClientRect();
const frame = surface.getBoundingClientRect();
return {
bodyScrolled: body.scrollTop,
surfaceScrolled: surface.scrollTop,
closeInsideFrame:
!!close &&
close.top >= frame.top - 1 &&
close.bottom <= frame.bottom + 1,
};
});
// The content is taller than the 520px window, and the body
// took the scroll: the header, and its close, stayed in place.
expect(state.bodyScrolled).toBeGreaterThan(0);
expect(state.surfaceScrolled).toBe(0);
expect(state.closeInsideFrame).toBe(true);
} finally {
await closeElectronApp(app);
}
});
});
@@ -298,4 +298,76 @@ test.describe('Electron M3U group management', () => {
await closeElectronApp(app);
}
});
test('keeps the search and bulk actions in view with many groups: the list scrolls, not the body', async ({
dataDir,
}) => {
// Eighty groups overflow the dialog's cap many times over. Only the
// list may scroll; the body itself holds still, so the search box
// and the bulk actions stay where they are.
const channels = Array.from({ length: 80 }, (_, index) => ({
groupTitle: `Group ${String(index + 1).padStart(2, '0')}`,
name: `Channel ${index + 1}`,
url: `https://streams.example.test/many-${index + 1}.m3u8`,
}));
const app = await launchElectronApp(dataDir);
try {
const filePath = writeTemporaryM3uFile(
dataDir,
'many-groups.m3u',
channels
);
await importM3uPlaylistFromNativeDialog(app, filePath);
await waitForM3uCatalog(app.mainWindow);
await app.mainWindow.setViewportSize({ width: 1280, height: 800 });
await openWorkspaceSection(app.mainWindow, 'Groups');
await app.mainWindow
.getByRole('button', { name: 'Manage groups', exact: true })
.click();
const dialog = app.mainWindow
.locator('mat-dialog-container')
.last();
await expect(dialog.locator('.group-item')).toHaveCount(80);
const geometry = () =>
dialog.evaluate((container) => {
const body = container.querySelector(
'mat-dialog-content'
) as HTMLElement;
const list = container.querySelector(
'.groups-list'
) as HTMLElement;
const search = container.querySelector(
'.search-inline'
) as HTMLElement;
const bodyBox = body.getBoundingClientRect();
const searchBox = search.getBoundingClientRect();
return {
bodyScrolls: body.scrollHeight > body.clientHeight + 1,
listScrolls: list.scrollHeight > list.clientHeight + 1,
searchInView:
searchBox.top >= bodyBox.top - 0.5 &&
searchBox.bottom <= bodyBox.bottom + 0.5,
listRows: list.scrollTop,
};
});
expect(await geometry()).toMatchObject({
bodyScrolls: false,
listScrolls: true,
searchInView: true,
});
// Scrolling to the last group moves the list only.
await dialog.locator('.group-item').last().scrollIntoViewIfNeeded();
const scrolled = await geometry();
expect(scrolled.listRows).toBeGreaterThan(0);
expect(scrolled).toMatchObject({
bodyScrolls: false,
searchInView: true,
});
} finally {
await closeElectronApp(app);
}
});
});
@@ -32,7 +32,7 @@ function expectedLabels(locale: Locale): string[] {
];
}
/** Below the 640px phone breakpoint the row stacks by design. */
/** Below the dialog shell's 480px phone breakpoint the row stacks by design. */
const PHONE_VIEWPORT = { width: 360, height: 740 };
/**
@@ -158,14 +158,8 @@ export class AppUpdateNotificationCardComponent {
}
const { AppUpdateReleaseNotesDialogComponent } = dialogModule;
this.dialog.open(AppUpdateReleaseNotesDialogComponent, {
autoFocus: false,
data: {
initialVersion: status.latestVersion,
},
maxWidth: 'calc(100vw - 32px)',
restoreFocus: true,
width: '720px',
AppUpdateReleaseNotesDialogComponent.open(this.dialog, {
initialVersion: status.latestVersion,
});
}
@@ -1,69 +1,73 @@
<h2 mat-dialog-title>
{{
<app-dialog-shell
icon="cloud_upload"
[heading]="
'SERVER_STORAGE.MIGRATION_TITLE'
| translate: { count: playlists.length }
}}
</h2>
<mat-dialog-content class="server-migration">
@if (result(); as done) {
<p data-test-id="server-migration-result">
{{ 'SERVER_STORAGE.MIGRATION_DONE' | translate: done }}
</p>
} @else {
<p>{{ 'SERVER_STORAGE.MIGRATION_DESCRIPTION' | translate }}</p>
<ul class="server-migration__list">
@for (playlist of playlists; track playlist.id) {
<li>
<mat-icon aria-hidden="true">{{
icons[playlist.kind]
}}</mat-icon>
<span>{{ playlist.title }}</span>
</li>
}
</ul>
<p class="server-migration__hint">
{{ 'SERVER_STORAGE.MIGRATION_LATER_HINT' | translate }}
</p>
@if (failed()) {
<p class="server-migration__error" role="status">
{{ 'SERVER_STORAGE.MIGRATION_FAILED' | translate }}
"
[showClose]="false"
>
<div class="server-migration">
@if (result(); as done) {
<p data-test-id="server-migration-result">
{{ 'SERVER_STORAGE.MIGRATION_DONE' | translate: done }}
</p>
}
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (result()) {
<button
mat-flat-button
type="button"
(click)="reload()"
data-test-id="server-migration-reload"
>
{{ 'SERVER_STORAGE.MIGRATION_RELOAD' | translate }}
</button>
} @else {
<button
mat-button
type="button"
[disabled]="busy()"
(click)="notNow()"
data-test-id="server-migration-not-now"
>
{{ 'SERVER_STORAGE.MIGRATION_NOT_NOW' | translate }}
</button>
<button
mat-flat-button
type="button"
[disabled]="busy()"
(click)="move()"
data-test-id="server-migration-move"
>
@if (busy()) {
<mat-progress-spinner diameter="16" mode="indeterminate" />
} @else {
<p>{{ 'SERVER_STORAGE.MIGRATION_DESCRIPTION' | translate }}</p>
<ul class="server-migration__list">
@for (playlist of playlists; track playlist.id) {
<li>
<mat-icon aria-hidden="true">{{
icons[playlist.kind]
}}</mat-icon>
<span>{{ playlist.title }}</span>
</li>
}
</ul>
<p class="server-migration__hint">
{{ 'SERVER_STORAGE.MIGRATION_LATER_HINT' | translate }}
</p>
@if (failed()) {
<p class="server-migration__error" role="status">
{{ 'SERVER_STORAGE.MIGRATION_FAILED' | translate }}
</p>
}
{{ 'SERVER_STORAGE.MIGRATION_MOVE' | translate }}
</button>
}
</mat-dialog-actions>
}
</div>
<!-- The dialog cannot be dismissed: "Not now" is the one way out before
the move, "Reload" after it, so the footer carries both states. -->
<div appDialogFooter>
@if (result()) {
<button
mat-flat-button
type="button"
(click)="reload()"
data-test-id="server-migration-reload"
>
{{ 'SERVER_STORAGE.MIGRATION_RELOAD' | translate }}
</button>
} @else {
<button
mat-button
type="button"
[disabled]="busy()"
(click)="notNow()"
data-test-id="server-migration-not-now"
>
{{ 'SERVER_STORAGE.MIGRATION_NOT_NOW' | translate }}
</button>
<button
mat-flat-button
type="button"
[disabled]="busy()"
(click)="move()"
data-test-id="server-migration-move"
>
@if (busy()) {
<mat-progress-spinner diameter="16" mode="indeterminate" />
}
{{ 'SERVER_STORAGE.MIGRATION_MOVE' | translate }}
</button>
}
</div>
</app-dialog-shell>
@@ -3,6 +3,10 @@
margin: 0 0 12px;
color: var(--app-body-color);
}
> :last-child {
margin-bottom: 0;
}
}
.server-migration__list {
@@ -19,10 +19,16 @@ import {
type ServerMigrationResult,
} from '@iptvnator/services/server-storage';
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
import {
DialogFooterDirective,
DialogShellComponent,
} from '@iptvnator/ui/components';
@Component({
selector: 'app-server-migration-dialog',
imports: [
DialogFooterDirective,
DialogShellComponent,
MatButtonModule,
MatDialogModule,
MatIconModule,
@@ -3,6 +3,7 @@ import { MatDialog } from '@angular/material/dialog';
import { NavigationEnd, Router } from '@angular/router';
import { filter, take } from 'rxjs';
import { ServerStorageStatusService } from '@iptvnator/services';
import { dialogSize } from '@iptvnator/ui/components/dialog-size';
import {
ServerMigrationService,
type ServerMigrationPlaylist,
@@ -41,9 +42,8 @@ export class ServerMigrationPrompt {
const { ServerMigrationDialogComponent } =
await import('./server-migration-dialog.component');
this.dialog.open(ServerMigrationDialogComponent, {
...dialogSize('md'),
data: playlists,
width: '540px',
maxWidth: 'calc(100vw - 32px)',
disableClose: true,
});
}
+2 -1
View File
@@ -10,6 +10,7 @@ import {
} from '@iptvnator/shared/interfaces';
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
import { LazyMatDialog } from '@iptvnator/ui/components/lazy-dialog';
import { dialogSize } from '@iptvnator/ui/components/dialog-size';
import { SettingsStore } from './settings-store.service';
@Injectable({
@@ -94,7 +95,7 @@ export class PlayerService {
this.loadExternalPlayerInfoDialog(),
])
.then(([dialog, { ExternalPlayerInfoDialogComponent }]) =>
dialog.open(ExternalPlayerInfoDialogComponent)
dialog.open(ExternalPlayerInfoDialogComponent, dialogSize('sm'))
)
.catch((error: unknown) => {
// Informational only: playback is already starting.
@@ -12,6 +12,7 @@ import { DatePipe } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
@@ -28,6 +29,12 @@ import {
ElectronBridgeAppUpdateReleaseNotesRequest,
isAppUpdateReleaseNotesNotFoundMessage,
} from '@iptvnator/shared/interfaces';
import {
DialogFooterDirective,
DialogFooterStartDirective,
DialogShellComponent,
dialogSize,
} from '@iptvnator/ui/components';
const RELEASE_NOTES_IMAGE_CLASS = 'release-notes-dialog__image';
@@ -69,6 +76,9 @@ function decorateReleaseNotesHtml(html: string): string {
@Component({
selector: 'app-update-release-notes-dialog',
imports: [
DialogFooterDirective,
DialogFooterStartDirective,
DialogShellComponent,
MatButtonModule,
MatDialogModule,
MatIconModule,
@@ -78,131 +88,134 @@ function decorateReleaseNotesHtml(html: string): string {
],
encapsulation: ViewEncapsulation.None,
template: `
<h2 mat-dialog-title>
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
</h2>
<app-dialog-shell
icon="new_releases"
[heading]="'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate"
>
<div class="release-notes-dialog">
<div class="release-notes-dialog__toolbar">
<button
mat-icon-button
type="button"
[disabled]="loading() || !notes()?.hasPrevious"
[attr.aria-label]="
'SETTINGS.APP_UPDATE_RELEASE_NOTES_OLDER'
| translate
"
(click)="load('previous')"
data-test-id="release-notes-previous"
>
<mat-icon>chevron_left</mat-icon>
</button>
<mat-dialog-content class="release-notes-dialog mat-typography">
<div class="release-notes-dialog__toolbar">
<button
mat-icon-button
type="button"
[disabled]="loading() || !notes()?.hasPrevious"
[attr.aria-label]="
'SETTINGS.APP_UPDATE_RELEASE_NOTES_OLDER' | translate
"
(click)="load('previous')"
data-test-id="release-notes-previous"
>
<mat-icon>chevron_left</mat-icon>
</button>
<div class="release-notes-dialog__version">
<strong>{{
notes()?.releaseName || notes()?.tagName
}}</strong>
@if (notes()?.publishedAt; as publishedAt) {
<span>{{ publishedAt | date: 'mediumDate' }}</span>
}
</div>
<div class="release-notes-dialog__version">
<strong>{{
notes()?.releaseName || notes()?.tagName
}}</strong>
@if (notes()?.publishedAt; as publishedAt) {
<span>{{ publishedAt | date: 'mediumDate' }}</span>
}
<button
mat-icon-button
type="button"
[disabled]="loading() || !notes()?.hasNext"
[attr.aria-label]="
'SETTINGS.APP_UPDATE_RELEASE_NOTES_NEWER'
| translate
"
(click)="load('next')"
data-test-id="release-notes-next"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<button
mat-icon-button
type="button"
[disabled]="loading() || !notes()?.hasNext"
[attr.aria-label]="
'SETTINGS.APP_UPDATE_RELEASE_NOTES_NEWER' | translate
"
(click)="load('next')"
data-test-id="release-notes-next"
>
<mat-icon>chevron_right</mat-icon>
@if (loading()) {
<div class="release-notes-dialog__loading">
<mat-spinner diameter="28" />
</div>
} @else if (error(); as loadError) {
<div
class="release-notes-dialog__error"
[class.release-notes-dialog__error--failed]="
loadError.kind === 'failed'
"
data-test-id="release-notes-error"
>
@if (loadError.kind === 'not-found') {
<p>
{{
'SETTINGS.APP_UPDATE_RELEASE_NOTES_NOT_FOUND'
| translate
: { version: loadError.version }
}}
</p>
} @else if (loadError.kind === 'unavailable') {
<p>
{{
'SETTINGS.APP_UPDATE_RELEASE_NOTES_UNAVAILABLE'
| translate
}}
</p>
} @else {
<p>
{{
'SETTINGS.APP_UPDATE_RELEASE_NOTES_LOAD_FAILED'
| translate
}}
</p>
<small>{{ loadError.message }}</small>
}
</div>
} @else {
<article
class="release-notes-dialog__body"
data-test-id="release-notes-body"
[innerHTML]="renderedMarkdown()"
></article>
}
</div>
<!-- The link to GitHub is a third action at the footer's start. The
error is checked first: a failed Previous/Next keeps the earlier
notes for navigation, but the body shows the error, so the
action must not open that earlier release. -->
<div appDialogFooterStart>
@if (error()) {
<button
mat-button
type="button"
(click)="openRelease(releasesPageUrl())"
data-test-id="release-notes-open-releases"
>
<mat-icon>open_in_new</mat-icon>
{{
'SETTINGS.APP_UPDATE_OPEN_RELEASES_PAGE' | translate
}}
</button>
} @else if (notes()?.htmlUrl; as htmlUrl) {
<button
mat-button
type="button"
(click)="openRelease(htmlUrl)"
data-test-id="release-notes-open-release"
>
<mat-icon>open_in_new</mat-icon>
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASE' | translate }}
</button>
}
</div>
<div appDialogFooter>
<button mat-flat-button type="button" (click)="close()">
{{ 'CLOSE' | translate }}
</button>
</div>
@if (loading()) {
<div class="release-notes-dialog__loading">
<mat-spinner diameter="28" />
</div>
} @else if (error(); as loadError) {
<div
class="release-notes-dialog__error"
[class.release-notes-dialog__error--failed]="
loadError.kind === 'failed'
"
data-test-id="release-notes-error"
>
@if (loadError.kind === 'not-found') {
<p>
{{
'SETTINGS.APP_UPDATE_RELEASE_NOTES_NOT_FOUND'
| translate: { version: loadError.version }
}}
</p>
} @else if (loadError.kind === 'unavailable') {
<p>
{{
'SETTINGS.APP_UPDATE_RELEASE_NOTES_UNAVAILABLE'
| translate
}}
</p>
} @else {
<p>
{{
'SETTINGS.APP_UPDATE_RELEASE_NOTES_LOAD_FAILED'
| translate
}}
</p>
<small>{{ loadError.message }}</small>
}
</div>
} @else {
<article
class="release-notes-dialog__body"
data-test-id="release-notes-body"
[innerHTML]="renderedMarkdown()"
></article>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<!-- The error is checked first: a failed Previous/Next keeps the
earlier notes for navigation, but the body shows the error,
so the action must not open that earlier release. -->
@if (error()) {
<button
mat-button
type="button"
(click)="openRelease(releasesPageUrl())"
data-test-id="release-notes-open-releases"
>
<mat-icon>open_in_new</mat-icon>
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASES_PAGE' | translate }}
</button>
} @else if (notes()?.htmlUrl; as htmlUrl) {
<button
mat-button
type="button"
(click)="openRelease(htmlUrl)"
data-test-id="release-notes-open-release"
>
<mat-icon>open_in_new</mat-icon>
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASE' | translate }}
</button>
}
<button mat-flat-button type="button" (click)="close()">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
</app-dialog-shell>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
.release-notes-dialog {
min-width: min(640px, calc(100vw - 64px));
max-width: min(760px, calc(100vw - 32px));
}
.release-notes-dialog__toolbar {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 40px;
@@ -302,6 +315,23 @@ function decorateReleaseNotesHtml(html: string): string {
],
})
export class AppUpdateReleaseNotesDialogComponent implements OnInit {
/**
* Opens the dialog with the settings every entry point shares: the large
* preset, no autofocus (the toolbar's arrows would take it) and focus
* back on the opener afterwards. A caller picks only which release.
*/
static open(
dialog: MatDialog,
data: AppUpdateReleaseNotesDialogData
): MatDialogRef<AppUpdateReleaseNotesDialogComponent> {
return dialog.open(AppUpdateReleaseNotesDialogComponent, {
...dialogSize('lg'),
autoFocus: false,
data,
restoreFocus: true,
});
}
private readonly data =
inject<AppUpdateReleaseNotesDialogData>(MAT_DIALOG_DATA);
private readonly dialogRef = inject(
@@ -131,15 +131,9 @@ export class SettingsAppUpdateFacade {
status?.currentVersion)
: status?.currentVersion;
this.matDialog.open(AppUpdateReleaseNotesDialogComponent, {
autoFocus: false,
data: {
...(!isUpdateRelease ? { fallbackToLatest: true } : {}),
initialVersion: initialReleaseNotesVersion,
},
maxWidth: 'calc(100vw - 32px)',
restoreFocus: true,
width: '720px',
AppUpdateReleaseNotesDialogComponent.open(this.matDialog, {
...(!isUpdateRelease ? { fallbackToLatest: true } : {}),
initialVersion: initialReleaseNotesVersion,
});
}
@@ -1,67 +1,70 @@
<h2 mat-dialog-title>
{{ 'SETTINGS.REMOVE_DIALOG.TITLE' | translate }}
</h2>
<mat-dialog-content class="mat-typography settings-delete-dialog">
<p class="settings-delete-dialog__lead">
{{
'SETTINGS.REMOVE_DIALOG.MESSAGE'
| translate: { count: dialogData.summary.total }
}}
</p>
@if (clearsServer()) {
<p
class="settings-delete-dialog__lead"
data-test-id="delete-all-server-warning"
>
{{ 'SERVER_STORAGE.DELETE_ALL_WARNING' | translate }}
<app-dialog-shell
icon="delete_forever"
iconTone="warn"
[heading]="'SETTINGS.REMOVE_DIALOG.TITLE' | translate"
>
<div class="settings-delete-dialog">
<p class="settings-delete-dialog__lead">
{{
'SETTINGS.REMOVE_DIALOG.MESSAGE'
| translate: { count: dialogData.summary.total }
}}
</p>
}
@if (clearsServer()) {
<p
class="settings-delete-dialog__lead"
data-test-id="delete-all-server-warning"
>
{{ 'SERVER_STORAGE.DELETE_ALL_WARNING' | translate }}
</p>
}
<section class="settings-delete-dialog__section">
<h3 class="settings-delete-dialog__section-title">
{{ 'SETTINGS.REMOVE_DIALOG.SUMMARY_TITLE' | translate }}
</h3>
<section class="settings-delete-dialog__section">
<h3 class="settings-delete-dialog__section-title">
{{ 'SETTINGS.REMOVE_DIALOG.SUMMARY_TITLE' | translate }}
</h3>
<div class="settings-delete-dialog__summary">
@for (item of summaryItems(); track item.id) {
<div class="settings-delete-dialog__summary-item">
<div class="settings-delete-dialog__summary-label">
<mat-icon aria-hidden="true">{{ item.icon }}</mat-icon>
<span>{{ item.labelKey | translate }}</span>
<div class="settings-delete-dialog__summary">
@for (item of summaryItems(); track item.id) {
<div class="settings-delete-dialog__summary-item">
<div class="settings-delete-dialog__summary-label">
<mat-icon aria-hidden="true">{{
item.icon
}}</mat-icon>
<span>{{ item.labelKey | translate }}</span>
</div>
<strong>{{ item.count }}</strong>
</div>
<strong>{{ item.count }}</strong>
</div>
}
</div>
</section>
}
</div>
</section>
<section class="settings-delete-dialog__section">
<h3 class="settings-delete-dialog__section-title">
{{ 'SETTINGS.REMOVE_DIALOG.CONSEQUENCES_TITLE' | translate }}
</h3>
<section class="settings-delete-dialog__section">
<h3 class="settings-delete-dialog__section-title">
{{ 'SETTINGS.REMOVE_DIALOG.CONSEQUENCES_TITLE' | translate }}
</h3>
<ul class="settings-delete-dialog__list">
@for (key of consequenceKeys; track key) {
<li>{{ key | translate }}</li>
}
</ul>
</section>
<ul class="settings-delete-dialog__list">
@for (key of consequenceKeys; track key) {
<li>{{ key | translate }}</li>
}
</ul>
</section>
<p class="settings-delete-dialog__hint">
{{ 'SETTINGS.REMOVE_DIALOG.BACKUP_HINT' | translate }}
</p>
</mat-dialog-content>
<mat-dialog-actions align="end" class="settings-delete-dialog__actions">
<button mat-button mat-dialog-close cdkFocusInitial>
{{ 'CANCEL' | translate }}
</button>
<button
mat-flat-button
class="app-destructive-button"
[mat-dialog-close]="true"
>
{{ 'SETTINGS.REMOVE_DIALOG.CONFIRM' | translate }}
</button>
</mat-dialog-actions>
<p class="settings-delete-dialog__hint">
{{ 'SETTINGS.REMOVE_DIALOG.BACKUP_HINT' | translate }}
</p>
</div>
<div appDialogFooter>
<button mat-button mat-dialog-close cdkFocusInitial>
{{ 'CANCEL' | translate }}
</button>
<button
mat-flat-button
class="app-destructive-button"
[mat-dialog-close]="true"
>
{{ 'SETTINGS.REMOVE_DIALOG.CONFIRM' | translate }}
</button>
</div>
</app-dialog-shell>
@@ -6,7 +6,6 @@
display: flex;
flex-direction: column;
gap: 18px;
min-width: min(100%, 360px);
}
.settings-delete-dialog__lead,
@@ -88,7 +87,3 @@
);
color: var(--mat-sys-on-surface-variant);
}
.settings-delete-dialog__actions {
padding-top: 2px;
}
@@ -87,12 +87,22 @@ describe('SettingsDeleteAllPlaylistsDialogComponent', () => {
expect(text).toContain('Use Export first if you need a backup.');
});
it('renders cancel and confirm actions', () => {
it('renders cancel and the destructive confirm in the footer, dismiss first', () => {
const buttons = Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll('button')
).map((button) => button.textContent?.trim());
(fixture.nativeElement as HTMLElement).querySelectorAll(
'[appDialogFooter] button'
)
);
expect(buttons).toEqual(['Cancel', 'Delete everything']);
expect(buttons.map((button) => button.textContent?.trim())).toEqual([
'Cancel',
'Delete everything',
]);
expect(
buttons.map((button) =>
button.classList.contains('app-destructive-button')
)
).toEqual([false, true]);
});
it('warns about every device only with server storage', () => {
@@ -11,6 +11,10 @@ import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { ServerStorageStatusService } from '@iptvnator/services';
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
import {
DialogFooterDirective,
DialogShellComponent,
} from '@iptvnator/ui/components';
export interface SettingsDeleteAllPlaylistsDialogData {
summary: {
@@ -35,6 +39,8 @@ type SettingsDeleteSummaryItem = {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
DialogFooterDirective,
DialogShellComponent,
MatButtonModule,
MatDialogModule,
MatIconModule,
@@ -4,6 +4,7 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { firstValueFrom, take } from 'rxjs';
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { dialogSize } from '@iptvnator/ui/components/dialog-size';
import {
ParentalLockService,
DatabaseService,
@@ -75,13 +76,12 @@ export class SettingsPlaylistResetFacade {
SettingsDeleteAllPlaylistsDialogData,
boolean
>(SettingsDeleteAllPlaylistsDialogComponent, {
...dialogSize('md'),
autoFocus: false,
data: {
summary: this.deleteSummary(),
},
maxWidth: 'calc(100vw - 32px)',
restoreFocus: true,
width: '460px',
})
.afterClosed()
.pipe(take(1))
@@ -3,6 +3,10 @@ import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import {
DialogFooterDirective,
DialogShellComponent,
} from '@iptvnator/ui/components';
/** What the user picked; closing via backdrop/Esc yields `undefined` = stay. */
export type SettingsUnsavedChangesChoice = 'save' | 'discard';
@@ -19,9 +23,20 @@ export interface SettingsUnsavedChangesDialogData {
* choice. Labels are single verbs so the row fits one line in every locale.
*/
@Component({
imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslateModule],
imports: [
DialogFooterDirective,
DialogShellComponent,
MatButtonModule,
MatDialogModule,
MatIconModule,
TranslateModule,
],
styles: [
`
.unsaved-dialog__message {
margin: 0;
}
.unsaved-dialog__invalid-hint {
display: flex;
align-items: center;
@@ -36,31 +51,19 @@ export interface SettingsUnsavedChangesDialogData {
height: 18px;
}
}
/* Phone breakpoint: one full-width action per row, in DOM order,
instead of letting a long translation wrap the row unevenly. */
@media (max-width: 640px) {
.unsaved-dialog__actions {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.unsaved-dialog__actions
.mat-mdc-button-base
+ .mat-mdc-button-base {
margin: 0;
}
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
// The shell stacks the three actions on a phone, one full-width button
// per row in DOM order, so a long translation never wraps the row.
template: `
<h2 mat-dialog-title>
{{ 'SETTINGS.UNSAVED_DIALOG_TITLE' | translate }}
</h2>
<mat-dialog-content class="mat-typography">
<p>{{ 'SETTINGS.UNSAVED_DIALOG_MESSAGE' | translate }}</p>
<app-dialog-shell
icon="edit_note"
[heading]="'SETTINGS.UNSAVED_DIALOG_TITLE' | translate"
>
<p class="unsaved-dialog__message">
{{ 'SETTINGS.UNSAVED_DIALOG_MESSAGE' | translate }}
</p>
@if (!data.canSave) {
<p class="unsaved-dialog__invalid-hint">
<mat-icon>error_outline</mat-icon>
@@ -69,34 +72,33 @@ export interface SettingsUnsavedChangesDialogData {
}}</span>
</p>
}
</mat-dialog-content>
<mat-dialog-actions align="end" class="unsaved-dialog__actions">
<button
mat-button
mat-dialog-close
cdkFocusInitial
data-test-id="unsaved-dialog-stay"
>
{{ 'CANCEL' | translate }}
</button>
<button
mat-button
class="app-destructive-button"
[mat-dialog-close]="'discard'"
data-test-id="unsaved-dialog-discard"
>
{{ 'SETTINGS.UNSAVED_DIALOG_DISCARD' | translate }}
</button>
<button
mat-flat-button
color="primary"
[disabled]="!data.canSave"
[mat-dialog-close]="'save'"
data-test-id="unsaved-dialog-save"
>
{{ 'SETTINGS.UNSAVED_DIALOG_SAVE' | translate }}
</button>
</mat-dialog-actions>
<div appDialogFooter class="unsaved-dialog__actions">
<button
mat-button
mat-dialog-close
cdkFocusInitial
data-test-id="unsaved-dialog-stay"
>
{{ 'CANCEL' | translate }}
</button>
<button
mat-button
class="app-destructive-button"
[mat-dialog-close]="'discard'"
data-test-id="unsaved-dialog-discard"
>
{{ 'SETTINGS.UNSAVED_DIALOG_DISCARD' | translate }}
</button>
<button
mat-flat-button
[disabled]="!data.canSave"
[mat-dialog-close]="'save'"
data-test-id="unsaved-dialog-save"
>
{{ 'SETTINGS.UNSAVED_DIALOG_SAVE' | translate }}
</button>
</div>
</app-dialog-shell>
`,
})
export class SettingsUnsavedChangesDialogComponent {
@@ -26,6 +26,7 @@ import {
injectTranslationTick,
} from '@iptvnator/services';
import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
import { dialogSize } from '@iptvnator/ui/components/dialog-size';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
Language,
@@ -422,7 +423,7 @@ export class SettingsComponent
{ canSave: boolean },
SettingsUnsavedChangesChoice
>(SettingsUnsavedChangesDialogComponent, {
width: '440px',
...dialogSize('sm'),
data: { canSave: this.settingsForm.valid },
})
.afterClosed()
+19 -2
View File
@@ -1180,6 +1180,21 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`.
action or a notice at the footer's start, `appDialogHeaderEnd` a chip
before the close button); a conditional action sits inside the wrapper,
since a control-flow block at the top level is projected into the body.
Every dialog uses it, apart from three that draw their own surface by
design: the command palette, the programme dialog and the EPG import
panel (a floating panel, not a dialog). A dialog with a hero band (the
account info and channel details dialogs) keeps the band as the body's
first block, edge to edge through negative margins, with its title and
subtitle in the shell's header and its status pills in
`appDialogHeaderEnd`; a media dialog (trailer, episode info) does the
same with its frame or still. Widths come from `dialogSize()`; the two
account dialogs keep their wider explicit width for their stats grid, and
the trailer its 880px for a 16:9 frame. A picker whose list should scroll
under fixed search and bulk actions sets `bodyLayout="column"`: the body
becomes a flex column, its wrapper `flex: 1 1 auto; min-height: 0` and
the list `flex: 1; overflow-y: auto`. A child cannot size itself from the
body's height (`height: 100%` never resolves on a flex item), so without
it the whole body scrolls and the controls scroll away.
Footer
buttons take the kit look from the shell: `mat-button` ghost,
`mat-flat-button` accent, `mat-flat-button.app-dialog__secondary`
@@ -1188,10 +1203,12 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`.
tinted blurred backdrop) is global in `m3-theme.scss` and reaches every
dialog; a dialog that draws its own surface strips it through its panel
class, as the command palette and the programme dialog do.
The shell caps itself at the viewport minus the preset's margins and
The shell sizes from the surface, so an opener's tighter `maxHeight`
holds, caps itself at the viewport minus the preset's margins otherwise, and
lets only the body scroll; under 600px of height the cap comes off and the
surface scrolls the whole dialog, so a stacked footer never squeezes the
body to a sliver.
body to a sliver. A dialog without a footer keeps its body scrolling
there: its header × is its only in-dialog dismissal and stays in place.
- **Dismiss.** The shell's header × (named "Close dialog", so a footer
"Close" stays the only button of that name) and the footer
"Cancel"/"Close" both dismiss; a dialog never adds a second footer
@@ -2,324 +2,275 @@
[formGroup]="playlistDetails"
(ngSubmit)="saveChanges(playlistDetails.value)"
>
<h2 mat-dialog-title>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_DETAILS' | translate }}
</h2>
<!-- The fieldset wraps the shell, so saving disables the footer too. -->
<fieldset
class="playlist-info-fields"
[disabled]="isSaving() || isHydratingStalkerPlaylist()"
[attr.aria-busy]="isSaving() || isHydratingStalkerPlaylist()"
>
<mat-dialog-content class="mat-typography">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.TITLE' | translate
}}</mat-label>
<input formControlName="title" matInput />
</mat-form-field>
@if (playlist.portalUrl) {
<app-dialog-shell
icon="tune"
[heading]="
'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_DETAILS' | translate
"
>
<div class="playlist-info-body">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
'HOME.PLAYLISTS.INFO_DIALOG.TITLE' | translate
}}</mat-label>
<input matInput formControlName="portalUrl" />
<mat-hint>{{
'HOME.STALKER_PORTAL.URL_HINT' | translate
}}</mat-hint>
<mat-error>{{
'HOME.STALKER_PORTAL.URL_VALIDATION_ERROR' | translate
}}</mat-error>
<input formControlName="title" matInput />
</mat-form-field>
}
@if (playlist.macAddress) {
<mat-form-field class="w-full" subscriptSizing="dynamic">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate
}}</mat-label>
<input
matInput
formControlName="macAddress"
(blur)="onMacAddressBlur()"
/>
<mat-error>{{
'HOME.STALKER_PORTAL.MAC_ADDRESS_ERROR' | translate
}}</mat-error>
</mat-form-field>
}
@if (playlist.portalUrl) {
@if (hasStoredStalkerDeviceIds) {
<p class="stalker-device-id-warning">
@if (playlist.portalUrl) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
}}</mat-label>
<input matInput formControlName="portalUrl" />
<mat-hint>{{
'HOME.STALKER_PORTAL.URL_HINT' | translate
}}</mat-hint>
<mat-error>{{
'HOME.STALKER_PORTAL.URL_VALIDATION_ERROR'
| translate
}}</mat-error>
</mat-form-field>
}
@if (playlist.macAddress) {
<mat-form-field class="w-full" subscriptSizing="dynamic">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate
}}</mat-label>
<input
matInput
formControlName="macAddress"
(blur)="onMacAddressBlur()"
/>
<mat-error>{{
'HOME.STALKER_PORTAL.MAC_ADDRESS_ERROR' | translate
}}</mat-error>
</mat-form-field>
}
@if (playlist.portalUrl) {
@if (hasStoredStalkerDeviceIds) {
<p class="stalker-device-id-warning">
{{
'HOME.STALKER_PORTAL.DEVICE_ID_PINNED_WARNING'
| translate
}}
</p>
}
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate
}}</mat-label>
<input matInput formControlName="stalkerSerialNumber" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate
}}</mat-label>
<input matInput formControlName="stalkerDeviceId1" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate
}}</mat-label>
<input matInput formControlName="stalkerDeviceId2" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.SIGNATURE_1' | translate
}}</mat-label>
<input matInput formControlName="stalkerSignature1" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.SIGNATURE_2' | translate
}}</mat-label>
<input matInput formControlName="stalkerSignature2" />
</mat-form-field>
}
@if (playlist.serverUrl) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
}}</mat-label>
<input matInput formControlName="serverUrl" />
</mat-form-field>
}
@if (playlist.portalUrl || playlist.username) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.USERNAME' | translate
}}</mat-label>
<input matInput formControlName="username" />
</mat-form-field>
}
@if (playlist.portalUrl || playlist.password) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.PASSWORD' | translate
}}</mat-label>
<input
matInput
[type]="passwordToggle.inputType()"
formControlName="password"
/>
<button
mat-icon-button
matSuffix
appPasswordVisibilityToggle
#passwordToggle="appPasswordVisibilityToggle"
[attr.aria-label]="'HOME.SHOW_PASSWORD' | translate"
>
<mat-icon>{{ passwordToggle.icon() }}</mat-icon>
</button>
</mat-form-field>
}
@if (playlist.serverUrl && !playlist.portalUrl) {
<p id="xtream-edit-http-test-notice">
{{
'HOME.STALKER_PORTAL.DEVICE_ID_PINNED_WARNING'
'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.HTTP_PERMISSION'
| translate
}}
</p>
}
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate
}}</mat-label>
<input matInput formControlName="stalkerSerialNumber" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate
}}</mat-label>
<input matInput formControlName="stalkerDeviceId1" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate
}}</mat-label>
<input matInput formControlName="stalkerDeviceId2" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.SIGNATURE_1' | translate
}}</mat-label>
<input matInput formControlName="stalkerSignature1" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.SIGNATURE_2' | translate
}}</mat-label>
<input matInput formControlName="stalkerSignature2" />
</mat-form-field>
}
@if (playlist.serverUrl) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
}}</mat-label>
<input matInput formControlName="serverUrl" />
</mat-form-field>
}
@if (playlist.portalUrl || playlist.username) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.USERNAME' | translate
}}</mat-label>
<input matInput formControlName="username" />
</mat-form-field>
}
@if (playlist.portalUrl || playlist.password) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.PASSWORD' | translate
}}</mat-label>
<input
matInput
[type]="passwordToggle.inputType()"
formControlName="password"
/>
<button
mat-icon-button
matSuffix
appPasswordVisibilityToggle
#passwordToggle="appPasswordVisibilityToggle"
[attr.aria-label]="'HOME.SHOW_PASSWORD' | translate"
>
<mat-icon>{{ passwordToggle.icon() }}</mat-icon>
</button>
</mat-form-field>
}
@if (playlist.serverUrl && !playlist.portalUrl) {
<p id="xtream-edit-http-test-notice">
{{
'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.HTTP_PERMISSION'
| translate
}}
</p>
<button
mat-stroked-button
type="button"
[disabled]="
connectionTest.testing() || playlistDetails.invalid
"
(click)="connectionTest.test(true)"
aria-describedby="xtream-edit-http-test-notice"
>
{{
'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.TEST_PROTOCOLS'
| translate
}}
</button>
@if (connectionTest.messageKey()) {
<p role="status" aria-live="polite">
{{
connectionTest.messageKey()
| translate: connectionTest.messageParams()
}}
</p>
}
}
@if (playlist.url) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.FROM_URL' | translate
}}</mat-label>
<input matInput formControlName="url" />
<button
mat-icon-button
matSuffix
mat-stroked-button
type="button"
(click)="copyUrl()"
[matTooltip]="
'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL' | translate
"
[attr.aria-label]="
'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL' | translate
[disabled]="
connectionTest.testing() || playlistDetails.invalid
"
(click)="connectionTest.test(true)"
aria-describedby="xtream-edit-http-test-notice"
>
<mat-icon>content_copy</mat-icon>
{{
'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.TEST_PROTOCOLS'
| translate
}}
</button>
</mat-form-field>
}
@if (playlist.filename) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.ORIGINAL_FILENAME'
| translate
}}</mat-label>
<input matInput formControlName="filename" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.IMPORT_DATE' | translate
}}</mat-label>
<input matInput formControlName="importDate" />
</mat-form-field>
}
@if (playlist.count) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.CHANNELS' | translate
}}</mat-label>
<input matInput formControlName="count" />
</mat-form-field>
}
@if (isDesktop || playlist.url) {
<mat-form-field class="w-full mb-10">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate
}}</mat-label>
<input formControlName="userAgent" matInput />
<mat-hint>{{
'HOME.PLAYLISTS.INFO_DIALOG.CUSTOM_USER_AGENT'
| translate
}}</mat-hint>
</mat-form-field>
}
@if (playlist.filePath) {
<mat-form-field class="w-full">
<mat-label>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.FILE_PATH' | translate }}
</mat-label>
<input formControlName="filePath" matInput />
@if (playlist.updateState === 2) {
<mat-hint>
@if (connectionTest.messageKey()) {
<p role="status" aria-live="polite">
{{
'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED'
connectionTest.messageKey()
| translate: connectionTest.messageParams()
}}
</p>
}
}
@if (playlist.url) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.FROM_URL' | translate
}}</mat-label>
<input matInput formControlName="url" />
<button
mat-icon-button
matSuffix
type="button"
(click)="copyUrl()"
[matTooltip]="
'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL'
| translate
"
[attr.aria-label]="
'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL'
| translate
"
>
<mat-icon>content_copy</mat-icon>
</button>
</mat-form-field>
}
@if (playlist.filename) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.ORIGINAL_FILENAME'
| translate
}}</mat-label>
<input matInput formControlName="filename" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.IMPORT_DATE' | translate
}}</mat-label>
<input matInput formControlName="importDate" />
</mat-form-field>
}
@if (playlist.count) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.CHANNELS' | translate
}}</mat-label>
<input matInput formControlName="count" />
</mat-form-field>
}
@if (isDesktop || playlist.url) {
<mat-form-field class="w-full mb-10">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate
}}</mat-label>
<input formControlName="userAgent" matInput />
<mat-hint>{{
'HOME.PLAYLISTS.INFO_DIALOG.CUSTOM_USER_AGENT'
| translate
}}</mat-hint>
</mat-form-field>
}
@if (playlist.filePath) {
<mat-form-field class="w-full">
<mat-label>
{{
'HOME.PLAYLISTS.INFO_DIALOG.FILE_PATH'
| translate
}}
</mat-hint>
}
</mat-form-field>
}
@if (canManagePlaylistEpgSources) {
<section
class="playlist-epg-sources"
data-test-id="playlist-epg-sources"
>
<div class="playlist-epg-sources__header">
<mat-icon>event_note</mat-icon>
<div>
<h3 class="playlist-epg-sources__title">
{{ 'SETTINGS.EPG_SOURCES' | translate }}
</h3>
<p>
@if (hiddenDetectedPlaylistEpgSourceCount > 0) {
{{
'SETTINGS.PLAYLIST_EPG_SOURCES_AUTO_SELECTED'
| translate
: {
enabled:
playlistEpgUrls.length,
detected:
playlistDetectedEpgUrls.length,
}
}}
} @else {
{{
'SETTINGS.EPG_SOURCES_DESCRIPTION'
| translate
}}
}
</p>
</div>
</div>
@for (epgUrl of playlistEpgUrls; track epgUrl) {
<div class="playlist-epg-source-row">
<mat-form-field
class="w-full"
subscriptSizing="dynamic"
>
<mat-label>{{
'SETTINGS.EPG_URL_LABEL' | translate
}}</mat-label>
<input matInput [value]="epgUrl" readonly />
</mat-form-field>
<button
mat-icon-button
type="button"
[disabled]="!canRefreshPlaylistEpg"
[matTooltip]="
'SETTINGS.REFRESH_EPG' | translate
"
[attr.aria-label]="
'SETTINGS.REFRESH_EPG' | translate
"
(click)="refreshPlaylistEpgSource(epgUrl)"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
type="button"
[disabled]="isGlobalEpgSource(epgUrl)"
[matTooltip]="
'SETTINGS.ADD_EPG_SOURCE' | translate
"
[attr.aria-label]="
'SETTINGS.ADD_EPG_SOURCE' | translate
"
(click)="addPlaylistEpgSourceToSettings(epgUrl)"
>
<mat-icon>playlist_add</mat-icon>
</button>
<button
mat-icon-button
type="button"
[matTooltip]="
'SETTINGS.REMOVE_EPG_SOURCE' | translate
"
[attr.aria-label]="
'SETTINGS.REMOVE_EPG_SOURCE' | translate
"
(click)="removePlaylistEpgSource(epgUrl)"
>
<mat-icon>delete</mat-icon>
</button>
</div>
}
<div
formArrayName="playlistEpgSourceInputs"
class="playlist-epg-source-form"
</mat-label>
<input formControlName="filePath" matInput />
@if (playlist.updateState === 2) {
<mat-hint>
{{
'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED'
| translate
}}
</mat-hint>
}
</mat-form-field>
}
@if (canManagePlaylistEpgSources) {
<section
class="playlist-epg-sources"
data-test-id="playlist-epg-sources"
>
@for (
control of playlistEpgSourceInputs.controls;
track $index
) {
<div class="playlist-epg-sources__header">
<mat-icon>event_note</mat-icon>
<div>
<h3 class="playlist-epg-sources__title">
{{ 'SETTINGS.EPG_SOURCES' | translate }}
</h3>
<p>
@if (
hiddenDetectedPlaylistEpgSourceCount > 0
) {
{{
'SETTINGS.PLAYLIST_EPG_SOURCES_AUTO_SELECTED'
| translate
: {
enabled:
playlistEpgUrls.length,
detected:
playlistDetectedEpgUrls.length,
}
}}
} @else {
{{
'SETTINGS.EPG_SOURCES_DESCRIPTION'
| translate
}}
}
</p>
</div>
</div>
@for (epgUrl of playlistEpgUrls; track epgUrl) {
<div class="playlist-epg-source-row">
<mat-form-field
class="w-full"
@@ -328,36 +279,38 @@
<mat-label>{{
'SETTINGS.EPG_URL_LABEL' | translate
}}</mat-label>
<input
matInput
[formControlName]="$index"
/>
@if (control.invalid && control.touched) {
<mat-error>{{
'SETTINGS.EPG_URL_ERROR' | translate
}}</mat-error>
}
<input matInput [value]="epgUrl" readonly />
</mat-form-field>
@if (canBrowseEpgFiles) {
<button
mat-icon-button
type="button"
data-test-id="playlist-epg-source-browse"
[matTooltip]="
'SETTINGS.BROWSE_EPG_FILE'
| translate
"
[attr.aria-label]="
'SETTINGS.BROWSE_EPG_FILE'
| translate
"
(click)="
browsePlaylistEpgSourceInput($index)
"
>
<mat-icon>folder_open</mat-icon>
</button>
}
<button
mat-icon-button
type="button"
[disabled]="!canRefreshPlaylistEpg"
[matTooltip]="
'SETTINGS.REFRESH_EPG' | translate
"
[attr.aria-label]="
'SETTINGS.REFRESH_EPG' | translate
"
(click)="refreshPlaylistEpgSource(epgUrl)"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
type="button"
[disabled]="isGlobalEpgSource(epgUrl)"
[matTooltip]="
'SETTINGS.ADD_EPG_SOURCE' | translate
"
[attr.aria-label]="
'SETTINGS.ADD_EPG_SOURCE' | translate
"
(click)="
addPlaylistEpgSourceToSettings(epgUrl)
"
>
<mat-icon>playlist_add</mat-icon>
</button>
<button
mat-icon-button
type="button"
@@ -367,94 +320,168 @@
[attr.aria-label]="
'SETTINGS.REMOVE_EPG_SOURCE' | translate
"
(click)="
removePlaylistEpgSourceInput($index)
"
(click)="removePlaylistEpgSource(epgUrl)"
>
<mat-icon>remove</mat-icon>
<mat-icon>delete</mat-icon>
</button>
</div>
}
<div class="playlist-epg-source-actions">
<button
mat-button
type="button"
(click)="addPlaylistEpgSourceInput()"
>
<mat-icon>add</mat-icon>
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
</button>
<button
mat-stroked-button
type="button"
[attr.aria-label]="
('HOME.PLAYLISTS.INFO_DIALOG.SAVE'
| translate) +
' ' +
('SETTINGS.EPG_SOURCES' | translate)
"
(click)="savePlaylistEpgSources()"
>
<mat-icon>playlist_add</mat-icon>
{{
'HOME.PLAYLISTS.INFO_DIALOG.SAVE'
| translate
}}
</button>
<div
formArrayName="playlistEpgSourceInputs"
class="playlist-epg-source-form"
>
@for (
control of playlistEpgSourceInputs.controls;
track $index
) {
<div class="playlist-epg-source-row">
<mat-form-field
class="w-full"
subscriptSizing="dynamic"
>
<mat-label>{{
'SETTINGS.EPG_URL_LABEL' | translate
}}</mat-label>
<input
matInput
[formControlName]="$index"
/>
@if (
control.invalid && control.touched
) {
<mat-error>{{
'SETTINGS.EPG_URL_ERROR'
| translate
}}</mat-error>
}
</mat-form-field>
@if (canBrowseEpgFiles) {
<button
mat-icon-button
type="button"
data-test-id="playlist-epg-source-browse"
[matTooltip]="
'SETTINGS.BROWSE_EPG_FILE'
| translate
"
[attr.aria-label]="
'SETTINGS.BROWSE_EPG_FILE'
| translate
"
(click)="
browsePlaylistEpgSourceInput(
$index
)
"
>
<mat-icon>folder_open</mat-icon>
</button>
}
<button
mat-icon-button
type="button"
[matTooltip]="
'SETTINGS.REMOVE_EPG_SOURCE'
| translate
"
[attr.aria-label]="
'SETTINGS.REMOVE_EPG_SOURCE'
| translate
"
(click)="
removePlaylistEpgSourceInput($index)
"
>
<mat-icon>remove</mat-icon>
</button>
</div>
}
<div class="playlist-epg-source-actions">
<button
mat-button
type="button"
(click)="addPlaylistEpgSourceInput()"
>
<mat-icon>add</mat-icon>
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
</button>
<button
mat-stroked-button
type="button"
[attr.aria-label]="
('HOME.PLAYLISTS.INFO_DIALOG.SAVE'
| translate) +
' ' +
('SETTINGS.EPG_SOURCES' | translate)
"
(click)="savePlaylistEpgSources()"
>
<mat-icon>playlist_add</mat-icon>
{{
'HOME.PLAYLISTS.INFO_DIALOG.SAVE'
| translate
}}
</button>
</div>
</div>
</div>
</section>
}
@if (isDesktop && (playlist.url || playlist.filePath)) {
<mat-checkbox class="w-full" formControlName="autoRefresh">
{{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }}
</mat-checkbox>
<p>
{{
'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE_DESCRIPTION'
| translate
}}
</p>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (!playlist.serverUrl && !playlist.portalUrl) {
</section>
}
@if (isDesktop && (playlist.url || playlist.filePath)) {
<mat-checkbox class="w-full" formControlName="autoRefresh">
{{
'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate
}}
</mat-checkbox>
<p>
{{
'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE_DESCRIPTION'
| translate
}}
</p>
}
</div>
<!-- Export is a third action at the footer's start; the decision
pair (Close, Save) keeps the end. -->
<div appDialogFooterStart>
@if (!playlist.serverUrl && !playlist.portalUrl) {
<button
mat-flat-button
class="app-dialog__secondary"
type="button"
(click)="exportPlaylist()"
>
<mat-icon>download</mat-icon>
{{
'HOME.PLAYLISTS.INFO_DIALOG.EXPORT_PLAYLIST'
| translate
}}
</button>
}
</div>
<div appDialogFooter>
<button mat-button mat-dialog-close>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
<button
mat-flat-button
color="primary"
type="button"
(click)="exportPlaylist()"
type="submit"
[disabled]="
!playlistDetails.valid ||
playlistDetails.pristine ||
connectionTest.testing() ||
isSaving() ||
isHydratingStalkerPlaylist() ||
stalkerPlaylistHydrationFailed()
"
>
<mat-icon>download</mat-icon>
{{
'HOME.PLAYLISTS.INFO_DIALOG.EXPORT_PLAYLIST' | translate
(isSaving()
? 'HOME.STALKER_PORTAL.VALIDATING'
: 'HOME.PLAYLISTS.INFO_DIALOG.SAVE'
) | translate
}}
</button>
}
<span class="spacer"></span>
<button mat-button mat-dialog-close>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
<button
mat-flat-button
color="primary"
type="submit"
[disabled]="
!playlistDetails.valid ||
playlistDetails.pristine ||
connectionTest.testing() ||
isSaving() ||
isHydratingStalkerPlaylist() ||
stalkerPlaylistHydrationFailed()
"
>
{{
(isSaving()
? 'HOME.STALKER_PORTAL.VALIDATING'
: 'HOME.PLAYLISTS.INFO_DIALOG.SAVE'
) | translate
}}
</button>
</mat-dialog-actions>
</div>
</app-dialog-shell>
</fieldset>
</form>
@@ -63,6 +63,11 @@ import {
STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS,
} from './stalker-playlist-connection-editor.token';
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
import {
DialogFooterDirective,
DialogFooterStartDirective,
DialogShellComponent,
} from '@iptvnator/ui/components';
type DesktopFileSaveBridge = Pick<
typeof window.electron,
@@ -74,10 +79,6 @@ type DesktopFileSaveBridge = Pick<
templateUrl: './playlist-info.component.html',
styles: [
`
.spacer {
flex: 1 1 auto;
}
.playlist-info-fields {
min-width: 0;
margin: 0;
@@ -85,32 +86,30 @@ type DesktopFileSaveBridge = Pick<
border: 0;
}
mat-dialog-content {
// The shell renders the body; this wrapper lays the fields out.
.playlist-info-body {
display: flex;
flex-direction: column;
gap: 14px;
}
// Material's '.mat-mdc-dialog-title + .mat-mdc-dialog-content' rule
// zeroes padding-top with higher specificity than a scoped override,
// which clips the first field's floating label. Pushing the first
// field down with margin-top side-steps that entirely.
mat-dialog-content > mat-form-field:first-child {
margin-top: 10px;
// Room for the first field's floating label under the header.
.playlist-info-body > mat-form-field:first-child {
margin-top: 6px;
}
mat-dialog-content mat-checkbox {
.playlist-info-body mat-checkbox {
margin-top: 4px;
}
mat-dialog-content p {
.playlist-info-body p {
margin: 0;
color: var(--mat-sys-on-surface-variant);
font-size: 12.5px;
line-height: 1.45;
}
mat-dialog-content p.stalker-device-id-warning {
.playlist-info-body p.stalker-device-id-warning {
color: var(--mat-sys-error);
}
@@ -169,6 +168,9 @@ type DesktopFileSaveBridge = Pick<
providers: [provideFormFieldDefaults(), DatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
DialogFooterDirective,
DialogFooterStartDirective,
DialogShellComponent,
ClipboardModule,
MatButton,
MatCheckboxModule,
@@ -21,6 +21,10 @@ import {
SourceCleanupService,
} from '@iptvnator/portal/shared/data-access';
import { PlaylistMeta, sourceHealthType } from '@iptvnator/shared/interfaces';
import {
DialogFooterDirective,
DialogShellComponent,
} from '@iptvnator/ui/components';
export interface SourceCleanupDialogData extends SourceCleanupContext {
playlists: readonly PlaylistMeta[];
@@ -29,6 +33,8 @@ export interface SourceCleanupDialogData extends SourceCleanupContext {
selector: 'app-source-cleanup-dialog',
imports: [
DatePipe,
DialogFooterDirective,
DialogShellComponent,
MatDialogModule,
MatButtonModule,
MatCheckboxModule,
@@ -36,10 +42,15 @@ export interface SourceCleanupDialogData extends SourceCleanupContext {
TranslatePipe,
],
providers: [SourceCleanupService],
template: ` <h2 mat-dialog-title>
{{ 'SOURCE_CLEANUP.TITLE' | translate }}
</h2>
<mat-dialog-content>
template: `
<!-- While sources are being deleted, Stop is the one way out: the
header close would end the dialog and the batch with it. -->
<app-dialog-shell
icon="cleaning_services"
iconTone="warn"
[heading]="'SOURCE_CLEANUP.TITLE' | translate"
[showClose]="!locked()"
>
<p>{{ 'SOURCE_CLEANUP.SCOPE' | translate }}</p>
<p>{{ 'SOURCE_CLEANUP.CONSEQUENCES' | translate }}</p>
@if (model.phase() === 'checking' || model.phase() === 'deleting') {
@@ -154,42 +165,40 @@ export interface SourceCleanupDialogData extends SourceCleanupContext {
}
}
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (locked()) {
<button
mat-button
[disabled]="model.stopRequested()"
(click)="model.stop()"
>
{{ 'SOURCE_CLEANUP.STOP' | translate }}
</button>
} @else {
<button mat-button mat-dialog-close>
{{ 'SOURCE_CLEANUP.CLOSE' | translate }}
</button>
<button
mat-flat-button
class="app-destructive-button"
[disabled]="!selectedCount() || checking()"
(click)="model.removeSelected()"
>
{{
'SOURCE_CLEANUP.DELETE'
| translate: { count: selectedCount() }
}}
</button>
}
</mat-dialog-actions>`,
<div appDialogFooter>
@if (locked()) {
<button
mat-button
[disabled]="model.stopRequested()"
(click)="model.stop()"
>
{{ 'SOURCE_CLEANUP.STOP' | translate }}
</button>
} @else {
<button mat-button mat-dialog-close>
{{ 'SOURCE_CLEANUP.CLOSE' | translate }}
</button>
<button
mat-flat-button
class="app-destructive-button"
[disabled]="!selectedCount() || checking()"
(click)="model.removeSelected()"
>
{{
'SOURCE_CLEANUP.DELETE'
| translate: { count: selectedCount() }
}}
</button>
}
</div>
</app-dialog-shell>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
display: block;
}
mat-dialog-content {
min-width: 0;
}
.selection-actions {
display: flex;
flex-wrap: wrap;
@@ -1,33 +1,15 @@
<header class="downloaded-series-dialog__header">
<div>
<span class="downloaded-series-dialog__eyebrow">
{{ 'DOWNLOADS.DOWNLOADED_EPISODES' | translate }}
</span>
<h2 mat-dialog-title>{{ group.title }}</h2>
<p>
{{
episodeCountKey(currentMembers().length)
| translate: { count: currentMembers().length }
}}
@if (group.sourceName.trim()) {
<span aria-hidden="true">·</span>
{{ group.sourceName }}
}
</p>
</div>
<button
mat-icon-button
type="button"
mat-dialog-close
data-test-id="downloaded-series-close"
[aria-label]="'CLOSE' | translate"
[matTooltip]="'CLOSE' | translate"
>
<mat-icon aria-hidden="true">close</mat-icon>
</button>
</header>
<mat-dialog-content>
<app-dialog-shell
icon="download_done"
[heading]="group.title"
[subtitle]="
(episodeCountKey(currentMembers().length)
| translate: { count: currentMembers().length }) +
(group.sourceName.trim() ? ' · ' + group.sourceName : '')
"
>
<span appDialogHeaderEnd class="downloaded-series-dialog__eyebrow">
{{ 'DOWNLOADS.DOWNLOADED_EPISODES' | translate }}
</span>
<ol class="downloaded-series-dialog__list">
@for (member of currentMembers(); track member.id) {
<li
@@ -114,4 +96,4 @@
</li>
}
</ol>
</mat-dialog-content>
</app-dialog-shell>
@@ -7,31 +7,6 @@
color: var(--app-body-color);
}
.downloaded-series-dialog__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 22px 24px 14px;
border-bottom: 1px solid var(--app-separator);
background: var(--app-widget-header-bg);
h2[mat-dialog-title] {
margin: 3px 0 0;
padding: 0;
color: var(--app-heading-color);
font-size: 1.35rem;
font-weight: 700;
line-height: 1.25;
}
p {
margin: 6px 0 0;
color: var(--app-muted-color);
font-size: 0.78rem;
}
}
.downloaded-series-dialog__eyebrow {
color: var(--app-eyebrow-color);
font-size: 0.68rem;
@@ -40,12 +15,6 @@
text-transform: uppercase;
}
mat-dialog-content {
max-height: min(66vh, 620px);
padding: 10px 12px 18px;
background: var(--app-widget-bg);
}
.downloaded-series-dialog__list {
display: grid;
gap: 4px;
@@ -136,10 +105,6 @@ button:disabled {
}
@media (max-width: 620px) {
.downloaded-series-dialog__header {
padding: 18px 16px 12px;
}
.downloaded-series-dialog__episode {
grid-template-columns: minmax(0, 1fr);
gap: 6px;
@@ -9,6 +9,7 @@ import { DownloadedSeriesDialogComponent } from './downloaded-series-dialog.comp
const TRANSLATIONS = {
CLOSE: 'Close',
CLOSE_DIALOG: 'Close dialog',
DOWNLOADS: {
COPY_URL: 'Copy download URL',
DOWNLOADED_EPISODES: 'Downloaded episodes',
@@ -124,12 +125,12 @@ describe('DownloadedSeriesDialogComponent', () => {
expect(rows()[2].textContent).toContain('S02E01');
});
it('gives the dialog close button an accessible name', () => {
it('closes through the shell header button, named for assistive tech', () => {
const closeButton = fixture.nativeElement.querySelector(
'[data-test-id="downloaded-series-close"]'
'.app-dialog__close'
) as HTMLButtonElement | null;
expect(closeButton?.getAttribute('aria-label')).toBe('Close');
expect(closeButton?.getAttribute('aria-label')).toBe('Close dialog');
});
it('includes the concrete episode identity in every action name', () => {
@@ -17,6 +17,10 @@ import type {
DownloadItemActionType,
} from './download-actions';
import type { DownloadSeriesCardViewModel } from './download-library.viewmodel';
import {
DialogHeaderEndDirective,
DialogShellComponent,
} from '@iptvnator/ui/components';
const EMPTY_PENDING_IDS: ReadonlySet<number> = new Set();
@@ -24,6 +28,8 @@ const EMPTY_PENDING_IDS: ReadonlySet<number> = new Set();
selector: 'app-downloaded-series-dialog',
standalone: true,
imports: [
DialogHeaderEndDirective,
DialogShellComponent,
MatButtonModule,
MatDialogModule,
MatIconModule,
@@ -898,8 +898,8 @@ describe('DownloadsComponent', () => {
expect.any(Function),
expect.objectContaining({
data: group,
maxWidth: 'min(720px, calc(100vw - 32px))',
width: 'min(720px, calc(100vw - 32px))',
maxWidth: 'calc(100vw - 32px)',
width: '760px',
})
);
expect(dialogSetInput).toHaveBeenCalledWith(
@@ -54,6 +54,7 @@ import { RecordingLibraryComponent } from './recording-library.component';
import { RecordingManagerActionsService } from './recording-manager-actions.service';
import { buildRecordingManagerViewModel } from './recording-manager.viewmodel';
import { RecordingQueueComponent } from './recording-queue.component';
import { dialogSize } from '@iptvnator/ui/components/dialog-size';
const FILTER_KEYS = {
all: 'DOWNLOADS.FILTER.ALL',
@@ -308,10 +309,8 @@ export class DownloadsComponent {
)?.members ?? []
);
const ref = this.dialog.open(DownloadedSeriesDialogComponent, {
...dialogSize('lg'),
data: group,
width: 'min(720px, calc(100vw - 32px))',
maxWidth: 'min(720px, calc(100vw - 32px))',
maxHeight: 'min(760px, calc(100vh - 32px))',
});
ref.componentRef?.setInput('members', liveMembers);
ref.componentRef?.setInput('pendingIds', this.pendingIds);
@@ -213,8 +213,8 @@ describe('GlobalFavoritesListComponent', () => {
ChannelDetailsDialogComponent,
expect.objectContaining({
data: channel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
maxWidth: 'calc(100vw - 32px)',
width: '760px',
})
);
});
@@ -296,11 +296,7 @@ export class GlobalFavoritesListComponent {
}
this.channelContextMenu().close();
this.dialog.open(ChannelDetailsDialogComponent, {
data: channel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
});
ChannelDetailsDialogComponent.open(this.dialog, channel);
}
removeContextMenuChannel(): void {
@@ -1,236 +1,226 @@
<section mat-dialog-title class="account-dialog__hero">
<div class="account-dialog__hero-copy">
<div class="account-dialog__eyebrow">
<span class="account-dialog__eyebrow-mark"></span>
Stalker
@if (isFullPortal) {
<span class="account-dialog__eyebrow-divider"></span>
{{ 'STALKER.ACCOUNT_INFO.PORTAL_TYPE_FULL' | translate }}
}
</div>
<div class="account-dialog__headline-row">
<div>
<h2 class="account-dialog__title">
{{ 'STALKER.ACCOUNT_INFO.TITLE' | translate }}
</h2>
<p class="account-dialog__subtitle">
{{ playlistLabel() }}
</p>
</div>
<div class="account-dialog__status-cluster">
@if (statusKind(); as kind) {
<span
class="account-dialog__status-pill"
[class.account-dialog__status-pill--active]="
kind === 'active'
"
>
@if (kind === 'active') {
{{ 'STALKER.ACCOUNT_INFO.ACTIVE' | translate }}
} @else {
{{ 'STALKER.ACCOUNT_INFO.INACTIVE' | translate }}
}
</span>
<app-dialog-shell
icon="account_circle"
[heading]="'STALKER.ACCOUNT_INFO.TITLE' | translate"
[subtitle]="playlistLabel()"
>
<div appDialogHeaderEnd class="account-dialog__status-cluster">
@if (statusKind(); as kind) {
<span
class="account-dialog__status-pill"
[class.account-dialog__status-pill--active]="kind === 'active'"
>
@if (kind === 'active') {
{{ 'STALKER.ACCOUNT_INFO.ACTIVE' | translate }}
} @else {
{{ 'STALKER.ACCOUNT_INFO.INACTIVE' | translate }}
}
@if (isCachedSnapshot() && loadState() === 'ready') {
<span
class="account-dialog__status-pill account-dialog__status-pill--cached"
>
{{ 'STALKER.ACCOUNT_INFO.CACHED_BADGE' | translate }}
</span>
}
</div>
</div>
@if (refreshFailed()) {
<div class="account-dialog__message">
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.DESCRIPTION'
: 'STALKER.ACCOUNT_INFO.REFRESH_FAILED'
) | translate
}}
@if (requestsPaused()) {
<button mat-button (click)="reload()">
{{ 'PORTALS.REQUESTS_PAUSED.RETRY' | translate }}
</button>
}
</div>
} @else if (loadState() === 'loading') {
<p class="account-dialog__loading-copy">
{{ 'LOADING' | translate }}...
</p>
</span>
}
@if (isCachedSnapshot() && loadState() === 'ready') {
<span
class="account-dialog__status-pill account-dialog__status-pill--cached"
>
{{ 'STALKER.ACCOUNT_INFO.CACHED_BADGE' | translate }}
</span>
}
</div>
<div class="account-dialog__hero-stats">
@if (loadState() === 'ready') {
@for (stat of heroStats(); track stat.labelKey) {
<article class="account-stat">
<div class="account-stat__icon">
<mat-icon>{{ stat.icon }}</mat-icon>
</div>
<div class="account-stat__label">
{{ stat.labelKey | translate }}
</div>
<div
class="account-stat__value"
[class.account-stat__value--warning]="stat.warning"
>
{{ stat.value }}
</div>
</article>
}
} @else {
@for (item of skeletonStats; track item) {
<div class="account-skeleton account-skeleton--stat"></div>
}
}
</div>
</section>
<mat-dialog-content class="account-dialog__content">
@if (loadState() === 'error') {
<section class="account-dialog__error-state">
<div class="account-dialog__error-icon">
<mat-icon>signal_wifi_connected_no_internet_4</mat-icon>
<section class="account-dialog__hero">
<div class="account-dialog__hero-copy">
<div class="account-dialog__eyebrow">
<span class="account-dialog__eyebrow-mark"></span>
Stalker
@if (isFullPortal) {
<span class="account-dialog__eyebrow-divider"></span>
{{ 'STALKER.ACCOUNT_INFO.PORTAL_TYPE_FULL' | translate }}
}
</div>
<h3>
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.TITLE'
: 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE'
) | translate
}}
</h3>
<p>
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.DESCRIPTION'
: 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION'
) | translate
}}
</p>
<button mat-flat-button color="primary" (click)="reload()">
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.RETRY'
: 'RETRY'
) | translate
}}
</button>
</section>
} @else if (loadState() === 'loading') {
<div class="account-dialog__grid">
@for (panel of skeletonPanels; track panel) {
<section class="account-panel account-panel--loading">
<div
class="account-skeleton account-skeleton--heading"
></div>
<div class="account-skeleton account-skeleton--row"></div>
<div class="account-skeleton account-skeleton--row"></div>
<div
class="account-skeleton account-skeleton--row short"
></div>
</section>
}
</div>
} @else {
<div class="account-dialog__grid">
<section class="account-panel">
<div class="account-panel__header">
<p class="account-panel__eyebrow">
{{ 'STALKER.ACCOUNT_INFO.ACCOUNT' | translate }}
</p>
<h3>{{ snapshot()?.login || playlistLabel() }}</h3>
@if (refreshFailed()) {
<div class="account-dialog__message">
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.DESCRIPTION'
: 'STALKER.ACCOUNT_INFO.REFRESH_FAILED'
) | translate
}}
@if (requestsPaused()) {
<button mat-button (click)="reload()">
{{ 'PORTALS.REQUESTS_PAUSED.RETRY' | translate }}
</button>
}
</div>
} @else if (loadState() === 'loading') {
<p class="account-dialog__loading-copy">
{{ 'LOADING' | translate }}...
</p>
}
</div>
<div class="account-dialog__hero-stats">
@if (loadState() === 'ready') {
@for (stat of heroStats(); track stat.labelKey) {
<article class="account-stat">
<div class="account-stat__icon">
<mat-icon>{{ stat.icon }}</mat-icon>
</div>
<div class="account-stat__label">
{{ stat.labelKey | translate }}
</div>
<div
class="account-stat__value"
[class.account-stat__value--warning]="stat.warning"
>
{{ stat.value }}
</div>
</article>
}
} @else {
@for (item of skeletonStats; track item) {
<div class="account-skeleton account-skeleton--stat"></div>
}
}
</div>
</section>
<div class="account-dialog__content">
@if (loadState() === 'error') {
<section class="account-dialog__error-state">
<div class="account-dialog__error-icon">
<mat-icon>signal_wifi_connected_no_internet_4</mat-icon>
</div>
<h3>
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.TITLE'
: 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE'
) | translate
}}
</h3>
<p>
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.DESCRIPTION'
: 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION'
) | translate
}}
</p>
<button mat-flat-button color="primary" (click)="reload()">
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.RETRY'
: 'RETRY'
) | translate
}}
</button>
</section>
} @else if (loadState() === 'loading') {
<div class="account-dialog__grid">
@for (panel of skeletonPanels; track panel) {
<section class="account-panel account-panel--loading">
<div
class="account-skeleton account-skeleton--heading"
></div>
<div
class="account-skeleton account-skeleton--row"
></div>
<div
class="account-skeleton account-skeleton--row"
></div>
<div
class="account-skeleton account-skeleton--row short"
></div>
</section>
}
</div>
} @else {
<div class="account-dialog__grid">
<section class="account-panel">
<div class="account-panel__header">
<p class="account-panel__eyebrow">
{{ 'STALKER.ACCOUNT_INFO.ACCOUNT' | translate }}
</p>
<h3>{{ snapshot()?.login || playlistLabel() }}</h3>
</div>
@if (accountDetails().length > 0) {
<dl class="account-detail-list">
@for (row of accountDetails(); track row.labelKey) {
<div class="account-detail-row">
<dt>{{ row.labelKey | translate }}</dt>
<dd
[class.account-detail-row__value--mono]="
row.mono
"
[class.account-detail-row__value--positive]="
row.tone === 'positive'
"
[class.account-detail-row__value--warning]="
row.tone === 'warning'
"
>
@if (translatedRowKeys.has(row.value)) {
{{ row.value | translate }}
} @else {
{{ row.value }}
}
</dd>
</div>
}
</dl>
} @else {
<div class="account-dialog__empty-copy">
<h4>
{{
'STALKER.ACCOUNT_INFO.NO_ACCOUNT_DATA_TITLE'
| translate
}}
</h4>
<p>
{{
'STALKER.ACCOUNT_INFO.NO_ACCOUNT_DATA_DESCRIPTION'
| translate
}}
</p>
</div>
}
</section>
<section class="account-panel">
<div class="account-panel__header">
<p class="account-panel__eyebrow">
{{ 'STALKER.ACCOUNT_INFO.PORTAL_INFO' | translate }}
</p>
<h3>
@if (isFullPortal) {
{{
'STALKER.ACCOUNT_INFO.PORTAL_TYPE_FULL'
| translate
}}
} @else {
{{
'STALKER.ACCOUNT_INFO.PORTAL_TYPE_LEGACY'
| translate
}}
}
</h3>
</div>
@if (accountDetails().length > 0) {
<dl class="account-detail-list">
@for (row of accountDetails(); track row.labelKey) {
@for (row of portalDetails(); track row.labelKey) {
<div class="account-detail-row">
<dt>{{ row.labelKey | translate }}</dt>
<dd
[class.account-detail-row__value--mono]="
row.mono
"
[class.account-detail-row__value--positive]="
row.tone === 'positive'
"
[class.account-detail-row__value--warning]="
row.tone === 'warning'
"
>
@if (translatedRowKeys.has(row.value)) {
{{ row.value | translate }}
} @else {
{{ row.value }}
}
{{ row.value }}
</dd>
</div>
}
</dl>
} @else {
<div class="account-dialog__empty-copy">
<h4>
{{
'STALKER.ACCOUNT_INFO.NO_ACCOUNT_DATA_TITLE'
| translate
}}
</h4>
<p>
{{
'STALKER.ACCOUNT_INFO.NO_ACCOUNT_DATA_DESCRIPTION'
| translate
}}
</p>
</div>
}
</section>
<section class="account-panel">
<div class="account-panel__header">
<p class="account-panel__eyebrow">
{{ 'STALKER.ACCOUNT_INFO.PORTAL_INFO' | translate }}
</p>
<h3>
@if (isFullPortal) {
{{
'STALKER.ACCOUNT_INFO.PORTAL_TYPE_FULL'
| translate
}}
} @else {
{{
'STALKER.ACCOUNT_INFO.PORTAL_TYPE_LEGACY'
| translate
}}
}
</h3>
</div>
<dl class="account-detail-list">
@for (row of portalDetails(); track row.labelKey) {
<div class="account-detail-row">
<dt>{{ row.labelKey | translate }}</dt>
<dd
[class.account-detail-row__value--mono]="
row.mono
"
>
{{ row.value }}
</dd>
</div>
}
</dl>
</section>
</div>
}
</mat-dialog-content>
<mat-dialog-actions class="account-dialog__actions">
<button mat-stroked-button mat-dialog-close class="account-dialog__close">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
</section>
</div>
}
</div>
</app-dialog-shell>
@@ -27,6 +27,10 @@ import {
PlaylistMeta,
type StalkerAccountInfoDialogData,
} from '@iptvnator/shared/interfaces';
import {
DialogHeaderEndDirective,
DialogShellComponent,
} from '@iptvnator/ui/components';
type AccountLoadState = 'loading' | 'ready' | 'error';
@@ -51,7 +55,14 @@ const DAY_IN_MS = 86_400_000;
@Component({
selector: 'app-stalker-account-info',
imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe],
imports: [
DialogHeaderEndDirective,
DialogShellComponent,
MatButtonModule,
MatDialogModule,
MatIconModule,
TranslatePipe,
],
templateUrl: './stalker-account-info.component.html',
styleUrl: './stalker-account-info.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -1,108 +1,117 @@
<h2 mat-dialog-title>{{ 'PARENTAL_LOCK.MANAGE_LOCKS' | translate }}</h2>
<mat-dialog-content>
<p class="lock-dialog__description">
{{ 'PARENTAL_LOCK.MANAGE_LOCKS_DESCRIPTION' | translate }}
</p>
<div class="header-actions">
<div class="selection-info">
{{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong> / {{ categories().length }}
</div>
<div class="bulk-actions">
@if (censoredCount() > 0) {
<app-dialog-shell
icon="lock"
[heading]="'PARENTAL_LOCK.MANAGE_LOCKS' | translate"
[subtitle]="'PARENTAL_LOCK.MANAGE_LOCKS_DESCRIPTION' | translate"
bodyLayout="column"
[showClose]="!isSaving()"
>
<div class="lock-dialog">
<div class="header-actions">
<div class="selection-info">
{{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong> / {{ categories().length }}
</div>
<div class="bulk-actions">
@if (censoredCount() > 0) {
<button
mat-stroked-button
type="button"
data-test-id="stalker-lock-censored"
[disabled]="isSaving()"
(click)="lockCensored()"
>
{{ 'PARENTAL_LOCK.LOCK_CENSORED' | translate }}
</button>
}
<button
mat-stroked-button
type="button"
data-test-id="stalker-lock-censored"
[disabled]="isSaving()"
(click)="lockCensored()"
[disabled]="isSaving() || lockedCount() === 0"
(click)="unlockAll()"
>
{{ 'PARENTAL_LOCK.LOCK_CENSORED' | translate }}
{{ 'PARENTAL_LOCK.UNLOCK_ALL' | translate }}
</button>
</div>
</div>
<label class="search-inline">
<mat-icon class="search-inline__icon">search</mat-icon>
<input
type="search"
spellcheck="false"
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
[placeholder]="
'XTREAM.CATEGORY_MANAGEMENT.SEARCH_PLACEHOLDER' | translate
"
/>
@if (searchTerm()) {
<button
type="button"
mat-icon-button
class="search-inline__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
</button>
}
<button
mat-stroked-button
type="button"
[disabled]="isSaving() || lockedCount() === 0"
(click)="unlockAll()"
>
{{ 'PARENTAL_LOCK.UNLOCK_ALL' | translate }}
</button>
</label>
<div class="categories-list">
@for (category of filteredCategories(); track category.id) {
<button
type="button"
class="category-item"
[class.category-item--locked]="category.locked"
[attr.aria-pressed]="category.locked"
[attr.data-test-id]="'stalker-category-lock-' + category.id"
(click)="toggle(category)"
>
<mat-icon class="category-item__icon">{{
category.locked ? 'lock' : 'lock_open'
}}</mat-icon>
<span class="category-name">{{ category.name }}</span>
@if (category.censored) {
<span
class="censored-chip"
[matTooltip]="
'PARENTAL_LOCK.CENSORED_HINT' | translate
"
>18+</span
>
}
</button>
} @empty {
<div class="empty-message">
{{ 'XTREAM.CATEGORY_MANAGEMENT.NO_RESULTS' | translate }}
</div>
}
</div>
</div>
<label class="search-inline">
<mat-icon class="search-inline__icon">search</mat-icon>
<input
type="search"
spellcheck="false"
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
[placeholder]="'XTREAM.CATEGORY_MANAGEMENT.SEARCH_PLACEHOLDER' | translate"
/>
@if (searchTerm()) {
<button
type="button"
mat-icon-button
class="search-inline__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
</button>
}
</label>
<div class="categories-list">
@for (category of filteredCategories(); track category.id) {
<button
type="button"
class="category-item"
[class.category-item--locked]="category.locked"
[attr.aria-pressed]="category.locked"
[attr.data-test-id]="'stalker-category-lock-' + category.id"
(click)="toggle(category)"
>
<mat-icon class="category-item__icon">{{
category.locked ? 'lock' : 'lock_open'
}}</mat-icon>
<span class="category-name">{{ category.name }}</span>
@if (category.censored) {
<span
class="censored-chip"
[matTooltip]="'PARENTAL_LOCK.CENSORED_HINT' | translate"
>18+</span
>
}
</button>
} @empty {
<div class="empty-message">
{{ 'XTREAM.CATEGORY_MANAGEMENT.NO_RESULTS' | translate }}
</div>
}
<div appDialogFooter>
<button
mat-button
type="button"
(click)="cancel()"
[disabled]="isSaving()"
>
{{ 'CLOSE' | translate }}
</button>
<button
mat-flat-button
type="button"
data-test-id="stalker-category-lock-save"
(click)="save()"
[disabled]="isSaving()"
>
@if (isSaving()) {
<mat-spinner diameter="20" />
} @else {
{{ 'XTREAM.CATEGORY_MANAGEMENT.SAVE' | translate }}
}
</button>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="cancel()" [disabled]="isSaving()">
{{ 'CLOSE' | translate }}
</button>
<button
mat-flat-button
color="primary"
type="button"
data-test-id="stalker-category-lock-save"
(click)="save()"
[disabled]="isSaving()"
>
@if (isSaving()) {
<mat-spinner diameter="20" />
} @else {
{{ 'XTREAM.CATEGORY_MANAGEMENT.SAVE' | translate }}
}
</button>
</mat-dialog-actions>
</app-dialog-shell>
@@ -10,16 +10,15 @@
--lock-dialog-error-color: #ffb4ab;
}
.mat-mdc-dialog-content {
// The list is the only part that scrolls: inside the shell's column body
// the wrapper flexes to the height left under the cap, and the list flexes
// inside it, so the search and bulk actions stay put.
.lock-dialog {
display: flex;
flex-direction: column;
gap: 10px;
}
.lock-dialog__description {
margin: 0;
font-size: 0.8125rem;
color: var(--app-body-color);
flex: 1 1 auto;
min-height: 0;
}
.header-actions {
@@ -103,7 +102,6 @@
.categories-list {
flex: 1;
min-height: 200px;
max-height: 50vh;
overflow-y: auto;
border: 1px solid var(--app-separator);
border-radius: 8px;
@@ -177,8 +175,3 @@
color: var(--app-body-color);
font-size: 0.8125rem;
}
// No min-width: the opener caps the panel at the viewport on a phone.
:host ::ng-deep .mat-mdc-dialog-content {
max-width: 600px;
}
@@ -48,6 +48,19 @@ describe('StalkerCategoryLockDialogComponent', () => {
fixture.detectChanges();
});
it('hides the header close while a save is in flight, so the caller always gets a result', () => {
const headerClose = () =>
(fixture.nativeElement as HTMLElement).querySelector(
'.app-dialog__close'
);
expect(headerClose()).not.toBeNull();
component.isSaving.set(true);
fixture.detectChanges();
expect(headerClose()).toBeNull();
});
it('seeds the draft from the stored locks', () => {
expect(component.categories().map((c) => c.locked)).toEqual([
false,
@@ -20,6 +20,10 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { StalkerCategoryItem } from '@iptvnator/portal/stalker/data-access';
import { ParentalLockService } from '@iptvnator/services';
import { ParentalLockStalkerCategoryType } from '@iptvnator/shared/interfaces';
import {
DialogFooterDirective,
DialogShellComponent,
} from '@iptvnator/ui/components';
export interface StalkerCategoryLockDialogData {
playlistId: string;
@@ -43,6 +47,8 @@ interface LockableCategory {
@Component({
selector: 'app-stalker-category-lock-dialog',
imports: [
DialogFooterDirective,
DialogShellComponent,
MatButtonModule,
MatDialogModule,
MatIconModule,
@@ -1,234 +1,232 @@
<section mat-dialog-title class="account-dialog__hero">
<div class="account-dialog__hero-copy">
<div class="account-dialog__eyebrow">
<span class="account-dialog__eyebrow-mark"></span>
@if (accountInfo(); as info) {
{{ info.server_info.server_protocol || 'Xtream' }}
@if (info.server_info.timezone) {
<span class="account-dialog__eyebrow-divider"></span>
{{ info.server_info.timezone }}
<app-dialog-shell
icon="account_circle"
[heading]="'XTREAM.ACCOUNT_INFO.TITLE' | translate"
[subtitle]="playlistLabel()"
>
<!-- The slot wrapper stays outside the condition: a control-flow block
at the content's top level would be projected into the body. -->
<div appDialogHeaderEnd class="account-dialog__status-cluster">
@if (accountInfo(); as info) {
<span
class="account-dialog__status-pill"
[class.account-dialog__status-pill--active]="isActive()"
>
{{ info.user_info.status || '-' }}
</span>
@if (isTrial()) {
<span
class="account-dialog__status-pill account-dialog__status-pill--trial"
>
{{ 'XTREAM.ACCOUNT_INFO.TRIAL_ACCOUNT' | translate }}
</span>
}
}
</div>
<section class="account-dialog__hero">
<div class="account-dialog__hero-copy">
<div class="account-dialog__eyebrow">
<span class="account-dialog__eyebrow-mark"></span>
@if (accountInfo(); as info) {
{{ info.server_info.server_protocol || 'Xtream' }}
@if (info.server_info.timezone) {
<span class="account-dialog__eyebrow-divider"></span>
{{ info.server_info.timezone }}
}
} @else {
Xtream
}
</div>
@if (accountInfo()?.user_info?.message) {
<div class="account-dialog__message">
{{ accountInfo()?.user_info?.message }}
</div>
} @else if (loadState() === 'loading') {
<p class="account-dialog__loading-copy">
{{ 'LOADING' | translate }}...
</p>
}
</div>
<div class="account-dialog__hero-stats">
@if (loadState() === 'ready') {
@for (stat of heroStats(); track stat.labelKey) {
<article class="account-stat">
<div class="account-stat__icon">
<mat-icon>{{ stat.icon }}</mat-icon>
</div>
<div class="account-stat__label">
{{ stat.labelKey | translate }}
</div>
<div class="account-stat__value">
{{ stat.value }}
</div>
@if (stat.meter !== null) {
<div class="account-stat__meter">
<span [style.width.%]="stat.meter"></span>
</div>
}
</article>
}
} @else {
Xtream
@for (item of skeletonStats; track item) {
<div class="account-skeleton account-skeleton--stat"></div>
}
}
</div>
</section>
<div class="account-dialog__headline-row">
<div>
<h2 class="account-dialog__title">
{{ 'XTREAM.ACCOUNT_INFO.TITLE' | translate }}
</h2>
<p class="account-dialog__subtitle">
{{ playlistLabel() }}
<div class="account-dialog__content">
@if (loadState() === 'error') {
<section class="account-dialog__error-state">
<div class="account-dialog__error-icon">
<mat-icon>signal_wifi_connected_no_internet_4</mat-icon>
</div>
<h3>
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.TITLE'
: 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE'
) | translate
}}
</h3>
<p>
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.DESCRIPTION'
: 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION'
) | translate
}}
</p>
</div>
@if (accountInfo(); as info) {
<div class="account-dialog__status-cluster">
<span
class="account-dialog__status-pill"
[class.account-dialog__status-pill--active]="isActive()"
>
{{ info.user_info.status || '-' }}
</span>
@if (isTrial()) {
<span
class="account-dialog__status-pill account-dialog__status-pill--trial"
>
{{
'XTREAM.ACCOUNT_INFO.TRIAL_ACCOUNT' | translate
}}
</span>
}
</div>
}
</div>
@if (accountInfo()?.user_info?.message) {
<div class="account-dialog__message">
{{ accountInfo()?.user_info?.message }}
</div>
<button mat-flat-button color="primary" (click)="reload()">
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.RETRY'
: 'RETRY'
) | translate
}}
</button>
</section>
} @else if (loadState() === 'loading') {
<p class="account-dialog__loading-copy">
{{ 'LOADING' | translate }}...
</p>
}
</div>
<div class="account-dialog__hero-stats">
@if (loadState() === 'ready') {
@for (stat of heroStats(); track stat.labelKey) {
<article class="account-stat">
<div class="account-stat__icon">
<mat-icon>{{ stat.icon }}</mat-icon>
</div>
<div class="account-stat__label">
{{ stat.labelKey | translate }}
</div>
<div class="account-stat__value">
{{ stat.value }}
</div>
@if (stat.meter !== null) {
<div class="account-stat__meter">
<span [style.width.%]="stat.meter"></span>
</div>
}
</article>
}
} @else {
@for (item of skeletonStats; track item) {
<div class="account-skeleton account-skeleton--stat"></div>
}
}
</div>
</section>
<mat-dialog-content class="account-dialog__content">
@if (loadState() === 'error') {
<section class="account-dialog__error-state">
<div class="account-dialog__error-icon">
<mat-icon>signal_wifi_connected_no_internet_4</mat-icon>
<div class="account-dialog__grid">
@for (panel of skeletonPanels; track panel) {
<section class="account-panel account-panel--loading">
<div
class="account-skeleton account-skeleton--heading"
></div>
<div
class="account-skeleton account-skeleton--row"
></div>
<div
class="account-skeleton account-skeleton--row"
></div>
<div
class="account-skeleton account-skeleton--row"
></div>
<div
class="account-skeleton account-skeleton--row short"
></div>
</section>
}
</div>
<h3>
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.TITLE'
: 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE'
) | translate
}}
</h3>
<p>
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.DESCRIPTION'
: 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION'
) | translate
}}
</p>
<button mat-flat-button color="primary" (click)="reload()">
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.RETRY'
: 'RETRY'
) | translate
}}
</button>
</section>
} @else if (loadState() === 'loading') {
<div class="account-dialog__grid">
@for (panel of skeletonPanels; track panel) {
<section class="account-panel account-panel--loading">
<div
class="account-skeleton account-skeleton--heading"
></div>
<div class="account-skeleton account-skeleton--row"></div>
<div class="account-skeleton account-skeleton--row"></div>
<div class="account-skeleton account-skeleton--row"></div>
<div
class="account-skeleton account-skeleton--row short"
></div>
} @else if (accountInfo(); as info) {
<div class="account-dialog__grid">
<section class="account-panel">
<div class="account-panel__header">
<p class="account-panel__eyebrow">
{{ 'XTREAM.ACCOUNT_INFO.USER_INFO' | translate }}
</p>
<h3>{{ info.user_info.username || '-' }}</h3>
</div>
<dl class="account-detail-list">
@for (row of userDetails(); track row.labelKey) {
<div class="account-detail-row">
<dt>{{ row.labelKey | translate }}</dt>
<dd
[class.account-detail-row__value--mono]="
row.mono
"
[class.account-detail-row__value--accent]="
row.tone === 'accent'
"
[class.account-detail-row__value--positive]="
row.tone === 'positive'
"
[class.account-detail-row__value--warning]="
row.tone === 'warning'
"
>
@if (row.translateValue) {
{{ row.value | translate }}
} @else {
{{ row.value }}
}
</dd>
</div>
}
</dl>
<div class="account-chip-section">
<p class="account-chip-section__label">
{{
'XTREAM.ACCOUNT_INFO.ALLOWED_FORMATS'
| translate
}}
</p>
<div class="account-chip-group">
@for (format of allowedFormats(); track format) {
<span class="account-chip">
{{ format }}
</span>
} @empty {
<span class="account-chip account-chip--muted">
-
</span>
}
</div>
</div>
</section>
}
</div>
} @else if (accountInfo(); as info) {
<div class="account-dialog__grid">
<section class="account-panel">
<div class="account-panel__header">
<p class="account-panel__eyebrow">
{{ 'XTREAM.ACCOUNT_INFO.USER_INFO' | translate }}
</p>
<h3>{{ info.user_info.username || '-' }}</h3>
</div>
<dl class="account-detail-list">
@for (row of userDetails(); track row.labelKey) {
<div class="account-detail-row">
<dt>{{ row.labelKey | translate }}</dt>
<dd
[class.account-detail-row__value--mono]="
row.mono
"
[class.account-detail-row__value--accent]="
row.tone === 'accent'
"
[class.account-detail-row__value--positive]="
row.tone === 'positive'
"
[class.account-detail-row__value--warning]="
row.tone === 'warning'
"
>
@if (row.translateValue) {
{{ row.value | translate }}
} @else {
<section class="account-panel">
<div class="account-panel__header">
<p class="account-panel__eyebrow">
{{ 'XTREAM.ACCOUNT_INFO.SERVER_INFO' | translate }}
</p>
<h3>{{ serverHost() }}</h3>
</div>
<dl class="account-detail-list">
@for (row of serverDetails(); track row.labelKey) {
<div class="account-detail-row">
<dt>{{ row.labelKey | translate }}</dt>
<dd
[class.account-detail-row__value--mono]="
row.mono
"
>
{{ row.value }}
}
</dd>
</div>
}
</dl>
<div class="account-chip-section">
<p class="account-chip-section__label">
{{ 'XTREAM.ACCOUNT_INFO.ALLOWED_FORMATS' | translate }}
</p>
<div class="account-chip-group">
@for (format of allowedFormats(); track format) {
<span class="account-chip">
{{ format }}
</span>
} @empty {
<span class="account-chip account-chip--muted">
-
</span>
</dd>
</div>
}
</div>
</div>
</section>
</dl>
<section class="account-panel">
<div class="account-panel__header">
<p class="account-panel__eyebrow">
{{ 'XTREAM.ACCOUNT_INFO.SERVER_INFO' | translate }}
</p>
<h3>{{ serverHost() }}</h3>
</div>
<dl class="account-detail-list">
@for (row of serverDetails(); track row.labelKey) {
<div class="account-detail-row">
<dt>{{ row.labelKey | translate }}</dt>
<dd
[class.account-detail-row__value--mono]="
row.mono
"
>
{{ row.value }}
</dd>
<div class="account-chip-section">
<p class="account-chip-section__label">
{{ 'XTREAM.ACCOUNT_INFO.PORTS' | translate }}
</p>
<div class="account-chip-group">
@for (port of ports(); track port.labelKey) {
<span class="account-chip account-chip--mono">
{{ port.labelKey | translate }}
{{ port.value }}
</span>
}
</div>
}
</dl>
<div class="account-chip-section">
<p class="account-chip-section__label">
{{ 'XTREAM.ACCOUNT_INFO.PORTS' | translate }}
</p>
<div class="account-chip-group">
@for (port of ports(); track port.labelKey) {
<span class="account-chip account-chip--mono">
{{ port.labelKey | translate }} {{ port.value }}
</span>
}
</div>
</div>
</section>
</div>
}
</mat-dialog-content>
<mat-dialog-actions class="account-dialog__actions">
<button mat-stroked-button mat-dialog-close class="account-dialog__close">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
</section>
</div>
}
</div>
</app-dialog-shell>
@@ -21,6 +21,10 @@ import {
resolveXtreamPortalStatus,
type XtreamAccountInfoDialogData,
} from '@iptvnator/shared/interfaces';
import {
DialogHeaderEndDirective,
DialogShellComponent,
} from '@iptvnator/ui/components';
type AccountLoadState = 'loading' | 'ready' | 'error';
@@ -46,7 +50,14 @@ interface AccountPort {
@Component({
selector: 'app-account-info',
imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe],
imports: [
DialogHeaderEndDirective,
DialogShellComponent,
MatButtonModule,
MatDialogModule,
MatIconModule,
TranslatePipe,
],
templateUrl: './account-info.component.html',
styleUrl: './account-info.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -1,181 +1,190 @@
<h2 mat-dialog-title>{{ 'XTREAM.CATEGORY_MANAGEMENT.TITLE' | translate }}</h2>
<mat-dialog-content>
@if (isLoading()) {
<div class="loading-container">
<mat-spinner diameter="40" />
<p>{{ 'XTREAM.CATEGORY_MANAGEMENT.LOADING' | translate }}</p>
</div>
} @else {
<!-- Hide/show is Electron-only: the PWA lists categories for their
lock toggles alone, so it shows no selection count or bulk
selection actions. -->
@if (supportsVisibility || showLocks()) {
<div class="header-actions">
<div class="selection-info">
<app-dialog-shell
icon="category"
[heading]="'XTREAM.CATEGORY_MANAGEMENT.TITLE' | translate"
bodyLayout="column"
[showClose]="!isSaving()"
>
<div class="category-management">
@if (isLoading()) {
<div class="loading-container">
<mat-spinner diameter="40" />
<p>{{ 'XTREAM.CATEGORY_MANAGEMENT.LOADING' | translate }}</p>
</div>
} @else {
<!-- Hide/show is Electron-only: the PWA lists categories for their
lock toggles alone, so it shows no selection count or bulk
selection actions. -->
@if (supportsVisibility || showLocks()) {
<div class="header-actions">
<div class="selection-info">
@if (supportsVisibility) {
{{
'XTREAM.CATEGORY_MANAGEMENT.SELECTED'
| translate
}}: <strong>{{ selectedCount() }}</strong> /
{{ totalCount() }}
}
@if (showLocks()) {
<span
class="selection-info__locks"
data-test-id="category-locked-count"
>
@if (supportsVisibility) {
·
}
{{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong>
</span>
}
</div>
@if (supportsVisibility) {
{{ 'XTREAM.CATEGORY_MANAGEMENT.SELECTED' | translate }}:
<strong>{{ selectedCount() }}</strong> /
{{ totalCount() }}
}
@if (showLocks()) {
<span
class="selection-info__locks"
data-test-id="category-locked-count"
>
@if (supportsVisibility) {
·
}
{{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong>
</span>
<div class="bulk-actions">
<button
mat-stroked-button
(click)="selectAll()"
[disabled]="
isSaving() ||
filteredCategories().length === 0 ||
allFilteredSelected()
"
>
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.SELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.SELECT_ALL'
) | translate
}}
</button>
<button
mat-stroked-button
(click)="deselectAll()"
[disabled]="
isSaving() || filteredSelectedCount() === 0
"
>
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_ALL'
) | translate
}}
</button>
</div>
}
</div>
@if (supportsVisibility) {
<div class="bulk-actions">
<button
mat-stroked-button
(click)="selectAll()"
[disabled]="
isSaving() ||
filteredCategories().length === 0 ||
allFilteredSelected()
"
}
<label class="search-inline">
<mat-icon class="search-inline__icon">search</mat-icon>
<input
type="search"
spellcheck="false"
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
[placeholder]="
'XTREAM.CATEGORY_MANAGEMENT.SEARCH_PLACEHOLDER'
| translate
"
/>
@if (searchTerm()) {
<button
type="button"
mat-icon-button
class="search-inline__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
</button>
}
</label>
<div class="categories-list">
@for (category of filteredCategories(); track category.id) {
<div
class="category-item"
[attr.data-category-id]="category.xtream_id"
(click)="toggleCategory(category)"
>
@if (supportsVisibility) {
<mat-checkbox
[checked]="category.selected"
[aria-label]="category.name"
(click)="$event.stopPropagation()"
(change)="toggleCategory(category)"
>
</mat-checkbox>
}
<span class="category-name">{{ category.name }}</span>
<span class="item-count"
>({{ getItemCount(category) }})</span
>
@if (showLocks()) {
<button
type="button"
mat-icon-button
class="lock-toggle"
[class.lock-toggle--locked]="
category.lockedDraft
"
[attr.aria-pressed]="category.lockedDraft"
[attr.data-test-id]="
'category-lock-' + category.xtream_id
"
[matTooltip]="
(category.lockedDraft
? 'PARENTAL_LOCK.UNLOCK_CATEGORY'
: 'PARENTAL_LOCK.LOCK_CATEGORY'
) | translate
"
[attr.aria-label]="
(category.lockedDraft
? 'PARENTAL_LOCK.UNLOCK_CATEGORY'
: 'PARENTAL_LOCK.LOCK_CATEGORY'
) | translate
"
(click)="toggleLock(category, $event)"
>
<mat-icon>{{
category.lockedDraft ? 'lock' : 'lock_open'
}}</mat-icon>
</button>
}
</div>
} @empty {
<div class="empty-message">
@if (searchTerm()) {
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.SELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.SELECT_ALL'
) | translate
'XTREAM.CATEGORY_MANAGEMENT.NO_RESULTS'
| translate
}}
</button>
<button
mat-stroked-button
(click)="deselectAll()"
[disabled]="
isSaving() || filteredSelectedCount() === 0
"
>
} @else {
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_ALL'
) | translate
'XTREAM.CATEGORY_MANAGEMENT.NO_CATEGORIES'
| translate
}}
</button>
}
</div>
}
</div>
}
<label class="search-inline">
<mat-icon class="search-inline__icon">search</mat-icon>
<input
type="search"
spellcheck="false"
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
[placeholder]="
'XTREAM.CATEGORY_MANAGEMENT.SEARCH_PLACEHOLDER' | translate
"
/>
@if (searchTerm()) {
<button
type="button"
mat-icon-button
class="search-inline__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
</button>
</div>
<div appDialogFooter>
<button mat-button (click)="cancel()" [disabled]="isSaving()">
{{ 'CLOSE' | translate }}
</button>
<button
mat-flat-button
(click)="save()"
[disabled]="isLoading() || isSaving()"
>
@if (isSaving()) {
<mat-spinner diameter="20" />
} @else {
{{ 'XTREAM.CATEGORY_MANAGEMENT.SAVE' | translate }}
}
</label>
<div class="categories-list">
@for (category of filteredCategories(); track category.id) {
<div
class="category-item"
[attr.data-category-id]="category.xtream_id"
(click)="toggleCategory(category)"
>
@if (supportsVisibility) {
<mat-checkbox
[checked]="category.selected"
[aria-label]="category.name"
(click)="$event.stopPropagation()"
(change)="toggleCategory(category)"
>
</mat-checkbox>
}
<span class="category-name">{{ category.name }}</span>
<span class="item-count"
>({{ getItemCount(category) }})</span
>
@if (showLocks()) {
<button
type="button"
mat-icon-button
class="lock-toggle"
[class.lock-toggle--locked]="category.lockedDraft"
[attr.aria-pressed]="category.lockedDraft"
[attr.data-test-id]="
'category-lock-' + category.xtream_id
"
[matTooltip]="
(category.lockedDraft
? 'PARENTAL_LOCK.UNLOCK_CATEGORY'
: 'PARENTAL_LOCK.LOCK_CATEGORY'
) | translate
"
[attr.aria-label]="
(category.lockedDraft
? 'PARENTAL_LOCK.UNLOCK_CATEGORY'
: 'PARENTAL_LOCK.LOCK_CATEGORY'
) | translate
"
(click)="toggleLock(category, $event)"
>
<mat-icon>{{
category.lockedDraft ? 'lock' : 'lock_open'
}}</mat-icon>
</button>
}
</div>
} @empty {
<div class="empty-message">
@if (searchTerm()) {
{{
'XTREAM.CATEGORY_MANAGEMENT.NO_RESULTS' | translate
}}
} @else {
{{
'XTREAM.CATEGORY_MANAGEMENT.NO_CATEGORIES'
| translate
}}
}
</div>
}
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="cancel()" [disabled]="isSaving()">
{{ 'CLOSE' | translate }}
</button>
<button
mat-flat-button
color="primary"
(click)="save()"
[disabled]="isLoading() || isSaving()"
>
@if (isSaving()) {
<mat-spinner diameter="20" />
} @else {
{{ 'XTREAM.CATEGORY_MANAGEMENT.SAVE' | translate }}
}
</button>
</mat-dialog-actions>
</button>
</div>
</app-dialog-shell>
@@ -1,12 +1,14 @@
@use '@angular/material' as mat;
::ng-deep .mat-mdc-dialog-content {
overflow: hidden !important;
}
.mat-mdc-dialog-content {
// The list is the only part that scrolls: inside the shell's column body
// the wrapper flexes to the height left under the cap, and the list flexes
// inside it, so the search and bulk actions stay put.
.category-management {
display: flex;
gap: 10px;
flex-direction: column;
gap: 10px;
flex: 1 1 auto;
min-height: 0;
}
.loading-container {
@@ -135,7 +137,11 @@
}
mat-checkbox {
@include mat.checkbox-overrides((state-layer-size: 28px));
@include mat.checkbox-overrides(
(
state-layer-size: 28px,
)
);
}
}
@@ -154,19 +160,9 @@
font-size: 0.8125rem;
}
:host ::ng-deep {
.mat-mdc-dialog-content {
max-width: 600px;
max-height: 85vh !important;
}
}
mat-dialog-actions {
button {
mat-spinner {
display: inline-block;
}
}
// The saving spinner replaces the label inside the accent button.
[appDialogFooter] button mat-spinner {
display: inline-block;
}
.selection-info__locks {
@@ -106,6 +106,19 @@ describe('CategoryManagementDialogComponent', () => {
);
}
it('hides the header close while a save is in flight, so the caller always gets a result', () => {
const headerClose = () =>
(fixture.nativeElement as HTMLElement).querySelector(
'.app-dialog__close'
);
expect(headerClose()).not.toBeNull();
component.isSaving.set(true);
fixture.detectChanges();
expect(headerClose()).toBeNull();
});
it('selects only matches and preserves both states outside the filter', () => {
component.searchTerm.set('fr');
component.selectAll();
@@ -28,6 +28,10 @@ import {
} from '@iptvnator/services';
import { createLogger } from '@iptvnator/portal/shared/util';
import { foldSearchText } from '@iptvnator/shared/interfaces';
import {
DialogFooterDirective,
DialogShellComponent,
} from '@iptvnator/ui/components';
export interface CategoryManagementDialogData {
playlistId: string;
@@ -44,6 +48,8 @@ interface CategoryWithSelection extends XCategoryFromDb {
@Component({
selector: 'app-category-management-dialog',
imports: [
DialogFooterDirective,
DialogShellComponent,
MatDialogModule,
MatButtonModule,
MatCheckboxModule,
@@ -290,8 +290,8 @@ describe('AllChannelsViewComponent', () => {
ChannelDetailsDialogComponent,
expect.objectContaining({
data: primaryChannel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
maxWidth: 'calc(100vw - 32px)',
width: '760px',
})
);
});
@@ -218,11 +218,7 @@ export class AllChannelsViewComponent {
}
this.channelContextMenu().close();
this.dialog.open(ChannelDetailsDialogComponent, {
data: channel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
});
ChannelDetailsDialogComponent.open(this.dialog, channel);
}
openEpgMapping(): void {
@@ -1,243 +1,250 @@
<section mat-dialog-title class="channel-dialog__hero">
<div class="channel-dialog__hero-copy">
<div class="channel-dialog__eyebrow">
<span class="channel-dialog__eyebrow-mark"></span>
{{ channel.group.title || 'M3U' }}
</div>
<div class="channel-dialog__headline-row">
<div class="channel-dialog__headline-text">
@if (channel.tvg.logo) {
<img
class="channel-dialog__logo"
[src]="channel.tvg.logo"
[alt]="channel.name"
(error)="logoError = true"
[hidden]="logoError"
/>
}
<div>
<h2 class="channel-dialog__title">{{ channel.name }}</h2>
<p class="channel-dialog__subtitle">
{{ 'CHANNELS.DETAILS_DIALOG.DESCRIPTION' | translate }}
</p>
</div>
</div>
<div class="channel-dialog__status-cluster">
@if (channel.radio === 'true') {
<span
class="channel-dialog__status-pill channel-dialog__status-pill--radio"
>
{{ 'CHANNELS.DETAILS_DIALOG.RADIO' | translate }}
</span>
}
@if (catchupAvailable) {
<span
class="channel-dialog__status-pill channel-dialog__status-pill--active"
>
{{ 'CHANNELS.DETAILS_DIALOG.CATCHUP_AVAILABLE' | translate }}
</span>
}
@if (catchupPlaybackSupported) {
<span
class="channel-dialog__status-pill channel-dialog__status-pill--playback"
>
{{ 'CHANNELS.DETAILS_DIALOG.PLAYBACK_SUPPORTED' | translate }}
</span>
}
</div>
</div>
</div>
<div class="channel-dialog__hero-stats">
@for (stat of heroStats; track stat.labelKey) {
<article class="channel-stat">
<div class="channel-stat__icon">
<mat-icon>{{ stat.icon }}</mat-icon>
</div>
<div class="channel-stat__label">
{{ stat.labelKey | translate }}
</div>
<div
class="channel-stat__value"
[class.channel-stat__value--mono]="stat.mono"
[class.channel-stat__value--empty]="stat.empty"
>
@if (stat.valueKey) {
{{ stat.valueKey | translate: stat.translateParams }}
} @else {
{{ stat.value }}
}
</div>
</article>
<app-dialog-shell
icon="live_tv"
[heading]="channel.name"
[subtitle]="'CHANNELS.DETAILS_DIALOG.DESCRIPTION' | translate"
>
<div appDialogHeaderEnd class="channel-dialog__status-cluster">
@if (channel.radio === 'true') {
<span
class="channel-dialog__status-pill channel-dialog__status-pill--radio"
>
{{ 'CHANNELS.DETAILS_DIALOG.RADIO' | translate }}
</span>
}
@if (catchupAvailable) {
<span
class="channel-dialog__status-pill channel-dialog__status-pill--active"
>
{{ 'CHANNELS.DETAILS_DIALOG.CATCHUP_AVAILABLE' | translate }}
</span>
}
@if (catchupPlaybackSupported) {
<span
class="channel-dialog__status-pill channel-dialog__status-pill--playback"
>
{{ 'CHANNELS.DETAILS_DIALOG.PLAYBACK_SUPPORTED' | translate }}
</span>
}
</div>
</section>
<mat-dialog-content class="channel-dialog__content">
<div class="channel-dialog__grid">
<section class="channel-panel">
<div class="channel-panel__header">
<p class="channel-panel__eyebrow">
{{ 'CHANNELS.DETAILS_DIALOG.SUMMARY' | translate }}
</p>
<h3>{{ 'CHANNELS.DETAILS_DIALOG.STREAM_URL' | translate }}</h3>
<section class="channel-dialog__hero">
<div class="channel-dialog__hero-copy">
<div class="channel-dialog__eyebrow">
<span class="channel-dialog__eyebrow-mark"></span>
{{ channel.group.title || 'M3U' }}
</div>
<div class="channel-dialog__url-block">
<code>{{ channel.url }}</code>
<button
mat-icon-button
type="button"
class="channel-dialog__copy-btn"
[cdkCopyToClipboard]="channel.url"
[attr.aria-label]="
'CHANNELS.DETAILS_DIALOG.COPY_STREAM_URL' | translate
"
>
<mat-icon>content_copy</mat-icon>
</button>
</div>
@if (channel.tvg.logo) {
<img
class="channel-dialog__logo"
[src]="channel.tvg.logo"
[alt]="channel.name"
(error)="logoError = true"
[hidden]="logoError"
/>
}
</div>
<dl class="channel-detail-list">
@for (field of streamFields; track field.labelKey) {
<div class="channel-detail-row">
<dt>{{ field.labelKey | translate }}</dt>
<dd
[class.channel-detail-row__value--mono]="
field.monospace
"
[class.channel-detail-row__value--empty]="
field.empty
"
>
@if (field.valueKey) {
{{
field.valueKey
| translate: field.translateParams
}}
} @else {
{{ field.value }}
<div class="channel-dialog__hero-stats">
@for (stat of heroStats; track stat.labelKey) {
<article class="channel-stat">
<div class="channel-stat__icon">
<mat-icon>{{ stat.icon }}</mat-icon>
</div>
<div class="channel-stat__label">
{{ stat.labelKey | translate }}
</div>
<div
class="channel-stat__value"
[class.channel-stat__value--mono]="stat.mono"
[class.channel-stat__value--empty]="stat.empty"
>
@if (stat.valueKey) {
{{
stat.valueKey | translate: stat.translateParams
}}
} @else {
{{ stat.value }}
}
</div>
</article>
}
</div>
</section>
<div class="channel-dialog__content">
<div class="channel-dialog__grid">
<section class="channel-panel">
<div class="channel-panel__header">
<p class="channel-panel__eyebrow">
{{ 'CHANNELS.DETAILS_DIALOG.SUMMARY' | translate }}
</p>
<h3>
{{ 'CHANNELS.DETAILS_DIALOG.STREAM_URL' | translate }}
</h3>
</div>
<div class="channel-dialog__url-block">
<code>{{ channel.url }}</code>
<button
mat-icon-button
type="button"
class="channel-dialog__copy-btn"
[cdkCopyToClipboard]="channel.url"
[attr.aria-label]="
'CHANNELS.DETAILS_DIALOG.COPY_STREAM_URL'
| translate
"
>
<mat-icon>content_copy</mat-icon>
</button>
</div>
<dl class="channel-detail-list">
@for (field of streamFields; track field.labelKey) {
<div class="channel-detail-row">
<dt>{{ field.labelKey | translate }}</dt>
<dd
[class.channel-detail-row__value--mono]="
field.monospace
"
[class.channel-detail-row__value--empty]="
field.empty
"
>
@if (field.valueKey) {
{{
field.valueKey
| translate: field.translateParams
}}
} @else {
{{ field.value }}
}
</dd>
</div>
}
</dl>
@if (hasTvgData) {
<div class="channel-chip-section">
<p class="channel-chip-section__label">
{{ 'CHANNELS.DETAILS_DIALOG.TVG' | translate }}
</p>
<dl class="channel-detail-list">
@for (field of tvgFields; track field.labelKey) {
<div class="channel-detail-row">
<dt>{{ field.labelKey | translate }}</dt>
<dd
[class.channel-detail-row__value--mono]="
field.monospace
"
[class.channel-detail-row__value--empty]="
field.empty
"
>
@if (field.valueKey) {
{{
field.valueKey
| translate
: field.translateParams
}}
} @else {
{{ field.value }}
}
</dd>
</div>
}
</dd>
</dl>
</div>
}
</dl>
</section>
@if (hasTvgData) {
<div class="channel-chip-section">
<p class="channel-chip-section__label">
{{ 'CHANNELS.DETAILS_DIALOG.TVG' | translate }}
<section class="channel-panel">
<div class="channel-panel__header">
<p class="channel-panel__eyebrow">
{{ 'CHANNELS.DETAILS_DIALOG.CATCHUP' | translate }}
</p>
<dl class="channel-detail-list">
@for (field of tvgFields; track field.labelKey) {
<div class="channel-detail-row">
<dt>{{ field.labelKey | translate }}</dt>
<dd
[class.channel-detail-row__value--mono]="
field.monospace
"
[class.channel-detail-row__value--empty]="
field.empty
"
>
@if (field.valueKey) {
{{
field.valueKey
| translate
: field.translateParams
}}
} @else {
{{ field.value }}
}
</dd>
</div>
}
</dl>
<h3>
{{
channel.catchup?.type ||
('CHANNELS.DETAILS_DIALOG.NOT_AVAILABLE'
| translate)
}}
</h3>
</div>
}
</section>
<section class="channel-panel">
<div class="channel-panel__header">
<p class="channel-panel__eyebrow">
{{ 'CHANNELS.DETAILS_DIALOG.CATCHUP' | translate }}
</p>
<h3>
{{ channel.catchup?.type || ('CHANNELS.DETAILS_DIALOG.NOT_AVAILABLE' | translate) }}
</h3>
</div>
<dl class="channel-detail-list">
@for (field of catchupFields; track field.labelKey) {
<div class="channel-detail-row">
<dt>{{ field.labelKey | translate }}</dt>
<dd
[class.channel-detail-row__value--mono]="
field.monospace
"
[class.channel-detail-row__value--empty]="
field.empty
"
>
@if (field.valueKey) {
{{
field.valueKey
| translate: field.translateParams
}}
} @else {
{{ field.value }}
}
</dd>
</div>
}
</dl>
<dl class="channel-detail-list">
@for (field of catchupFields; track field.labelKey) {
<div class="channel-detail-row">
<dt>{{ field.labelKey | translate }}</dt>
<dd
[class.channel-detail-row__value--mono]="
field.monospace
"
[class.channel-detail-row__value--empty]="
field.empty
"
>
@if (field.valueKey) {
{{
field.valueKey
| translate: field.translateParams
}}
} @else {
{{ field.value }}
@if (hasHttpData) {
<div class="channel-chip-section">
<p class="channel-chip-section__label">
{{ 'CHANNELS.DETAILS_DIALOG.HTTP' | translate }}
</p>
<dl class="channel-detail-list">
@for (field of httpFields; track field.labelKey) {
<div class="channel-detail-row">
<dt>{{ field.labelKey | translate }}</dt>
<dd
[class.channel-detail-row__value--mono]="
field.monospace
"
[class.channel-detail-row__value--empty]="
field.empty
"
>
@if (field.valueKey) {
{{
field.valueKey
| translate
: field.translateParams
}}
} @else {
{{ field.value }}
}
</dd>
</div>
}
</dd>
</dl>
</div>
}
</dl>
@if (hasHttpData) {
<div class="channel-chip-section">
<p class="channel-chip-section__label">
{{ 'CHANNELS.DETAILS_DIALOG.HTTP' | translate }}
</p>
<dl class="channel-detail-list">
@for (field of httpFields; track field.labelKey) {
<div class="channel-detail-row">
<dt>{{ field.labelKey | translate }}</dt>
<dd
[class.channel-detail-row__value--mono]="
field.monospace
"
[class.channel-detail-row__value--empty]="
field.empty
"
>
@if (field.valueKey) {
{{
field.valueKey
| translate
: field.translateParams
}}
} @else {
{{ field.value }}
}
</dd>
</div>
}
</dl>
</div>
}
</section>
</section>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions class="channel-dialog__actions">
@if (supportsEpgMapping) {
<button mat-stroked-button (click)="openEpgMapping()">
<mat-icon>settings_remote</mat-icon>
{{ 'CHANNELS.MAP_EPG' | translate }}
<div appDialogFooter>
<button mat-button mat-dialog-close>
{{ 'CLOSE' | translate }}
</button>
}
<button mat-stroked-button mat-dialog-close class="channel-dialog__close">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
@if (supportsEpgMapping) {
<button
mat-flat-button
class="app-dialog__secondary"
(click)="openEpgMapping()"
>
<mat-icon>settings_remote</mat-icon>
{{ 'CHANNELS.MAP_EPG' | translate }}
</button>
}
</div>
</app-dialog-shell>
@@ -5,15 +5,18 @@
}
// ─── Hero header ────────────────────────────────────────────────────────────
// The hero band runs edge to edge under the shell's header: it cancels the
// body's 20px padding on three sides.
.channel-dialog__hero {
margin: 0;
margin: -20px -20px 20px;
padding: 20px 20px 18px;
display: grid;
gap: 18px;
background:
var(--app-accent-gradient),
var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
border-bottom: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
border-bottom: 1px solid
var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
}
.channel-dialog__hero-copy {
@@ -38,21 +41,8 @@
height: 8px;
border-radius: 999px;
background: var(--mat-sys-primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--mat-sys-primary) 14%, transparent);
}
.channel-dialog__headline-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.channel-dialog__headline-text {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
box-shadow: 0 0 0 4px
color-mix(in srgb, var(--mat-sys-primary) 14%, transparent);
}
.channel-dialog__logo {
@@ -65,21 +55,6 @@
flex-shrink: 0;
}
.channel-dialog__title {
margin: 0;
font-size: 1.3rem;
line-height: 1.2;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.channel-dialog__subtitle {
margin: 4px 0 0;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-size: 0.88rem;
}
// ─── Status pills ───────────────────────────────────────────────────────────
.channel-dialog__status-cluster {
display: flex;
@@ -194,8 +169,6 @@
// ─── Content area ───────────────────────────────────────────────────────────
.channel-dialog__content {
margin: 0;
padding: 20px;
background: var(--app-widget-bg, var(--mat-sys-surface));
}
.channel-dialog__grid {
@@ -343,34 +316,11 @@
}
// ─── Footer actions ─────────────────────────────────────────────────────────
.channel-dialog__actions {
padding: 0 20px 20px;
display: flex;
justify-content: flex-end;
gap: 10px;
border-top: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
background: var(--app-widget-bg, var(--mat-sys-surface));
}
.channel-dialog__close {
min-width: 100px;
border-radius: 999px;
}
// ─── Responsive breakpoints ─────────────────────────────────────────────────
@media (max-width: 720px) {
.channel-dialog__hero,
.channel-dialog__content,
.channel-dialog__actions {
padding-left: 16px;
padding-right: 16px;
}
.channel-dialog__headline-row {
flex-direction: column;
align-items: flex-start;
}
// Only the pills re-align: the hero is a grid whose column must keep
// stretching to the band, or its stats size to their content.
.channel-dialog__status-cluster {
justify-content: flex-start;
}
@@ -5,14 +5,24 @@ import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { getM3uArchiveDays, isM3uCatchupPlaybackSupported } from '@iptvnator/shared/m3u-utils';
import {
getM3uArchiveDays,
isM3uCatchupPlaybackSupported,
} from '@iptvnator/shared/m3u-utils';
import { Channel } from '@iptvnator/shared/interfaces';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component';
import { DialogShellComponent } from '../../dialog-shell/dialog-shell.component';
import { dialogSize } from '../../dialog-shell/dialog-size';
import {
DialogFooterDirective,
DialogHeaderEndDirective,
} from '../../dialog-shell/dialog-shell.directives';
interface ChannelDetailField {
readonly empty?: boolean;
@@ -38,10 +48,30 @@ interface HeroStat {
templateUrl: './channel-details-dialog.component.html',
styleUrls: ['./channel-details-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ClipboardModule, MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe],
imports: [
DialogFooterDirective,
DialogHeaderEndDirective,
DialogShellComponent,
ClipboardModule,
MatButtonModule,
MatDialogModule,
MatIconModule,
TranslatePipe,
],
})
export class ChannelDetailsDialogComponent {
readonly channel = inject<Channel>(MAT_DIALOG_DATA);
/** Opens the dialog with the size every entry point shares. */
static open(
dialog: MatDialog,
channel: Channel
): MatDialogRef<ChannelDetailsDialogComponent> {
return dialog.open(ChannelDetailsDialogComponent, {
...dialogSize('lg'),
data: channel,
});
}
private readonly dialog = inject(MatDialog);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
@@ -113,7 +143,11 @@ export class ChannelDetailsDialogComponent {
];
readonly tvgFields: ChannelDetailField[] = [
this.field('CHANNELS.DETAILS_DIALOG.TVG_ID', this.channel.tvg?.id, true),
this.field(
'CHANNELS.DETAILS_DIALOG.TVG_ID',
this.channel.tvg?.id,
true
),
this.field('CHANNELS.DETAILS_DIALOG.TVG_NAME', this.channel.tvg?.name),
this.field(
'CHANNELS.DETAILS_DIALOG.TVG_URL',
@@ -1,21 +1,27 @@
<h2 mat-dialog-title>{{ 'EPG_MAPPING_DIALOG.TITLE' | translate }}</h2>
<mat-dialog-content>
<p class="epg-mapping-subtitle">
{{ 'EPG_MAPPING_DIALOG.SUBTITLE' | translate : { channelName: data.channelName } }}
</p>
<app-dialog-shell
icon="settings_remote"
[heading]="'EPG_MAPPING_DIALOG.TITLE' | translate"
[subtitle]="
'EPG_MAPPING_DIALOG.SUBTITLE'
| translate: { channelName: data.channelName }
"
>
<div class="epg-mapping-current">
<span class="epg-mapping-current__label">
{{ 'EPG_MAPPING_DIALOG.CURRENT_MAPPING' | translate }}:
</span>
<span class="epg-mapping-current__value">
{{ currentMapping() || ('EPG_MAPPING_DIALOG.NO_MAPPING' | translate) }}
{{
currentMapping() ||
('EPG_MAPPING_DIALOG.NO_MAPPING' | translate)
}}
</span>
</div>
<mat-form-field class="epg-mapping-search" appearance="outline">
<mat-label>{{ 'EPG_MAPPING_DIALOG.SEARCH_PLACEHOLDER' | translate }}</mat-label>
<mat-label>{{
'EPG_MAPPING_DIALOG.SEARCH_PLACEHOLDER' | translate
}}</mat-label>
<input
matInput
[ngModel]="searchTerm()"
@@ -41,41 +47,45 @@
@for (channel of results(); track channel.id) {
<button
class="epg-mapping-result"
[class.epg-mapping-result--selected]="selectedId() === channel.id"
[class.epg-mapping-result--selected]="
selectedId() === channel.id
"
(click)="selectChannel(channel.id)"
>
<span class="epg-mapping-result__name">{{ channel.displayName }}</span>
<span class="epg-mapping-result__name">{{
channel.displayName
}}</span>
<span class="epg-mapping-result__id">{{ channel.id }}</span>
</button>
} @empty {
<div class="epg-mapping-empty">
<mat-icon>search_off</mat-icon>
<span>{{ 'EPG_MAPPING_DIALOG.NO_RESULTS' | translate }}</span>
<span>{{
'EPG_MAPPING_DIALOG.NO_RESULTS' | translate
}}</span>
</div>
}
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (currentMapping()) {
<button
mat-button
class="app-destructive-button"
(click)="removeMapping()"
>
{{ 'EPG_MAPPING_DIALOG.REMOVE' | translate }}
<!-- Removing the mapping is a third action tied to the current value,
so it sits at the footer's start, away from the decision pair. -->
<div appDialogFooterStart>
@if (currentMapping()) {
<button
mat-button
class="app-destructive-button"
(click)="removeMapping()"
>
{{ 'EPG_MAPPING_DIALOG.REMOVE' | translate }}
</button>
}
</div>
<div appDialogFooter>
<button mat-button (click)="close()">
{{ 'EPG_MAPPING_DIALOG.CLOSE' | translate }}
</button>
}
<button mat-button (click)="close()">
{{ 'EPG_MAPPING_DIALOG.CLOSE' | translate }}
</button>
<button
mat-raised-button
color="primary"
[disabled]="!selectedId()"
(click)="save()"
>
{{ 'EPG_MAPPING_DIALOG.SAVE' | translate }}
</button>
</mat-dialog-actions>
<button mat-flat-button [disabled]="!selectedId()" (click)="save()">
{{ 'EPG_MAPPING_DIALOG.SAVE' | translate }}
</button>
</div>
</app-dialog-shell>
@@ -1,9 +1,3 @@
.epg-mapping-subtitle {
margin: 0 0 16px;
color: var(--mat-sys-on-surface-variant);
font-size: 14px;
}
.epg-mapping-current {
display: flex;
align-items: center;
@@ -51,7 +45,9 @@
text-align: left;
width: 100%;
color: inherit;
transition: background 0.15s, border-color 0.15s;
transition:
background 0.15s,
border-color 0.15s;
&:hover {
background: var(--mat-sys-surface-container-high);
@@ -59,11 +55,7 @@
&--selected {
border-color: var(--mat-sys-primary);
background: color-mix(
in srgb,
var(--mat-sys-primary) 10%,
transparent
);
background: color-mix(in srgb, var(--mat-sys-primary) 10%, transparent);
}
&__name {
@@ -19,10 +19,19 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access';
import {
EpgRuntimeBridgeService,
EpgService,
} from '@iptvnator/epg/data-access';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { debounceTime, from, Subject, switchMap } from 'rxjs';
import { provideFormFieldDefaults } from '../../form-field-defaults/form-field-defaults';
import { DialogShellComponent } from '../../dialog-shell/dialog-shell.component';
import {
DialogFooterDirective,
DialogFooterStartDirective,
} from '../../dialog-shell/dialog-shell.directives';
import { dialogSize } from '../../dialog-shell/dialog-size';
export interface EpgMappingDialogData {
channelKey: string;
@@ -45,6 +54,9 @@ const SEARCH_MIN_CHARS = 2;
styleUrl: './epg-mapping-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
DialogFooterDirective,
DialogFooterStartDirective,
DialogShellComponent,
FormsModule,
MatButtonModule,
MatDialogModule,
@@ -82,9 +94,8 @@ export class EpgMappingDialogComponent {
data: EpgMappingDialogData
): MatDialogRef<EpgMappingDialogComponent, EpgMappingDialogResult> {
return dialog.open(EpgMappingDialogComponent, {
...dialogSize('md'),
data,
width: '500px',
maxHeight: '90vh',
});
}
@@ -95,8 +95,8 @@ describe('FavoritesViewComponent', () => {
ChannelDetailsDialogComponent,
expect.objectContaining({
data: primaryChannel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
maxWidth: 'calc(100vw - 32px)',
width: '760px',
})
);
});
@@ -185,11 +185,7 @@ export class FavoritesViewComponent {
}
this.channelContextMenu().close();
this.dialog.open(ChannelDetailsDialogComponent, {
data: channel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
});
ChannelDetailsDialogComponent.open(this.dialog, channel);
}
onDrop(event: CdkDragDrop<Channel[]>): void {
@@ -1,121 +1,124 @@
<h2 mat-dialog-title>{{ 'CHANNELS.GROUP_MANAGEMENT.TITLE' | translate }}</h2>
<mat-dialog-content>
<div class="header-actions">
<div class="selection-info">
{{ 'CHANNELS.GROUP_MANAGEMENT.SELECTED' | translate }}:
<strong>{{ selectedCount() }}</strong> / {{ totalCount() }}
@if (showLocks) {
<span class="selection-info__locks">
· {{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong>
</span>
}
</div>
<div class="bulk-actions">
<button
mat-stroked-button
(click)="selectAll()"
[disabled]="allSelected()"
>
<mat-icon>check_box</mat-icon>
{{ 'CHANNELS.GROUP_MANAGEMENT.SELECT_ALL' | translate }}
</button>
<button
mat-stroked-button
(click)="deselectAll()"
[disabled]="selectedCount() === 0"
>
<mat-icon>check_box_outline_blank</mat-icon>
{{ 'CHANNELS.GROUP_MANAGEMENT.DESELECT_ALL' | translate }}
</button>
</div>
</div>
<label class="search-inline">
<mat-icon class="search-inline__icon">search</mat-icon>
<input
type="search"
spellcheck="false"
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
[placeholder]="
'CHANNELS.GROUP_MANAGEMENT.SEARCH_PLACEHOLDER' | translate
"
/>
@if (searchTerm()) {
<button
type="button"
mat-icon-button
class="search-inline__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
</button>
}
</label>
<div class="groups-list">
@for (group of filteredGroups(); track group.key) {
<div class="group-item" (click)="toggleGroup(group)">
<mat-checkbox
[checked]="group.selected"
(click)="$event.stopPropagation()"
(change)="toggleGroup(group)"
>
</mat-checkbox>
<span class="group-name">
@if (group.key) {
{{ group.key | titlecase }}
} @else {
{{ 'CHANNELS.UNGROUPED' | translate }}
}
</span>
<span class="item-count">({{ group.count }})</span>
<app-dialog-shell
icon="folder"
[heading]="'CHANNELS.GROUP_MANAGEMENT.TITLE' | translate"
bodyLayout="column"
>
<div class="group-management">
<div class="header-actions">
<div class="selection-info">
{{ 'CHANNELS.GROUP_MANAGEMENT.SELECTED' | translate }}:
<strong>{{ selectedCount() }}</strong> / {{ totalCount() }}
@if (showLocks) {
<button
type="button"
mat-icon-button
class="lock-toggle"
[class.lock-toggle--locked]="group.locked"
[attr.aria-pressed]="group.locked"
[attr.data-test-id]="'group-lock-' + group.key"
[matTooltip]="
(group.locked
? 'PARENTAL_LOCK.UNLOCK_CATEGORY'
: 'PARENTAL_LOCK.LOCK_CATEGORY'
) | translate
"
[attr.aria-label]="
(group.locked
? 'PARENTAL_LOCK.UNLOCK_CATEGORY'
: 'PARENTAL_LOCK.LOCK_CATEGORY'
) | translate
"
(click)="toggleLock(group, $event)"
>
<mat-icon>{{
group.locked ? 'lock' : 'lock_open'
}}</mat-icon>
</button>
<span class="selection-info__locks">
· {{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong>
</span>
}
</div>
} @empty {
<div class="empty-message">
{{ 'CHANNELS.GROUP_MANAGEMENT.NO_RESULTS' | translate }}
<div class="bulk-actions">
<button
mat-stroked-button
(click)="selectAll()"
[disabled]="allSelected()"
>
<mat-icon>check_box</mat-icon>
{{ 'CHANNELS.GROUP_MANAGEMENT.SELECT_ALL' | translate }}
</button>
<button
mat-stroked-button
(click)="deselectAll()"
[disabled]="selectedCount() === 0"
>
<mat-icon>check_box_outline_blank</mat-icon>
{{ 'CHANNELS.GROUP_MANAGEMENT.DESELECT_ALL' | translate }}
</button>
</div>
}
</div>
</mat-dialog-content>
</div>
<mat-dialog-actions align="end">
<button mat-button (click)="cancel()">
{{ 'CLOSE' | translate }}
</button>
<button mat-flat-button color="primary" (click)="save()">
{{ 'CHANNELS.GROUP_MANAGEMENT.SAVE' | translate }}
</button>
</mat-dialog-actions>
<label class="search-inline">
<mat-icon class="search-inline__icon">search</mat-icon>
<input
type="search"
spellcheck="false"
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
[placeholder]="
'CHANNELS.GROUP_MANAGEMENT.SEARCH_PLACEHOLDER' | translate
"
/>
@if (searchTerm()) {
<button
type="button"
mat-icon-button
class="search-inline__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
</button>
}
</label>
<div class="groups-list">
@for (group of filteredGroups(); track group.key) {
<div class="group-item" (click)="toggleGroup(group)">
<mat-checkbox
[checked]="group.selected"
(click)="$event.stopPropagation()"
(change)="toggleGroup(group)"
>
</mat-checkbox>
<span class="group-name">
@if (group.key) {
{{ group.key | titlecase }}
} @else {
{{ 'CHANNELS.UNGROUPED' | translate }}
}
</span>
<span class="item-count">({{ group.count }})</span>
@if (showLocks) {
<button
type="button"
mat-icon-button
class="lock-toggle"
[class.lock-toggle--locked]="group.locked"
[attr.aria-pressed]="group.locked"
[attr.data-test-id]="'group-lock-' + group.key"
[matTooltip]="
(group.locked
? 'PARENTAL_LOCK.UNLOCK_CATEGORY'
: 'PARENTAL_LOCK.LOCK_CATEGORY'
) | translate
"
[attr.aria-label]="
(group.locked
? 'PARENTAL_LOCK.UNLOCK_CATEGORY'
: 'PARENTAL_LOCK.LOCK_CATEGORY'
) | translate
"
(click)="toggleLock(group, $event)"
>
<mat-icon>{{
group.locked ? 'lock' : 'lock_open'
}}</mat-icon>
</button>
}
</div>
} @empty {
<div class="empty-message">
{{ 'CHANNELS.GROUP_MANAGEMENT.NO_RESULTS' | translate }}
</div>
}
</div>
</div>
<div appDialogFooter>
<button mat-button (click)="cancel()">
{{ 'CLOSE' | translate }}
</button>
<button mat-flat-button (click)="save()">
{{ 'CHANNELS.GROUP_MANAGEMENT.SAVE' | translate }}
</button>
</div>
</app-dialog-shell>
@@ -1,11 +1,14 @@
@use '@angular/material' as mat;
.mat-mdc-dialog-content {
// The list is the only part that scrolls: inside the shell's column body
// the wrapper flexes to the height left under the cap, and the list flexes
// inside it, so the search and bulk actions stay put.
.group-management {
display: flex;
gap: 10px;
flex-direction: column;
overflow: hidden !important;
max-width: 600px;
max-height: 85vh !important;
gap: 10px;
flex: 1 1 auto;
min-height: 0;
}
.header-actions {
@@ -125,7 +128,11 @@
}
mat-checkbox {
@include mat.checkbox-overrides((state-layer-size: 28px));
@include mat.checkbox-overrides(
(
state-layer-size: 28px,
)
);
}
}
@@ -19,6 +19,8 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { ParentalLockService } from '@iptvnator/services';
import { foldSearchText } from '@iptvnator/shared/interfaces';
import { DialogShellComponent } from '../../../dialog-shell/dialog-shell.component';
import { DialogFooterDirective } from '../../../dialog-shell/dialog-shell.directives';
export interface GroupManagementDialogGroup {
readonly count: number;
@@ -46,6 +48,8 @@ interface GroupWithSelection extends GroupManagementDialogGroup {
@Component({
selector: 'app-group-management-dialog',
imports: [
DialogFooterDirective,
DialogShellComponent,
MatDialogModule,
MatButtonModule,
MatCheckboxModule,
@@ -541,9 +541,9 @@ describe('GroupsViewComponent', () => {
{ key: 'Sports', count: 2 },
]),
}),
maxHeight: '90vh',
maxHeight: 'calc(100vh - 32px)',
maxWidth: 'calc(100vw - 32px)',
width: '500px',
width: '560px',
})
);
expect(hiddenGroupTitlesChanged).toHaveBeenCalledWith([
@@ -760,8 +760,8 @@ describe('GroupsViewComponent', () => {
ChannelDetailsDialogComponent,
expect.objectContaining({
data: movieClassic,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
maxWidth: 'calc(100vw - 32px)',
width: '760px',
})
);
});
@@ -52,6 +52,7 @@ import {
GroupManagementDialogComponent,
GroupManagementDialogGroup,
} from './group-management-dialog/group-management-dialog.component';
import { dialogSize } from '../../dialog-shell/dialog-size';
const GROUP_CHANNEL_SORT_STORAGE_KEY = 'm3u-groups-channel-sort-mode';
/** Rail width inside the fullscreen channel panel; see the `compact` input. */
@@ -583,9 +584,7 @@ export class GroupsViewComponent {
hiddenGroupTitles: this.hiddenGroupTitles(),
...(lockedGroupTitles ? { lockedGroupTitles } : {}),
},
width: '500px',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '90vh',
...dialogSize('md'),
});
dialogRef.afterClosed().subscribe((result) => {
@@ -674,11 +673,7 @@ export class GroupsViewComponent {
}
this.channelContextMenu().close();
this.dialog.open(ChannelDetailsDialogComponent, {
data: channel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
});
ChannelDetailsDialogComponent.open(this.dialog, channel);
}
/**
@@ -101,8 +101,8 @@ describe('RecentViewComponent', () => {
ChannelDetailsDialogComponent,
expect.objectContaining({
data: primaryChannel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
maxWidth: 'calc(100vw - 32px)',
width: '760px',
})
);
});
@@ -166,11 +166,7 @@ export class RecentViewComponent {
}
this.channelContextMenu().close();
this.dialog.open(ChannelDetailsDialogComponent, {
data: channel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
});
ChannelDetailsDialogComponent.open(this.dialog, channel);
}
removeContextMenuChannel(): void {
@@ -3,6 +3,8 @@ import { DomSanitizer, type SafeResourceUrl } from '@angular/platform-browser';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { TranslatePipe } from '@ngx-translate/core';
import { DialogShellComponent } from '../dialog-shell/dialog-shell.component';
import { DialogFooterDirective } from '../dialog-shell/dialog-shell.directives';
export interface TrailerDialogData {
/** A `youtube-nocookie.com/embed/...` URL, see `youtubeEmbedUrl`. */
@@ -11,14 +13,25 @@ export interface TrailerDialogData {
}
/**
* The trailer, as a 16:9 YouTube embed in a modal over the details column.
* Escape and the backdrop close it; the title row keeps the single Close.
* The trailer, as a 16:9 YouTube embed in a modal over the details column,
* in the dialog shell: the heading names it, the frame fills the body and
* the footer keeps a Close beside the header's.
*/
@Component({
selector: 'app-trailer-dialog',
imports: [MatDialogModule, MatButtonModule, TranslatePipe],
imports: [
DialogFooterDirective,
DialogShellComponent,
MatDialogModule,
MatButtonModule,
TranslatePipe,
],
template: `
<div class="trailer">
<app-dialog-shell
[heading]="
'PORTALS.DETAIL.TRAILER_OF' | translate: { title: data.title }
"
>
<div class="trailer__frame">
<iframe
class="trailer__iframe"
@@ -29,35 +42,28 @@ export interface TrailerDialogData {
allowfullscreen
></iframe>
</div>
<div class="trailer__bar">
<h2 mat-dialog-title class="trailer__title">
{{ 'PORTALS.DETAIL.TRAILER_OF' | translate: { title: data.title } }}
</h2>
<div appDialogFooter>
<button
mat-flat-button
mat-button
type="button"
class="trailer__close"
mat-dialog-close
data-test-id="trailer-dialog-close"
>
{{ 'CLOSE' | translate }}
</button>
</div>
</div>
</app-dialog-shell>
`,
styles: `
:host {
display: block;
}
.trailer {
display: flex;
flex-direction: column;
}
// The 16:9 frame fills the body edge to edge: it cancels the
// body's 20px padding.
.trailer__frame {
position: relative;
width: 100%;
margin: -20px;
aspect-ratio: 16 / 9;
background: #000;
}
@@ -69,36 +75,6 @@ export interface TrailerDialogData {
height: 100%;
border: 0;
}
.trailer__bar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
}
// Keeps the directive (the dialog's accessible name) but drops
// Material's headline padding and the 40px strut before it.
.trailer__title {
flex: 1 1 auto;
min-width: 0;
margin: 0;
padding: 0;
overflow: hidden;
font-size: 14px;
font-weight: 600;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
&::before {
display: none;
}
}
.trailer__close {
flex: 0 0 auto;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -18,7 +18,10 @@
<ng-content select="[appDialogHeaderEnd]" />
</div>
</header>
<mat-dialog-content class="app-dialog__body">
<mat-dialog-content
class="app-dialog__body"
[class.app-dialog__body--column]="bodyLayout() === 'column'"
>
<ng-content />
</mat-dialog-content>
@if (hasFooter()) {
@@ -29,10 +32,13 @@
</mat-dialog-actions>
}
@if (showClose()) {
<!-- Closes with no result, like Escape and the backdrop: a bare
mat-dialog-close attribute would close with an empty string, which
a caller may take for a value. -->
<button
type="button"
mat-icon-button
mat-dialog-close
[mat-dialog-close]="undefined"
class="app-dialog__close"
[aria-label]="'CLOSE_DIALOG' | translate"
>
@@ -8,10 +8,10 @@ app-dialog-shell {
display: flex;
flex-direction: column;
min-height: 0;
// The pane's height limit does not reach the shell through the dialog
// component and its wrappers (an inherited `max-height` is `none`
// there), so the shell caps itself at the viewport minus the preset's
// margins and the surface borders; only the body scrolls.
// An opener without a `maxHeight` leaves the pane unbounded, so the
// shell caps itself at the viewport minus the preset's margins and the
// surface borders; only the body scrolls. A pane limit tighter than
// this reaches the shell through the chain below.
max-height: calc(100vh - 34px);
// The shell draws its own section paddings and headline. Material's
@@ -68,6 +68,9 @@ app-dialog-shell {
// Material names and pads the title from `.mat-mdc-dialog-container
// .mat-mdc-dialog-title`; the header prefix matches that specificity.
// A heading may be provider text (an episode's file name, a channel name)
// with no break opportunity: it wraps anywhere rather than running under
// the close button.
.app-dialog__header .app-dialog__title {
margin: 0;
padding: 0;
@@ -76,6 +79,7 @@ app-dialog-shell {
font-weight: 600;
letter-spacing: -0.2px;
line-height: 1.3;
overflow-wrap: anywhere;
&::before {
display: none;
@@ -87,6 +91,20 @@ app-dialog-shell {
color: var(--app-body-color);
font-size: 13px;
line-height: 1.45;
overflow-wrap: anywhere;
}
// The opener's `maxHeight` reaches Material's surface (`max-height:
// inherit`), a flex column, but the dialog component between the surface
// and the shell, and a form around the shell, are blocks that never shrink
// below their content: a limit tighter than the cap above (an opener's
// `90vh`) would leave the surface scrolling the whole dialog, with the
// header's top cut off. Those ancestors become shrinkable flex columns, so
// the shell sizes from the surface and only the body scrolls.
.mat-mdc-dialog-surface :has(app-dialog-shell) {
display: flex;
flex-direction: column;
min-height: 0;
}
.app-dialog__body {
@@ -95,6 +113,14 @@ app-dialog-shell {
flex: 1 1 auto;
min-height: 0;
max-height: none;
// A child cannot size itself from the body's height (a flex item's
// height is indefinite for percentages), so a column body lets the
// child flex to the space left and own the scrolling instead.
&--column {
display: flex;
flex-direction: column;
}
}
.app-dialog__header-end {
@@ -166,14 +192,19 @@ app-dialog-shell {
// Short windows: a stacked footer can take most of the cap and leave the
// body a sliver, so the cap comes off and Material's surface scrolls the
// whole dialog as the single scroll owner, as it did before the shell.
// A dialog without a footer has no such footer to protect and keeps its
// header in place with the body scrolling: the header close is its only
// in-dialog dismissal, and a scrolling surface would carry it away.
@media (max-height: 600px) {
app-dialog-shell {
app-dialog-shell:not(.app-dialog--footerless) {
max-height: none;
}
// Not shrunk by the surface either: it scrolls the whole shell.
flex-shrink: 0;
.app-dialog__body {
flex: none;
overflow: visible;
.app-dialog__body {
flex: none;
overflow: visible;
}
}
}
@@ -53,6 +53,16 @@ class FullHostComponent {
})
class BareHostComponent {}
@Component({
imports: [DialogShellComponent],
template: `
<app-dialog-shell heading="Manage groups" bodyLayout="column">
<div>A list that scrolls</div>
</app-dialog-shell>
`,
})
class ColumnHostComponent {}
describe('DialogShellComponent', () => {
const close = jest.fn();
@@ -96,6 +106,11 @@ describe('DialogShellComponent', () => {
root.querySelectorAll('.app-dialog__footer button')
).map((button) => button.textContent?.trim());
expect(footerTexts).toEqual(['Clear', 'Cancel', 'Remove']);
expect(
root
.querySelector('app-dialog-shell')
?.classList.contains('app-dialog--footerless')
).toBe(false);
});
it('drops a conditional action from the footer without losing the others', async () => {
@@ -107,10 +122,17 @@ describe('DialogShellComponent', () => {
expect(footerTexts).toEqual(['Clear', 'Cancel']);
});
it('renders no footer, icon tile or subtitle when nothing is projected or set', async () => {
it('renders no footer, icon tile or subtitle when nothing is projected or set, and marks itself footer-less', async () => {
const root = await render(BareHostComponent);
expect(root.querySelector('.app-dialog__footer')).toBeNull();
// The short-window stylesheet keys on the class: such a dialog keeps
// its body scrolling so the header close stays in place.
expect(
root
.querySelector('app-dialog-shell')
?.classList.contains('app-dialog--footerless')
).toBe(true);
expect(root.querySelector('.app-dialog__icon')).toBeNull();
expect(root.querySelector('.app-dialog__subtitle')).toBeNull();
expect(root.querySelector('.app-dialog__body')?.textContent).toContain(
@@ -127,6 +149,23 @@ describe('DialogShellComponent', () => {
expect(closeButton.getAttribute('aria-label')).toBe('CLOSE_DIALOG');
closeButton.click();
expect(close).toHaveBeenCalledTimes(1);
expect(close).toHaveBeenCalledWith(undefined);
});
it('lays the body out as a flex column on request, so a child can own the scrolling', async () => {
const root = await render(BareHostComponent);
const body = root.querySelector('.app-dialog__body');
expect(body?.classList.contains('app-dialog__body--column')).toBe(
false
);
TestBed.resetTestingModule();
const column = await render(ColumnHostComponent);
expect(
column
.querySelector('.app-dialog__body')
?.classList.contains('app-dialog__body--column')
).toBe(true);
});
it('hides the close button on request', async () => {
@@ -16,6 +16,13 @@ import {
} from './dialog-shell.directives';
export type DialogIconTone = 'default' | 'warn';
/**
* How the body lays out its content. `block` is a normal flow; `column`
* makes the body a flex column, so a child can take the height left under
* the dialog's cap and own the scrolling (a picker whose search and bulk
* actions stay put while its list scrolls).
*/
export type DialogBodyLayout = 'block' | 'column';
/**
* The UI kit's dialog shell: a header with an optional icon tile, the
@@ -44,6 +51,10 @@ export type DialogIconTone = 'default' | 'warn';
// `app-dialog__*` classes.
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
// A footer-less dialog keeps its body as the scroll owner in a short
// window (see the stylesheet): its header close is its only in-dialog
// dismissal and must stay in place.
host: { '[class.app-dialog--footerless]': '!hasFooter()' },
})
export class DialogShellComponent {
/** The translated heading; it names the dialog for assistive tech. */
@@ -52,8 +63,15 @@ export class DialogShellComponent {
/** A Material icon name for the 40px tile before the heading. */
readonly icon = input<string>();
readonly iconTone = input<DialogIconTone>('default');
/** The header close button; Escape and the backdrop still close. */
/**
* The header close button. It closes with no result, as Escape and the
* backdrop do, so a caller that reads the result sees a cancel. A dialog
* that must not be dismissed while it works (a save or a deletion in
* flight) hides it meanwhile, since `mat-dialog-close` ignores
* `disableClose`.
*/
readonly showClose = input(true);
readonly bodyLayout = input<DialogBodyLayout>('block');
private readonly footer = contentChild(DialogFooterDirective);
private readonly footerStart = contentChild(DialogFooterStartDirective);
@@ -1,94 +1,111 @@
<h2 mat-dialog-title class="pin-dialog__title">
<mat-icon aria-hidden="true">{{ isSetMode() ? 'lock_reset' : 'lock' }}</mat-icon>
<span>{{ titleKey() | translate }}</span>
</h2>
<form (ngSubmit)="submit()" novalidate>
<mat-dialog-content class="pin-dialog__content">
<p class="pin-dialog__description">{{ descriptionKey() | translate }}</p>
<mat-form-field
appearance="outline"
subscriptSizing="dynamic"
class="pin-dialog__field"
[class.pin-dialog__field--shake]="shake() === 'pin'"
>
<mat-label>{{ 'PARENTAL_LOCK.PIN_DIALOG.PIN_LABEL' | translate }}</mat-label>
<input
#pinInput
matInput
type="password"
inputmode="numeric"
data-test-id="parental-lock-pin"
[attr.autocomplete]="isSetMode() ? 'new-password' : 'off'"
[attr.maxlength]="maxLength"
[disabled]="busy() || inCooldown()"
[errorStateMatcher]="pinErrorState"
[ngModel]="pin()"
(ngModelChange)="onPinInput($event)"
(keydown.enter)="onPinEnter($event)"
name="pin"
/>
<mat-hint>
{{
'PARENTAL_LOCK.PIN_DIALOG.PIN_HINT'
| translate: { min: minLength, max: maxLength }
}}
</mat-hint>
@if (pinErrorKey(); as errorKey) {
<mat-error data-test-id="parental-lock-pin-error">
{{ errorKey | translate: { min: minLength, max: maxLength } }}
</mat-error>
}
</mat-form-field>
@if (isSetMode()) {
<!-- No header close while a verification is pending, as Cancel is
disabled then: a dismissal would hand the caller a cancel and the
accepted PIN could no longer be delivered. -->
<app-dialog-shell
[icon]="isSetMode() ? 'lock_reset' : 'lock'"
[heading]="titleKey() | translate"
[subtitle]="descriptionKey() | translate"
[showClose]="!busy()"
>
<div class="pin-dialog__content">
<mat-form-field
appearance="outline"
subscriptSizing="dynamic"
class="pin-dialog__field"
[class.pin-dialog__field--shake]="shake() === 'confirmation'"
[class.pin-dialog__field--shake]="shake() === 'pin'"
>
<mat-label>{{ 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_LABEL' | translate }}</mat-label>
<mat-label>{{
'PARENTAL_LOCK.PIN_DIALOG.PIN_LABEL' | translate
}}</mat-label>
<input
#confirmationInput
#pinInput
matInput
type="password"
inputmode="numeric"
autocomplete="new-password"
data-test-id="parental-lock-pin-confirm"
data-test-id="parental-lock-pin"
[attr.autocomplete]="isSetMode() ? 'new-password' : 'off'"
[attr.maxlength]="maxLength"
[disabled]="busy()"
[errorStateMatcher]="confirmationErrorState"
[ngModel]="confirmation()"
(ngModelChange)="onConfirmationInput($event)"
name="confirmation"
[disabled]="busy() || inCooldown()"
[errorStateMatcher]="pinErrorState"
[ngModel]="pin()"
(ngModelChange)="onPinInput($event)"
(keydown.enter)="onPinEnter($event)"
name="pin"
/>
@if (mismatch()) {
<mat-error data-test-id="parental-lock-pin-mismatch">
{{ 'PARENTAL_LOCK.PIN_DIALOG.MISMATCH' | translate }}
<mat-hint>
{{
'PARENTAL_LOCK.PIN_DIALOG.PIN_HINT'
| translate: { min: minLength, max: maxLength }
}}
</mat-hint>
@if (pinErrorKey(); as errorKey) {
<mat-error data-test-id="parental-lock-pin-error">
{{
errorKey
| translate: { min: minLength, max: maxLength }
}}
</mat-error>
}
</mat-form-field>
<p class="pin-dialog__note">
{{ 'PARENTAL_LOCK.PIN_DIALOG.NO_RECOVERY' | translate }}
</p>
}
</mat-dialog-content>
@if (isSetMode()) {
<mat-form-field
appearance="outline"
subscriptSizing="dynamic"
class="pin-dialog__field"
[class.pin-dialog__field--shake]="
shake() === 'confirmation'
"
>
<mat-label>{{
'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_LABEL' | translate
}}</mat-label>
<input
#confirmationInput
matInput
type="password"
inputmode="numeric"
autocomplete="new-password"
data-test-id="parental-lock-pin-confirm"
[attr.maxlength]="maxLength"
[disabled]="busy()"
[errorStateMatcher]="confirmationErrorState"
[ngModel]="confirmation()"
(ngModelChange)="onConfirmationInput($event)"
name="confirmation"
/>
@if (mismatch()) {
<mat-error data-test-id="parental-lock-pin-mismatch">
{{
'PARENTAL_LOCK.PIN_DIALOG.MISMATCH' | translate
}}
</mat-error>
}
</mat-form-field>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="cancel()" [disabled]="busy()">
{{ 'CANCEL' | translate }}
</button>
<button
mat-flat-button
color="primary"
type="submit"
data-test-id="parental-lock-pin-submit"
[disabled]="busy() || inCooldown()"
>
{{ submitKey() | translate }}
</button>
</mat-dialog-actions>
<p class="pin-dialog__note">
{{ 'PARENTAL_LOCK.PIN_DIALOG.NO_RECOVERY' | translate }}
</p>
}
</div>
<div appDialogFooter>
<button
mat-button
type="button"
(click)="cancel()"
[disabled]="busy()"
>
{{ 'CANCEL' | translate }}
</button>
<button
mat-flat-button
type="submit"
data-test-id="parental-lock-pin-submit"
[disabled]="busy() || inCooldown()"
>
{{ submitKey() | translate }}
</button>
</div>
</app-dialog-shell>
</form>
@@ -29,24 +29,12 @@
--pin-dialog-error-color: #ffb4ab;
}
.pin-dialog__title {
display: flex;
align-items: center;
gap: 8px;
}
.pin-dialog__content {
display: flex;
flex-direction: column;
gap: 12px;
}
.pin-dialog__description {
margin: 0;
color: var(--app-body-color);
font-size: 0.875rem;
}
.pin-dialog__field {
width: 100%;
@@ -103,6 +103,55 @@ describe('ParentalLockPinDialogComponent', () => {
});
describe('set mode', () => {
it('cancels through the header close with no result, never an empty PIN', async () => {
const dialog = await openDialog({ mode: 'unlock' });
dialog.fixture.detectChanges();
const headerClose = (
dialog.fixture.nativeElement as HTMLElement
).querySelector<HTMLButtonElement>('.app-dialog__close');
expect(headerClose).not.toBeNull();
headerClose?.click();
// A bare dismissal must read as a cancel: the lock services take
// any string, an empty one included, for an entered PIN.
expect(dialog.close).toHaveBeenCalledTimes(1);
expect(dialog.close).toHaveBeenCalledWith(undefined);
});
it('hides the header close while a verification is pending, like Cancel', async () => {
let settle: (accepted: boolean) => void = () => undefined;
const verify = jest.fn(
() => new Promise<boolean>((resolve) => (settle = resolve))
);
const dialog = await openDialog({ mode: 'unlock', verify });
const root = dialog.fixture.nativeElement as HTMLElement;
const headerClose = () =>
root.querySelector<HTMLButtonElement>('.app-dialog__close');
dialog.fixture.detectChanges();
expect(headerClose()).not.toBeNull();
await dialog.type('parental-lock-pin', '2468');
await dialog.pressEnter();
dialog.fixture.detectChanges();
// A dismissal now would hand the caller a cancel while the PIN
// is being checked, and the accepted PIN could no longer be
// delivered; the footer's Cancel is disabled for the same reason.
expect(verify).toHaveBeenCalledWith('2468');
expect(headerClose()).toBeNull();
expect(
root.querySelector<HTMLButtonElement>(
'mat-dialog-actions button[type="button"]'
)?.disabled
).toBe(true);
expect(dialog.close).not.toHaveBeenCalled();
settle(true);
await dialog.fixture.whenStable();
expect(dialog.close).toHaveBeenCalledWith('2468');
});
it('asks password managers for a new password in both fields', async () => {
const dialog = await openDialog({ mode: 'set' });
@@ -17,7 +17,6 @@ import {
MatDialogRef,
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import {
@@ -28,6 +27,9 @@ import {
PARENTAL_LOCK_PIN_MIN_LENGTH,
} from '@iptvnator/shared/interfaces';
import { provideFormFieldDefaults } from '../form-field-defaults/form-field-defaults';
import { DialogShellComponent } from '../dialog-shell/dialog-shell.component';
import { DialogFooterDirective } from '../dialog-shell/dialog-shell.directives';
import { dialogSize } from '../dialog-shell/dialog-size';
export type ParentalLockPinDialogMode = 'unlock' | 'set';
@@ -65,11 +67,12 @@ export interface ParentalLockPinDialogData {
styleUrl: './parental-lock-pin-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
DialogFooterDirective,
DialogShellComponent,
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
TranslatePipe,
],
@@ -167,9 +170,8 @@ export class ParentalLockPinDialogComponent {
data: ParentalLockPinDialogData
): MatDialogRef<ParentalLockPinDialogComponent, string | undefined> {
return dialog.open(ParentalLockPinDialogComponent, {
...dialogSize('sm'),
data,
width: '400px',
maxWidth: 'calc(100vw - 32px)',
autoFocus: 'input',
restoreFocus: true,
});
@@ -8,6 +8,8 @@ import {
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import { DialogShellComponent } from '../dialog-shell/dialog-shell.component';
import { DialogFooterDirective } from '../dialog-shell/dialog-shell.directives';
export interface EpisodeInfoDialogData {
/** e.g. "S01E05" */
@@ -51,78 +53,85 @@ export const EPISODE_INFO_PLAY = 'play' as const;
@Component({
selector: 'app-episode-info-dialog',
standalone: true,
imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslateModule],
imports: [
DialogFooterDirective,
DialogShellComponent,
MatButtonModule,
MatDialogModule,
MatIconModule,
TranslateModule,
],
template: `
<div class="episode-info">
@if (data.thumbnailUrl) {
<img
class="episode-info__thumb"
[src]="data.thumbnailUrl"
[alt]="data.title"
(error)="thumbError = true"
[style.display]="thumbError ? 'none' : ''"
/>
}
<div class="episode-info__body">
<div class="episode-info__eyebrow">
{{ data.episodeLabel }}
@if (data.duration) {
<span class="episode-info__chip">{{
data.duration
}}</span>
}
@if (data.airDate) {
<span class="episode-info__chip">{{
data.airDate
}}</span>
}
@if (data.rating) {
<span class="episode-info__chip"
>⭐ {{ data.rating }}</span
>
<app-dialog-shell [heading]="data.title">
<div class="episode-info">
@if (data.thumbnailUrl) {
<img
class="episode-info__thumb"
[src]="data.thumbnailUrl"
[alt]="data.title"
(error)="thumbError = true"
[style.display]="thumbError ? 'none' : ''"
/>
}
<div class="episode-info__body">
<div class="episode-info__eyebrow">
{{ data.episodeLabel }}
@if (data.duration) {
<span class="episode-info__chip">{{
data.duration
}}</span>
}
@if (data.airDate) {
<span class="episode-info__chip">{{
data.airDate
}}</span>
}
@if (data.rating) {
<span class="episode-info__chip"
>⭐ {{ data.rating }}</span
>
}
</div>
@if (data.plot) {
<p class="episode-info__plot">{{ data.plot }}</p>
}
</div>
<h2 mat-dialog-title class="episode-info__title">
{{ data.title }}
</h2>
@if (data.plot) {
<p class="episode-info__plot">{{ data.plot }}</p>
}
</div>
</div>
<div mat-dialog-actions align="end">
<button mat-button mat-dialog-close type="button">
{{ 'CLOSE' | translate }}
</button>
<button
mat-flat-button
type="button"
data-testid="episode-info-play"
(click)="play()"
>
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.PLAY' | translate }}
</button>
</div>
<div appDialogFooter>
<button mat-button mat-dialog-close type="button">
{{ 'CLOSE' | translate }}
</button>
<button
mat-flat-button
type="button"
data-testid="episode-info-play"
(click)="play()"
>
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.PLAY' | translate }}
</button>
</div>
</app-dialog-shell>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
display: block;
max-width: 560px;
}
// The still runs edge to edge under the header: it cancels the
// body's 20px padding on three sides.
.episode-info__thumb {
width: 100%;
display: block;
width: calc(100% + 40px);
margin: -20px -20px 0;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 12px 12px 0 0;
display: block;
}
.episode-info__body {
padding: 16px 24px 0;
.episode-info__thumb + .episode-info__body {
padding-top: 16px;
}
.episode-info__eyebrow {
@@ -143,25 +152,11 @@ export const EPISODE_INFO_PLAY = 'play' as const;
text-transform: none;
}
.episode-info__title {
margin: 6px 0 0;
padding: 0;
font-size: 1.25rem;
line-height: 1.3;
overflow-wrap: anywhere;
&::before {
display: none;
}
}
.episode-info__plot {
margin: 10px 0 0;
font-size: 0.9375rem;
line-height: 1.6;
opacity: 0.85;
max-height: 50vh;
overflow-y: auto;
}
`,
],
@@ -59,6 +59,7 @@ import {
buildWatchedEpisodePosition,
resolveEpisodeInfo,
} from './season-watch-toggle.util';
import { dialogSize } from '../dialog-shell/dialog-size';
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
@@ -412,6 +413,7 @@ export class SeasonContainerComponent implements OnInit {
event.stopPropagation();
this.dialog
.open(EpisodeInfoDialogComponent, {
...dialogSize('md'),
data: buildEpisodeInfoDialogData(
episode,
this.selectedSeason()
@@ -5,62 +5,62 @@ import { MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import {
DialogFooterDirective,
DialogFooterStartDirective,
DialogShellComponent,
} from '@iptvnator/ui/components';
@Component({
template: `
<h2 mat-dialog-title>{{ 'INFORMATION' | translate }}</h2>
<mat-dialog-content class="mat-typography">
<div class="centered">
<mat-icon class="icon">live_tv</mat-icon>
<div>
{{ 'EXTERNAL_PLAYER.INFO_DIALOG.PLAYING' | translate
}}<br />
{{ 'EXTERNAL_PLAYER.INFO_DIALOG.INSTALL_HINT' | translate
}}<br />
<a
[routerLink]
style="cursor: pointer"
(click)="
openUrl(
'https://github.com/4gray/iptvnator/wiki/What-is-mpv-video-player-and-how-to-install-it-on-different-operating-systems%3F'
)
"
>{{
'EXTERNAL_PLAYER.INFO_DIALOG.INSTALL_INSTRUCTIONS'
| translate
}}</a
>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions style="justify-content: space-between;">
<div>
<app-dialog-shell icon="live_tv" [heading]="'INFORMATION' | translate">
<p class="external-player-info">
{{ 'EXTERNAL_PLAYER.INFO_DIALOG.PLAYING' | translate }}<br />
{{ 'EXTERNAL_PLAYER.INFO_DIALOG.INSTALL_HINT' | translate
}}<br />
<a
[routerLink]
class="external-player-info__link"
(click)="
openUrl(
'https://github.com/4gray/iptvnator/wiki/What-is-mpv-video-player-and-how-to-install-it-on-different-operating-systems%3F'
)
"
>{{
'EXTERNAL_PLAYER.INFO_DIALOG.INSTALL_INSTRUCTIONS'
| translate
}}</a
>
</p>
<div appDialogFooterStart>
<mat-checkbox (change)="setVisibility($event.checked)">{{
'EXTERNAL_PLAYER.INFO_DIALOG.DONT_SHOW_AGAIN' | translate
}}</mat-checkbox>
</div>
<button mat-button mat-dialog-close cdkFocusInitial>
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
<div appDialogFooter>
<button mat-button mat-dialog-close cdkFocusInitial>
{{ 'CLOSE' | translate }}
</button>
</div>
</app-dialog-shell>
`,
styles: [
`
.centered {
text-align: center;
line-height: 24px;
margin: 20px;
.external-player-info {
margin: 0;
line-height: 1.6;
}
.icon {
font-size: 64px;
height: 64px;
width: 64px;
}
.external-player-info__link {
cursor: pointer;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
DialogFooterDirective,
DialogFooterStartDirective,
DialogShellComponent,
MatButtonModule,
MatCheckboxModule,
MatDialogModule,
+5 -52
View File
@@ -7,8 +7,10 @@
// tweaks (e.g. the stats column count) stay in the consumer stylesheet.
// ─── Hero header ────────────────────────────────────────────────────────────
// The hero band runs edge to edge under the shell's header: it cancels the
// body's 20px padding on three sides.
.account-dialog__hero {
margin: 0;
margin: -20px -20px 20px;
padding: 20px 20px 18px;
display: grid;
gap: 18px;
@@ -57,28 +59,6 @@
opacity: 0.5;
}
.account-dialog__headline-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.account-dialog__title {
margin: 0;
font-size: 1.3rem;
line-height: 1.2;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.account-dialog__subtitle {
margin: 4px 0 0;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-size: 0.88rem;
}
// ─── Status pills ───────────────────────────────────────────────────────────
.account-dialog__status-cluster {
display: flex;
@@ -212,8 +192,6 @@
// ─── Content area ───────────────────────────────────────────────────────────
.account-dialog__content {
margin: 0;
padding: 20px;
background: var(--app-widget-bg, var(--mat-sys-surface));
}
.account-dialog__grid {
@@ -477,21 +455,6 @@
}
// ─── Footer actions ─────────────────────────────────────────────────────────
.account-dialog__actions {
padding: 0 20px 20px;
display: flex;
justify-content: flex-end;
gap: 10px;
border-top: 1px solid
var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
background: var(--app-widget-bg, var(--mat-sys-surface));
}
.account-dialog__close {
min-width: 100px;
border-radius: 999px;
}
@keyframes account-dialog-shimmer {
100% {
transform: translateX(100%);
@@ -507,18 +470,8 @@
}
@media (max-width: 720px) {
.account-dialog__hero,
.account-dialog__content,
.account-dialog__actions {
padding-left: 16px;
padding-right: 16px;
}
.account-dialog__headline-row {
flex-direction: column;
align-items: flex-start;
}
// Only the pills re-align: the hero is a grid whose column must keep
// stretching to the band, or its stats size to their content.
.account-dialog__status-cluster {
justify-content: flex-start;
}
@@ -1036,7 +1036,7 @@ describe('WorkspaceContextPanelComponent', () => {
expect(dialog.open).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
width: '500px',
width: '560px',
maxWidth: 'calc(100vw - 32px)',
})
);
@@ -56,6 +56,7 @@ import { WorkspaceContextErrorViewComponent } from './components/workspace-conte
import { hasActiveLiveCategoryRoute } from './workspace-context-panel-route.utils';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
import { foldSearchText } from '@iptvnator/shared/interfaces';
import { dialogSize } from '@iptvnator/ui/components/dialog-size';
type WorkspaceProvider = 'xtreams' | 'stalker' | 'playlists';
@@ -594,10 +595,8 @@ export class WorkspaceContextPanelComponent {
return;
}
this.dialog.open(dialogComponent, {
...dialogSize('md'),
data: { playlistId: context.playlistId, contentType, categories },
width: '500px',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '90vh',
});
}
@@ -649,9 +648,7 @@ export class WorkspaceContextPanelComponent {
contentType,
itemCounts: this.xtreamStore.getCategoryItemCounts(),
},
width: '500px',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '90vh',
...dialogSize('md'),
});
dialogRef
.afterClosed()
@@ -18,6 +18,7 @@ import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { map } from 'rxjs';
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { dialogSize } from '@iptvnator/ui/components';
import {
SourceStatusService,
sourceStatusBucket,
@@ -202,9 +203,8 @@ export class WorkspaceSourcesComponent {
const activity = this.injector.get(SourceActivityService);
if (dialogs.getDialogById('source-cleanup')) return;
dialogs.open(SourceCleanupDialogComponent, {
...dialogSize('lg'),
id: 'source-cleanup',
width: '680px',
maxWidth: '95vw',
data: {
playlists: this.playlists(),
current: (id: string) =>