mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
style: enhance channel details dialog with improved layout and additional stats
Entire-Checkpoint: db35a98d337f
This commit is contained in:
1 parent
c97673b928
commit
dd58e4a529
4 files changed
+671
-280
No files matched your search
+218
-94
@@ -1,109 +1,233 @@
|
||||
<h2 mat-dialog-title>{{ 'CHANNELS.DETAILS_DIALOG.TITLE' | translate }}</h2>
|
||||
<section mat-dialog-title class="channel-dialog__hero">
|
||||
<div class="channel-dialog__hero-copy">
|
||||
<div class="channel-dialog__eyebrow">
|
||||
<span class="channel-dialog__eyebrow-mark"></span>
|
||||
{{ channel.group?.title || 'M3U' }}
|
||||
</div>
|
||||
|
||||
<mat-dialog-content class="channel-details-dialog mat-typography">
|
||||
<p class="dialog-description">
|
||||
{{ 'CHANNELS.DETAILS_DIALOG.DESCRIPTION' | translate }}
|
||||
</p>
|
||||
<div class="channel-dialog__headline-row">
|
||||
<div class="channel-dialog__headline-text">
|
||||
@if (channel.tvg?.logo) {
|
||||
<img
|
||||
class="channel-dialog__logo"
|
||||
[src]="channel.tvg.logo"
|
||||
[alt]="channel.name"
|
||||
(error)="logoError = true"
|
||||
[hidden]="logoError"
|
||||
/>
|
||||
}
|
||||
<div>
|
||||
<h2 class="channel-dialog__title">{{ channel.name }}</h2>
|
||||
<p class="channel-dialog__subtitle">
|
||||
{{ 'CHANNELS.DETAILS_DIALOG.DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="details-section">
|
||||
<h3 class="section-title">
|
||||
{{ 'CHANNELS.DETAILS_DIALOG.SUMMARY' | translate }}
|
||||
</h3>
|
||||
<dl class="details-grid">
|
||||
@for (field of summaryFields; track field.labelKey) {
|
||||
<div class="detail-row">
|
||||
<dt>{{ field.labelKey | translate }}</dt>
|
||||
<dd
|
||||
[class.empty]="field.empty"
|
||||
[class.monospace]="field.monospace"
|
||||
[class.wrap]="field.wrap"
|
||||
<div class="channel-dialog__status-cluster">
|
||||
@if (channel.radio === 'true') {
|
||||
<span
|
||||
class="channel-dialog__status-pill channel-dialog__status-pill--radio"
|
||||
>
|
||||
@if (field.valueKey) {
|
||||
{{ field.valueKey | translate: field.translateParams }}
|
||||
} @else {
|
||||
{{ field.value }}
|
||||
{{ 'CHANNELS.DETAILS_DIALOG.RADIO' | translate }}
|
||||
</span>
|
||||
}
|
||||
@if (catchupAvailable) {
|
||||
<span
|
||||
class="channel-dialog__status-pill channel-dialog__status-pill--active"
|
||||
>
|
||||
{{ 'CHANNELS.DETAILS_DIALOG.CATCHUP_AVAILABLE' | translate }}
|
||||
</span>
|
||||
}
|
||||
@if (catchupPlaybackSupported) {
|
||||
<span
|
||||
class="channel-dialog__status-pill channel-dialog__status-pill--playback"
|
||||
>
|
||||
{{ 'CHANNELS.DETAILS_DIALOG.PLAYBACK_SUPPORTED' | translate }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="channel-dialog__hero-stats">
|
||||
@for (stat of heroStats; track stat.labelKey) {
|
||||
<article class="channel-stat">
|
||||
<div class="channel-stat__icon">
|
||||
<mat-icon>{{ stat.icon }}</mat-icon>
|
||||
</div>
|
||||
<div class="channel-stat__label">
|
||||
{{ stat.labelKey | translate }}
|
||||
</div>
|
||||
<div
|
||||
class="channel-stat__value"
|
||||
[class.channel-stat__value--mono]="stat.mono"
|
||||
[class.channel-stat__value--empty]="stat.empty"
|
||||
>
|
||||
@if (stat.valueKey) {
|
||||
{{ stat.valueKey | translate: stat.translateParams }}
|
||||
} @else {
|
||||
{{ stat.value }}
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<mat-dialog-content class="channel-dialog__content">
|
||||
<div class="channel-dialog__grid">
|
||||
<section class="channel-panel">
|
||||
<div class="channel-panel__header">
|
||||
<p class="channel-panel__eyebrow">
|
||||
{{ 'CHANNELS.DETAILS_DIALOG.SUMMARY' | translate }}
|
||||
</p>
|
||||
<h3>{{ 'CHANNELS.DETAILS_DIALOG.STREAM_URL' | translate }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="channel-dialog__url-block">
|
||||
<code>{{ channel.url }}</code>
|
||||
<button
|
||||
mat-icon-button
|
||||
class="channel-dialog__copy-btn"
|
||||
[cdkCopyToClipboard]="channel.url"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl class="channel-detail-list">
|
||||
@for (field of streamFields; track field.labelKey) {
|
||||
<div class="channel-detail-row">
|
||||
<dt>{{ field.labelKey | translate }}</dt>
|
||||
<dd
|
||||
[class.channel-detail-row__value--mono]="
|
||||
field.monospace
|
||||
"
|
||||
[class.channel-detail-row__value--empty]="
|
||||
field.empty
|
||||
"
|
||||
>
|
||||
@if (field.valueKey) {
|
||||
{{
|
||||
field.valueKey
|
||||
| translate: field.translateParams
|
||||
}}
|
||||
} @else {
|
||||
{{ field.value }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
@if (hasTvgData) {
|
||||
<div class="channel-chip-section">
|
||||
<p class="channel-chip-section__label">
|
||||
{{ 'CHANNELS.DETAILS_DIALOG.TVG' | translate }}
|
||||
</p>
|
||||
<dl class="channel-detail-list">
|
||||
@for (field of tvgFields; track field.labelKey) {
|
||||
<div class="channel-detail-row">
|
||||
<dt>{{ field.labelKey | translate }}</dt>
|
||||
<dd
|
||||
[class.channel-detail-row__value--mono]="
|
||||
field.monospace
|
||||
"
|
||||
[class.channel-detail-row__value--empty]="
|
||||
field.empty
|
||||
"
|
||||
>
|
||||
@if (field.valueKey) {
|
||||
{{
|
||||
field.valueKey
|
||||
| translate
|
||||
: field.translateParams
|
||||
}}
|
||||
} @else {
|
||||
{{ field.value }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="details-section">
|
||||
<h3 class="section-title">
|
||||
{{ 'CHANNELS.DETAILS_DIALOG.TVG' | translate }}
|
||||
</h3>
|
||||
<dl class="details-grid">
|
||||
@for (field of tvgFields; track field.labelKey) {
|
||||
<div class="detail-row">
|
||||
<dt>{{ field.labelKey | translate }}</dt>
|
||||
<dd
|
||||
[class.empty]="field.empty"
|
||||
[class.monospace]="field.monospace"
|
||||
[class.wrap]="field.wrap"
|
||||
>
|
||||
@if (field.valueKey) {
|
||||
{{ field.valueKey | translate: field.translateParams }}
|
||||
} @else {
|
||||
{{ field.value }}
|
||||
<section class="channel-panel">
|
||||
<div class="channel-panel__header">
|
||||
<p class="channel-panel__eyebrow">
|
||||
{{ 'CHANNELS.DETAILS_DIALOG.CATCHUP' | translate }}
|
||||
</p>
|
||||
<h3>
|
||||
{{ channel.catchup?.type || ('CHANNELS.DETAILS_DIALOG.NOT_AVAILABLE' | translate) }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<dl class="channel-detail-list">
|
||||
@for (field of catchupFields; track field.labelKey) {
|
||||
<div class="channel-detail-row">
|
||||
<dt>{{ field.labelKey | translate }}</dt>
|
||||
<dd
|
||||
[class.channel-detail-row__value--mono]="
|
||||
field.monospace
|
||||
"
|
||||
[class.channel-detail-row__value--empty]="
|
||||
field.empty
|
||||
"
|
||||
>
|
||||
@if (field.valueKey) {
|
||||
{{
|
||||
field.valueKey
|
||||
| translate: field.translateParams
|
||||
}}
|
||||
} @else {
|
||||
{{ field.value }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
@if (hasHttpData) {
|
||||
<div class="channel-chip-section">
|
||||
<p class="channel-chip-section__label">
|
||||
{{ 'CHANNELS.DETAILS_DIALOG.HTTP' | translate }}
|
||||
</p>
|
||||
<dl class="channel-detail-list">
|
||||
@for (field of httpFields; track field.labelKey) {
|
||||
<div class="channel-detail-row">
|
||||
<dt>{{ field.labelKey | translate }}</dt>
|
||||
<dd
|
||||
[class.channel-detail-row__value--mono]="
|
||||
field.monospace
|
||||
"
|
||||
[class.channel-detail-row__value--empty]="
|
||||
field.empty
|
||||
"
|
||||
>
|
||||
@if (field.valueKey) {
|
||||
{{
|
||||
field.valueKey
|
||||
| translate
|
||||
: field.translateParams
|
||||
}}
|
||||
} @else {
|
||||
{{ field.value }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="details-section">
|
||||
<h3 class="section-title">
|
||||
{{ 'CHANNELS.DETAILS_DIALOG.CATCHUP' | translate }}
|
||||
</h3>
|
||||
<dl class="details-grid">
|
||||
@for (field of catchupFields; track field.labelKey) {
|
||||
<div class="detail-row">
|
||||
<dt>{{ field.labelKey | translate }}</dt>
|
||||
<dd
|
||||
[class.empty]="field.empty"
|
||||
[class.monospace]="field.monospace"
|
||||
[class.wrap]="field.wrap"
|
||||
>
|
||||
@if (field.valueKey) {
|
||||
{{ field.valueKey | translate: field.translateParams }}
|
||||
} @else {
|
||||
{{ field.value }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="details-section">
|
||||
<h3 class="section-title">
|
||||
{{ 'CHANNELS.DETAILS_DIALOG.HTTP' | translate }}
|
||||
</h3>
|
||||
<dl class="details-grid">
|
||||
@for (field of httpFields; track field.labelKey) {
|
||||
<div class="detail-row">
|
||||
<dt>{{ field.labelKey | translate }}</dt>
|
||||
<dd
|
||||
[class.empty]="field.empty"
|
||||
[class.monospace]="field.monospace"
|
||||
[class.wrap]="field.wrap"
|
||||
>
|
||||
@if (field.valueKey) {
|
||||
{{ field.valueKey | translate: field.translateParams }}
|
||||
} @else {
|
||||
{{ field.value }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close>
|
||||
<mat-dialog-actions class="channel-dialog__actions">
|
||||
<button mat-stroked-button mat-dialog-close class="channel-dialog__close">
|
||||
{{ 'CLOSE' | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
+362
-52
@@ -1,80 +1,390 @@
|
||||
:host {
|
||||
display: block;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
|
||||
.channel-details-dialog {
|
||||
max-height: min(78vh, 720px);
|
||||
}
|
||||
|
||||
.dialog-description {
|
||||
margin: 0 0 20px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.details-section + .details-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--mat-sys-outline-variant);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
// ─── Hero header ────────────────────────────────────────────────────────────
|
||||
.channel-dialog__hero {
|
||||
margin: 0;
|
||||
padding: 20px 20px 18px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
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));
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
.channel-dialog__hero-copy {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 180px) minmax(0, 1fr);
|
||||
gap: 6px 16px;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
dt {
|
||||
.channel-dialog__eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
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;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
.channel-dialog__eyebrow-mark {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--mat-sys-primary);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--mat-sys-primary) 14%, transparent);
|
||||
}
|
||||
|
||||
.channel-dialog__headline-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.channel-dialog__headline-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
&.empty {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
.channel-dialog__logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
object-fit: contain;
|
||||
background: var(--mat-sys-surface-container);
|
||||
border: 1px solid var(--app-separator);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.channel-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));
|
||||
}
|
||||
|
||||
.channel-dialog__subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
// ─── Status pills ───────────────────────────────────────────────────────────
|
||||
.channel-dialog__status-cluster {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.channel-dialog__status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 26px;
|
||||
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.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.channel-dialog__status-pill--active {
|
||||
border-color: color-mix(in srgb, #34d399 30%, transparent);
|
||||
background: color-mix(in srgb, #34d399 12%, transparent);
|
||||
color: #2db36f;
|
||||
}
|
||||
|
||||
.channel-dialog__status-pill--radio {
|
||||
border-color: color-mix(in srgb, var(--mat-sys-tertiary) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--mat-sys-tertiary) 12%, transparent);
|
||||
color: var(--mat-sys-tertiary);
|
||||
}
|
||||
|
||||
.channel-dialog__status-pill--playback {
|
||||
border-color: color-mix(in srgb, var(--mat-sys-primary) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
// ─── Hero stat cards ────────────────────────────────────────────────────────
|
||||
.channel-dialog__hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.channel-stat {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
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);
|
||||
outline: 1px solid rgba(255, 255, 255, 0.04);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.channel-stat__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
|
||||
color: var(--mat-sys-primary);
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-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;
|
||||
}
|
||||
|
||||
.channel-stat__value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--app-heading-color, var(--mat-sys-on-surface));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&--mono {
|
||||
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
&--empty {
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.monospace {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Monaco,
|
||||
Consolas,
|
||||
'Liberation Mono',
|
||||
'Courier New',
|
||||
monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
// ─── Content area ───────────────────────────────────────────────────────────
|
||||
.channel-dialog__content {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: var(--app-widget-bg, var(--mat-sys-surface));
|
||||
}
|
||||
|
||||
&.wrap {
|
||||
.channel-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
// ─── URL block ──────────────────────────────────────────────────────────────
|
||||
.channel-dialog__url-block {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 10px 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--app-separator);
|
||||
background: var(--mat-sys-surface-container);
|
||||
|
||||
code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.detail-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
.channel-dialog__copy-btn {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
--mdc-icon-button-state-layer-size: 32px;
|
||||
--mdc-icon-button-icon-size: 18px;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
}
|
||||
|
||||
// ─── Detail panels ──────────────────────────────────────────────────────────
|
||||
.channel-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
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;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.channel-panel__header {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.channel-panel__header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--app-heading-color, var(--mat-sys-on-surface));
|
||||
}
|
||||
|
||||
.channel-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;
|
||||
}
|
||||
|
||||
// ─── Detail rows ────────────────────────────────────────────────────────────
|
||||
.channel-detail-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.channel-detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(100px, 130px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--app-separator);
|
||||
}
|
||||
|
||||
.channel-detail-row:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.channel-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;
|
||||
}
|
||||
|
||||
.channel-detail-row dd {
|
||||
margin: 0;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface));
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.channel-detail-row__value--mono {
|
||||
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.channel-detail-row__value--empty {
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// ─── Chip sections (sub-group headers) ──────────────────────────────────────
|
||||
.channel-chip-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.channel-chip-section__label {
|
||||
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;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--app-separator);
|
||||
}
|
||||
|
||||
// ─── Footer actions ─────────────────────────────────────────────────────────
|
||||
.channel-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));
|
||||
}
|
||||
|
||||
.channel-dialog__close {
|
||||
min-width: 100px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
// ─── Responsive breakpoints ─────────────────────────────────────────────────
|
||||
@media (max-width: 720px) {
|
||||
.channel-dialog__hero,
|
||||
.channel-dialog__content,
|
||||
.channel-dialog__actions {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.channel-dialog__headline-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.channel-dialog__status-cluster {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.channel-dialog__hero-stats,
|
||||
.channel-dialog__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.channel-detail-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.channel-dialog__hero-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.channel-dialog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
+2
-12
@@ -62,23 +62,13 @@ describe('ChannelDetailsDialogComponent', () => {
|
||||
expect(component.archiveDays).toBe(5);
|
||||
expect(component.catchupAvailable).toBe(true);
|
||||
expect(component.catchupPlaybackSupported).toBe(true);
|
||||
expect(component.summaryFields).toEqual(
|
||||
expect(component.heroStats).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
labelKey: 'CHANNELS.DETAILS_DIALOG.CATCHUP_AVAILABLE',
|
||||
valueKey: 'YES',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
labelKey: 'CHANNELS.DETAILS_DIALOG.WINDOW',
|
||||
translateParams: {
|
||||
count: 5,
|
||||
},
|
||||
translateParams: { count: 5 },
|
||||
valueKey: 'CHANNELS.DETAILS_DIALOG.DAYS_OTHER',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
labelKey: 'CHANNELS.DETAILS_DIALOG.PLAYBACK_SUPPORTED',
|
||||
valueKey: 'YES',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
+89
-122
@@ -1,6 +1,8 @@
|
||||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { getM3uArchiveDays, isM3uCatchupPlaybackSupported } from 'm3u-utils';
|
||||
import { Channel } from 'shared-interfaces';
|
||||
@@ -12,7 +14,16 @@ interface ChannelDetailField {
|
||||
readonly translateParams?: Record<string, number>;
|
||||
readonly value?: string;
|
||||
readonly valueKey?: string;
|
||||
readonly wrap?: boolean;
|
||||
}
|
||||
|
||||
interface HeroStat {
|
||||
readonly empty?: boolean;
|
||||
readonly icon: string;
|
||||
readonly labelKey: string;
|
||||
readonly mono?: boolean;
|
||||
readonly translateParams?: Record<string, number>;
|
||||
readonly value?: string;
|
||||
readonly valueKey?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -20,7 +31,7 @@ interface ChannelDetailField {
|
||||
templateUrl: './channel-details-dialog.component.html',
|
||||
styleUrls: ['./channel-details-dialog.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatButtonModule, MatDialogModule, TranslatePipe],
|
||||
imports: [ClipboardModule, MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe],
|
||||
})
|
||||
export class ChannelDetailsDialogComponent {
|
||||
readonly channel = inject<Channel>(MAT_DIALOG_DATA);
|
||||
@@ -31,151 +42,121 @@ export class ChannelDetailsDialogComponent {
|
||||
this.channel
|
||||
);
|
||||
|
||||
readonly summaryFields: ChannelDetailField[] = [
|
||||
this.createTextField(
|
||||
'CHANNELS.DETAILS_DIALOG.NAME',
|
||||
this.channel.name
|
||||
),
|
||||
this.createTextField(
|
||||
'CHANNELS.DETAILS_DIALOG.CHANNEL_ID',
|
||||
this.channel.id,
|
||||
{
|
||||
monospace: true,
|
||||
}
|
||||
),
|
||||
this.createTextField(
|
||||
'CHANNELS.DETAILS_DIALOG.STREAM_URL',
|
||||
this.channel.url,
|
||||
{
|
||||
monospace: true,
|
||||
wrap: true,
|
||||
}
|
||||
),
|
||||
this.createTextField(
|
||||
'CHANNELS.DETAILS_DIALOG.GROUP',
|
||||
this.channel.group?.title
|
||||
),
|
||||
this.createBooleanField(
|
||||
'CHANNELS.DETAILS_DIALOG.RADIO',
|
||||
this.channel.radio === 'true'
|
||||
),
|
||||
this.createBooleanField(
|
||||
'CHANNELS.DETAILS_DIALOG.CATCHUP_AVAILABLE',
|
||||
this.catchupAvailable
|
||||
),
|
||||
this.createArchiveWindowField(),
|
||||
this.createBooleanField(
|
||||
'CHANNELS.DETAILS_DIALOG.PLAYBACK_SUPPORTED',
|
||||
this.catchupPlaybackSupported
|
||||
),
|
||||
this.createTextField(
|
||||
logoError = false;
|
||||
|
||||
readonly hasTvgData = !!(
|
||||
this.channel.tvg?.id ||
|
||||
this.channel.tvg?.name ||
|
||||
this.channel.tvg?.url ||
|
||||
this.channel.tvg?.rec
|
||||
);
|
||||
|
||||
readonly hasHttpData = !!(
|
||||
this.channel.http?.origin ||
|
||||
this.channel.http?.referrer ||
|
||||
this.channel.http?.['user-agent']
|
||||
);
|
||||
|
||||
readonly heroStats: HeroStat[] = [
|
||||
{
|
||||
icon: 'tag',
|
||||
labelKey: 'CHANNELS.DETAILS_DIALOG.CHANNEL_ID',
|
||||
value: this.channel.id?.trim() || undefined,
|
||||
valueKey: this.channel.id?.trim()
|
||||
? undefined
|
||||
: 'CHANNELS.DETAILS_DIALOG.EMPTY',
|
||||
mono: true,
|
||||
empty: !this.channel.id?.trim(),
|
||||
},
|
||||
{
|
||||
icon: 'folder',
|
||||
labelKey: 'CHANNELS.DETAILS_DIALOG.GROUP',
|
||||
value: this.channel.group?.title?.trim() || undefined,
|
||||
valueKey: this.channel.group?.title?.trim()
|
||||
? undefined
|
||||
: 'CHANNELS.DETAILS_DIALOG.EMPTY',
|
||||
empty: !this.channel.group?.title?.trim(),
|
||||
},
|
||||
this.createArchiveHeroStat(),
|
||||
{
|
||||
icon: 'schedule',
|
||||
labelKey: 'CHANNELS.DETAILS_DIALOG.TIMESHIFT',
|
||||
value: this.channel.timeshift?.trim() || undefined,
|
||||
valueKey: this.channel.timeshift?.trim()
|
||||
? undefined
|
||||
: 'CHANNELS.DETAILS_DIALOG.NOT_AVAILABLE',
|
||||
empty: !this.channel.timeshift?.trim(),
|
||||
},
|
||||
];
|
||||
|
||||
readonly streamFields: ChannelDetailField[] = [
|
||||
this.field(
|
||||
'CHANNELS.DETAILS_DIALOG.EPG_PARAMS',
|
||||
this.channel.epgParams,
|
||||
{
|
||||
monospace: true,
|
||||
wrap: true,
|
||||
}
|
||||
true
|
||||
),
|
||||
this.createTextField(
|
||||
'CHANNELS.DETAILS_DIALOG.TIMESHIFT',
|
||||
this.channel.timeshift
|
||||
this.field(
|
||||
'CHANNELS.DETAILS_DIALOG.TVG_LOGO',
|
||||
this.channel.tvg?.logo,
|
||||
true
|
||||
),
|
||||
];
|
||||
|
||||
readonly tvgFields: ChannelDetailField[] = [
|
||||
this.createTextField(
|
||||
'CHANNELS.DETAILS_DIALOG.TVG_ID',
|
||||
this.channel.tvg?.id,
|
||||
{
|
||||
monospace: true,
|
||||
}
|
||||
),
|
||||
this.createTextField(
|
||||
'CHANNELS.DETAILS_DIALOG.TVG_NAME',
|
||||
this.channel.tvg?.name
|
||||
),
|
||||
this.createTextField(
|
||||
this.field('CHANNELS.DETAILS_DIALOG.TVG_ID', this.channel.tvg?.id, true),
|
||||
this.field('CHANNELS.DETAILS_DIALOG.TVG_NAME', this.channel.tvg?.name),
|
||||
this.field(
|
||||
'CHANNELS.DETAILS_DIALOG.TVG_URL',
|
||||
this.channel.tvg?.url,
|
||||
{
|
||||
monospace: true,
|
||||
wrap: true,
|
||||
}
|
||||
),
|
||||
this.createTextField(
|
||||
'CHANNELS.DETAILS_DIALOG.TVG_LOGO',
|
||||
this.channel.tvg?.logo,
|
||||
{
|
||||
monospace: true,
|
||||
wrap: true,
|
||||
}
|
||||
),
|
||||
this.createTextField(
|
||||
'CHANNELS.DETAILS_DIALOG.TVG_REC',
|
||||
this.channel.tvg?.rec
|
||||
true
|
||||
),
|
||||
this.field('CHANNELS.DETAILS_DIALOG.TVG_REC', this.channel.tvg?.rec),
|
||||
];
|
||||
|
||||
readonly catchupFields: ChannelDetailField[] = [
|
||||
this.createTextField(
|
||||
'CHANNELS.DETAILS_DIALOG.CATCHUP_TYPE',
|
||||
this.channel.catchup?.type
|
||||
),
|
||||
this.createTextField(
|
||||
this.field(
|
||||
'CHANNELS.DETAILS_DIALOG.CATCHUP_SOURCE',
|
||||
this.channel.catchup?.source,
|
||||
{
|
||||
monospace: true,
|
||||
wrap: true,
|
||||
}
|
||||
true
|
||||
),
|
||||
this.createTextField(
|
||||
this.field(
|
||||
'CHANNELS.DETAILS_DIALOG.CATCHUP_DAYS',
|
||||
this.channel.catchup?.days
|
||||
),
|
||||
];
|
||||
|
||||
readonly httpFields: ChannelDetailField[] = [
|
||||
this.createTextField(
|
||||
this.field(
|
||||
'CHANNELS.DETAILS_DIALOG.HTTP_ORIGIN',
|
||||
this.channel.http?.origin,
|
||||
{
|
||||
monospace: true,
|
||||
wrap: true,
|
||||
}
|
||||
true
|
||||
),
|
||||
this.createTextField(
|
||||
this.field(
|
||||
'CHANNELS.DETAILS_DIALOG.HTTP_REFERRER',
|
||||
this.channel.http?.referrer,
|
||||
{
|
||||
monospace: true,
|
||||
wrap: true,
|
||||
}
|
||||
true
|
||||
),
|
||||
this.createTextField(
|
||||
this.field(
|
||||
'CHANNELS.DETAILS_DIALOG.HTTP_USER_AGENT',
|
||||
this.channel.http?.['user-agent'],
|
||||
{
|
||||
monospace: true,
|
||||
wrap: true,
|
||||
}
|
||||
true
|
||||
),
|
||||
];
|
||||
|
||||
private createArchiveWindowField(): ChannelDetailField {
|
||||
private createArchiveHeroStat(): HeroStat {
|
||||
if (!this.catchupAvailable) {
|
||||
return {
|
||||
empty: true,
|
||||
icon: 'history',
|
||||
labelKey: 'CHANNELS.DETAILS_DIALOG.WINDOW',
|
||||
valueKey: 'CHANNELS.DETAILS_DIALOG.NOT_AVAILABLE',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
icon: 'history',
|
||||
labelKey: 'CHANNELS.DETAILS_DIALOG.WINDOW',
|
||||
translateParams: {
|
||||
count: this.archiveDays,
|
||||
},
|
||||
translateParams: { count: this.archiveDays },
|
||||
valueKey:
|
||||
this.archiveDays === 1
|
||||
? 'CHANNELS.DETAILS_DIALOG.DAYS_ONE'
|
||||
@@ -183,36 +164,22 @@ export class ChannelDetailsDialogComponent {
|
||||
};
|
||||
}
|
||||
|
||||
private createBooleanField(
|
||||
labelKey: string,
|
||||
value: boolean
|
||||
): ChannelDetailField {
|
||||
return {
|
||||
labelKey,
|
||||
valueKey: value ? 'YES' : 'NO',
|
||||
};
|
||||
}
|
||||
|
||||
private createTextField(
|
||||
private field(
|
||||
labelKey: string,
|
||||
value: string | null | undefined,
|
||||
options: Pick<ChannelDetailField, 'monospace' | 'wrap'> = {}
|
||||
monospace = false
|
||||
): ChannelDetailField {
|
||||
const normalized = value?.trim() ?? '';
|
||||
|
||||
if (!normalized) {
|
||||
return {
|
||||
...options,
|
||||
empty: true,
|
||||
labelKey,
|
||||
monospace,
|
||||
valueKey: 'CHANNELS.DETAILS_DIALOG.EMPTY',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...options,
|
||||
labelKey,
|
||||
value: normalized,
|
||||
};
|
||||
return { labelKey, monospace, value: normalized };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user