From 87bf0f617898be29f7ab9feda4b93de071ec69f1 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 2 Jan 2026 01:02:20 +0100 Subject: [PATCH] feat(ui): add EPG import progress panel component Introduce a standalone EpgProgressPanelComponent with template and bindings to show active, completed and queued EPG imports. The new component: - provides a header with title and optional "dismiss all" button when multiple imports exist. - lists active/completed imports with status icon, URL display, dismiss button, and conditional progress bar for loading state. - shows stats (channels/programs) or error text when available. - renders a queued section with positions and dismiss controls. - wires to EpgProgressService observables (imports, visibility and counts) and implements helper getters (activeImports). This adds UI for import progress feedback and controls so users can monitor and manage EPG import operations. Files added: - epg-progress-panel.component.ts - epg-progress-panel.component.html --- .../epg-progress-panel.component.html | 95 ++++ .../epg-progress-panel.component.scss | 512 ++++++++++++++++++ .../epg-progress-panel.component.ts | 78 +++ 3 files changed, 685 insertions(+) create mode 100644 apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.html create mode 100644 apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.scss create mode 100644 apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.ts diff --git a/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.html b/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.html new file mode 100644 index 000000000..888106b0d --- /dev/null +++ b/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.html @@ -0,0 +1,95 @@ +@if (isVisible()) { +
+
+ + live_tv + {{ 'EPG.IMPORT_PROGRESS' | translate }} + + @if (imports().length > 1) { + + } +
+ +
+ + @for (item of activeImports; track item.url) { +
+
+
+ + {{ getStatusIcon(item.status) }} + +
+ + {{ getDisplayUrl(item.url) }} + + +
+ + @if (item.status === 'loading') { + + } + +
+ @if (item.stats) { + + tv + {{ item.stats.totalChannels }} + + + event + {{ item.stats.totalPrograms }} + + } @else if (item.error) { + {{ item.error }} + } @else if (item.status === 'loading') { + {{ 'EPG.LOADING' | translate }}... + } +
+
+ } + + + @if (queuedImports.length > 0) { +
+
+ queue + {{ 'EPG.QUEUED' | translate }} ({{ queuedImports.length }}) +
+
+ @for (item of queuedImports; track item.url; let i = $index) { +
+ {{ i + 1 }} + + {{ getDisplayUrl(item.url) }} + + +
+ } +
+
+ } +
+
+} diff --git a/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.scss b/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.scss new file mode 100644 index 000000000..ac6f8b72d --- /dev/null +++ b/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.scss @@ -0,0 +1,512 @@ +// EPG Progress Panel - Broadcast Control Room Aesthetic +// Sleek, modern design with glassmorphism and subtle glow effects + +// CSS Custom Properties for theming +:host { + --epg-panel-bg: rgba(18, 22, 28, 0.92); + --epg-panel-bg-solid: #12161c; + --epg-panel-border: rgba(56, 189, 248, 0.15); + --epg-panel-border-glow: rgba(56, 189, 248, 0.25); + --epg-header-bg: rgba(56, 189, 248, 0.08); + --epg-header-border: rgba(56, 189, 248, 0.12); + --epg-text-primary: rgba(255, 255, 255, 0.95); + --epg-text-secondary: rgba(255, 255, 255, 0.6); + --epg-text-tertiary: rgba(255, 255, 255, 0.4); + --epg-accent: #38bdf8; + --epg-accent-glow: rgba(56, 189, 248, 0.4); + --epg-success: #22c55e; + --epg-success-glow: rgba(34, 197, 94, 0.3); + --epg-error: #ef4444; + --epg-error-glow: rgba(239, 68, 68, 0.3); + --epg-queued: #a78bfa; + --epg-queued-glow: rgba(167, 139, 250, 0.3); + --epg-item-border: rgba(255, 255, 255, 0.06); + --epg-item-hover: rgba(56, 189, 248, 0.05); + --epg-progress-track: rgba(255, 255, 255, 0.08); + --epg-scrollbar-thumb: rgba(56, 189, 248, 0.3); + --epg-scrollbar-track: rgba(255, 255, 255, 0.05); + --epg-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); + --epg-icon-bg: rgba(56, 189, 248, 0.12); + --epg-queue-bg: rgba(167, 139, 250, 0.06); + --epg-queue-border: rgba(167, 139, 250, 0.15); +} + +// Light theme overrides +:host-context(.light-theme), +:host-context([data-theme='light']), +:host-context(.mat-app-background.light) { + --epg-panel-bg: rgba(255, 255, 255, 0.95); + --epg-panel-bg-solid: #ffffff; + --epg-panel-border: rgba(15, 118, 110, 0.2); + --epg-panel-border-glow: rgba(15, 118, 110, 0.15); + --epg-header-bg: rgba(15, 118, 110, 0.06); + --epg-header-border: rgba(15, 118, 110, 0.1); + --epg-text-primary: rgba(15, 23, 42, 0.95); + --epg-text-secondary: rgba(15, 23, 42, 0.6); + --epg-text-tertiary: rgba(15, 23, 42, 0.4); + --epg-accent: #0f766e; + --epg-accent-glow: rgba(15, 118, 110, 0.25); + --epg-success: #16a34a; + --epg-success-glow: rgba(22, 163, 74, 0.2); + --epg-error: #dc2626; + --epg-error-glow: rgba(220, 38, 38, 0.2); + --epg-queued: #7c3aed; + --epg-queued-glow: rgba(124, 58, 237, 0.2); + --epg-item-border: rgba(15, 23, 42, 0.08); + --epg-item-hover: rgba(15, 118, 110, 0.04); + --epg-progress-track: rgba(15, 23, 42, 0.08); + --epg-scrollbar-thumb: rgba(15, 118, 110, 0.3); + --epg-scrollbar-track: rgba(15, 23, 42, 0.05); + --epg-shadow: + 0 8px 32px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(15, 23, 42, 0.08); + --epg-icon-bg: rgba(15, 118, 110, 0.1); + --epg-queue-bg: rgba(124, 58, 237, 0.05); + --epg-queue-border: rgba(124, 58, 237, 0.12); +} + +.epg-progress-panel { + position: fixed; + bottom: 20px; + left: 20px; + width: 340px; + max-width: calc(100vw - 40px); + background: var(--epg-panel-bg); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-radius: 16px; + box-shadow: var(--epg-shadow); + z-index: 1000; + overflow: hidden; + border: 1px solid var(--epg-panel-border); + + // Subtle glow effect on top edge + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient( + 90deg, + transparent 0%, + var(--epg-panel-border-glow) 20%, + var(--epg-accent-glow) 50%, + var(--epg-panel-border-glow) 80%, + transparent 100% + ); + } + + // Entry animation + animation: slideInFromLeft 0.35s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes slideInFromLeft { + from { + opacity: 0; + transform: translateX(-24px) scale(0.96); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + background: var(--epg-header-bg); + border-bottom: 1px solid var(--epg-header-border); + position: relative; + + .header-title { + display: flex; + align-items: center; + gap: 10px; + font-weight: 600; + font-size: 13px; + letter-spacing: 0.02em; + color: var(--epg-text-primary); + text-transform: uppercase; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: var(--epg-accent); + filter: drop-shadow(0 0 6px var(--epg-accent-glow)); + animation: pulse-glow 2s ease-in-out infinite; + } + } + + .dismiss-all-btn { + width: 28px; + height: 28px; + line-height: 28px; + border-radius: 8px; + transition: all 0.2s ease; + color: var(--epg-text-tertiary); + + &:hover { + background: var(--epg-item-hover); + color: var(--epg-text-primary); + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } +} + +@keyframes pulse-glow { + 0%, + 100% { + filter: drop-shadow(0 0 4px var(--epg-accent-glow)); + } + 50% { + filter: drop-shadow(0 0 10px var(--epg-accent-glow)); + } +} + +.imports-list { + max-height: 360px; + overflow-y: auto; + padding: 4px 0; + + // Custom scrollbar + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: var(--epg-scrollbar-track); + border-radius: 3px; + margin: 4px 0; + } + + &::-webkit-scrollbar-thumb { + background: var(--epg-scrollbar-thumb); + border-radius: 3px; + + &:hover { + background: var(--epg-accent); + } + } +} + +.import-item { + padding: 14px 16px; + margin: 0 8px; + border-radius: 10px; + transition: all 0.2s ease; + position: relative; + + &:not(:last-child) { + margin-bottom: 2px; + } + + &:hover { + background: var(--epg-item-hover); + } + + .item-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + + .status-icon-wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + background: var(--epg-icon-bg); + flex-shrink: 0; + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + transition: all 0.3s ease; + } + + .item-url { + flex: 1; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--epg-text-secondary); + font-family: 'SF Mono', 'Fira Code', 'Monaco', monospace; + letter-spacing: -0.02em; + } + + .dismiss-btn { + width: 26px; + height: 26px; + line-height: 26px; + border-radius: 6px; + opacity: 0; + transition: all 0.2s ease; + color: var(--epg-text-tertiary); + + &:hover { + background: var(--epg-error-glow); + color: var(--epg-error); + } + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + } + } + + &:hover .dismiss-btn { + opacity: 1; + } + + mat-progress-bar { + margin-bottom: 10px; + border-radius: 2px; + height: 4px; + + ::ng-deep { + .mdc-linear-progress__buffer { + background-color: var(--epg-progress-track); + } + + .mdc-linear-progress__bar-inner { + border-color: var(--epg-accent); + } + + .mdc-linear-progress__buffer-bar { + background-color: var(--epg-progress-track); + } + } + } + + .item-stats { + font-size: 11px; + color: var(--epg-text-tertiary); + display: flex; + align-items: center; + gap: 8px; + + .stat-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + background: var(--epg-icon-bg); + border-radius: 6px; + font-weight: 600; + font-variant-numeric: tabular-nums; + font-size: 12px; + color: var(--epg-text-secondary); + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + opacity: 0.7; + } + } + } + + // Status variations + &.status-loading { + .status-icon-wrapper { + background: var(--epg-icon-bg); + } + + .item-header mat-icon { + color: var(--epg-accent); + } + + .item-stats { + color: var(--epg-text-tertiary); + } + } + + &.status-complete { + .status-icon-wrapper { + background: var(--epg-success-glow); + } + + .item-header mat-icon { + color: var(--epg-success); + filter: drop-shadow(0 0 6px var(--epg-success-glow)); + } + + .item-stats { + color: var(--epg-success); + + .stat-badge { + background: var(--epg-success-glow); + color: var(--epg-success); + } + } + } + + &.status-error { + .status-icon-wrapper { + background: var(--epg-error-glow); + } + + .item-header mat-icon { + color: var(--epg-error); + filter: drop-shadow(0 0 6px var(--epg-error-glow)); + } + + .error-text { + color: var(--epg-error); + font-size: 11px; + font-weight: 500; + } + } +} + +// Queue section styles +.queue-section { + margin: 8px; + padding: 12px; + border-radius: 10px; + animation: fadeIn 0.3s ease; + + .queue-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid var(--epg-queue-border); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--epg-queued); + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + } + + .queue-list { + display: flex; + flex-direction: column; + gap: 6px; + } + + .queue-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: rgba(167, 139, 250, 0.04); + border-radius: 6px; + transition: all 0.2s ease; + + &:hover { + background: rgba(167, 139, 250, 0.08); + + .dismiss-btn { + opacity: 1; + } + } + + .queue-position { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 6px; + background: var(--epg-queued-glow); + color: var(--epg-queued); + font-size: 11px; + font-weight: 700; + flex-shrink: 0; + } + + .queue-url { + flex: 1; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--epg-text-secondary); + font-family: 'SF Mono', 'Fira Code', 'Monaco', monospace; + letter-spacing: -0.02em; + } + + .dismiss-btn { + width: 22px; + height: 22px; + line-height: 22px; + border-radius: 4px; + opacity: 0; + transition: all 0.2s ease; + color: var(--epg-text-tertiary); + flex-shrink: 0; + + &:hover { + background: var(--epg-error-glow); + color: var(--epg-error); + } + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.spinning { + animation: spin 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +// Responsive adjustments +@media (max-width: 400px) { + .epg-progress-panel { + left: 12px; + right: 12px; + bottom: 12px; + width: auto; + max-width: none; + } +} diff --git a/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.ts b/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.ts new file mode 100644 index 000000000..8775a29d2 --- /dev/null +++ b/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.ts @@ -0,0 +1,78 @@ +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { TranslateModule } from '@ngx-translate/core'; +import { + EpgProgressService, + EpgImportProgress, +} from '../../services/epg-progress.service'; + +@Component({ + selector: 'app-epg-progress-panel', + standalone: true, + imports: [ + CommonModule, + MatProgressBarModule, + MatIconModule, + MatButtonModule, + TranslateModule, + ], + templateUrl: './epg-progress-panel.component.html', + styleUrl: './epg-progress-panel.component.scss', +}) +export class EpgProgressPanelComponent { + private epgProgress = inject(EpgProgressService); + + readonly imports = this.epgProgress.imports; + readonly isVisible = this.epgProgress.isVisible; + readonly queuedCount = this.epgProgress.queuedCount; + readonly activeCount = this.epgProgress.activeCount; + + /** Active and completed imports (not queued) */ + get activeImports() { + return this.imports().filter((i) => i.status !== 'queued'); + } + + /** Queued imports waiting to be processed */ + get queuedImports() { + return this.imports() + .filter((i) => i.status === 'queued') + .sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)); + } + + getStatusIcon(status: EpgImportProgress['status']): string { + switch (status) { + case 'queued': + return 'schedule'; + case 'loading': + return 'sync'; + case 'complete': + return 'check_circle'; + case 'error': + return 'error'; + } + } + + getStatusClass(status: EpgImportProgress['status']): string { + return `status-${status}`; + } + + getDisplayUrl(url: string): string { + try { + const urlObj = new URL(url); + return urlObj.hostname + urlObj.pathname.split('/').pop(); + } catch { + return url.length > 40 ? url.substring(0, 40) + '...' : url; + } + } + + dismiss(url: string): void { + this.epgProgress.dismiss(url); + } + + dismissAll(): void { + this.epgProgress.dismissAll(); + } +}