mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
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:
1 parent
b64b766f0a
commit
e6df866787
76 files changed
+2920
-2409
No files matched your search
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
+430
-403
@@ -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>
|
||||
+16
-14
@@ -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);
|
||||
|
||||
+2
-2
@@ -213,8 +213,8 @@ describe('GlobalFavoritesListComponent', () => {
|
||||
ChannelDetailsDialogComponent,
|
||||
expect.objectContaining({
|
||||
data: channel,
|
||||
maxWidth: '720px',
|
||||
width: 'calc(100vw - 32px)',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
width: '760px',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
+1
-5
@@ -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 {
|
||||
|
||||
+208
-218
@@ -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>
|
||||
+12
-1
@@ -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,
|
||||
|
||||
+108
-99
@@ -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>
|
||||
+6
-13
@@ -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;
|
||||
}
|
||||
+13
@@ -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,
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+177
-168
@@ -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>
|
||||
+15
-19
@@ -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 {
|
||||
|
||||
+13
@@ -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();
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+2
-2
@@ -290,8 +290,8 @@ describe('AllChannelsViewComponent', () => {
|
||||
ChannelDetailsDialogComponent,
|
||||
expect.objectContaining({
|
||||
data: primaryChannel,
|
||||
maxWidth: '720px',
|
||||
width: 'calc(100vw - 32px)',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
width: '760px',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
+1
-5
@@ -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 {
|
||||
|
||||
+232
-225
@@ -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>
|
||||
+9
-59
@@ -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;
|
||||
}
|
||||
|
||||
+37
-3
@@ -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',
|
||||
|
||||
+45
-35
@@ -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>
|
||||
+4
-12
@@ -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 {
|
||||
|
||||
+14
-3
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -95,8 +95,8 @@ describe('FavoritesViewComponent', () => {
|
||||
ChannelDetailsDialogComponent,
|
||||
expect.objectContaining({
|
||||
data: primaryChannel,
|
||||
maxWidth: '720px',
|
||||
width: 'calc(100vw - 32px)',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
width: '760px',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
+1
-5
@@ -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 {
|
||||
|
||||
+119
-116
@@ -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>
|
||||
+13
-6
@@ -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,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+4
-4
@@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
+3
-8
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -101,8 +101,8 @@ describe('RecentViewComponent', () => {
|
||||
ChannelDetailsDialogComponent,
|
||||
expect.objectContaining({
|
||||
data: primaryChannel,
|
||||
maxWidth: '720px',
|
||||
width: 'calc(100vw - 32px)',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
width: '760px',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
+1
-5
@@ -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);
|
||||
|
||||
+93
-76
@@ -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>
|
||||
-12
@@ -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%;
|
||||
|
||||
|
||||
+49
@@ -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' });
|
||||
|
||||
|
||||
+6
-4
@@ -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()
|
||||
|
||||
+40
-40
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1036,7 +1036,7 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
expect(dialog.open).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
width: '500px',
|
||||
width: '560px',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
})
|
||||
);
|
||||
|
||||
+3
-6
@@ -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()
|
||||
|
||||
+2
-2
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user