refactor(add-playlist): replace 2-level type×subtype with flat 5-card picker

The Add Playlist dialog used a nested control: pick category
(M3U / Xtream / Stalker) then pick subtype (URL / file / text). That's
9 cells of which only 5 are real — Xtream-via-file and Stalker-via-text
don't exist. Users spent visible time hunting the right path through
the matrix.

Replace it with a flat 5-card method grid that matches the v0.22 mockup
exactly: each card IS a method (M3U URL, M3U file, Xtream credentials,
Stalker portal, Paste raw m3u text), shown side by side with an icon,
label, and one-line description of when to pick it.

Component state collapses from `category` + `m3uSubType` (two signals
with an entangled `playlistType()` mapping) into a single `method`
signal of type PlaylistType. `initFromType` simplifies to one line.
`playlistType()` is kept as a thin alias so the template @switch and
the action-button branches stay untouched.

The selected card is the only place that needs to change for users to
pick a different method — no more "first click type, then click
subtype, then look for the form" two-step.

i18n: 6 new HOME.ADD_PLAYLIST.* keys (subtitle + 5 method subs)
translated across all 17 locales by per-locale agents, with the
existing HOME.TABS.* keys reused for card titles. Placeholder
integrity verified.

Tests: existing clearCurrentForm scenarios still pass after the signal
refactor; added 6 new tests covering the default method (URL) and the
MAT_DIALOG_DATA.type deep-link path for each of the 5 method values.
25/25 in playlist-import-feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-19 01:09:43 +02:00
1 parent fe849b2de2
commit 210baa2e75
22 files changed
+398 -121

No files matched your search

