mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
style: enhance account info component with improved loading states, error handling, and UI updates
This commit is contained in:
1 parent
8b5344ef26
commit
a2e09dacdc
3 files changed
+863
-187
No files matched your search
@@ -1,114 +1,218 @@
|
||||
<h2 mat-dialog-title>{{ 'XTREAM.ACCOUNT_INFO.TITLE' | translate }}</h2>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
@if (accountInfo?.user_info?.message) {
|
||||
<div class="welcome-message">
|
||||
{{ accountInfo?.user_info?.message }}
|
||||
</div>
|
||||
}
|
||||
<div class="info-grid">
|
||||
<div class="info-section">
|
||||
<h3>{{ 'XTREAM.ACCOUNT_INFO.USER_INFO' | translate }}</h3>
|
||||
<mat-list>
|
||||
<mat-list-item>
|
||||
{{ 'XTREAM.ACCOUNT_INFO.STATUS' | translate }}:
|
||||
<span [class.active]="isActive">{{
|
||||
accountInfo?.user_info?.status
|
||||
}}</span>
|
||||
</mat-list-item>
|
||||
<mat-list-item>
|
||||
{{ 'XTREAM.ACCOUNT_INFO.USERNAME' | translate }}:
|
||||
{{ accountInfo?.user_info?.username }}
|
||||
</mat-list-item>
|
||||
<mat-list-item>
|
||||
{{ 'XTREAM.ACCOUNT_INFO.ACTIVE_CONNECTIONS' | translate }}:
|
||||
{{ accountInfo?.user_info?.active_cons }}/{{
|
||||
accountInfo?.user_info?.max_connections
|
||||
}}
|
||||
</mat-list-item>
|
||||
<mat-list-item>
|
||||
{{ 'XTREAM.ACCOUNT_INFO.CREATED' | translate }}:
|
||||
{{ formattedCreatedDate }}
|
||||
</mat-list-item>
|
||||
<mat-list-item>
|
||||
{{ 'XTREAM.ACCOUNT_INFO.EXPIRES' | translate }}:
|
||||
{{ formattedExpDate }}
|
||||
</mat-list-item>
|
||||
<mat-list-item>
|
||||
{{ 'XTREAM.ACCOUNT_INFO.TRIAL_ACCOUNT' | translate }}:
|
||||
<span [class.trial]="isTrial">{{
|
||||
(isTrial
|
||||
? 'XTREAM.ACCOUNT_INFO.YES'
|
||||
: 'XTREAM.ACCOUNT_INFO.NO'
|
||||
) | translate
|
||||
}}</span>
|
||||
</mat-list-item>
|
||||
<mat-list-item>
|
||||
{{ 'XTREAM.ACCOUNT_INFO.ALLOWED_FORMATS' | translate }}:
|
||||
<span class="formats">
|
||||
{{
|
||||
accountInfo?.user_info?.allowed_output_formats?.join(
|
||||
', '
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</mat-list-item>
|
||||
</mat-list>
|
||||
<section mat-dialog-title class="account-dialog__hero">
|
||||
<div class="account-dialog__hero-copy">
|
||||
<div class="account-dialog__eyebrow">
|
||||
<span class="account-dialog__eyebrow-mark"></span>
|
||||
@if (accountInfo(); as info) {
|
||||
{{ info.server_info?.server_protocol || 'Xtream' }}
|
||||
@if (info.server_info?.timezone) {
|
||||
<span class="account-dialog__eyebrow-divider"></span>
|
||||
{{ info.server_info?.timezone }}
|
||||
}
|
||||
} @else {
|
||||
Xtream
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h3>{{ 'XTREAM.ACCOUNT_INFO.SERVER_INFO' | translate }}</h3>
|
||||
<mat-list>
|
||||
<mat-list-item>
|
||||
{{ 'XTREAM.ACCOUNT_INFO.LIVE_TV' | translate }}:
|
||||
{{ liveStreamsCount }}
|
||||
</mat-list-item>
|
||||
<mat-list-item>
|
||||
{{ 'XTREAM.ACCOUNT_INFO.MOVIES' | translate }}:
|
||||
{{ vodStreamsCount }}
|
||||
</mat-list-item>
|
||||
<mat-list-item>
|
||||
{{ 'XTREAM.ACCOUNT_INFO.TV_SERIES' | translate }}:
|
||||
{{ seriesCount }}
|
||||
</mat-list-item>
|
||||
<mat-list-item>
|
||||
{{ 'XTREAM.ACCOUNT_INFO.URL' | translate }}:
|
||||
{{ accountInfo?.server_info?.url }}
|
||||
</mat-list-item>
|
||||
<mat-list-item>
|
||||
{{ 'XTREAM.ACCOUNT_INFO.PROTOCOL' | translate }}:
|
||||
{{ accountInfo?.server_info?.server_protocol }}
|
||||
</mat-list-item>
|
||||
<mat-list-item>
|
||||
{{ 'XTREAM.ACCOUNT_INFO.TIMEZONE' | translate }}:
|
||||
{{ accountInfo?.server_info?.timezone }}
|
||||
</mat-list-item>
|
||||
<mat-list-item>
|
||||
{{ 'XTREAM.ACCOUNT_INFO.SERVER_TIME' | translate }}:
|
||||
{{ accountInfo?.server_info?.time_now }}
|
||||
</mat-list-item>
|
||||
<mat-list-item>
|
||||
{{ 'XTREAM.ACCOUNT_INFO.PORTS' | translate }}:
|
||||
<div class="ports">
|
||||
<div class="account-dialog__headline-row">
|
||||
<div>
|
||||
<h2 class="account-dialog__title">
|
||||
{{ 'XTREAM.ACCOUNT_INFO.TITLE' | translate }}
|
||||
</h2>
|
||||
<p class="account-dialog__subtitle">
|
||||
{{ playlistLabel() }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (accountInfo(); as info) {
|
||||
<div class="account-dialog__status-cluster">
|
||||
<span
|
||||
class="account-dialog__status-pill"
|
||||
[class.account-dialog__status-pill--active]="isActive()"
|
||||
>
|
||||
{{ info.user_info?.status || '-' }}
|
||||
</span>
|
||||
|
||||
@if (isTrial()) {
|
||||
<span
|
||||
>{{ 'XTREAM.ACCOUNT_INFO.HTTP_PORT' | translate }}:
|
||||
{{ accountInfo?.server_info?.port }}</span
|
||||
class="account-dialog__status-pill account-dialog__status-pill--trial"
|
||||
>
|
||||
<span
|
||||
>{{ 'XTREAM.ACCOUNT_INFO.HTTPS_PORT' | translate }}:
|
||||
{{ accountInfo?.server_info?.https_port }}</span
|
||||
>
|
||||
<span
|
||||
>{{ 'XTREAM.ACCOUNT_INFO.RTMP_PORT' | translate }}:
|
||||
{{ accountInfo?.server_info?.rtmp_port }}</span
|
||||
>
|
||||
</div>
|
||||
</mat-list-item>
|
||||
</mat-list>
|
||||
{{
|
||||
'XTREAM.ACCOUNT_INFO.TRIAL_ACCOUNT' | translate
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (accountInfo()?.user_info?.message) {
|
||||
<div class="account-dialog__message">
|
||||
{{ accountInfo()?.user_info?.message }}
|
||||
</div>
|
||||
} @else if (loadState() === 'loading') {
|
||||
<p class="account-dialog__loading-copy">
|
||||
{{ 'LOADING' | translate }}...
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="account-dialog__hero-stats">
|
||||
@if (loadState() === 'ready') {
|
||||
@for (stat of heroStats(); track stat.labelKey) {
|
||||
<article class="account-stat">
|
||||
<div class="account-stat__icon">
|
||||
<mat-icon>{{ stat.icon }}</mat-icon>
|
||||
</div>
|
||||
<div class="account-stat__label">
|
||||
{{ stat.labelKey | translate }}
|
||||
</div>
|
||||
<div class="account-stat__value">
|
||||
{{ stat.value }}
|
||||
</div>
|
||||
@if (stat.meter !== null) {
|
||||
<div class="account-stat__meter">
|
||||
<span [style.width.%]="stat.meter"></span>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
} @else {
|
||||
@for (item of skeletonStats; track item) {
|
||||
<div class="account-skeleton account-skeleton--stat"></div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<mat-dialog-content class="account-dialog__content">
|
||||
@if (loadState() === 'error') {
|
||||
<section class="account-dialog__error-state">
|
||||
<div class="account-dialog__error-icon">
|
||||
<mat-icon>signal_wifi_connected_no_internet_4</mat-icon>
|
||||
</div>
|
||||
<h3>
|
||||
{{ 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE' | translate }}
|
||||
</h3>
|
||||
<p>
|
||||
{{
|
||||
'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<button mat-flat-button color="primary" (click)="reload()">
|
||||
{{ 'RETRY' | translate }}
|
||||
</button>
|
||||
</section>
|
||||
} @else if (loadState() === 'loading') {
|
||||
<div class="account-dialog__grid">
|
||||
@for (panel of skeletonPanels; track panel) {
|
||||
<section class="account-panel account-panel--loading">
|
||||
<div class="account-skeleton account-skeleton--heading"></div>
|
||||
<div class="account-skeleton account-skeleton--row"></div>
|
||||
<div class="account-skeleton account-skeleton--row"></div>
|
||||
<div class="account-skeleton account-skeleton--row"></div>
|
||||
<div class="account-skeleton account-skeleton--row short"></div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
} @else if (accountInfo(); as info) {
|
||||
<div class="account-dialog__grid">
|
||||
<section class="account-panel">
|
||||
<div class="account-panel__header">
|
||||
<p class="account-panel__eyebrow">
|
||||
{{ 'XTREAM.ACCOUNT_INFO.USER_INFO' | translate }}
|
||||
</p>
|
||||
<h3>{{ info.user_info?.username || '-' }}</h3>
|
||||
</div>
|
||||
|
||||
<dl class="account-detail-list">
|
||||
@for (row of userDetails(); track row.labelKey) {
|
||||
<div class="account-detail-row">
|
||||
<dt>{{ row.labelKey | translate }}</dt>
|
||||
<dd
|
||||
[class.account-detail-row__value--mono]="
|
||||
row.mono
|
||||
"
|
||||
[class.account-detail-row__value--accent]="
|
||||
row.tone === 'accent'
|
||||
"
|
||||
[class.account-detail-row__value--positive]="
|
||||
row.tone === 'positive'
|
||||
"
|
||||
[class.account-detail-row__value--warning]="
|
||||
row.tone === 'warning'
|
||||
"
|
||||
>
|
||||
@if (row.translateValue) {
|
||||
{{ row.value | translate }}
|
||||
} @else {
|
||||
{{ row.value }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
<div class="account-chip-section">
|
||||
<p class="account-chip-section__label">
|
||||
{{ 'XTREAM.ACCOUNT_INFO.ALLOWED_FORMATS' | translate }}
|
||||
</p>
|
||||
<div class="account-chip-group">
|
||||
@for (format of allowedFormats(); track format) {
|
||||
<span class="account-chip">
|
||||
{{ format }}
|
||||
</span>
|
||||
} @empty {
|
||||
<span class="account-chip account-chip--muted">
|
||||
-
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="account-panel">
|
||||
<div class="account-panel__header">
|
||||
<p class="account-panel__eyebrow">
|
||||
{{ 'XTREAM.ACCOUNT_INFO.SERVER_INFO' | translate }}
|
||||
</p>
|
||||
<h3>{{ serverHost() }}</h3>
|
||||
</div>
|
||||
|
||||
<dl class="account-detail-list">
|
||||
@for (row of serverDetails(); track row.labelKey) {
|
||||
<div class="account-detail-row">
|
||||
<dt>{{ row.labelKey | translate }}</dt>
|
||||
<dd
|
||||
[class.account-detail-row__value--mono]="
|
||||
row.mono
|
||||
"
|
||||
>
|
||||
{{ row.value }}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
<div class="account-chip-section">
|
||||
<p class="account-chip-section__label">
|
||||
{{ 'XTREAM.ACCOUNT_INFO.PORTS' | translate }}
|
||||
</p>
|
||||
<div class="account-chip-group">
|
||||
@for (port of ports(); track port.labelKey) {
|
||||
<span class="account-chip account-chip--mono">
|
||||
{{ port.labelKey | translate }} {{ port.value }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions>
|
||||
<button mat-button mat-dialog-close color="accent">
|
||||
|
||||
<mat-dialog-actions class="account-dialog__actions">
|
||||
<button mat-stroked-button mat-dialog-close class="account-dialog__close">
|
||||
{{ 'CLOSE' | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -1,89 +1,477 @@
|
||||
.account-info-card {
|
||||
margin: 16px;
|
||||
max-width: 1200px;
|
||||
:host {
|
||||
display: block;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
// ─── Hero header ────────────────────────────────────────────────────────────
|
||||
.account-dialog__hero {
|
||||
margin: 0;
|
||||
padding: 20px 20px 18px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
gap: 18px;
|
||||
background:
|
||||
var(--app-accent-gradient),
|
||||
var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
|
||||
border-bottom: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.info-section {
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 8px;
|
||||
.account-dialog__hero-copy {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.account-dialog__eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-dialog__eyebrow-mark {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--app-provider-xtream, var(--mat-sys-primary));
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 14%, transparent);
|
||||
}
|
||||
|
||||
.account-dialog__eyebrow-divider {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.account-dialog__headline-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.account-dialog__title {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--app-heading-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
|
||||
.account-dialog__subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
// ─── Status pills ───────────────────────────────────────────────────────────
|
||||
.account-dialog__status-cluster {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.account-dialog__status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--app-separator);
|
||||
background: var(--mat-sys-surface-container);
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-dialog__status-pill--active {
|
||||
border-color: color-mix(in srgb, #34d399 30%, transparent);
|
||||
background: color-mix(in srgb, #34d399 12%, transparent);
|
||||
color: #2db36f;
|
||||
}
|
||||
|
||||
.account-dialog__status-pill--trial {
|
||||
border-color: color-mix(in srgb, #fbbf24 28%, transparent);
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
color: #d18b09;
|
||||
}
|
||||
|
||||
// ─── Messages ───────────────────────────────────────────────────────────────
|
||||
.account-dialog__message,
|
||||
.account-dialog__loading-copy {
|
||||
margin: 0;
|
||||
max-width: 60ch;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
|
||||
.account-dialog__message {
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--app-separator);
|
||||
background: var(--mat-sys-surface-container);
|
||||
line-height: 1.5;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
// ─── Hero stat cards ────────────────────────────────────────────────────────
|
||||
.account-dialog__hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.account-stat {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--app-widget-border, transparent);
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface-container));
|
||||
box-shadow: var(--app-widget-shadow, none);
|
||||
// Subtle inset highlight matching widget-shell pattern
|
||||
outline: 1px solid rgba(255, 255, 255, 0.04);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.account-stat__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 12%, transparent);
|
||||
color: var(--app-provider-xtream, var(--mat-sys-primary));
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.account-stat__label {
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-stat__value {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--app-heading-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
|
||||
.account-stat__meter {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--app-separator);
|
||||
}
|
||||
|
||||
.account-stat__meter span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--app-provider-xtream, var(--mat-sys-primary));
|
||||
}
|
||||
|
||||
// ─── Content area ───────────────────────────────────────────────────────────
|
||||
.account-dialog__content {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface));
|
||||
}
|
||||
|
||||
.account-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
// ─── Detail panels ──────────────────────────────────────────────────────────
|
||||
.account-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--app-widget-border, transparent);
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface-container-low));
|
||||
box-shadow: var(--app-widget-shadow, none);
|
||||
outline: 1px solid rgba(255, 255, 255, 0.04);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.info-section h3 {
|
||||
margin-bottom: 16px;
|
||||
padding-left: 16px;
|
||||
.account-panel__header {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.account-panel__header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
border-bottom: 1px solid var(--mat-sys-outline-variant);
|
||||
padding-bottom: 8px;
|
||||
color: var(--app-heading-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
|
||||
mat-list-item {
|
||||
height: auto !important;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 16px;
|
||||
.account-panel__eyebrow {
|
||||
margin: 0;
|
||||
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
strong {
|
||||
margin-right: 8px;
|
||||
min-width: 140px;
|
||||
display: inline-block;
|
||||
// ─── Detail rows ────────────────────────────────────────────────────────────
|
||||
.account-detail-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: var(--mat-sys-primary);
|
||||
.account-detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 140px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--app-separator);
|
||||
}
|
||||
|
||||
.account-detail-row:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.account-detail-row dt {
|
||||
margin: 0;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-detail-row dd {
|
||||
margin: 0;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.trial {
|
||||
color: var(--mat-sys-error);
|
||||
font-weight: 500;
|
||||
.account-detail-row__value--mono {
|
||||
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.formats {
|
||||
color: var(--mat-sys-tertiary);
|
||||
.account-detail-row__value--accent {
|
||||
color: var(--app-provider-xtream, var(--mat-sys-primary));
|
||||
}
|
||||
|
||||
.ports {
|
||||
.account-detail-row__value--positive {
|
||||
color: #2db36f;
|
||||
}
|
||||
|
||||
.account-detail-row__value--warning {
|
||||
color: #d18b09;
|
||||
}
|
||||
|
||||
// ─── Chip sections ──────────────────────────────────────────────────────────
|
||||
.account-chip-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.account-chip-section__label {
|
||||
margin: 0;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-chip-group {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ports span {
|
||||
padding: 4px 8px;
|
||||
background: var(--mat-sys-primary);
|
||||
color: var(--mat-sys-on-primary);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
.account-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 16%, transparent);
|
||||
background: color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 8%, transparent);
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.welcome-message {
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
.account-chip--mono {
|
||||
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.account-chip--muted {
|
||||
border-color: var(--app-separator);
|
||||
background: var(--mat-sys-surface-container);
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
}
|
||||
|
||||
// ─── Error state ────────────────────────────────────────────────────────────
|
||||
.account-dialog__error-state {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 12px;
|
||||
padding: 36px 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed var(--app-separator);
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface-container-low));
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-grid {
|
||||
.account-dialog__error-state h3,
|
||||
.account-dialog__error-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.account-dialog__error-state p {
|
||||
max-width: 52ch;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
line-height: 1.5;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.account-dialog__error-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent);
|
||||
color: var(--mat-sys-error);
|
||||
}
|
||||
|
||||
// ─── Skeleton loading ───────────────────────────────────────────────────────
|
||||
.account-panel--loading {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.account-skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--app-separator);
|
||||
}
|
||||
|
||||
.account-skeleton::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateX(-100%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
color-mix(in srgb, var(--app-heading-color, var(--mat-sys-on-surface)) 10%, transparent),
|
||||
transparent
|
||||
);
|
||||
animation: account-dialog-shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.account-skeleton--stat {
|
||||
min-height: 110px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.account-skeleton--heading {
|
||||
width: 54%;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.account-skeleton--row {
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.account-skeleton--row.short {
|
||||
width: 62%;
|
||||
}
|
||||
|
||||
// ─── Footer actions ─────────────────────────────────────────────────────────
|
||||
.account-dialog__actions {
|
||||
padding: 0 20px 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
border-top: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface));
|
||||
}
|
||||
|
||||
.account-dialog__close {
|
||||
min-width: 100px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@keyframes account-dialog-shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Responsive breakpoints ─────────────────────────────────────────────────
|
||||
@media (max-width: 980px) {
|
||||
.account-dialog__hero-stats,
|
||||
.account-dialog__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.account-dialog__hero,
|
||||
.account-dialog__content,
|
||||
.account-dialog__actions {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.account-dialog__headline-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.account-dialog__status-cluster {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.account-dialog__hero-stats,
|
||||
.account-dialog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.account-info-card {
|
||||
margin: 8px;
|
||||
|
||||
.account-detail-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
.ports {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
XtreamAccountInfo,
|
||||
@@ -10,11 +16,34 @@ import {
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
|
||||
type AccountLoadState = 'loading' | 'ready' | 'error';
|
||||
|
||||
interface AccountStat {
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
value: string;
|
||||
meter: number | null;
|
||||
}
|
||||
|
||||
interface AccountDetailRow {
|
||||
labelKey: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
tone?: 'accent' | 'positive' | 'warning';
|
||||
translateValue?: boolean;
|
||||
}
|
||||
|
||||
interface AccountPort {
|
||||
labelKey: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-account-info',
|
||||
imports: [MatButton, MatDialogModule, MatListModule, TranslatePipe],
|
||||
imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe],
|
||||
templateUrl: './account-info.component.html',
|
||||
styleUrl: './account-info.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AccountInfoComponent {
|
||||
readonly data = inject<{
|
||||
@@ -26,51 +55,206 @@ export class AccountInfoComponent {
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly logger = createLogger('XtreamAccountInfo');
|
||||
|
||||
accountInfo: XtreamAccountInfo;
|
||||
formattedExpDate: string;
|
||||
formattedCreatedDate: string;
|
||||
vodStreamsCount: number;
|
||||
liveStreamsCount: number;
|
||||
seriesCount: number;
|
||||
|
||||
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
|
||||
readonly loadState = signal<AccountLoadState>('loading');
|
||||
readonly accountInfo = signal<XtreamAccountInfo | null>(null);
|
||||
readonly skeletonStats = [1, 2, 3, 4];
|
||||
readonly skeletonPanels = [1, 2];
|
||||
readonly vodStreamsCount = this.data.vodStreamsCount;
|
||||
readonly liveStreamsCount = this.data.liveStreamsCount;
|
||||
readonly seriesCount = this.data.seriesCount;
|
||||
|
||||
readonly isActive = computed(
|
||||
() => this.accountInfo()?.user_info?.status === 'Active'
|
||||
);
|
||||
readonly isTrial = computed(
|
||||
() => this.accountInfo()?.user_info?.is_trial === '1'
|
||||
);
|
||||
readonly playlistLabel = computed(() => {
|
||||
const playlist = this.currentPlaylist();
|
||||
const info = this.accountInfo();
|
||||
|
||||
return (
|
||||
playlist?.title ||
|
||||
info?.server_info?.url ||
|
||||
info?.user_info?.username ||
|
||||
'-'
|
||||
);
|
||||
});
|
||||
readonly serverHost = computed(
|
||||
() => this.accountInfo()?.server_info?.url || '-'
|
||||
);
|
||||
readonly activeConnections = computed(() =>
|
||||
this.parseNumber(this.accountInfo()?.user_info?.active_cons)
|
||||
);
|
||||
readonly maxConnections = computed(() =>
|
||||
this.parseNumber(this.accountInfo()?.user_info?.max_connections)
|
||||
);
|
||||
readonly connectionUsagePercent = computed(() => {
|
||||
const maxConnections = this.maxConnections();
|
||||
|
||||
if (maxConnections <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
100,
|
||||
Math.round((this.activeConnections() / maxConnections) * 100)
|
||||
);
|
||||
});
|
||||
readonly activeConnectionsLabel = computed(
|
||||
() => `${this.activeConnections()}/${Math.max(this.maxConnections(), 0)}`
|
||||
);
|
||||
readonly formattedExpDate = computed(() =>
|
||||
this.formatUnixDate(this.accountInfo()?.user_info?.exp_date)
|
||||
);
|
||||
readonly formattedCreatedDate = computed(() =>
|
||||
this.formatUnixDate(this.accountInfo()?.user_info?.created_at)
|
||||
);
|
||||
readonly allowedFormats = computed(
|
||||
() => this.accountInfo()?.user_info?.allowed_output_formats ?? []
|
||||
);
|
||||
readonly ports = computed<AccountPort[]>(() => {
|
||||
const serverInfo = this.accountInfo()?.server_info;
|
||||
|
||||
return [
|
||||
{
|
||||
labelKey: 'XTREAM.ACCOUNT_INFO.HTTP_PORT',
|
||||
value: serverInfo?.port || '-',
|
||||
},
|
||||
{
|
||||
labelKey: 'XTREAM.ACCOUNT_INFO.HTTPS_PORT',
|
||||
value: serverInfo?.https_port || '-',
|
||||
},
|
||||
{
|
||||
labelKey: 'XTREAM.ACCOUNT_INFO.RTMP_PORT',
|
||||
value: serverInfo?.rtmp_port || '-',
|
||||
},
|
||||
];
|
||||
});
|
||||
readonly heroStats = computed<AccountStat[]>(() => [
|
||||
{
|
||||
icon: 'bolt',
|
||||
labelKey: 'XTREAM.ACCOUNT_INFO.ACTIVE_CONNECTIONS',
|
||||
value: this.activeConnectionsLabel(),
|
||||
meter: this.connectionUsagePercent(),
|
||||
},
|
||||
{
|
||||
icon: 'live_tv',
|
||||
labelKey: 'XTREAM.ACCOUNT_INFO.LIVE_TV',
|
||||
value: String(this.liveStreamsCount),
|
||||
meter: null,
|
||||
},
|
||||
{
|
||||
icon: 'movie',
|
||||
labelKey: 'XTREAM.ACCOUNT_INFO.MOVIES',
|
||||
value: String(this.vodStreamsCount),
|
||||
meter: null,
|
||||
},
|
||||
{
|
||||
icon: 'tv',
|
||||
labelKey: 'XTREAM.ACCOUNT_INFO.TV_SERIES',
|
||||
value: String(this.seriesCount),
|
||||
meter: null,
|
||||
},
|
||||
]);
|
||||
readonly userDetails = computed<AccountDetailRow[]>(() => [
|
||||
{
|
||||
labelKey: 'XTREAM.ACCOUNT_INFO.STATUS',
|
||||
value: this.accountInfo()?.user_info?.status || '-',
|
||||
tone: this.isActive() ? 'positive' : undefined,
|
||||
},
|
||||
{
|
||||
labelKey: 'XTREAM.ACCOUNT_INFO.USERNAME',
|
||||
value: this.accountInfo()?.user_info?.username || '-',
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
labelKey: 'XTREAM.ACCOUNT_INFO.ACTIVE_CONNECTIONS',
|
||||
value: this.activeConnectionsLabel(),
|
||||
tone: 'accent',
|
||||
},
|
||||
{
|
||||
labelKey: 'XTREAM.ACCOUNT_INFO.CREATED',
|
||||
value: this.formattedCreatedDate(),
|
||||
},
|
||||
{
|
||||
labelKey: 'XTREAM.ACCOUNT_INFO.EXPIRES',
|
||||
value: this.formattedExpDate(),
|
||||
},
|
||||
{
|
||||
labelKey: 'XTREAM.ACCOUNT_INFO.TRIAL_ACCOUNT',
|
||||
value: this.isTrial()
|
||||
? 'XTREAM.ACCOUNT_INFO.YES'
|
||||
: 'XTREAM.ACCOUNT_INFO.NO',
|
||||
translateValue: true,
|
||||
tone: this.isTrial() ? 'warning' : undefined,
|
||||
},
|
||||
]);
|
||||
readonly serverDetails = computed<AccountDetailRow[]>(() => [
|
||||
{
|
||||
labelKey: 'XTREAM.ACCOUNT_INFO.URL',
|
||||
value: this.accountInfo()?.server_info?.url || '-',
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
labelKey: 'XTREAM.ACCOUNT_INFO.PROTOCOL',
|
||||
value: this.accountInfo()?.server_info?.server_protocol || '-',
|
||||
},
|
||||
{
|
||||
labelKey: 'XTREAM.ACCOUNT_INFO.TIMEZONE',
|
||||
value: this.accountInfo()?.server_info?.timezone || '-',
|
||||
},
|
||||
{
|
||||
labelKey: 'XTREAM.ACCOUNT_INFO.SERVER_TIME',
|
||||
value: this.accountInfo()?.server_info?.time_now || '-',
|
||||
mono: true,
|
||||
},
|
||||
]);
|
||||
|
||||
constructor() {
|
||||
this.setAccountInfo();
|
||||
this.vodStreamsCount = this.data.vodStreamsCount;
|
||||
this.liveStreamsCount = this.data.liveStreamsCount;
|
||||
this.seriesCount = this.data.seriesCount;
|
||||
void this.reload();
|
||||
}
|
||||
|
||||
async setAccountInfo() {
|
||||
async reload(): Promise<void> {
|
||||
const playlist = this.currentPlaylist();
|
||||
if (!playlist) return;
|
||||
|
||||
if (!playlist) {
|
||||
this.loadState.set('error');
|
||||
this.accountInfo.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadState.set('loading');
|
||||
|
||||
try {
|
||||
this.accountInfo = await this.xtreamApiService.getAccountInfo({
|
||||
const accountInfo = await this.xtreamApiService.getAccountInfo({
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
});
|
||||
|
||||
if (this.accountInfo) {
|
||||
this.formattedExpDate = new Date(
|
||||
parseInt(this.accountInfo.user_info.exp_date) * 1000
|
||||
).toLocaleDateString();
|
||||
this.formattedCreatedDate = new Date(
|
||||
parseInt(this.accountInfo.user_info.created_at) * 1000
|
||||
).toLocaleDateString();
|
||||
}
|
||||
this.accountInfo.set(accountInfo);
|
||||
this.loadState.set('ready');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to fetch account info', error);
|
||||
this.accountInfo.set(null);
|
||||
this.loadState.set('error');
|
||||
}
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.accountInfo?.user_info?.status === 'Active';
|
||||
private formatUnixDate(timestamp?: string): string {
|
||||
const value = Number.parseInt(timestamp ?? '', 10);
|
||||
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return new Date(value * 1000).toLocaleDateString();
|
||||
}
|
||||
|
||||
get isTrial(): boolean {
|
||||
return this.accountInfo?.user_info?.is_trial === '1';
|
||||
private parseNumber(value?: string): number {
|
||||
const parsed = Number.parseInt(value ?? '', 10);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user