diff --git a/apps/web/src/app/server-storage/server-migration-dialog.component.html b/apps/web/src/app/server-storage/server-migration-dialog.component.html new file mode 100644 index 000000000..b5c06a9da --- /dev/null +++ b/apps/web/src/app/server-storage/server-migration-dialog.component.html @@ -0,0 +1,69 @@ +

+ {{ + 'SERVER_STORAGE.MIGRATION_TITLE' + | translate: { count: playlists.length } + }} +

+ + + @if (result(); as done) { +

+ {{ 'SERVER_STORAGE.MIGRATION_DONE' | translate: done }} +

+ } @else { +

{{ 'SERVER_STORAGE.MIGRATION_DESCRIPTION' | translate }}

+ +

+ {{ 'SERVER_STORAGE.MIGRATION_LATER_HINT' | translate }} +

+ @if (failed()) { +

+ {{ 'SERVER_STORAGE.MIGRATION_FAILED' | translate }} +

+ } + } +
+ + + @if (result()) { + + } @else { + + + } + diff --git a/apps/web/src/app/server-storage/server-migration-dialog.component.scss b/apps/web/src/app/server-storage/server-migration-dialog.component.scss new file mode 100644 index 000000000..a5030ec0b --- /dev/null +++ b/apps/web/src/app/server-storage/server-migration-dialog.component.scss @@ -0,0 +1,46 @@ +.server-migration { + p { + margin: 0 0 12px; + color: var(--app-body-color); + } +} + +.server-migration__list { + list-style: none; + margin: 0 0 12px; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; + + li { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + padding: 10px 12px; + border-radius: 12px; + background: var(--app-card-hover-bg); + color: var(--app-heading-color); + + mat-icon { + flex-shrink: 0; + } + + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} + +.server-migration .server-migration__hint { + color: var(--app-muted-color); + font-size: 0.8rem; +} + +.server-migration .server-migration__error { + color: var(--app-expiry-expired-text); +} diff --git a/apps/web/src/app/server-storage/server-migration-dialog.component.spec.ts b/apps/web/src/app/server-storage/server-migration-dialog.component.spec.ts new file mode 100644 index 000000000..2abb52a8f --- /dev/null +++ b/apps/web/src/app/server-storage/server-migration-dialog.component.spec.ts @@ -0,0 +1,72 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { TranslateModule } from '@ngx-translate/core'; +import { ServerMigrationService } from '@iptvnator/services/server-storage'; +import { ServerMigrationDialogComponent } from './server-migration-dialog.component'; + +describe('ServerMigrationDialogComponent', () => { + let fixture: ComponentFixture; + const migration = { skip: jest.fn(), moveBrowserData: jest.fn() }; + const dialogRef = { close: jest.fn() }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + ServerMigrationDialogComponent, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: [{ id: 'm', title: 'Family', kind: 'm3u' }], + }, + { provide: ServerMigrationService, useValue: migration }, + { provide: MatDialogRef, useValue: dialogRef }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(ServerMigrationDialogComponent); + fixture.detectChanges(); + }); + + afterEach(() => jest.resetAllMocks()); + + const byTestId = (id: string) => + fixture.nativeElement.querySelector(`[data-test-id="${id}"]`); + + it('lists the browser playlists', () => { + expect(fixture.nativeElement.textContent).toContain('Family'); + }); + + it('records "not now" and closes', () => { + byTestId('server-migration-not-now').click(); + + expect(migration.skip).toHaveBeenCalled(); + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('moves the data and offers a reload', async () => { + const result = { imported: 1, merged: 0, failed: 0 }; + migration.moveBrowserData.mockResolvedValue(result); + + await fixture.componentInstance.move(); + fixture.detectChanges(); + + expect(fixture.componentInstance.result()).toEqual(result); + expect(byTestId('server-migration-result')).not.toBeNull(); + expect(byTestId('server-migration-reload')).not.toBeNull(); + expect(byTestId('server-migration-move')).toBeNull(); + }); + + it('keeps the dialog open with a message when moving fails', async () => { + migration.moveBrowserData.mockRejectedValue(new Error('offline')); + + await fixture.componentInstance.move(); + fixture.detectChanges(); + + expect(fixture.componentInstance.failed()).toBe(true); + expect(fixture.nativeElement.textContent).toContain( + 'SERVER_STORAGE.MIGRATION_FAILED' + ); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/server-storage/server-migration-dialog.component.ts b/apps/web/src/app/server-storage/server-migration-dialog.component.ts new file mode 100644 index 000000000..dd741a96d --- /dev/null +++ b/apps/web/src/app/server-storage/server-migration-dialog.component.ts @@ -0,0 +1,70 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + signal, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TranslatePipe } from '@ngx-translate/core'; +import { + ServerMigrationService, + type ServerMigrationPlaylist, + type ServerMigrationResult, +} from '@iptvnator/services/server-storage'; +import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces'; + +@Component({ + selector: 'app-server-migration-dialog', + imports: [ + MatButtonModule, + MatDialogModule, + MatIconModule, + MatProgressSpinnerModule, + TranslatePipe, + ], + templateUrl: './server-migration-dialog.component.html', + styleUrl: './server-migration-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ServerMigrationDialogComponent { + private readonly migration = inject(ServerMigrationService); + private readonly dialogRef = inject( + MatDialogRef + ); + + readonly playlists = + inject(MAT_DIALOG_DATA); + readonly icons = SOURCE_TYPE_ICONS; + readonly busy = signal(false); + readonly failed = signal(false); + readonly result = signal(null); + + notNow(): void { + this.migration.skip(); + this.dialogRef.close(); + } + + async move(): Promise { + this.busy.set(true); + this.failed.set(false); + try { + this.result.set(await this.migration.moveBrowserData()); + } catch { + this.failed.set(true); + } finally { + this.busy.set(false); + } + } + + /** The library was replaced underneath every open view. */ + reload(): void { + window.location.reload(); + } +} diff --git a/apps/web/src/app/server-storage/server-sign-in.component.html b/apps/web/src/app/server-storage/server-sign-in.component.html new file mode 100644 index 000000000..00eda3fdc --- /dev/null +++ b/apps/web/src/app/server-storage/server-sign-in.component.html @@ -0,0 +1,62 @@ +
+ +
diff --git a/apps/web/src/app/server-storage/server-sign-in.component.scss b/apps/web/src/app/server-storage/server-sign-in.component.scss new file mode 100644 index 000000000..14880084f --- /dev/null +++ b/apps/web/src/app/server-storage/server-sign-in.component.scss @@ -0,0 +1,56 @@ +:host { + display: block; + min-height: 100%; + background: var(--app-shell-bg); +} + +.server-sign-in { + min-height: 100vh; + display: grid; + place-items: center; + padding: 16px; + box-sizing: border-box; +} + +.server-sign-in__card { + width: min(100%, 420px); + display: flex; + flex-direction: column; + gap: 14px; + padding: 28px; + box-sizing: border-box; + border-radius: 20px; + background: var(--app-widget-bg); + box-shadow: var(--app-widget-shadow); + color: var(--app-on-surface); + + h1 { + margin: 0; + font-size: 1.3rem; + font-weight: 600; + color: var(--app-heading-color); + } + + p { + margin: 0; + color: var(--app-body-color); + font-size: 0.9rem; + line-height: 1.45; + } + + button[type='submit'] { + align-self: flex-end; + } +} + +.server-sign-in__logo { + border-radius: 12px; +} + +.server-sign-in .server-sign-in__feedback { + color: var(--app-expiry-expired-text); + + &:empty { + display: none; + } +} diff --git a/apps/web/src/app/server-storage/server-sign-in.component.spec.ts b/apps/web/src/app/server-storage/server-sign-in.component.spec.ts new file mode 100644 index 000000000..748cf6096 --- /dev/null +++ b/apps/web/src/app/server-storage/server-sign-in.component.spec.ts @@ -0,0 +1,85 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { + ServerApiError, + ServerStorageSession, +} from '@iptvnator/services/server-storage'; +import { ServerSignInComponent } from './server-sign-in.component'; + +const TOKEN_KEY = 'iptvnator-server:access-token'; + +describe('ServerSignInComponent', () => { + let fixture: ComponentFixture; + let component: ServerSignInComponent; + const session = { fetchStatus: jest.fn() }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ServerSignInComponent, TranslateModule.forRoot()], + providers: [{ provide: ServerStorageSession, useValue: session }], + }).compileComponents(); + fixture = TestBed.createComponent(ServerSignInComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + localStorage.clear(); + jest.resetAllMocks(); + }); + + const feedbackText = () => + fixture.nativeElement + .querySelector('[data-test-id="server-sign-in-feedback"]') + .textContent.trim(); + + it('keeps an accepted token and reloads into the workspace', async () => { + session.fetchStatus.mockResolvedValue({ playlists: 0 }); + const open = jest + .spyOn(component, 'openWorkspace') + .mockImplementation(() => undefined); + + component.edit(' right '); + await component.submit(new Event('submit')); + + expect(localStorage.getItem(TOKEN_KEY)).toBe('right'); + expect(open).toHaveBeenCalled(); + }); + + it('says the token was refused and forgets it', async () => { + session.fetchStatus.mockRejectedValue( + new ServerApiError(401, 'unauthorized', 'refused') + ); + + component.edit('wrong'); + await component.submit(new Event('submit')); + fixture.detectChanges(); + + expect(component.feedback()).toBe('rejected'); + expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_REJECTED'); + expect(localStorage.getItem(TOKEN_KEY)).toBeNull(); + }); + + it('tells an unreachable server apart and clears the message on edit', async () => { + session.fetchStatus.mockRejectedValue( + new ServerApiError(0, undefined, 'offline') + ); + + component.edit('token'); + await component.submit(new Event('submit')); + fixture.detectChanges(); + expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_UNREACHABLE'); + + component.edit('token2'); + fixture.detectChanges(); + expect(feedbackText()).toBe(''); + }); + + it('does not submit an empty token', async () => { + component.edit(' '); + await component.submit(new Event('submit')); + + expect(session.fetchStatus).not.toHaveBeenCalled(); + expect(localStorage.getItem(TOKEN_KEY)).toBeNull(); + }); +}); diff --git a/apps/web/src/app/server-storage/server-sign-in.component.ts b/apps/web/src/app/server-storage/server-sign-in.component.ts new file mode 100644 index 000000000..dd7da1bb3 --- /dev/null +++ b/apps/web/src/app/server-storage/server-sign-in.component.ts @@ -0,0 +1,78 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + signal, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +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 { + clearServerAccessToken, + writeServerAccessToken, +} from '@iptvnator/services'; +import { + ServerApiError, + ServerStorageSession, +} from '@iptvnator/services/server-storage'; +import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle'; + +export type ServerSignInFeedback = 'rejected' | 'unreachable'; + +@Component({ + selector: 'app-server-sign-in', + imports: [ + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + PasswordVisibilityToggleDirective, + TranslatePipe, + ], + templateUrl: './server-sign-in.component.html', + styleUrl: './server-sign-in.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ServerSignInComponent { + private readonly session = inject(ServerStorageSession); + + readonly token = signal(''); + readonly busy = signal(false); + readonly feedback = signal(null); + + edit(value: string): void { + this.token.set(value); + this.feedback.set(null); + } + + async submit(event: Event): Promise { + event.preventDefault(); + const token = this.token().trim(); + if (!token || this.busy()) { + return; + } + this.busy.set(true); + this.feedback.set(null); + writeServerAccessToken(token); + try { + await this.session.fetchStatus(); + this.openWorkspace(); + } catch (error) { + clearServerAccessToken(); + this.feedback.set( + error instanceof ServerApiError && error.status === 401 + ? 'rejected' + : 'unreachable' + ); + } finally { + this.busy.set(false); + } + } + + /** Full reload so every service starts with the token. */ + openWorkspace(): void { + window.location.assign(new URL('workspace', document.baseURI).href); + } +}