mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
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:
1 parent
fe849b2de2
commit
210baa2e75
22 files changed
+398
-121
No files matched your search
+26
-34
@@ -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') {
|
||||
|
||||
+127
-35
@@ -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;
|
||||
|
||||
+49
-12
@@ -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);
|
||||
}
|
||||
});
|
||||
+52
-40
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user