mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
* fix(ui): welcome screens and drop overlay follow the app theme The welcome dashboard, the empty Sources page and the playlist drop overlay switched on `prefers-color-scheme`, so with the app set to dark on a light OS (or light on a dark OS) they painted the other theme's colours. They now read `--app-*` tokens, which follow the `.dark-theme` class set from Settings, and the hard-coded blues are derived from the selection colour (a local "strong" accent mixed toward the heading ink keeps chips and filled labels at 4.5:1 in both themes). White rgba() fills that vanished in the light theme (season empty panel, catalog refinement chips and menu divider, Xtream archive banner and disabled paginator icons, shell download-activity track, search field) now use the widget surface, on-surface mixes or the search tokens. Reads of custom properties that nothing declares are fixed: the release notes error, the search-layout empty state and the collection reload dim now use declared tokens; unset hooks are replaced by their fallback value. New guard `pnpm run styles:theme-references:validate` (CI) rejects undeclared var() reads and prefers-color-scheme outside the settings resolver. Electron E2E os-color-scheme.e2e.ts flips the OS scheme under each explicit app theme and checks colours, contrast and screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): retry the rejected-drop comparison when the card dismisses mid-read The rejection hides itself after 1.8s, and the OS-scheme comparison reads the overlay twice with an emulateMedia call between. A slow runner could lose the card between the reads; the comparison now drops again until both reads see it. A colour that follows the OS still fails every attempt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): measure welcome text contrast from rendered pixels The feature and source cards paint gradients, which a backgroundColor walk ignores, so card titles, descriptions and chips could pass while falling short on the actual card. Every text on the three surfaces is now measured against the pixels under it, as the filled button labels were. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tools): scan only runtime sources in the theme-references guard The guard read every tracked file under apps/ and libs/ except specs, so an E2E, mock-server or test-helper declaration could satisfy a runtime var() read that nothing in the app declares (dashboard-rail-focus.e2e.ts sets --cover-rail-width), and a test-only read could fail the check. It now skips spec/test/e2e files, test helpers and stubs, testing projects, the E2E and mock-server apps and the marketing website. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(xtream): set live paginator tokens through mat.paginator-overrides The live layout hand-declared --mat-paginator-* tokens, which the UI guidelines route through the overrides mixin so a mistyped name fails the build instead of silently doing nothing. Same values, same output. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tools): skip every test-only naming convention in the theme guard The runtime scan still read .stub, .harness, .fixtures, .spec-stubs, .spec-helpers, .spec-fixtures, test-setup and test-double sources, and test-stubs/ or *fixtures/ directories, so a declaration in one could mask an undeclared runtime read. A file is now test-only when a dot segment after its name marks it (spec, stub, fixture, harness, mock, spec-*, test-*, *-fixtures), its stem is a test bootstrap, or it sits in a test directory. Runtime names such as xtream-connection-test.service.ts stay in the scan; no runtime source imports an excluded file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ui): keep welcome card text legible while hovered At the 16%/10% hover tint the dark theme's body text on a feature card measured 4.45:1. The hover fill steps up to 10%/6% instead (4.78:1 in dark, 6.6:1 in light); lift, border and shadow carry the rest. The E2E now measures feature and source card text while hovered, in both themes; with the old tint it fails at 4.45. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
import {
|
|
Component,
|
|
computed,
|
|
inject,
|
|
OnInit,
|
|
SecurityContext,
|
|
signal,
|
|
ViewEncapsulation,
|
|
ChangeDetectionStrategy,
|
|
} from '@angular/core';
|
|
import { DatePipe } from '@angular/common';
|
|
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 { DomSanitizer } from '@angular/platform-browser';
|
|
import { TranslatePipe } from '@ngx-translate/core';
|
|
import { marked } from 'marked';
|
|
import {
|
|
appUpdateReleasesListUrl,
|
|
appVersionChannel,
|
|
ElectronBridgeAppUpdateReleaseNotes,
|
|
ElectronBridgeAppUpdateReleaseNotesDirection,
|
|
ElectronBridgeAppUpdateReleaseNotesRequest,
|
|
isAppUpdateReleaseNotesNotFoundMessage,
|
|
} from '@iptvnator/shared/interfaces';
|
|
|
|
const RELEASE_NOTES_IMAGE_CLASS = 'release-notes-dialog__image';
|
|
|
|
/** `ipcRenderer.invoke` wraps a main-process rejection; the wrapper is noise. */
|
|
const IPC_REJECTION_PREFIX =
|
|
/^Error invoking remote method '[^']*': (?:Error: )?/;
|
|
|
|
interface ReleaseNotesLoadError {
|
|
/**
|
|
* `not-found`: GitHub has no release for that version; `unavailable`: this
|
|
* build has no release-notes bridge; `failed`: anything else.
|
|
*/
|
|
kind: 'not-found' | 'unavailable' | 'failed';
|
|
/** Raw detail shown under the generic failure; empty for the other kinds. */
|
|
message: string;
|
|
version?: string;
|
|
}
|
|
|
|
export interface AppUpdateReleaseNotesDialogData {
|
|
initialVersion?: string;
|
|
fallbackToLatest?: boolean;
|
|
}
|
|
|
|
function decorateReleaseNotesHtml(html: string): string {
|
|
if (typeof document === 'undefined') {
|
|
return html;
|
|
}
|
|
|
|
const template = document.createElement('template');
|
|
template.innerHTML = html;
|
|
|
|
template.content.querySelectorAll('img').forEach((image) => {
|
|
image.classList.add(RELEASE_NOTES_IMAGE_CLASS);
|
|
});
|
|
|
|
return template.innerHTML;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-update-release-notes-dialog',
|
|
imports: [
|
|
MatButtonModule,
|
|
MatDialogModule,
|
|
MatIconModule,
|
|
MatProgressSpinnerModule,
|
|
DatePipe,
|
|
TranslatePipe,
|
|
],
|
|
encapsulation: ViewEncapsulation.None,
|
|
template: `
|
|
<h2 mat-dialog-title>
|
|
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
|
|
</h2>
|
|
|
|
<mat-dialog-content class="release-notes-dialog mat-typography">
|
|
<div class="release-notes-dialog__toolbar">
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
[disabled]="loading() || !notes()?.hasPrevious"
|
|
[attr.aria-label]="
|
|
'SETTINGS.APP_UPDATE_RELEASE_NOTES_OLDER' | translate
|
|
"
|
|
(click)="load('previous')"
|
|
data-test-id="release-notes-previous"
|
|
>
|
|
<mat-icon>chevron_left</mat-icon>
|
|
</button>
|
|
|
|
<div class="release-notes-dialog__version">
|
|
<strong>{{
|
|
notes()?.releaseName || notes()?.tagName
|
|
}}</strong>
|
|
@if (notes()?.publishedAt; as publishedAt) {
|
|
<span>{{ publishedAt | date: 'mediumDate' }}</span>
|
|
}
|
|
</div>
|
|
|
|
<button
|
|
mat-icon-button
|
|
type="button"
|
|
[disabled]="loading() || !notes()?.hasNext"
|
|
[attr.aria-label]="
|
|
'SETTINGS.APP_UPDATE_RELEASE_NOTES_NEWER' | translate
|
|
"
|
|
(click)="load('next')"
|
|
data-test-id="release-notes-next"
|
|
>
|
|
<mat-icon>chevron_right</mat-icon>
|
|
</button>
|
|
</div>
|
|
|
|
@if (loading()) {
|
|
<div class="release-notes-dialog__loading">
|
|
<mat-spinner diameter="28" />
|
|
</div>
|
|
} @else if (error(); as loadError) {
|
|
<div
|
|
class="release-notes-dialog__error"
|
|
[class.release-notes-dialog__error--failed]="
|
|
loadError.kind === 'failed'
|
|
"
|
|
data-test-id="release-notes-error"
|
|
>
|
|
@if (loadError.kind === 'not-found') {
|
|
<p>
|
|
{{
|
|
'SETTINGS.APP_UPDATE_RELEASE_NOTES_NOT_FOUND'
|
|
| translate: { version: loadError.version }
|
|
}}
|
|
</p>
|
|
} @else if (loadError.kind === 'unavailable') {
|
|
<p>
|
|
{{
|
|
'SETTINGS.APP_UPDATE_RELEASE_NOTES_UNAVAILABLE'
|
|
| translate
|
|
}}
|
|
</p>
|
|
} @else {
|
|
<p>
|
|
{{
|
|
'SETTINGS.APP_UPDATE_RELEASE_NOTES_LOAD_FAILED'
|
|
| translate
|
|
}}
|
|
</p>
|
|
<small>{{ loadError.message }}</small>
|
|
}
|
|
</div>
|
|
} @else {
|
|
<article
|
|
class="release-notes-dialog__body"
|
|
data-test-id="release-notes-body"
|
|
[innerHTML]="renderedMarkdown()"
|
|
></article>
|
|
}
|
|
</mat-dialog-content>
|
|
|
|
<mat-dialog-actions align="end">
|
|
<!-- The error is checked first: a failed Previous/Next keeps the
|
|
earlier notes for navigation, but the body shows the error,
|
|
so the action must not open that earlier release. -->
|
|
@if (error()) {
|
|
<button
|
|
mat-button
|
|
type="button"
|
|
(click)="openRelease(releasesPageUrl())"
|
|
data-test-id="release-notes-open-releases"
|
|
>
|
|
<mat-icon>open_in_new</mat-icon>
|
|
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASES_PAGE' | translate }}
|
|
</button>
|
|
} @else if (notes()?.htmlUrl; as htmlUrl) {
|
|
<button
|
|
mat-button
|
|
type="button"
|
|
(click)="openRelease(htmlUrl)"
|
|
data-test-id="release-notes-open-release"
|
|
>
|
|
<mat-icon>open_in_new</mat-icon>
|
|
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASE' | translate }}
|
|
</button>
|
|
}
|
|
<button mat-flat-button type="button" (click)="close()">
|
|
{{ 'CLOSE' | translate }}
|
|
</button>
|
|
</mat-dialog-actions>
|
|
`,
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
styles: [
|
|
`
|
|
.release-notes-dialog {
|
|
min-width: min(640px, calc(100vw - 64px));
|
|
max-width: min(760px, calc(100vw - 32px));
|
|
}
|
|
|
|
.release-notes-dialog__toolbar {
|
|
display: grid;
|
|
grid-template-columns: 40px minmax(0, 1fr) 40px;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 4px 0 12px;
|
|
border-bottom: 1px solid var(--app-separator);
|
|
}
|
|
|
|
.release-notes-dialog__version {
|
|
min-width: 0;
|
|
text-align: center;
|
|
}
|
|
|
|
.release-notes-dialog__version strong,
|
|
.release-notes-dialog__version span {
|
|
display: block;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.release-notes-dialog__version span {
|
|
margin-top: 2px;
|
|
color: var(--app-muted-color);
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
.release-notes-dialog__loading {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 32px;
|
|
}
|
|
|
|
.release-notes-dialog__error {
|
|
padding: 16px 2px 0;
|
|
color: var(--app-body-color);
|
|
}
|
|
|
|
.release-notes-dialog__error--failed {
|
|
color: var(--mat-sys-error);
|
|
}
|
|
|
|
.release-notes-dialog__error p {
|
|
margin: 0;
|
|
}
|
|
|
|
.release-notes-dialog__error small {
|
|
display: block;
|
|
margin-top: 6px;
|
|
color: var(--app-muted-color);
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.release-notes-dialog__body {
|
|
max-height: min(58vh, 560px);
|
|
overflow: auto;
|
|
padding: 12px 2px 0;
|
|
color: var(--app-body-color);
|
|
}
|
|
|
|
.release-notes-dialog__body h1,
|
|
.release-notes-dialog__body h2,
|
|
.release-notes-dialog__body h3 {
|
|
color: var(--app-heading-color);
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.release-notes-dialog__body h1 {
|
|
font-size: 1.3rem;
|
|
}
|
|
|
|
.release-notes-dialog__body h2 {
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.release-notes-dialog__body h3 {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.release-notes-dialog__body code {
|
|
border-radius: 4px;
|
|
padding: 1px 4px;
|
|
background: var(--app-hover-overlay);
|
|
}
|
|
|
|
.release-notes-dialog__body img,
|
|
.release-notes-dialog__image {
|
|
display: block;
|
|
box-sizing: border-box;
|
|
max-width: 100%;
|
|
height: auto;
|
|
margin: 16px auto;
|
|
object-fit: contain;
|
|
}
|
|
`,
|
|
],
|
|
})
|
|
export class AppUpdateReleaseNotesDialogComponent implements OnInit {
|
|
private readonly data =
|
|
inject<AppUpdateReleaseNotesDialogData>(MAT_DIALOG_DATA);
|
|
private readonly dialogRef = inject(
|
|
MatDialogRef<AppUpdateReleaseNotesDialogComponent>
|
|
);
|
|
private readonly sanitizer = inject(DomSanitizer);
|
|
|
|
readonly loading = signal(false);
|
|
readonly error = signal<ReleaseNotesLoadError | null>(null);
|
|
readonly notes = signal<ElectronBridgeAppUpdateReleaseNotes | null>(null);
|
|
/** Release list of the channel the failed request was about. */
|
|
readonly releasesPageUrl = computed(() =>
|
|
appUpdateReleasesListUrl(
|
|
appVersionChannel(
|
|
this.error()?.version ??
|
|
this.notes()?.version ??
|
|
this.data.initialVersion
|
|
)
|
|
)
|
|
);
|
|
readonly renderedMarkdown = computed(() => {
|
|
const markdown = this.notes()?.bodyMarkdown ?? '';
|
|
const html = marked.parse(markdown, { async: false }) as string;
|
|
const sanitizedHtml =
|
|
this.sanitizer.sanitize(SecurityContext.HTML, html) ?? '';
|
|
|
|
return decorateReleaseNotesHtml(sanitizedHtml);
|
|
});
|
|
|
|
ngOnInit(): void {
|
|
void this.load();
|
|
}
|
|
|
|
async load(
|
|
direction?: ElectronBridgeAppUpdateReleaseNotesDirection
|
|
): Promise<void> {
|
|
if (!window.electron?.getAppUpdateReleaseNotes) {
|
|
this.error.set({ kind: 'unavailable', message: '' });
|
|
return;
|
|
}
|
|
|
|
const version = direction
|
|
? this.notes()?.tagName
|
|
: this.data.initialVersion;
|
|
const fallbackToLatest = direction
|
|
? undefined
|
|
: this.data.fallbackToLatest;
|
|
|
|
this.loading.set(true);
|
|
this.error.set(null);
|
|
|
|
try {
|
|
const request: ElectronBridgeAppUpdateReleaseNotesRequest = {};
|
|
|
|
if (direction) {
|
|
request.direction = direction;
|
|
}
|
|
|
|
if (version) {
|
|
request.version = version;
|
|
}
|
|
|
|
if (fallbackToLatest) {
|
|
request.fallbackToLatest = true;
|
|
}
|
|
|
|
this.notes.set(
|
|
await window.electron.getAppUpdateReleaseNotes(request)
|
|
);
|
|
} catch (error) {
|
|
const message = (
|
|
error instanceof Error ? error.message : String(error)
|
|
).replace(IPC_REJECTION_PREFIX, '');
|
|
|
|
this.error.set({
|
|
kind: isAppUpdateReleaseNotesNotFoundMessage(message)
|
|
? 'not-found'
|
|
: 'failed',
|
|
message,
|
|
version,
|
|
});
|
|
} finally {
|
|
this.loading.set(false);
|
|
}
|
|
}
|
|
|
|
openRelease(url: string): void {
|
|
window.open(url, '_blank', 'noreferrer');
|
|
}
|
|
|
|
close(): void {
|
|
this.dialogRef.close();
|
|
}
|
|
}
|