@@ -1,49 +1,41 @@
<div class="apd">
<h2 mat-dialog-title>
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</h2>
<div mat-dialog-title class="apd-head">
<h2 class="apd-title">
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</h2>
<p class="apd-sub">
{{ 'HOME.ADD_PLAYLIST.PICK_METHOD' | translate }}
</p>
</div>
<mat-dialog-content>
<div
class="apd-segmented"
role="tablist"
aria-label="Playlist category"
class="apd-method-grid"
role="radiogroup"
aria-label="Source method"
>
@for (opt of categoryOptions; track opt.value) {
@for (opt of methodOptions; track opt.value) {
<button
type="button"
role="tab"
[attr.aria-selected]="category() === opt.value"
class="apd-segment"
[class.active]="category() === opt.value"
(click)="category.set(opt.value)"
role="radio"
[attr.aria-checked]="method() === opt.value"
class="apd-method"
[class.active]="method() === opt.value"
(click)="method.set(opt.value)"
>
{{ opt.label }}
<span class="apd-method__icon" aria-hidden="true">
<mat-icon>{{ opt.icon }}</mat-icon>
</span>
<span class="apd-method__title">
{{ opt.labelKey | translate }}
</span>
<span class="apd-method__sub">
{{ opt.subKey | translate }}
</span>
</button>
}
</div>
@if (category() === 'm3u') {
<div
class="apd-segmented apd-segmented--sub"
role="tablist"
aria-label="M3U source"
>
@for (opt of subtypeOptions; track opt.value) {
<button
type="button"
role="tab"
[attr.aria-selected]="m3uSubType() === opt.value"
class="apd-segment"
[class.active]="m3uSubType() === opt.value"
(click)="m3uSubType.set(opt.value)"
>
{{ opt.labelKey | translate }}
</button>
}
</div>
}
<div class="apd-form-area">
@switch (playlistType()) {
@case ('file') {
@@ -17,53 +17,92 @@
);
}
// ─── Segmented control ──────────────────────────────────────────────────────
.apd-segmented {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
background: var(--apd-segment-track);
border-radius: 12px;
padding: 4px;
gap: 2px;
margin-bottom: 14px;
&--sub {
background: color-mix(
in srgb,
var(--apd-segment-track) 85%,
transparent
);
}
// ─── Dialog head (title + helper subtitle) ──────────────────────────────────
.apd-head {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0 0 8px;
padding: 0;
}
.apd-segment {
appearance: none;
border: 0;
background: transparent;
color: var(--apd-body);
padding: 8px 14px;
border-radius: 9px;
font: inherit;
.apd-title {
margin: 0;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--apd-heading);
}
.apd-sub {
margin: 0;
font-size: 13px;
font-weight: 500;
color: var(--apd-body);
line-height: 1.45;
}
// ─── Method grid (5 flat cards instead of 2-level segmented control) ────────
// One card per source method. Replaces the prior 3×3 type×subtype matrix
// which had 9 cells of which only 5 were real (Xtream-via-file, etc. didn't
// exist). Each card is self-describing — icon + label + one-line sub.
.apd-method-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
margin: 4px 0 16px;
}
.apd-method {
appearance: none;
background: var(--apd-segment-track);
border: 1px solid var(--apd-border);
border-radius: 10px;
padding: 12px 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
line-height: 1.3;
text-align: left;
font: inherit;
color: var(--apd-heading);
min-width: 0;
transition:
background-color 160ms ease,
color 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease;
&:hover:not(.active) {
color: var(--apd-heading);
background: var(--apd-segment-active);
border-color: color-mix(in srgb, var(--apd-accent) 32%, transparent);
.apd-method__icon {
background: color-mix(
in srgb,
var(--apd-accent) 14%,
transparent
);
color: var(--apd-accent);
}
}
&.active {
background: var(--apd-segment-active);
color: var(--apd-heading);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.25),
inset 0 0 0 1px color-mix(in srgb, white 6%, transparent);
background: color-mix(in srgb, var(--apd-accent) 12%, transparent);
border-color: var(--apd-accent);
box-shadow: 0 1px 2px
color-mix(in srgb, var(--apd-accent) 32%, transparent);
.apd-method__icon {
background: color-mix(
in srgb,
var(--apd-accent) 22%,
transparent
);
color: var(--apd-accent);
}
.apd-method__title {
color: var(--apd-accent);
}
}
&:focus-visible {
@@ -72,6 +111,59 @@
}
}
.apd-method__icon {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 8px;
background: color-mix(
in srgb,
var(--apd-surface-raised) 75%,
transparent
);
color: var(--apd-muted);
transition:
background-color 160ms ease,
color 160ms ease;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
}
}
.apd-method__title {
font-size: 13px;
font-weight: 600;
line-height: 1.25;
color: var(--apd-heading);
transition: color 160ms ease;
}
.apd-method__sub {
font-size: 11.5px;
color: var(--apd-muted);
line-height: 1.4;
}
// On narrow widths the 5-column grid wraps to 2 columns to stay legible.
// 480px keeps the dialog usable down to small modals; below that it stacks
// fully.
@media (max-width: 720px) {
.apd-method-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 480px) {
.apd-method-grid {
grid-template-columns: minmax(0, 1fr);
}
}
// ─── Form area ──────────────────────────────────────────────────────────────
.apd-form-area {
min-height: 0;
@@ -178,18 +178,55 @@ describe('AddPlaylistDialogComponent', () => {
expect(component.isClearDisabled()).toBeTruthy();
});
it('defaults to the URL method when no deep-link type is provided', () => {
// Constructor-injected MAT_DIALOG_DATA is null in this spec setup,
// so the dialog should land on its default first-row method.
expect(component.method()).toBe('url');
expect(component.playlistType()).toBe('url');
});
it.each([
['url' as PlaylistType, 'url' as PlaylistType],
['file' as PlaylistType, 'file' as PlaylistType],
['text' as PlaylistType, 'text' as PlaylistType],
['xtream' as PlaylistType, 'xtream' as PlaylistType],
['stalker' as PlaylistType, 'stalker' as PlaylistType],
])(
'opens directly on the %s method when MAT_DIALOG_DATA.type === %s (deep-link from rail / shell action)',
(deepLinkType, expectedMethod) => {
// Rebuild the component with a deep-link to confirm the
// flat-method signal honours the legacy { type } payload shape.
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
{ provide: DataService, useValue: dataService },
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: Store, useValue: store },
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: TranslateService,
useValue: {
instant: jest.fn((value: string) => value),
},
},
{
provide: MAT_DIALOG_DATA,
useValue: { type: deepLinkType },
},
],
});
const deepLinked = TestBed.runInInjectionContext(
() => new AddPlaylistDialogComponent()
);
expect(deepLinked.method()).toBe(expectedMethod);
expect(deepLinked.playlistType()).toBe(expectedMethod);
}
);
function selectType(type: PlaylistType): void {
if (type === 'xtream') {
component.category.set('xtream');
return;
}
if (type === 'stalker') {
component.category.set('stalker');
return;
}
component.category.set('m3u');
component.m3uSubType.set(type);
// The dialog now uses a single `method` signal across all 5 source
// types — no more category × subtype matrix.
component.method.set(type);
}
});
@@ -12,6 +12,7 @@ import {
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
@@ -25,17 +26,16 @@ import { TextImportComponent } from '../text-import/text-import.component';
import { UrlUploadComponent } from '../url-upload/url-upload.component';
import { XtreamCodeImportComponent } from '../xtream-code-import/xtream-code-import.component';
type PlaylistCategory = 'm3u' | Extract<PlaylistType, 'xtream' | 'stalker'>;
type M3uSubType = Extract<PlaylistType, 'url' | 'file' | 'text'>;
interface CategoryOption {
value: PlaylistCategory;
label: string;
}
interface SubtypeOption {
value: M3uSubType;
/**
* Flat 5-method option model — replaces the prior category × subtype matrix
* (M3U/Xtream/Stalker × URL/File/Text) which created 9 combinations of which
* only 5 were real. Now each entry IS a method, no nesting.
*/
export interface PlaylistMethodOption {
value: PlaylistType;
icon: string;
labelKey: string;
subKey: string;
}
@Component({
@@ -43,6 +43,7 @@ interface SubtypeOption {
FileUploadComponent,
MatButtonModule,
MatDialogModule,
MatIcon,
StalkerPortalImportComponent,
TextImportComponent,
TranslateModule,
@@ -70,42 +71,53 @@ export class AddPlaylistDialogComponent {
readonly xtreamImport = viewChild(XtreamCodeImportComponent);
readonly stalkerImport = viewChild(StalkerPortalImportComponent);
readonly category = signal<PlaylistCategory>('m3u');
readonly m3uSubType = signal<M3uSubType>('url');
readonly method = signal<PlaylistType>('url');
readonly categoryOptions: CategoryOption[] = [
{ value: 'm3u', label: 'M3U' },
{ value: 'xtream', label: 'Xtream' },
{ value: 'stalker', label: 'Stalker' },
// Order matches the v0.22 mockup left-to-right: URL first (Most common),
// then File, Xtream credentials, Stalker portal, raw text paste. Each
// entry stands on its own — no nested subtypes.
readonly methodOptions: PlaylistMethodOption[] = [
{
value: 'url',
icon: 'public',
labelKey: 'HOME.TABS.URL_UPLOAD',
subKey: 'HOME.ADD_PLAYLIST.METHOD_URL_SUB',
},
{
value: 'file',
icon: 'folder_open',
labelKey: 'HOME.TABS.FILE_UPLOAD',
subKey: 'HOME.ADD_PLAYLIST.METHOD_FILE_SUB',
},
{
value: 'xtream',
icon: 'vpn_key',
labelKey: 'HOME.TABS.XTREME_IMPORT',
subKey: 'HOME.ADD_PLAYLIST.METHOD_XTREAM_SUB',
},
{
value: 'stalker',
icon: 'cast',
labelKey: 'HOME.TABS.STALKER_PORTAL_IMPORT',
subKey: 'HOME.ADD_PLAYLIST.METHOD_STALKER_SUB',
},
{
value: 'text',
icon: 'subject',
labelKey: 'HOME.TABS.TEXT_IMPORT',
subKey: 'HOME.ADD_PLAYLIST.METHOD_TEXT_SUB',
},
];
readonly subtypeOptions: SubtypeOption[] = [
{ value: 'url', labelKey: 'HOME.TABS.URL_UPLOAD' },
{ value: 'file', labelKey: 'HOME.TABS.FILE_UPLOAD' },
{ value: 'text', labelKey: 'HOME.TABS.TEXT_IMPORT' },
];
readonly playlistType = computed<PlaylistType>(() => {
const cat = this.category();
if (cat === 'xtream') return 'xtream';
if (cat === 'stalker') return 'stalker';
return this.m3uSubType();
});
/**
* Backwards-compatible alias. The template's @switch and the action
* buttons key off this; keeping the name avoids churn in 5 case branches.
*/
readonly playlistType = computed<PlaylistType>(() => this.method());
constructor() {
if (this.data?.type) {
this.initFromType(this.data.type);
}
}
private initFromType(type: PlaylistType): void {
if (type === 'xtream') {
this.category.set('xtream');
} else if (type === 'stalker') {
this.category.set('stalker');
} else {
this.category.set('m3u');
this.m3uSubType.set(type as M3uSubType);
this.method.set(this.data.type);
}
}