fix(ui): destructive confirmations, verb labels and provider icons (#1783)

* fix(ui): destructive confirmations, verb labels and provider icons

Confirmations: ConfirmDialogData.confirmLabel is required, so no dialog can
fall back to "Yes"/"No"; the dismiss defaults to "Cancel" and
`tone: 'destructive'` styles the confirm with .app-destructive-button. Every
caller names its action ("Remove playlist", "Clear", "Refresh playlist",
"Cancel download" with a "Close" dismiss). The confirm button has the
confirm-dialog-confirm test id and drops its no-op color="primary".

The no-op `warn` color input becomes .app-destructive-button on the EPG
mapping, playlist item, error view, EPG/reset settings, delete-all and source
cleanup buttons, and on the unsaved-changes dialog's Discard.

Provider icons come from SOURCE_TYPE_ICONS in shared/interfaces (Xtream
cloud, Stalker cast, M3U playlist_play / link / description / subject) in the
add dialog, auto-import, empty state, playlist switcher, playlist rows,
dashboard source rail, command palette, Sources filters and both reset
summaries. Stalker no longer borrows the Dashboard icon, and Xtream no longer
shares a glyph with M3U URL playlists.

The playlist error view removed a playlist through the stale
PlaylistActions.removePlaylist: it dropped the playlist from state before the
delete ran, swallowed failures, skipped the source activity guard and showed
no toast. It now uses PlaylistDeleteActionService like every other removal,
commits only a completed delete, toasts and goes home. The unused action and
its effect are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): one provider icon per playlist row, imperative Korean remove label

A restored Stalker or Xtream playlist can also carry a URL, and the row's
independent checks then showed the M3U URL icon next to the provider icon.
The row now switches on resolvePlaylistSourceIconKey(), the precedence every
other surface uses, so each source shows exactly one icon.

HOME.PLAYLISTS.REMOVE now names the confirm button and the row's delete
tooltip; in Korean it read "the playlist has been removed". It now says
"remove playlist", like every other locale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): keep the auto-refresh badge on playlist rows with one provider icon

Showing one provider icon per row moved the auto-refresh badge into the M3U
branches only, so a restored Stalker playlist with a URL and auto-refresh
lost it although the URL is still re-fetched. The row now renders one icon
container: the provider icon from the shared precedence, then the badge for
any row with a URL or a local M3U, exactly the rows that showed it before.
The Xtream portal-status dot, used without source health, keeps that corner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): let the playlist row's cancel action render in the error color

The row's action buttons set `color: inherit`, and the selected row does so
again with more specific selectors. Both beat Material's token-driven icon
color, so the .app-destructive-button cancel action kept the row color
(selection blue on the active row). Pin the cancel button to
--mat-sys-error in both row states.

The large-deletion Electron E2E now checks the cancel color in both themes;
without this rule it reads rgb(47, 123, 255) instead of the error red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ui): give the dialog service spec the now-required confirm labels

ConfirmDialogData.confirmLabel became required, and the spec still built
confirmations without one. Jest only transpiles, so the suite stayed green,
but the "Typecheck Jest spec programs" CI step rejected it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-02 20:36:16 +02:00
1 parent b6357da6af
commit 23a1860119
54 files changed
+730 -161

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: ui
---
Removing or clearing data now asks with a named action on a red button instead of "Yes"/"No", and delete buttons are red again. Each source type has one icon everywhere. Deleting a playlist from its error screen now confirms with a message and keeps the playlist if the delete fails.
+2 -2
View File
@@ -29,8 +29,8 @@ consumers currently use relative `@use` paths to the needed partial.
them outside Material components only for roles without an app token.
- Set component tokens through `mat.*-overrides()`; retired `--mdc-*` names
do nothing and `pnpm run styles:material-tokens:validate` rejects them.
- Destructive buttons use `.app-destructive-button` (`color="warn"` is a no-op
with M3).
- Destructive buttons use `.app-destructive-button` (the `warn` color input is
a no-op with M3); provider icons come from `SOURCE_TYPE_ICONS`.
- Local semantic status colors are acceptable. Existing hard-coded layout,
selection, and EPG surface colors are migration debt, not precedent.
@@ -551,7 +551,9 @@ async function refreshFromWorkspaceHeader(page: Page): Promise<void> {
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Yes', exact: true }).click();
await dialog
.getByRole('button', { name: 'Refresh playlist', exact: true })
.click();
const refreshOverlay = page.locator('app-workspace-shell-import-overlay');
await expect(refreshOverlay).toBeVisible({ timeout: 5000 });
@@ -1266,7 +1266,7 @@ export async function clearCurrentUnifiedCollection(page: Page): Promise<void> {
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Yes$/i }).click();
await dialog.getByRole('button', { name: 'Clear', exact: true }).click();
await expect(dialog).toBeHidden();
}
@@ -1664,7 +1664,7 @@ export async function deleteSource(page: Page, title: string): Promise<void> {
await expect(row).toBeVisible();
await row.locator('.delete-btn').click();
await confirmDialog(page);
await confirmDialog(page, 'Remove playlist');
}
export async function refreshSource(
@@ -1681,7 +1681,7 @@ export async function refreshSource(
await row.locator('.refresh-btn').click();
if (confirm) {
await confirmDialog(page);
await confirmDialog(page, 'Refresh playlist');
}
}
@@ -2098,7 +2098,7 @@ async function openCommandPalette(page: Page): Promise<Locator> {
return dialog;
}
async function confirmDialog(page: Page, buttonLabel = 'Yes'): Promise<void> {
async function confirmDialog(page: Page, buttonLabel: string): Promise<void> {
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
@@ -163,7 +163,10 @@ test.describe('Live channel rail collapse (#1458)', () => {
await app.mainWindow
.getByRole('button', { name: 'Clear recently viewed Live TV' })
.click();
await app.mainWindow.getByRole('button', { name: 'Yes' }).click();
await app.mainWindow
.locator('mat-dialog-container')
.getByRole('button', { name: 'Clear', exact: true })
.click();
await expect(
channelItemByTitle(app.mainWindow, 'Channel Alpha')
).toHaveCount(0);
@@ -35,7 +35,8 @@ describe('Xtream scenario driver source wiring', () => {
"'app-playlist-item'",
"'.refresh-btn'",
"'.delete-btn'",
"'Yes'",
"'Refresh playlist'",
"'Remove playlist'",
"'.busy-state__message'",
"'.action-spinner'",
"'.cancel-btn'",
@@ -157,10 +157,15 @@ describe('Xtream Playwright scenario driver', () => {
});
it('does not require a misleading portal-status badge before refresh or delete', async () => {
for (const [scenarioId, selector] of [
[XTREAM_SCENARIO_ID.REFRESH_LARGE, '.refresh-btn'],
[XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn'],
for (const [scenarioId, selector, confirmLabel] of [
[
XTREAM_SCENARIO_ID.REFRESH_LARGE,
'.refresh-btn',
'Refresh playlist',
],
[XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn', 'Remove playlist'],
] as const) {
const confirmClick = `name=${confirmLabel};exact=true`;
const fake = new FakePage();
const prepared = await prepareXtreamScenario(
fake.asPage(),
@@ -168,7 +173,7 @@ describe('Xtream Playwright scenario driver', () => {
);
assert.equal(fake.clicksMatching(selector), 1);
assert.equal(fake.clicksMatching('name=Yes;exact=true'), 0);
assert.equal(fake.clicksMatching(confirmClick), 0);
const portalReadyIndex = fake.log.findIndex((entry) =>
entry.includes('[aria-label="Portal status: active"]:visible')
);
@@ -179,7 +184,7 @@ describe('Xtream Playwright scenario driver', () => {
assert.equal(portalReadyIndex, -1);
assert.ok(sourceActionIndex >= 0);
await prepared.trigger();
assert.equal(fake.clicksMatching('name=Yes;exact=true'), 1);
assert.equal(fake.clicksMatching(confirmClick), 1);
}
});
@@ -83,7 +83,10 @@ export async function prepareXtreamSourceActionTrigger(
const dialog = page.locator('mat-dialog-container').last();
await dialog.waitFor({ state: 'visible' });
const confirmation = dialog
.getByRole('button', { name: 'Yes', exact: true })
.getByRole('button', {
name: action === 'refresh' ? 'Refresh playlist' : 'Remove playlist',
exact: true,
})
.last();
await assertTriggerReady(confirmation, `xtream-source-${action}-confirm`);
return confirmation;
@@ -245,7 +245,9 @@ async function refreshRecentXtreamSourceFromDashboard(
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Yes', exact: true }).click();
await dialog
.getByRole('button', { name: 'Refresh playlist', exact: true })
.click();
const refreshOverlay = page.locator('app-workspace-shell-import-overlay');
await expect(refreshOverlay).toBeVisible({ timeout: 5000 });
+4 -1
View File
@@ -676,7 +676,10 @@ async function clearRecentItems(page: Page, typeLabel: string): Promise<void> {
await page
.getByRole('button', { name: `Clear recently viewed ${typeLabel}` })
.click();
await page.getByRole('button', { name: 'Yes' }).click();
await page
.locator('mat-dialog-container')
.getByRole('button', { name: 'Clear', exact: true })
.click();
}
async function expectUnifiedLiveDetailOpen(
@@ -4,6 +4,7 @@ import {
expect,
launchElectronApp,
openSettings,
openSettingsSection,
test,
} from './electron-test-fixtures';
import { applyTheme } from './theme-contrast';
@@ -193,4 +194,55 @@ test.describe('Theme tokens', () => {
await closeElectronApp(app);
}
});
test('@theme @electron destructive actions name the action and use the error color', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await openSettings(page);
await openSettingsSection(page, 'epg');
for (const theme of ['light', 'dark'] as const) {
await applyTheme(page, theme);
const error = await resolveColor(page, 'var(--mat-sys-error)');
const trigger = page.getByRole('button', {
name: 'Clear EPG data',
exact: true,
});
// The `warn` color input was a no-op with M3: the trigger
// rendered in the primary color.
await expect
.poll(() =>
trigger.evaluate((el) => getComputedStyle(el).color)
)
.toBe(error);
await trigger.click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await expect(
dialog.getByRole('button', { name: 'Yes' })
).toHaveCount(0);
const confirm = dialog.getByTestId('confirm-dialog-confirm');
await expect(confirm).toHaveText('Clear EPG data');
await expect(confirm).toHaveClass(/app-destructive-button/);
await expect
.poll(() =>
confirm.evaluate(
(el) => getComputedStyle(el).backgroundColor
)
)
.toBe(error);
await dialog
.getByRole('button', { name: 'Cancel', exact: true })
.click();
await expect(dialog).toBeHidden();
}
} finally {
await closeElectronApp(app);
}
});
});
@@ -11,6 +11,7 @@ import {
waitForXtreamCatalog,
xtreamMockServer,
} from './electron-test-fixtures';
import { applyTheme } from './theme-contrast';
const stressPortalName = 'Stress Xtream Portal';
const stressXtreamUsername = 'stress';
@@ -292,7 +293,7 @@ test.describe('Electron Xtream Responsiveness', () => {
);
await expect(confirmDialog).toBeVisible();
await confirmDialog
.getByRole('button', { name: 'Yes', exact: true })
.getByRole('button', { name: 'Remove playlist', exact: true })
.click();
await waitForDbOperationEvent(app.mainWindow, {
@@ -304,7 +305,25 @@ test.describe('Electron Xtream Responsiveness', () => {
await expect(
playlistRow.locator('.busy-state__message')
).toBeVisible({ timeout: 20000 });
await expect(playlistRow.locator('.cancel-btn')).toBeVisible();
const cancel = playlistRow.locator('.cancel-btn');
await expect(cancel).toBeVisible();
// The row's action buttons inherit its color; the cancel action
// must still read as destructive on the selected row.
for (const theme of ['light', 'dark'] as const) {
await applyTheme(app.mainWindow, theme);
const error = await app.mainWindow.evaluate(() => {
const probe = document.createElement('div');
probe.style.color = 'var(--mat-sys-error)';
document.body.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
});
expect(
await cancel.evaluate((el) => getComputedStyle(el).color),
`cancel color in ${theme} theme`
).toBe(error);
}
await waitForDbOperationEvent(app.mainWindow, {
operation: 'delete-playlist',
@@ -49,7 +49,11 @@
<button mat-button mat-dialog-close cdkFocusInitial>
{{ 'CANCEL' | translate }}
</button>
<button mat-flat-button [mat-dialog-close]="true" color="warn">
<button
mat-flat-button
class="app-destructive-button"
[mat-dialog-close]="true"
>
{{ 'SETTINGS.REMOVE_DIALOG.CONFIRM' | translate }}
</button>
</mat-dialog-actions>
@@ -9,6 +9,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
export interface SettingsDeleteAllPlaylistsDialogData {
summary: {
@@ -48,19 +49,19 @@ export class SettingsDeleteAllPlaylistsDialogComponent {
{
id: 'm3u',
count: this.dialogData.summary.m3u,
icon: 'playlist_play',
icon: SOURCE_TYPE_ICONS.m3u,
labelKey: 'HOME.PLAYLIST_TYPES.M3U',
},
{
id: 'xtream',
count: this.dialogData.summary.xtream,
icon: 'cloud',
icon: SOURCE_TYPE_ICONS.xtream,
labelKey: 'HOME.PLAYLIST_TYPES.XTREAM',
},
{
id: 'stalker',
count: this.dialogData.summary.stalker,
icon: 'router',
icon: SOURCE_TYPE_ICONS.stalker,
labelKey: 'HOME.PLAYLIST_TYPES.STALKER',
},
]);
@@ -217,7 +217,7 @@
</button>
<button
mat-button
color="warn"
class="app-destructive-button"
type="button"
[disabled]="isClearingEpgData()"
(click)="clearEpgData.emit()"
@@ -89,6 +89,8 @@ export class SettingsEpgFacade {
message: this.translate.instant(
'SETTINGS.CLEAR_EPG_DIALOG.MESSAGE'
),
confirmLabel: this.translate.instant('SETTINGS.CLEAR_EPG_DATA'),
tone: 'destructive',
onConfirm: async (): Promise<void> => {
if (
!this.epgBridge.supportsDataManagement ||
@@ -20,7 +20,7 @@
</div>
<button
mat-button
class="danger-zone__button"
class="danger-zone__button app-destructive-button"
[class.danger-zone__button--busy]="isRemovingAllPlaylists()"
[disabled]="!canRemoveAllPlaylists()"
[attr.aria-busy]="isRemovingAllPlaylists()"
@@ -29,7 +29,6 @@
$event.stopPropagation();
removeAll.emit()
"
color="warn"
>
@if (isRemovingAllPlaylists()) {
<mat-progress-spinner
@@ -49,9 +48,9 @@
</div>
<div class="danger-zone__summary" aria-live="polite">
<span class="danger-zone__summary-pill">
<mat-icon class="danger-zone__summary-icon"
>folder_open</mat-icon
>
<mat-icon class="danger-zone__summary-icon">{{
sourceIcons.m3u
}}</mat-icon>
<span class="danger-zone__summary-label">{{
'HOME.PLAYLIST_TYPES.M3U' | translate
}}</span>
@@ -60,7 +59,9 @@
}}</span>
</span>
<span class="danger-zone__summary-pill">
<mat-icon class="danger-zone__summary-icon">cloud</mat-icon>
<mat-icon class="danger-zone__summary-icon">{{
sourceIcons.xtream
}}</mat-icon>
<span class="danger-zone__summary-label">{{
'HOME.PLAYLIST_TYPES.XTREAM' | translate
}}</span>
@@ -69,7 +70,9 @@
}}</span>
</span>
<span class="danger-zone__summary-pill">
<mat-icon class="danger-zone__summary-icon">cast</mat-icon>
<mat-icon class="danger-zone__summary-icon">{{
sourceIcons.stalker
}}</mat-icon>
<span class="danger-zone__summary-label">{{
'HOME.PLAYLIST_TYPES.STALKER' | translate
}}</span>
@@ -9,6 +9,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslateModule } from '@ngx-translate/core';
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
import { SettingsPlaylistDeleteSummary } from './settings.models';
@Component({
@@ -26,6 +27,7 @@ import { SettingsPlaylistDeleteSummary } from './settings.models';
styles: [':host { display: contents; }'],
})
export class SettingsResetSectionComponent {
readonly sourceIcons = SOURCE_TYPE_ICONS;
readonly isRemovingAllPlaylists = input(false);
readonly canRemoveAllPlaylists = input(false);
readonly playlistDeleteSummary =
@@ -68,6 +68,16 @@ describe('SettingsUnsavedChangesDialogComponent', () => {
expect(save.hasAttribute('cdkFocusInitial')).toBe(false);
});
it('marks only Discard as destructive: it throws away the edits', async () => {
await render({ canSave: true });
expect(
actionButtons().map((button) =>
button.classList.contains('app-destructive-button')
)
).toEqual([false, true, false]);
});
it('disables save while the form cannot be saved', async () => {
await render({ canSave: false });
@@ -82,6 +82,7 @@ export interface SettingsUnsavedChangesDialogData {
</button>
<button
mat-button
class="app-destructive-button"
[mat-dialog-close]="'discard'"
data-test-id="unsaved-dialog-discard"
>
+1 -1
View File
@@ -65,7 +65,7 @@
"CHANNELS": "채널",
"ADDED": "추가되었습니다",
"SHOW_DETAILS": "재생 목록 세부 정보 표시",
"REMOVE": "재생 목록이 제거되었습니다",
"REMOVE": "재생 목록 제거",
"SEARCH_PLAYLISTS": "제목으로 재생목록 검색",
"INFO_DIALOG": {
"PLAYLIST_DETAILS": "재생 목록 세부 사항",
+2 -2
View File
@@ -287,8 +287,8 @@ html {
}
// ─── Destructive actions ───────────────────────────────────────────────
// Material only emits `.mat-warn` button colors for M2 themes, so
// `color="warn"` is a no-op with this M3 theme. Buttons that remove or
// Material only emits `.mat-warn` button colors for M2 themes, so the
// `warn` color input is a no-op with this M3 theme. Buttons that remove or
// discard user data opt in with this class instead; the error tokens are
// declared per theme context above, so light and dark each get their own
// error/on-error pair.
@@ -701,6 +701,27 @@ Settings use the same system but are flatter than content-heavy views.
`EpgProgrammeDialogService` opens the programme dialog at 540px from the
timeline, list, guide and channel rows, with a panel class that scopes its
surface overrides.
- **Destructive actions.** Material only emits `warn` button colors for M2
themes, so the `color` input is a no-op here. A button that removes or
discards user data uses the global `.app-destructive-button` class from
`m3-theme.scss` (error/on-error tokens per theme, for filled, text,
outlined and icon buttons), as the unsaved-changes dialog's Discard does.
Confirmations go through `DialogService.openConfirmDialog` with a
translated verb as the required `confirmLabel` ("Remove playlist",
"Clear") and `tone: 'destructive'` when the action loses data; the dismiss
defaults to "Cancel". Never confirm with "Yes"/"No". When the verb itself
is "Cancel …", pass `cancelLabel` "Close" so the two buttons do not read
alike. `theme-tokens.e2e.ts` checks the label and the error fill in both
themes.
## Source Type Icons
`SOURCE_TYPE_ICONS` in `@iptvnator/shared/interfaces` is the only source of
provider icons: Xtream `cloud`, Stalker `cast`, the M3U family
`playlist_play`, and per playlist `link` (URL), `description` (local file or
text) and `subject` (pasted text in the add flow). Use
`getPlaylistSourceIcon()` for a stored playlist. An icon never stands for two
providers, and the Dashboard rail icon is never a provider icon.
## Phone Layout
-1
View File
@@ -15,7 +15,6 @@ export const PlaylistActions = createActionGroup({
'Load Playlists Failure': emptyProps(),
'Add Playlist': props<{ playlist: Playlist }>(),
'Add Many Playlists': props<{ playlists: Playlist[] }>(),
'Remove Playlist': props<{ playlistId: string }>(),
'Playlist Removal Committed': props<{ playlistId: string }>(),
'Update Playlist Meta': props<{
playlist: PlaylistMetaUpdate;
-15
View File
@@ -336,21 +336,6 @@ export class PlaylistEffects {
{ dispatch: false }
);
removePlaylist$ = createEffect(
() => {
return this.actions$.pipe(
ofType(PlaylistActions.removePlaylist),
switchMap(async (action) => {
this.playlistScopedEpgFetchKeys.delete(action.playlistId);
await firstValueFrom(
this.playlistsService.deletePlaylist(action.playlistId)
);
})
);
},
{ dispatch: false }
);
updatePlaylist$ = createEffect(
() => {
return this.actions$.pipe(
@@ -29,7 +29,6 @@ export const playlistReducers = [
};
}),
on(
PlaylistActions.removePlaylist,
PlaylistActions.playlistRemovalCommitted,
(state, action): PlaylistState => {
const playlists = playlistsAdapter.removeOne(
@@ -24,6 +24,7 @@ import { DataService } from '@iptvnator/services';
import {
PLAYLIST_PARSE_BY_URL,
ProviderImportCandidate,
SOURCE_TYPE_ICONS,
} from '@iptvnator/shared/interfaces';
import { AutoImportComponent } from '../auto-import/auto-import.component';
import { FileUploadComponent } from '../file-upload/file-upload.component';
@@ -119,31 +120,31 @@ export class AddPlaylistDialogComponent {
readonly methodOptions: PlaylistMethodOption[] = [
{
value: 'url',
icon: 'public',
icon: SOURCE_TYPE_ICONS['m3u-url'],
labelKey: 'HOME.ADD_PLAYLIST.METHOD_URL_LABEL',
subKey: 'HOME.ADD_PLAYLIST.METHOD_URL_SUB',
},
{
value: 'file',
icon: 'folder_open',
icon: SOURCE_TYPE_ICONS['m3u-local'],
labelKey: 'HOME.ADD_PLAYLIST.METHOD_FILE_LABEL',
subKey: 'HOME.ADD_PLAYLIST.METHOD_FILE_SUB',
},
{
value: 'xtream',
icon: 'vpn_key',
icon: SOURCE_TYPE_ICONS.xtream,
labelKey: 'HOME.ADD_PLAYLIST.METHOD_XTREAM_LABEL',
subKey: 'HOME.ADD_PLAYLIST.METHOD_XTREAM_SUB',
},
{
value: 'stalker',
icon: 'cast',
icon: SOURCE_TYPE_ICONS.stalker,
labelKey: 'HOME.ADD_PLAYLIST.METHOD_STALKER_LABEL',
subKey: 'HOME.ADD_PLAYLIST.METHOD_STALKER_SUB',
},
{
value: 'text',
icon: 'subject',
icon: SOURCE_TYPE_ICONS['m3u-text'],
labelKey: 'HOME.ADD_PLAYLIST.METHOD_TEXT_LABEL',
subKey: 'HOME.ADD_PLAYLIST.METHOD_TEXT_SUB',
},
@@ -18,6 +18,7 @@ import {
ProviderImportCandidate,
ProviderImportConfidence,
ProviderImportKind,
SOURCE_TYPE_ICONS,
} from '@iptvnator/shared/interfaces';
interface CandidateSummaryRow {
@@ -26,10 +27,10 @@ interface CandidateSummaryRow {
}
const KIND_ICONS: Record<ProviderImportKind, string> = {
xtream: 'vpn_key',
stalker: 'cast',
'm3u-url': 'public',
'm3u-text': 'subject',
xtream: SOURCE_TYPE_ICONS.xtream,
stalker: SOURCE_TYPE_ICONS.stalker,
'm3u-url': SOURCE_TYPE_ICONS['m3u-url'],
'm3u-text': SOURCE_TYPE_ICONS['m3u-text'],
};
const KIND_LABEL_KEYS: Record<ProviderImportKind, string> = {
@@ -39,6 +39,7 @@ import {
PortalStatusService,
} from '@iptvnator/services';
import {
getPlaylistSourceIcon,
isPortalAccountPlaylist,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
@@ -330,6 +331,8 @@ export class PlaylistSwitcherComponent {
message: this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
),
confirmLabel: this.translate.instant('HOME.PLAYLISTS.REMOVE'),
tone: 'destructive',
onConfirm: () => this.removePlaylistConfirmed(playlist),
});
}
@@ -357,16 +360,7 @@ export class PlaylistSwitcherComponent {
}
getPlaylistIcon(playlist: PlaylistMeta): string {
if (playlist.macAddress) {
return 'dashboard';
}
if (playlist.serverUrl) {
return 'public';
}
if (playlist.url) {
return 'cloud';
}
return 'folder';
return getPlaylistSourceIcon(playlist);
}
getPlaylistTypeLabel(playlist: PlaylistMeta): string {
@@ -8,6 +8,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import type { PlaylistType } from '../../add-playlist-menu/playlist-type';
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
export type EmptyStateType =
'welcome-dashboard' | 'welcome-sources' | 'no-results' | 'no-data';
@@ -55,7 +56,7 @@ const FEATURE_CARDS: readonly FeatureCard[] = [
const SOURCE_CARDS: readonly SourceCard[] = [
{
type: 'url',
icon: 'folder_open',
icon: SOURCE_TYPE_ICONS.m3u,
nameKey: 'HOME.PLAYLISTS.FEATURE_M3U',
needsKey: 'HOME.PLAYLISTS.SOURCE_M3U_NEEDS',
addLabelKey: 'HOME.PLAYLISTS.SOURCE_M3U_ADD',
@@ -66,7 +67,7 @@ const SOURCE_CARDS: readonly SourceCard[] = [
},
{
type: 'xtream',
icon: 'cloud',
icon: SOURCE_TYPE_ICONS.xtream,
nameKey: 'HOME.PLAYLISTS.FEATURE_XTREAM',
needsKey: 'HOME.PLAYLISTS.SOURCE_XTREAM_NEEDS',
addLabelKey: 'HOME.PLAYLISTS.SOURCE_XTREAM_ADD',
@@ -79,7 +80,7 @@ const SOURCE_CARDS: readonly SourceCard[] = [
},
{
type: 'stalker',
icon: 'cast',
icon: SOURCE_TYPE_ICONS.stalker,
nameKey: 'HOME.PLAYLISTS.FEATURE_STALKER',
needsKey: 'HOME.PLAYLISTS.SOURCE_STALKER_NEEDS',
addLabelKey: 'HOME.PLAYLISTS.SOURCE_STALKER_ADD',
@@ -17,70 +17,66 @@
[enabled]="!isBusy()"
/>
}
@if (item.url) {
<div class="icon-container">
<!-- One provider icon per row, by the shared precedence: a restored
Stalker or Xtream playlist can also carry a URL. -->
<div class="icon-container">
@switch (sourceIconKey) {
@case ('stalker') {
<mat-icon
class="upload-type-icon"
[matTooltip]="
'HOME.PLAYLISTS.STALKER_PORTAL' | translate
"
>{{ sourceIcons.stalker }}</mat-icon
>
}
@case ('xtream') {
<mat-icon
class="upload-type-icon"
[matTooltip]="
'HOME.PLAYLISTS.XTREAM_PLAYLIST' | translate
"
>{{ sourceIcons.xtream }}</mat-icon
>
}
@case ('m3u-url') {
<mat-icon
class="upload-type-icon"
[matTooltip]="
('HOME.PLAYLISTS.ADDED_VIA_URL' | translate) +
' ' +
item.url
"
>{{ sourceIcons['m3u-url'] }}</mat-icon
>
}
@default {
<mat-icon
class="upload-type-icon"
[matTooltip]="
'HOME.PLAYLISTS.ADDED_VIA_FILE' | translate
"
>{{ sourceIcons['m3u-local'] }}</mat-icon
>
}
}
@if (showsPortalStatusDot) {
<span
class="status-dot"
[class]="getStatusClass()"
[matTooltip]="'Portal status: ' + portalStatus"
[attr.aria-label]="'Portal status: ' + portalStatus"
></span>
} @else if (showsAutoRefresh) {
<mat-icon
class="upload-type-icon"
class="auto-refresh-indicator"
[matTooltip]="
('HOME.PLAYLISTS.ADDED_VIA_URL' | translate) +
' ' +
item.url
'HOME.PLAYLISTS.AUTO_REFRESH_ENABLED' | translate
"
>cloud</mat-icon
>autorenew</mat-icon
>
@if (item.autoRefresh) {
<mat-icon
class="auto-refresh-indicator"
[matTooltip]="
'HOME.PLAYLISTS.AUTO_REFRESH_ENABLED' | translate
"
>autorenew</mat-icon
>
}
</div>
}
@if (!item.url && !item.serverUrl && !item.macAddress) {
<div class="icon-container">
<mat-icon
class="upload-type-icon"
[matTooltip]="'HOME.PLAYLISTS.ADDED_VIA_FILE' | translate"
>folder</mat-icon
>
@if (item.autoRefresh) {
<mat-icon
class="auto-refresh-indicator"
[matTooltip]="
'HOME.PLAYLISTS.AUTO_REFRESH_ENABLED' | translate
"
>autorenew</mat-icon
>
}
</div>
}
@if (item.serverUrl) {
<div class="icon-container">
<mat-icon
class="upload-type-icon"
[matTooltip]="'HOME.PLAYLISTS.XTREAM_PLAYLIST' | translate"
>public</mat-icon
>
@if (!runtime.supportsSourceHealth) {
<span
class="status-dot"
[class]="getStatusClass()"
[matTooltip]="'Portal status: ' + portalStatus"
[attr.aria-label]="'Portal status: ' + portalStatus"
></span>
}
</div>
}
@if (item.macAddress) {
<mat-icon
class="upload-type-icon"
[matTooltip]="'HOME.PLAYLISTS.STALKER_PORTAL' | translate"
>dashboard</mat-icon
>
}
}
</div>
</div>
<div class="playlist-content">
<div class="playlist-title">
@@ -180,8 +176,7 @@
@if (canCancelBusyAction()) {
<button
mat-icon-button
color="warn"
class="cancel-btn"
class="cancel-btn app-destructive-button"
(click)="
$event.stopPropagation(); cancelBusyActionClicked.emit()
"
@@ -189,6 +189,13 @@
}
}
// The action buttons inherit the row color, including the selected row's,
// which masks the .app-destructive-button token on the cancel action.
.playlist-item .action-buttons .cancel-btn,
.playlist-item.selected .action-buttons .cancel-btn {
color: var(--mat-sys-error);
}
.action-spinner {
display: block;
margin: 0 auto;
@@ -221,4 +221,41 @@ describe('PlaylistItemComponent', () => {
).toContain('42%');
expect(nativeElement.querySelector('.cancel-btn')).not.toBeNull();
});
it.each([
[
{ macAddress: '00:1A:79:00:00:01', url: 'http://portal.test' },
'cast',
true,
],
[{ macAddress: '00:1A:79:00:00:01' }, 'cast', false],
[{ serverUrl: 'http://xtream.test' }, 'cloud', false],
[{ url: 'http://list.test/playlist.m3u' }, 'link', true],
[{}, 'description', true],
])(
'shows one provider icon for %o (%s) and keeps the auto-refresh badge: %s',
(source, icon, autoRefreshBadge) => {
fixture.destroy();
fixture = TestBed.createComponent(PlaylistItemComponent);
fixture.componentInstance.item = {
title: 'Source',
_id: 'source',
count: 10,
importDate: Date.now().toString(),
autoRefresh: true,
...source,
};
fixture.detectChanges();
const row = fixture.nativeElement as HTMLElement;
const icons = Array.from(
row.querySelectorAll('.upload-type-icon'),
(element) => element.textContent?.trim()
);
expect(icons).toEqual([icon]);
expect(row.querySelector('.auto-refresh-indicator') !== null).toBe(
autoRefreshBadge
);
}
);
});
@@ -1,6 +1,11 @@
import { Injector } from '@angular/core';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { sourceHealthType } from '@iptvnator/shared/interfaces';
import {
PlaylistSourceIconKey,
resolvePlaylistSourceIconKey,
SOURCE_TYPE_ICONS,
sourceHealthType,
} from '@iptvnator/shared/interfaces';
import { SourceHealthIndicatorComponent } from '../../source-health/source-health-indicator.component';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { DatePipe } from '@angular/common';
@@ -68,6 +73,30 @@ export class PlaylistItemComponent implements OnInit {
portalStatus: PortalStatus = 'unavailable';
private readonly portalStatusService = inject(PortalStatusService);
readonly runtime = inject(RuntimeCapabilitiesService);
readonly sourceIcons = SOURCE_TYPE_ICONS;
get sourceIconKey(): PlaylistSourceIconKey {
return resolvePlaylistSourceIconKey(this.item);
}
/** Without source health, Xtream rows badge the portal status instead. */
get showsPortalStatusDot(): boolean {
return (
this.sourceIconKey === 'xtream' &&
!this.runtime.supportsSourceHealth
);
}
/**
* Auto-refresh re-fetches a URL or a local file, so any row with a URL
* keeps the badge, whichever provider icon it shows.
*/
get showsAutoRefresh(): boolean {
return (
!!this.item.autoRefresh &&
(!!this.item.url || this.sourceIconKey === 'm3u-local')
);
}
private readonly translate = inject(TranslateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
@@ -250,6 +250,8 @@ export class RecentPlaylistsComponent {
message: this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
),
confirmLabel: this.translate.instant('HOME.PLAYLISTS.REMOVE'),
tone: 'destructive',
onConfirm: () => {
this.removePlaylist(item);
},
@@ -170,6 +170,7 @@ export interface SourceCleanupDialogData extends SourceCleanupContext {
</button>
<button
mat-flat-button
class="app-destructive-button"
[disabled]="!selectedCount() || checking()"
(click)="model.removeSelected()"
>
@@ -109,6 +109,7 @@ export class XtreamRefreshFlowService {
message: this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.MESSAGE'
),
confirmLabel: this.translate.instant('HOME.PLAYLISTS.REFRESH'),
width: '400px',
onConfirm: () => this.runRefresh(item, reporter),
});
@@ -58,6 +58,7 @@ export class DownloadManagerActionsService {
'DOWNLOADS.CLEAR_FINISHED_DIALOG.MESSAGE'
),
confirmLabel: this.translate.instant('DOWNLOADS.CLEAR_FINISHED'),
tone: 'destructive',
onConfirm: () => this.performClearFinished(scopePlaylistId),
});
}
@@ -88,6 +89,7 @@ export class DownloadManagerActionsService {
confirmLabel: this.translate.instant(
'DOWNLOADS.REMOVE_FROM_MANAGER'
),
tone: 'destructive',
onConfirm: () =>
this.withPending(item.id, () =>
this.downloads.removeDownload(item.id)
@@ -70,6 +70,7 @@ export class RecordingManagerActionsService {
confirmLabel: this.translate.instant(
'DOWNLOADS.REMOVE_FROM_MANAGER'
),
tone: 'destructive',
onConfirm: () =>
this.withPending(item.id, () =>
this.recordings.removeRecording(item.id)
@@ -36,7 +36,11 @@
<mat-icon>settings</mat-icon>
{{ 'PORTALS.ERROR_VIEW.PLAYLIST_SETTINGS' | translate }}
</button>
<button mat-stroked-button color="warn" (click)="removeClicked()">
<button
mat-stroked-button
class="app-destructive-button"
(click)="removeClicked()"
>
<mat-icon>delete</mat-icon>
{{ 'PORTALS.ERROR_VIEW.DELETE' | translate }}
</button>
@@ -0,0 +1,156 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { provideRouter, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
DatabaseService,
PlaylistsService,
RuntimeCapabilitiesService,
SourceActivityService,
} from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { ConfirmDialogData, DialogService } from '@iptvnator/ui/components';
import { PlaylistErrorViewComponent } from './playlist-error-view.component';
const XTREAM_PLAYLIST = {
_id: 'xtream-1',
title: 'Portal',
serverUrl: 'http://portal.test',
username: 'user',
password: 'pass',
importDate: '2026-09-30T10:00:00.000Z',
} as PlaylistMeta;
describe('PlaylistErrorViewComponent removal', () => {
let activity: SourceActivityService;
let busyDuringDelete: boolean[];
let deleteResult: Observable<{ success: boolean }>;
let playlistsService: { deletePlaylist: jest.Mock };
let dialogService: { openConfirmDialog: jest.Mock };
let store: { dispatch: jest.Mock };
let snackBar: { open: jest.Mock };
let navigate: jest.SpyInstance;
function setup(): PlaylistErrorViewComponent {
busyDuringDelete = [];
deleteResult = of({ success: true });
playlistsService = {
deletePlaylist: jest.fn((id: string) => {
busyDuringDelete.push(activity.isBusy(id));
return deleteResult;
}),
};
dialogService = { openConfirmDialog: jest.fn() };
store = { dispatch: jest.fn() };
snackBar = { open: jest.fn() };
TestBed.configureTestingModule({
imports: [PlaylistErrorViewComponent, TranslateModule.forRoot()],
providers: [
{
provide: PlaylistContextFacade,
useValue: { activePlaylist: signal(XTREAM_PLAYLIST) },
},
{ provide: PlaylistsService, useValue: playlistsService },
{
provide: DatabaseService,
useValue: { createOperationId: () => 'op-1' },
},
{
provide: RuntimeCapabilitiesService,
useValue: { supportsXtreamSqliteDataSource: true },
},
{ provide: DialogService, useValue: dialogService },
{ provide: MatDialog, useValue: { open: jest.fn() } },
{ provide: MatSnackBar, useValue: snackBar },
{ provide: Store, useValue: store },
provideRouter([]),
],
});
activity = TestBed.inject(SourceActivityService);
navigate = jest
.spyOn(TestBed.inject(Router), 'navigate')
.mockResolvedValue(true);
return TestBed.createComponent(PlaylistErrorViewComponent)
.componentInstance;
}
function confirmRemoval(component: PlaylistErrorViewComponent) {
component.removeClicked();
const data = dialogService.openConfirmDialog.mock
.calls[0][0] as ConfirmDialogData;
data.onConfirm();
return data;
}
it('asks with a named destructive action', () => {
const component = setup();
component.removeClicked();
expect(dialogService.openConfirmDialog).toHaveBeenCalledWith(
expect.objectContaining({
confirmLabel: 'HOME.PLAYLISTS.REMOVE',
tone: 'destructive',
})
);
});
it('removes through the shared delete action: busy source, Xtream cache, commit, toast', async () => {
const component = setup();
confirmRemoval(component);
await new Promise((resolve) => setTimeout(resolve));
// The worker delete (with an operation id) drops the Xtream cache.
expect(playlistsService.deletePlaylist).toHaveBeenCalledWith(
'xtream-1',
expect.objectContaining({ operationId: 'op-1' })
);
// The source is marked busy while it is deleted, then released.
expect(busyDuringDelete).toEqual([true]);
expect(activity.isBusy('xtream-1')).toBe(false);
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.playlistRemovalCommitted({
playlistId: 'xtream-1',
})
);
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS',
undefined,
{ duration: 2000 }
);
expect(navigate).toHaveBeenCalledWith(['/']);
});
it('keeps the playlist and stays put when the delete fails', async () => {
const component = setup();
deleteResult = new Observable((subscriber) =>
subscriber.error(new Error('worker failed'))
);
await component.removePlaylist(XTREAM_PLAYLIST);
expect(store.dispatch).not.toHaveBeenCalled();
expect(snackBar.open).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
expect(activity.isBusy('xtream-1')).toBe(false);
});
it('does not start a second removal while the source is busy', async () => {
const component = setup();
const release = activity.begin(['xtream-1']);
component.removeClicked();
await component.removePlaylist(XTREAM_PLAYLIST);
expect(dialogService.openConfirmDialog).not.toHaveBeenCalled();
expect(playlistsService.deletePlaylist).not.toHaveBeenCalled();
release();
});
});
@@ -7,13 +7,19 @@ import {
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router, RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { PlaylistInfoComponent } from '@iptvnator/playlist/shared/ui';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
PlaylistDeleteActionService,
SourceActivityService,
} from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { DialogService } from '@iptvnator/ui/components';
import { PlaylistActions } from '@iptvnator/m3u-state';
@Component({
selector: 'app-playlist-error-view',
@@ -25,8 +31,11 @@ import { PlaylistActions } from '@iptvnator/m3u-state';
export class PlaylistErrorViewComponent {
private dialog = inject(MatDialog);
private dialogService = inject(DialogService);
private readonly activity = inject(SourceActivityService);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly playlistDeleteAction = inject(PlaylistDeleteActionService);
private router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private store = inject(Store);
private translate = inject(TranslateService);
@@ -48,7 +57,10 @@ export class PlaylistErrorViewComponent {
removeClicked(): void {
const currentPlaylist = this.currentPlaylist();
if (!currentPlaylist?._id) {
if (
!currentPlaylist?._id ||
this.activity.isBusy(currentPlaylist._id)
) {
return;
}
@@ -57,12 +69,38 @@ export class PlaylistErrorViewComponent {
message: this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
),
onConfirm: (): void => this.removePlaylist(currentPlaylist._id),
confirmLabel: this.translate.instant('HOME.PLAYLISTS.REMOVE'),
tone: 'destructive',
onConfirm: (): void => void this.removePlaylist(currentPlaylist),
});
}
removePlaylist(playlistId: string): void {
this.store.dispatch(PlaylistActions.removePlaylist({ playlistId }));
this.router.navigate(['/']);
/**
* Same path as every other source removal: the shared delete action marks
* the source busy, lets persistence drop the Xtream cache and cleanups,
* and only a completed delete is committed to the store.
*/
async removePlaylist(playlist: PlaylistMeta): Promise<void> {
if (this.activity.isBusy(playlist._id)) {
return;
}
const deleted =
await this.playlistDeleteAction.deletePlaylist(playlist);
if (!deleted) {
return;
}
this.store.dispatch(
PlaylistActions.playlistRemovalCommitted({
playlistId: playlist._id,
})
);
this.snackBar.open(
this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'),
undefined,
{ duration: 2000 }
);
void this.router.navigate(['/']);
}
}
@@ -67,6 +67,8 @@ export function createClearCollectionAction(options: {
dialogService.openConfirmDialog({
title: translate.instant(titleKey, { type }),
message: translate.instant(messageKey, { type }),
confirmLabel: translate.instant('CLEAR'),
tone: 'destructive',
onConfirm: async () => {
if (isFavorites) {
await clearFavorites(itemsToRemove);
@@ -747,6 +747,12 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
message: this.translateService.instant(
'DOWNLOADS.CANCEL_CONFIRM_MESSAGE'
),
confirmLabel: this.translateService.instant(
'DOWNLOADS.CANCEL_CONFIRM_TITLE'
),
// "Cancel" next to "Cancel download" would read as the same action.
cancelLabel: this.translateService.instant('CLOSE'),
tone: 'destructive',
onConfirm: () => void this.downloads.cancelActive(),
});
}
+1
View File
@@ -47,6 +47,7 @@ export * from './lib/portal-debug.interface';
export * from './lib/playlist-display-label.util';
export * from './lib/portal-playback.interface';
export * from './lib/provider-import-detection.util';
export * from './lib/source-type-icons.util';
export * from './lib/provider-overview.util';
export * from './lib/random-id.util';
export * from './lib/recording-metadata.interface';
@@ -0,0 +1,43 @@
import {
getPlaylistSourceIcon,
resolvePlaylistSourceIconKey,
SOURCE_TYPE_ICONS,
} from './source-type-icons.util';
describe('source type icons', () => {
it('never lets one icon stand for two providers', () => {
const byProvider = {
xtream: [SOURCE_TYPE_ICONS.xtream],
stalker: [SOURCE_TYPE_ICONS.stalker],
m3u: [
SOURCE_TYPE_ICONS.m3u,
SOURCE_TYPE_ICONS['m3u-url'],
SOURCE_TYPE_ICONS['m3u-local'],
SOURCE_TYPE_ICONS['m3u-text'],
],
};
const owners = new Map<string, string>();
for (const [provider, icons] of Object.entries(byProvider)) {
for (const icon of icons) {
expect(owners.get(icon) ?? provider).toBe(provider);
owners.set(icon, provider);
}
}
});
it('does not reuse the Dashboard rail icon for a provider', () => {
expect(Object.values(SOURCE_TYPE_ICONS)).not.toContain('dashboard');
});
it.each([
[{ macAddress: '00:1A:79:00:00:01', url: 'http://portal' }, 'stalker'],
[{ serverUrl: 'http://xtream' }, 'xtream'],
[{ url: 'http://list.m3u' }, 'm3u-url'],
[{}, 'm3u-local'],
] as const)('resolves %o to %s', (playlist, expected) => {
expect(resolvePlaylistSourceIconKey(playlist)).toBe(expected);
expect(getPlaylistSourceIcon(playlist)).toBe(
SOURCE_TYPE_ICONS[expected]
);
});
});
@@ -0,0 +1,50 @@
import type { PlaylistMeta } from './playlist-meta.type';
/**
* One Material icon ligature per source type, used everywhere a source is
* shown: the add dialog, provider auto-detection, the Sources list and its
* filters, the playlist switcher, dashboard cards, the command palette and
* the reset summaries. A provider must look the same on every screen, and no
* icon may stand for two providers.
*
* `m3u` is the provider family (filters and summaries that count every M3U
* source); the other M3U keys name how a single playlist was added. Stored
* playlists cannot tell a local file from pasted text, so both render as
* `m3u-local`; only the add dialog and auto-detection show `m3u-text`.
*/
export const SOURCE_TYPE_ICONS = {
m3u: 'playlist_play',
'm3u-url': 'link',
'm3u-local': 'description',
'm3u-text': 'subject',
xtream: 'cloud',
stalker: 'cast',
} as const;
export type SourceTypeIconKey = keyof typeof SOURCE_TYPE_ICONS;
export type PlaylistSourceIconKey = Extract<
SourceTypeIconKey,
'm3u-url' | 'm3u-local' | 'xtream' | 'stalker'
>;
export function resolvePlaylistSourceIconKey(
playlist: Pick<PlaylistMeta, 'macAddress' | 'serverUrl' | 'url'>
): PlaylistSourceIconKey {
if (playlist.macAddress) {
return 'stalker';
}
if (playlist.serverUrl) {
return 'xtream';
}
if (playlist.url) {
return 'm3u-url';
}
return 'm3u-local';
}
export function getPlaylistSourceIcon(
playlist: Pick<PlaylistMeta, 'macAddress' | 'serverUrl' | 'url'>
): string {
return SOURCE_TYPE_ICONS[resolvePlaylistSourceIconKey(playlist)];
}
@@ -59,7 +59,11 @@
<mat-dialog-actions align="end">
@if (currentMapping()) {
<button mat-button color="warn" (click)="removeMapping()">
<button
mat-button
class="app-destructive-button"
(click)="removeMapping()"
>
{{ 'EPG_MAPPING_DIALOG.REMOVE' | translate }}
</button>
}
@@ -52,3 +52,49 @@ describe('ConfirmDialogComponent actions', () => {
}
);
});
describe('ConfirmDialogComponent labels and tone', () => {
async function render(data: Record<string, unknown>) {
await TestBed.configureTestingModule({
imports: [
ConfirmDialogComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: {
title: 'Remove playlist',
message: 'Delete it?',
confirmLabel: 'Remove playlist',
onConfirm: jest.fn(),
...data,
},
},
{ provide: MatDialogRef, useValue: { close: jest.fn() } },
],
}).compileComponents();
const fixture = TestBed.createComponent(ConfirmDialogComponent);
fixture.detectChanges();
const buttons = fixture.nativeElement.querySelectorAll(
'button'
) as NodeListOf<HTMLButtonElement>;
return { cancel: buttons[0], confirm: buttons[1] };
}
it('names the action instead of answering "Yes", and cancels by default', async () => {
const { cancel, confirm } = await render({});
expect(confirm.textContent?.trim()).toBe('Remove playlist');
// No translations are loaded, so the key is the rendered text.
expect(cancel.textContent?.trim()).toBe('CANCEL');
expect(confirm.classList).not.toContain('app-destructive-button');
});
it('styles destructive confirmations as destructive', async () => {
const { confirm } = await render({ tone: 'destructive' });
expect(confirm.classList).toContain('app-destructive-button');
});
});
@@ -3,12 +3,25 @@ import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
/**
* `destructive` renders the confirm action with the app-wide error styling
* (`.app-destructive-button`). Use it whenever confirming removes or discards
* user data; Material's `warn` color input has no effect with the M3 theme.
*/
export type ConfirmDialogTone = 'default' | 'destructive';
export interface ConfirmDialogData {
title: string;
message: string;
width?: string;
confirmLabel?: string;
/**
* Translated verb that names the action ("Remove playlist", "Clear").
* Required so a confirmation never falls back to an ambiguous "Yes".
*/
confirmLabel: string;
/** Translated dismiss label; defaults to "Cancel". */
cancelLabel?: string;
tone?: ConfirmDialogTone;
/** Run the action in the dialog; only Close/backdrop/Escape dismiss it. */
keepOpenOnConfirm?: boolean;
onConfirm: () => void;
@@ -27,23 +40,25 @@ export interface ConfirmDialogData {
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close cdkFocusInitial>
{{ dialogData.cancelLabel || 'NO' | translate }}
{{ dialogData.cancelLabel || ('CANCEL' | translate) }}
</button>
@if (dialogData.keepOpenOnConfirm) {
<button
mat-flat-button
data-test-id="confirm-dialog-confirm"
[class.app-destructive-button]="isDestructive"
(click)="dialogData.onConfirm()"
color="primary"
>
{{ dialogData.confirmLabel || 'YES' | translate }}
{{ dialogData.confirmLabel }}
</button>
} @else {
<button
mat-flat-button
data-test-id="confirm-dialog-confirm"
[class.app-destructive-button]="isDestructive"
[mat-dialog-close]="true"
color="primary"
>
{{ dialogData.confirmLabel || 'YES' | translate }}
{{ dialogData.confirmLabel }}
</button>
}
</mat-dialog-actions>
@@ -56,4 +71,8 @@ export class ConfirmDialogComponent {
constructor() {
this.dialogData = this.data;
}
get isDestructive(): boolean {
return this.dialogData.tone === 'destructive';
}
}
@@ -33,6 +33,7 @@ describe('DialogService', () => {
service.openConfirmDialog({
title: 'Remove',
message: 'Confirm removal?',
confirmLabel: 'Remove playlist',
onConfirm: jest.fn(),
});
@@ -57,6 +58,7 @@ describe('DialogService', () => {
service.openConfirmDialog({
title: 'Refresh Xtream Playlist',
message: 'Confirm refresh?',
confirmLabel: 'Refresh playlist',
width: '400px',
onConfirm: jest.fn(),
});
@@ -78,6 +80,7 @@ describe('DialogService', () => {
service.openConfirmDialog({
title: 'Remove',
message: 'Confirm removal?',
confirmLabel: 'Remove playlist',
onConfirm,
});
@@ -89,6 +92,7 @@ describe('DialogService', () => {
service.openConfirmDialog({
title: 'Recovery',
message: 'Path',
confirmLabel: 'Copy path',
keepOpenOnConfirm: true,
onConfirm,
});
@@ -9,6 +9,7 @@ import {
import { toSignal } from '@angular/core/rxjs-interop';
import { startWith } from 'rxjs';
import {
getPlaylistSourceIcon,
isStalkerAccountPlaylist,
isXtreamAccountPlaylist,
normalizeDashboardRailsSettings,
@@ -286,11 +287,7 @@ export class WorkspaceDashboardRailsComponent {
playlist.filename ||
this.t('WORKSPACE.DASHBOARD.UNTITLED_SOURCE'),
subtitle: this.data.getPlaylistProvider(playlist),
icon: playlist.serverUrl
? 'cloud'
: playlist.macAddress
? 'cast'
: 'folder_open',
icon: getPlaylistSourceIcon(playlist),
link: this.data.getPlaylistLink(playlist),
actions: buildDashboardSourceActions(
playlist,
@@ -675,6 +672,8 @@ export class WorkspaceDashboardRailsComponent {
message: this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
),
confirmLabel: this.translate.instant('HOME.PLAYLISTS.REMOVE'),
tone: 'destructive',
onConfirm: () => {
void this.removePlaylist(playlist);
},
@@ -8,6 +8,7 @@ import {
WorkspacePortalContext,
WorkspaceShellRoute,
} from '@iptvnator/workspace/shell/util';
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
import { TranslateFn } from './workspace-shell-search-labels';
export interface CommandBuilderActions {
@@ -275,7 +276,7 @@ export function getGlobalCommandDefinitions(
{
id: 'add-playlist-m3u',
group: 'global',
icon: 'folder_open',
icon: SOURCE_TYPE_ICONS.m3u,
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_M3U_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_M3U_DESCRIPTION',
@@ -286,7 +287,7 @@ export function getGlobalCommandDefinitions(
{
id: 'add-playlist-xtream',
group: 'global',
icon: 'cloud',
icon: SOURCE_TYPE_ICONS.xtream,
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_XTREAM_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_XTREAM_DESCRIPTION',
@@ -297,7 +298,7 @@ export function getGlobalCommandDefinitions(
{
id: 'add-playlist-stalker',
group: 'global',
icon: 'cast',
icon: SOURCE_TYPE_ICONS.stalker,
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_STALKER_LABEL',
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_STALKER_DESCRIPTION',
@@ -14,6 +14,7 @@ import {
} from '@iptvnator/m3u-state';
import { TranslatePipe } from '@ngx-translate/core';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
type PlaylistFilterId = 'all' | 'm3u' | 'xtream' | 'stalker';
@@ -55,17 +56,17 @@ export class WorkspaceSourcesFiltersPanelComponent {
},
{
id: 'm3u',
icon: 'playlist_play',
icon: SOURCE_TYPE_ICONS.m3u,
translationKey: 'HOME.PLAYLIST_TYPES.M3U',
},
{
id: 'xtream',
icon: 'cloud',
icon: SOURCE_TYPE_ICONS.xtream,
translationKey: 'HOME.PLAYLIST_TYPES.XTREAM',
},
{
id: 'stalker',
icon: 'router',
icon: SOURCE_TYPE_ICONS.stalker,
translationKey: 'HOME.PLAYLIST_TYPES.STALKER',
},
];