mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
refactor(dashboard): improvements for dashboard widgets
- Updated dashboard activity items styles for improved layout and media representation. - Introduced new styles for movie and series media types. - Enhanced placeholder styles for live, movie, and series items using oklch color model. - Added hover effects for activity items and action buttons in grid mode. - Refactored dashboard widget shell styles for better alignment and padding. - Created new on-air widget component with category filtering and loading skeletons. - Implemented responsive design for on-air widget and improved empty state handling. - Removed deprecated recent sources widget and integrated recent sources functionality into existing components. - Updated recently watched widget to include action links for adding sources. - Enhanced source stats widget with new styling for different source types (Xtream, Stalker, M3U).
This commit is contained in:
1 parent
a8fd16b16d
commit
268a710bf2
27 files changed
+1487
-352
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { Injectable, NgZone, computed, inject, signal } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
PlaylistActions,
|
||||
@@ -58,6 +58,7 @@ interface DashboardNavigationTarget {
|
||||
export class DashboardDataService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
|
||||
private readonly xtreamGlobalRecentItems = signal<GlobalRecentItem[]>([]);
|
||||
private readonly xtreamGlobalFavorites = signal<DashboardFavoriteItem[]>(
|
||||
@@ -133,9 +134,13 @@ export class DashboardDataService {
|
||||
const normalized = recentItems.map((item) =>
|
||||
this.mapGlobalRecent(item)
|
||||
);
|
||||
this.xtreamGlobalRecentItems.set(normalized);
|
||||
} catch {
|
||||
this.xtreamGlobalRecentItems.set([]);
|
||||
this.ngZone.run(() => this.xtreamGlobalRecentItems.set(normalized));
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[DashboardData] Failed to reload global recent items',
|
||||
err
|
||||
);
|
||||
this.ngZone.run(() => this.xtreamGlobalRecentItems.set([]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,9 +155,13 @@ export class DashboardDataService {
|
||||
const normalized = favorites.map((item) =>
|
||||
this.mapGlobalFavorite(item)
|
||||
);
|
||||
this.xtreamGlobalFavorites.set(normalized);
|
||||
} catch {
|
||||
this.xtreamGlobalFavorites.set([]);
|
||||
this.ngZone.run(() => this.xtreamGlobalFavorites.set(normalized));
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[DashboardData] Failed to reload global favorites',
|
||||
err
|
||||
);
|
||||
this.ngZone.run(() => this.xtreamGlobalFavorites.set([]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export type DashboardWidgetType =
|
||||
| 'recent-sources'
|
||||
| 'source-stats'
|
||||
| 'continue-watching'
|
||||
| 'on-air'
|
||||
| 'recently-watched'
|
||||
| 'global-favorites';
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface DashboardLayoutState {
|
||||
widgets: DashboardWidgetConfig[];
|
||||
}
|
||||
|
||||
export const DASHBOARD_LAYOUT_VERSION = 6;
|
||||
export const DASHBOARD_LAYOUT_VERSION = 12;
|
||||
export const DASHBOARD_WIDGET_SIZE_OPTIONS: DashboardWidgetSize[] = [
|
||||
'one-third',
|
||||
'half',
|
||||
@@ -58,22 +58,13 @@ export function createDefaultWidgetScope(): DashboardWidgetScopeSettings {
|
||||
}
|
||||
|
||||
export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
|
||||
{
|
||||
id: 'recent-sources',
|
||||
type: 'recent-sources',
|
||||
title: 'Recent Sources',
|
||||
description: 'Latest added or refreshed playlists across providers.',
|
||||
size: 'half',
|
||||
enabled: true,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: 'source-stats',
|
||||
type: 'source-stats',
|
||||
title: 'Source Statistics',
|
||||
description: 'Current source mix and total connected libraries.',
|
||||
size: 'one-third',
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
@@ -81,10 +72,19 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
|
||||
type: 'continue-watching',
|
||||
title: 'Continue Watching',
|
||||
description: 'Jump back into the last active source.',
|
||||
size: 'half',
|
||||
size: 'full',
|
||||
enabled: true,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 'on-air',
|
||||
type: 'on-air',
|
||||
title: 'On Air Now',
|
||||
description: 'Live programs currently airing across your EPG channels.',
|
||||
size: 'full',
|
||||
enabled: true,
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
id: 'recently-watched',
|
||||
type: 'recently-watched',
|
||||
@@ -92,7 +92,7 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
|
||||
description: 'Global watch history across channels, VOD and series.',
|
||||
size: 'two-thirds',
|
||||
enabled: true,
|
||||
order: 3,
|
||||
order: 4,
|
||||
settings: {
|
||||
scope: createDefaultWidgetScope(),
|
||||
},
|
||||
@@ -104,7 +104,7 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
|
||||
description: 'Pinned items across all connected providers.',
|
||||
size: 'one-third',
|
||||
enabled: true,
|
||||
order: 4,
|
||||
order: 5,
|
||||
settings: {
|
||||
scope: createDefaultWidgetScope(),
|
||||
},
|
||||
|
||||
@@ -1,43 +1,45 @@
|
||||
<section class="dashboard-page">
|
||||
<header class="dashboard-header">
|
||||
<div>
|
||||
<p class="eyebrow">Workspace Dashboard</p>
|
||||
<h1>Control Center</h1>
|
||||
<p class="description">
|
||||
Keep your daily sources in one place. In customize mode, edit
|
||||
directly on the canvas, then use the sidebar for visibility and
|
||||
scope.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section
|
||||
class="dashboard-toolbar"
|
||||
[class.dashboard-toolbar--editing]="editMode()"
|
||||
class="dashboard-topbar"
|
||||
[class.dashboard-topbar--editing]="editMode()"
|
||||
>
|
||||
<p class="toolbar-hint">
|
||||
@if (editMode()) {
|
||||
Drag cards by the handle. Resize inline. Use the sidebar for
|
||||
visibility and scope.
|
||||
} @else {
|
||||
Start customize mode to rearrange and resize widgets in place.
|
||||
}
|
||||
</p>
|
||||
<div class="toolbar-actions">
|
||||
@if (editMode()) {
|
||||
@if (editMode()) {
|
||||
<p class="topbar-edit-hint">
|
||||
{{ 'WORKSPACE.DASHBOARD.TOOLBAR_HINT_EDITING' | translate }}
|
||||
</p>
|
||||
<div class="topbar-edit-actions">
|
||||
<button
|
||||
type="button"
|
||||
mat-button
|
||||
(click)="layoutService.reset()"
|
||||
>
|
||||
Reset Layout
|
||||
{{ 'WORKSPACE.DASHBOARD.TOOLBAR_RESET' | translate }}
|
||||
</button>
|
||||
}
|
||||
<button type="button" mat-flat-button (click)="toggleEditMode()">
|
||||
<mat-icon>{{ editMode() ? 'check' : 'tune' }}</mat-icon>
|
||||
{{ editMode() ? 'Done' : 'Customize' }}
|
||||
<button
|
||||
type="button"
|
||||
mat-stroked-button
|
||||
(click)="toggleEditMode()"
|
||||
>
|
||||
<mat-icon>check</mat-icon>
|
||||
{{ 'WORKSPACE.DASHBOARD.TOOLBAR_DONE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="topbar-label">
|
||||
{{ 'WORKSPACE.DASHBOARD.TOPBAR_LABEL' | translate }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
(click)="toggleEditMode()"
|
||||
[matTooltip]="
|
||||
'WORKSPACE.DASHBOARD.TOOLBAR_CUSTOMIZE' | translate
|
||||
"
|
||||
aria-label="Customize dashboard layout"
|
||||
>
|
||||
<mat-icon>tune</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -80,26 +82,31 @@
|
||||
>
|
||||
<mat-icon>drag_indicator</mat-icon>
|
||||
</button>
|
||||
<p class="widget-inline-editor__title">
|
||||
{{ widget.title }}
|
||||
</p>
|
||||
<mat-button-toggle-group
|
||||
class="inline-size-control"
|
||||
[value]="widget.size"
|
||||
(change)="
|
||||
setWidgetSize(widget.id, $event.value)
|
||||
"
|
||||
aria-label="Widget width"
|
||||
>
|
||||
@for (
|
||||
size of sizeOptions;
|
||||
track size.value
|
||||
) {
|
||||
<mat-button-toggle [value]="size.value">
|
||||
{{ size.label }}
|
||||
</mat-button-toggle>
|
||||
}
|
||||
</mat-button-toggle-group>
|
||||
<div class="size-stepper">
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="size-stepper__btn"
|
||||
[disabled]="isFirstSize(widget.size)"
|
||||
(click)="cycleWidgetSize(widget.id, -1)"
|
||||
aria-label="Make widget narrower"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<span class="size-stepper__label">{{
|
||||
getSizeLabel(widget.size)
|
||||
}}</span>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="size-stepper__btn"
|
||||
[disabled]="isLastSize(widget.size)"
|
||||
(click)="cycleWidgetSize(widget.id, 1)"
|
||||
aria-label="Make widget wider"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
@@ -115,8 +122,17 @@
|
||||
</article>
|
||||
} @empty {
|
||||
<article class="empty-state">
|
||||
<h3>No visible widgets</h3>
|
||||
<p>Enable at least one widget from the sidebar.</p>
|
||||
<h3>
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.EMPTY_WIDGETS' | translate
|
||||
}}
|
||||
</h3>
|
||||
<p>
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.EMPTY_WIDGETS_HINT'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
@@ -125,10 +141,17 @@
|
||||
@if (editMode()) {
|
||||
<aside class="widget-library">
|
||||
<section class="layout-editor">
|
||||
<h2>Widget Library</h2>
|
||||
<h2>
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.WIDGET_LIBRARY_TITLE'
|
||||
| translate
|
||||
}}
|
||||
</h2>
|
||||
<p class="layout-editor__hint">
|
||||
Show/hide widgets and configure provider scope without
|
||||
leaving the canvas.
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.WIDGET_LIBRARY_HINT'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<div class="editor-list">
|
||||
@for (widget of allWidgets(); track widget.id) {
|
||||
@@ -139,7 +162,10 @@
|
||||
</div>
|
||||
<div class="editor-item__actions">
|
||||
<p class="editor-item__action-label">
|
||||
Visibility
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.WIDGET_VISIBILITY'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<mat-slide-toggle
|
||||
[checked]="widget.enabled"
|
||||
@@ -148,19 +174,16 @@
|
||||
widget.id
|
||||
)
|
||||
"
|
||||
color="primary"
|
||||
>
|
||||
Visible
|
||||
</mat-slide-toggle>
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
|
||||
@if (supportsScope(widget)) {
|
||||
<mat-expansion-panel class="advanced-panel">
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title
|
||||
>Advanced
|
||||
settings</mat-panel-title
|
||||
>
|
||||
<mat-panel-title>{{
|
||||
'WORKSPACE.DASHBOARD.WIDGET_ADVANCED'
|
||||
| translate
|
||||
}}</mat-panel-title>
|
||||
<mat-panel-description>
|
||||
Scope ({{
|
||||
getScopeProviderSummary(
|
||||
@@ -171,10 +194,16 @@
|
||||
</mat-expansion-panel-header>
|
||||
<section class="scope-editor">
|
||||
<p class="scope-editor__title">
|
||||
Scope
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.WIDGET_SCOPE'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<p class="scope-editor__subtitle">
|
||||
Provider filters:
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.WIDGET_SCOPE_PROVIDERS'
|
||||
| translate
|
||||
}}
|
||||
{{
|
||||
getScopeProviderSummary(
|
||||
widget
|
||||
@@ -185,8 +214,10 @@
|
||||
<p
|
||||
class="scope-editor__subtitle"
|
||||
>
|
||||
No providers selected means
|
||||
all providers are included.
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.WIDGET_SCOPE_ALL_HINT'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
<mat-chip-listbox
|
||||
@@ -221,9 +252,10 @@
|
||||
</mat-chip-listbox>
|
||||
|
||||
<p class="scope-editor__subtitle">
|
||||
Playlists (leave empty to use
|
||||
all playlists in current
|
||||
provider scope)
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.WIDGET_SCOPE_PLAYLISTS'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
<div class="scope-playlists">
|
||||
@for (
|
||||
@@ -254,8 +286,10 @@
|
||||
</mat-checkbox>
|
||||
} @empty {
|
||||
<p class="scope-empty">
|
||||
No playlists available
|
||||
for selected providers.
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.WIDGET_SCOPE_EMPTY'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
var(--mat-sys-surface-container-high) 88%
|
||||
);
|
||||
--widget-grid-gap: 12px;
|
||||
--dashboard-widget-height: 530px;
|
||||
--dashboard-widget-height: 400px;
|
||||
}
|
||||
|
||||
.dashboard-page {
|
||||
@@ -24,84 +24,62 @@
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
border: 1px solid var(--dashboard-frame-border);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
radial-gradient(
|
||||
1100px 300px at 105% -40%,
|
||||
color-mix(in srgb, var(--dashboard-accent) 24%, transparent),
|
||||
transparent 58%
|
||||
),
|
||||
linear-gradient(
|
||||
165deg,
|
||||
var(--mat-sys-surface-container-highest) 0%,
|
||||
var(--mat-sys-surface-container-high) 55%,
|
||||
var(--mat-sys-surface-container) 100%
|
||||
);
|
||||
padding: 20px;
|
||||
box-shadow: 0 16px 30px -28px var(--dashboard-frame-glow);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.11em;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 8px 0 10px;
|
||||
font-size: clamp(1.44rem, 2vw, 1.95rem);
|
||||
line-height: 1.14;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
max-width: 780px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.dashboard-toolbar {
|
||||
border: 1px solid var(--dashboard-frame-border);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--mat-sys-surface-container-high) 0%,
|
||||
var(--mat-sys-surface-container) 100%
|
||||
);
|
||||
padding: 10px 12px;
|
||||
// ─── Dashboard Topbar ────────────────────────────────────────────────────────
|
||||
.dashboard-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
height: 44px;
|
||||
padding: 0 4px 0 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.dashboard-toolbar--editing {
|
||||
.dashboard-topbar--editing {
|
||||
position: sticky;
|
||||
top: 8px;
|
||||
z-index: 20;
|
||||
box-shadow: 0 14px 26px -24px var(--dashboard-frame-glow);
|
||||
padding: 0 8px 0 12px;
|
||||
border: 1px solid var(--dashboard-frame-border);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--mat-sys-surface-container-highest) 0%,
|
||||
var(--mat-sys-surface-container-high) 100%
|
||||
);
|
||||
box-shadow: 0 8px 24px -16px var(--dashboard-frame-glow);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.toolbar-hint {
|
||||
.topbar-label {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
.topbar-edit-hint {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar-edit-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -146,12 +124,16 @@ h1 {
|
||||
}
|
||||
|
||||
.layout-editor {
|
||||
padding: 14px;
|
||||
padding: 14px 14px 24px;
|
||||
}
|
||||
|
||||
.layout-editor h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.layout-editor__hint {
|
||||
@@ -184,7 +166,9 @@ h1 {
|
||||
|
||||
.editor-item__meta h3 {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
.editor-item__meta p {
|
||||
@@ -199,7 +183,9 @@ h1 {
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
padding-top: 4px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--dashboard-frame-border) 50%, transparent);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.editor-item__action-label {
|
||||
@@ -327,6 +313,8 @@ h1 {
|
||||
border-color 0.2s ease;
|
||||
z-index: 1;
|
||||
height: var(--dashboard-widget-height);
|
||||
// Suppress the inner mat-card outlined border — slot provides the only visible border
|
||||
--mat-card-outline-width: 0;
|
||||
}
|
||||
|
||||
.widget-slot:hover {
|
||||
@@ -336,13 +324,17 @@ h1 {
|
||||
0 18px 28px -20px var(--dashboard-frame-glow);
|
||||
}
|
||||
|
||||
.widget-slot[data-widget-type="source-stats"] {
|
||||
// Content-light widgets: collapse to their natural height instead of the fixed row height
|
||||
.widget-slot[data-widget-type='source-stats'],
|
||||
.widget-slot[data-widget-type='continue-watching'] {
|
||||
height: auto;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.widget-slot--editing {
|
||||
cursor: grab;
|
||||
// Push mat-card content below the floating inline editor bar
|
||||
padding-top: 52px;
|
||||
}
|
||||
|
||||
.widget-slot--editing:active {
|
||||
@@ -374,29 +366,60 @@ h1 {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.widget-inline-editor__title {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inline-size-control {
|
||||
// ── Size stepper ─────────────────────────────────────────────────────────────
|
||||
|
||||
.size-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
margin-left: auto;
|
||||
background: var(--mat-sys-surface-container);
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 8px;
|
||||
background: var(--mat-sys-surface-container);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.size-stepper__btn {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
border-radius: 0 !important;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.size-stepper__label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--mat-sys-on-surface);
|
||||
min-width: 26px;
|
||||
text-align: center;
|
||||
letter-spacing: 0.02em;
|
||||
border-left: 1px solid var(--mat-sys-outline-variant);
|
||||
border-right: 1px solid var(--mat-sys-outline-variant);
|
||||
padding: 0 2px;
|
||||
line-height: 32px;
|
||||
height: 32px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.widget-inline-editor__hide {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.widget-slot app-dashboard-widget-host {
|
||||
@@ -471,15 +494,10 @@ h1 {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.dashboard-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
.dashboard-topbar--editing {
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
padding: 8px 8px;
|
||||
}
|
||||
|
||||
.dashboard-editor-shell--editing {
|
||||
@@ -496,15 +514,11 @@ h1 {
|
||||
}
|
||||
|
||||
.widget-inline-editor {
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.widget-inline-editor__title {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.inline-size-control {
|
||||
margin-left: 0;
|
||||
.size-stepper__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.widget-grid {
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { computed, Component, inject, signal } from '@angular/core';
|
||||
import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import {
|
||||
CdkDragDrop,
|
||||
DragDropModule,
|
||||
moveItemInArray,
|
||||
} from '@angular/cdk/drag-drop';
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { selectAllPlaylistsMeta } from 'm3u-state';
|
||||
import { DashboardLayoutService } from 'workspace-dashboard-data-access';
|
||||
import { DashboardWidgetHostComponent } from 'workspace-dashboard-ui';
|
||||
import {
|
||||
DashboardLayoutService,
|
||||
DashboardWidgetConfig,
|
||||
DashboardWidgetProvider,
|
||||
DashboardWidgetSize,
|
||||
createDefaultWidgetScope,
|
||||
} from 'workspace-dashboard-data-access';
|
||||
import { DashboardWidgetHostComponent } from 'workspace-dashboard-ui';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-dashboard',
|
||||
@@ -24,12 +29,13 @@ import {
|
||||
DashboardWidgetHostComponent,
|
||||
DragDropModule,
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatCheckboxModule,
|
||||
MatChipsModule,
|
||||
MatExpansionModule,
|
||||
MatIcon,
|
||||
MatSlideToggleModule,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './workspace-dashboard.component.html',
|
||||
styleUrl: './workspace-dashboard.component.scss',
|
||||
@@ -44,21 +50,26 @@ export class WorkspaceDashboardComponent {
|
||||
'xtream',
|
||||
'stalker',
|
||||
];
|
||||
readonly sizeOptions: Array<{ value: DashboardWidgetSize; label: string }> = [
|
||||
{ value: 'one-third', label: '1/3' },
|
||||
{ value: 'half', label: '1/2' },
|
||||
{ value: 'two-thirds', label: '2/3' },
|
||||
{ value: 'full', label: 'Full' },
|
||||
private readonly sizeOrder: DashboardWidgetSize[] = [
|
||||
'one-third',
|
||||
'half',
|
||||
'two-thirds',
|
||||
'full',
|
||||
];
|
||||
|
||||
readonly allWidgets = computed(() =>
|
||||
[...this.layoutService.state().widgets].sort((a, b) => a.order - b.order)
|
||||
[...this.layoutService.state().widgets].sort(
|
||||
(a, b) => a.order - b.order
|
||||
)
|
||||
);
|
||||
readonly visibleWidgets = computed(() =>
|
||||
this.allWidgets().filter((widget) => widget.enabled)
|
||||
);
|
||||
|
||||
constructor(readonly layoutService: DashboardLayoutService) {}
|
||||
constructor(readonly layoutService: DashboardLayoutService) {
|
||||
// Data reload is handled reactively by DashboardDataService
|
||||
// via Router NavigationEnd events
|
||||
}
|
||||
|
||||
toggleEditMode(): void {
|
||||
this.editMode.update((value) => !value);
|
||||
@@ -70,7 +81,9 @@ export class WorkspaceDashboardComponent {
|
||||
}
|
||||
const reordered = [...this.visibleWidgets()];
|
||||
moveItemInArray(reordered, event.previousIndex, event.currentIndex);
|
||||
this.layoutService.reorderVisibleWidgets(reordered.map((widget) => widget.id));
|
||||
this.layoutService.reorderVisibleWidgets(
|
||||
reordered.map((widget) => widget.id)
|
||||
);
|
||||
}
|
||||
|
||||
setWidgetSize(widgetId: string, size: unknown): void {
|
||||
@@ -84,6 +97,32 @@ export class WorkspaceDashboardComponent {
|
||||
}
|
||||
}
|
||||
|
||||
cycleWidgetSize(widgetId: string, dir: 1 | -1): void {
|
||||
const widget = this.allWidgets().find((w) => w.id === widgetId);
|
||||
if (!widget) return;
|
||||
const idx = this.sizeOrder.indexOf(widget.size);
|
||||
const next = this.sizeOrder[idx + dir];
|
||||
if (next) this.layoutService.setWidgetSize(widgetId, next);
|
||||
}
|
||||
|
||||
getSizeLabel(size: DashboardWidgetSize): string {
|
||||
const labels: Record<DashboardWidgetSize, string> = {
|
||||
'one-third': '1/3',
|
||||
half: '1/2',
|
||||
'two-thirds': '2/3',
|
||||
full: 'Full',
|
||||
};
|
||||
return labels[size];
|
||||
}
|
||||
|
||||
isFirstSize(size: DashboardWidgetSize): boolean {
|
||||
return this.sizeOrder.indexOf(size) === 0;
|
||||
}
|
||||
|
||||
isLastSize(size: DashboardWidgetSize): boolean {
|
||||
return this.sizeOrder.indexOf(size) === this.sizeOrder.length - 1;
|
||||
}
|
||||
|
||||
hideWidget(widgetId: string): void {
|
||||
this.layoutService.toggleWidget(widgetId);
|
||||
}
|
||||
@@ -103,7 +142,8 @@ export class WorkspaceDashboardComponent {
|
||||
}
|
||||
|
||||
getScopeProviderCount(widget: DashboardWidgetConfig): number {
|
||||
return (widget.settings?.scope ?? createDefaultWidgetScope()).providers.length;
|
||||
return (widget.settings?.scope ?? createDefaultWidgetScope()).providers
|
||||
.length;
|
||||
}
|
||||
|
||||
getScopeProviderSummary(widget: DashboardWidgetConfig): string {
|
||||
@@ -120,27 +160,28 @@ export class WorkspaceDashboardComponent {
|
||||
provider: DashboardWidgetProvider
|
||||
): boolean {
|
||||
return (
|
||||
(widget.settings?.scope ?? createDefaultWidgetScope()).providers.includes(
|
||||
provider
|
||||
)
|
||||
);
|
||||
widget.settings?.scope ?? createDefaultWidgetScope()
|
||||
).providers.includes(provider);
|
||||
}
|
||||
|
||||
getScopePlaylists(widget: DashboardWidgetConfig) {
|
||||
const scope = widget.settings?.scope ?? createDefaultWidgetScope();
|
||||
const scopedProviders =
|
||||
scope.providers.length === 0 ? this.providerOptions : scope.providers;
|
||||
scope.providers.length === 0
|
||||
? this.providerOptions
|
||||
: scope.providers;
|
||||
return this.playlists().filter((playlist) =>
|
||||
scopedProviders.includes(this.getPlaylistProviderType(playlist))
|
||||
);
|
||||
}
|
||||
|
||||
isPlaylistSelected(widget: DashboardWidgetConfig, playlistId: string): boolean {
|
||||
isPlaylistSelected(
|
||||
widget: DashboardWidgetConfig,
|
||||
playlistId: string
|
||||
): boolean {
|
||||
return (
|
||||
(widget.settings?.scope ?? createDefaultWidgetScope()).playlistIds.includes(
|
||||
playlistId
|
||||
)
|
||||
);
|
||||
widget.settings?.scope ?? createDefaultWidgetScope()
|
||||
).playlistIds.includes(playlistId);
|
||||
}
|
||||
|
||||
toggleScopeProvider(
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
@switch (widget().type) {
|
||||
@case ('recent-sources') {
|
||||
<app-recent-sources-widget />
|
||||
}
|
||||
@case ('source-stats') {
|
||||
<app-source-stats-widget />
|
||||
}
|
||||
@case ('continue-watching') {
|
||||
<app-continue-watching-widget />
|
||||
}
|
||||
@case ('on-air') {
|
||||
<app-on-air-widget />
|
||||
}
|
||||
@case ('recently-watched') {
|
||||
<app-recently-watched-widget [widget]="widget()" />
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { DashboardWidgetConfig } from 'workspace-dashboard-data-access';
|
||||
import { ContinueWatchingWidgetComponent } from './widgets/continue-watching-widget.component';
|
||||
import { GlobalFavoritesWidgetComponent } from './widgets/global-favorites-widget.component';
|
||||
import { RecentSourcesWidgetComponent } from './widgets/recent-sources-widget.component';
|
||||
import { OnAirWidgetComponent } from './widgets/on-air-widget.component';
|
||||
import { RecentlyWatchedWidgetComponent } from './widgets/recently-watched-widget.component';
|
||||
import { SourceStatsWidgetComponent } from './widgets/source-stats-widget.component';
|
||||
|
||||
@@ -12,7 +12,7 @@ import { SourceStatsWidgetComponent } from './widgets/source-stats-widget.compon
|
||||
imports: [
|
||||
ContinueWatchingWidgetComponent,
|
||||
GlobalFavoritesWidgetComponent,
|
||||
RecentSourcesWidgetComponent,
|
||||
OnAirWidgetComponent,
|
||||
RecentlyWatchedWidgetComponent,
|
||||
SourceStatsWidgetComponent,
|
||||
TranslatePipe,
|
||||
|
||||
+48
-38
@@ -2,36 +2,47 @@
|
||||
[eyebrow]="'WORKSPACE.DASHBOARD.FOCUS' | translate"
|
||||
[title]="'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate"
|
||||
>
|
||||
@if (data.activePlaylist(); as active) {
|
||||
<article class="focus-source">
|
||||
<p class="focus-source__provider">
|
||||
{{ data.getPlaylistProvider(active) }}
|
||||
</p>
|
||||
<h4
|
||||
class="focus-source__title"
|
||||
[attr.title]="
|
||||
active.title ||
|
||||
active.filename ||
|
||||
('WORKSPACE.DASHBOARD.UNTITLED_SOURCE' | translate)
|
||||
"
|
||||
>
|
||||
{{
|
||||
active.title ||
|
||||
active.filename ||
|
||||
('WORKSPACE.DASHBOARD.UNTITLED_SOURCE' | translate)
|
||||
}}
|
||||
</h4>
|
||||
<div class="focus-source__actions">
|
||||
<a mat-flat-button [routerLink]="data.getPlaylistLink(active)">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'WORKSPACE.DASHBOARD.OPEN_SOURCE' | translate }}
|
||||
</a>
|
||||
<a mat-stroked-button routerLink="/workspace/sources">
|
||||
<mat-icon>library_books</mat-icon>
|
||||
{{ 'WORKSPACE.DASHBOARD.MANAGE_SOURCES' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
@if (lastItem(); as item) {
|
||||
<a class="hero" [routerLink]="getLink(item)">
|
||||
<!-- Thumbnail -->
|
||||
<span class="hero__thumb" [class]="'hero__thumb--' + item.type">
|
||||
@if (hasImage(item)) {
|
||||
<img
|
||||
[src]="item.poster_url"
|
||||
[alt]="item.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span class="hero__thumb-placeholder">
|
||||
<mat-icon>{{ typeIcon(item.type) }}</mat-icon>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
<!-- Meta -->
|
||||
<span class="hero__body">
|
||||
<span
|
||||
class="hero__type-badge hero__type-badge--{{ item.type }}"
|
||||
>
|
||||
<mat-icon>{{ typeIcon(item.type) }}</mat-icon>
|
||||
{{ typeKey(item.type) | translate }}
|
||||
</span>
|
||||
<span class="hero__title" [attr.title]="item.title">{{
|
||||
item.title
|
||||
}}</span>
|
||||
@if (item.playlist_name) {
|
||||
<span class="hero__playlist">{{ item.playlist_name }}</span>
|
||||
}
|
||||
<span class="hero__time">{{
|
||||
relativeTime(item.viewed_at)
|
||||
}}</span>
|
||||
</span>
|
||||
|
||||
<!-- Play CTA -->
|
||||
<span class="hero__play" aria-hidden="true">
|
||||
<mat-icon>play_circle</mat-icon>
|
||||
</span>
|
||||
</a>
|
||||
} @else {
|
||||
<article class="focus-empty">
|
||||
<div class="focus-empty__content">
|
||||
@@ -40,17 +51,16 @@
|
||||
>
|
||||
<div class="focus-empty__text">
|
||||
<p class="focus-empty__title">
|
||||
{{ 'WORKSPACE.DASHBOARD.NO_ACTIVE_SOURCE' | translate }}
|
||||
{{ 'WORKSPACE.DASHBOARD.NO_HISTORY_CW' | translate }}
|
||||
</p>
|
||||
<p class="focus-empty__hint">
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.NO_ACTIVE_SOURCE_HINT'
|
||||
| translate
|
||||
'WORKSPACE.DASHBOARD.NO_HISTORY_CW_HINT' | translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a mat-flat-button routerLink="/workspace/sources">
|
||||
<a mat-stroked-button routerLink="/workspace/sources">
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'WORKSPACE.DASHBOARD.ADD_SOURCE' | translate }}
|
||||
</a>
|
||||
@@ -59,7 +69,7 @@
|
||||
|
||||
@if (data.quickRecent().length > 0) {
|
||||
<div class="quick-list">
|
||||
<p class="quick-list__title">
|
||||
<p class="quick-list__label">
|
||||
{{ 'WORKSPACE.DASHBOARD.RECENTLY_USED' | translate }}
|
||||
</p>
|
||||
@for (item of data.quickRecent(); track item._id) {
|
||||
@@ -75,9 +85,9 @@
|
||||
| translate)
|
||||
}}
|
||||
</span>
|
||||
<span class="quick-item__meta">
|
||||
{{ data.getPlaylistProvider(item) }}
|
||||
</span>
|
||||
<span class="quick-item__meta">{{
|
||||
data.getPlaylistProvider(item)
|
||||
}}</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
+219
-40
@@ -1,44 +1,213 @@
|
||||
.focus-source {
|
||||
:host {
|
||||
// Deep-resume accent — vivid green
|
||||
--app-eyebrow-color: oklch(60% 0.18 155);
|
||||
--app-widget-border: color-mix(
|
||||
in srgb,
|
||||
oklch(60% 0.18 155) 22%,
|
||||
var(--mat-sys-outline-variant) 78%
|
||||
);
|
||||
|
||||
// Per-type accent vars (used by hero thumb + badge)
|
||||
--_live-accent: oklch(58% 0.21 25);
|
||||
--_movie-accent: oklch(60% 0.17 295);
|
||||
--_series-accent: oklch(68% 0.16 55);
|
||||
}
|
||||
|
||||
// ── Hero card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
background: var(--mat-sys-surface-container-low);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
min-width: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--mat-sys-surface-container);
|
||||
border-color: color-mix(in srgb, var(--mat-sys-outline) 60%, var(--app-eyebrow-color) 40%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.hero__play mat-icon {
|
||||
color: var(--mat-sys-primary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mat-sys-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Thumbnail ─────────────────────────────────────────────────────────────────
|
||||
|
||||
.hero__thumb {
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--mat-sys-surface-container-highest);
|
||||
}
|
||||
|
||||
// Live: square logo
|
||||
.hero__thumb--live {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
// Movie: portrait poster 2:3
|
||||
.hero__thumb--movie {
|
||||
width: 45px;
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// Series: widescreen 16:9
|
||||
.hero__thumb--series {
|
||||
width: 80px;
|
||||
height: 45px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.hero__thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hero__thumb--live img {
|
||||
object-fit: contain;
|
||||
padding: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero__thumb-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero__thumb--live .hero__thumb-placeholder {
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--_live-accent) 14%, transparent),
|
||||
color-mix(in srgb, var(--_live-accent) 2%, transparent));
|
||||
color: var(--_live-accent);
|
||||
}
|
||||
|
||||
.hero__thumb--movie .hero__thumb-placeholder {
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--_movie-accent) 14%, transparent),
|
||||
color-mix(in srgb, var(--_movie-accent) 2%, transparent));
|
||||
color: var(--_movie-accent);
|
||||
}
|
||||
|
||||
.hero__thumb--series .hero__thumb-placeholder {
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--_series-accent) 14%, transparent),
|
||||
color-mix(in srgb, var(--_series-accent) 2%, transparent));
|
||||
color: var(--_series-accent);
|
||||
}
|
||||
|
||||
// ── Body ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
.hero__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.focus-source__provider {
|
||||
margin: 0;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
.hero__type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
width: fit-content;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.focus-source h4 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
.hero__type-badge--live { color: var(--_live-accent); }
|
||||
.hero__type-badge--movie { color: var(--_movie-accent); }
|
||||
.hero__type-badge--series { color: var(--_series-accent); }
|
||||
|
||||
.hero__title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--mat-sys-on-surface);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.focus-source__title {
|
||||
.hero__playlist {
|
||||
font-size: 0.73rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.focus-source__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
.hero__time {
|
||||
font-size: 0.68rem;
|
||||
color: color-mix(in srgb, var(--mat-sys-on-surface-variant) 60%, transparent);
|
||||
}
|
||||
|
||||
// ── Play icon ─────────────────────────────────────────────────────────────────
|
||||
|
||||
.hero__play {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: color-mix(in srgb, var(--mat-sys-on-surface) 30%, transparent);
|
||||
transition: color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empty state ───────────────────────────────────────────────────────────────
|
||||
|
||||
.focus-empty {
|
||||
border: 1px dashed var(--mat-sys-outline);
|
||||
background: var(--mat-sys-surface-container-low);
|
||||
border-radius: 12px;
|
||||
border: 1px dashed color-mix(in srgb, var(--app-eyebrow-color) 30%, var(--mat-sys-outline-variant) 70%);
|
||||
background: radial-gradient(
|
||||
ellipse at 0% 50%,
|
||||
color-mix(in srgb, var(--app-eyebrow-color) 6%, transparent),
|
||||
transparent 70%
|
||||
);
|
||||
border-radius: 14px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -54,11 +223,16 @@
|
||||
}
|
||||
|
||||
.focus-empty__icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--mat-sys-outline);
|
||||
opacity: 0.7;
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--app-eyebrow-color);
|
||||
opacity: 0.8;
|
||||
background: color-mix(in srgb, var(--app-eyebrow-color) 12%, transparent);
|
||||
padding: 12px;
|
||||
border-radius: 50%;
|
||||
box-sizing: content-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.focus-empty__text {
|
||||
@@ -78,19 +252,21 @@
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
max-width: 320px;
|
||||
max-width: 260px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// ── Quick playlist list ───────────────────────────────────────────────────────
|
||||
|
||||
.quick-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.quick-list__title {
|
||||
.quick-list__label {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.73rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
@@ -105,27 +281,30 @@
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 10px;
|
||||
background: var(--mat-sys-surface-container-low);
|
||||
padding: 8px 10px;
|
||||
padding: 7px 10px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
transition: background 0.12s ease, border-color 0.12s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
.quick-item:hover {
|
||||
border-color: var(--mat-sys-outline);
|
||||
background: var(--mat-sys-surface-container-high);
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--mat-sys-outline) 60%, var(--app-eyebrow-color) 40%);
|
||||
background: var(--mat-sys-surface-container-high);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-item__title {
|
||||
min-width: 0;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.quick-item__meta {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.72rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.74rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { DashboardDataService } from 'workspace-dashboard-data-access';
|
||||
import {
|
||||
DashboardDataService,
|
||||
GlobalRecentItem,
|
||||
} from 'workspace-dashboard-data-access';
|
||||
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
|
||||
|
||||
@Component({
|
||||
@@ -20,4 +23,39 @@ import { DashboardWidgetShellComponent } from './dashboard-widget-shell.componen
|
||||
})
|
||||
export class ContinueWatchingWidgetComponent {
|
||||
readonly data = inject(DashboardDataService);
|
||||
|
||||
/** The most recently watched item across all sources. */
|
||||
readonly lastItem = computed<GlobalRecentItem | null>(
|
||||
() => this.data.globalRecentItems()[0] ?? null
|
||||
);
|
||||
|
||||
hasImage(item: GlobalRecentItem): boolean {
|
||||
return !!item.poster_url;
|
||||
}
|
||||
|
||||
getLink(item: GlobalRecentItem): string[] {
|
||||
return this.data.getRecentItemLink(item);
|
||||
}
|
||||
|
||||
typeIcon(type: GlobalRecentItem['type']): string {
|
||||
if (type === 'live') return 'live_tv';
|
||||
if (type === 'movie') return 'movie';
|
||||
return 'video_library';
|
||||
}
|
||||
|
||||
typeKey(type: GlobalRecentItem['type']): string {
|
||||
if (type === 'live') return 'WORKSPACE.DASHBOARD.TYPE_LIVE';
|
||||
if (type === 'movie') return 'WORKSPACE.DASHBOARD.TYPE_MOVIE';
|
||||
return 'WORKSPACE.DASHBOARD.TYPE_SERIES';
|
||||
}
|
||||
|
||||
relativeTime(dateStr: string): string {
|
||||
const diffMs = Date.now() - new Date(dateStr).getTime();
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
if (diffMin < 1) return 'Just now';
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffH = Math.floor(diffMin / 60);
|
||||
if (diffH < 24) return `${diffH}h ago`;
|
||||
return `${Math.floor(diffH / 24)}d ago`;
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -13,6 +13,10 @@
|
||||
<span
|
||||
class="activity-item__media"
|
||||
[class.activity-item__media--live]="item.type === 'live'"
|
||||
[class.activity-item__media--movie]="item.type === 'movie'"
|
||||
[class.activity-item__media--series]="
|
||||
item.type === 'series'
|
||||
"
|
||||
[class.activity-item__media--poster]="item.type !== 'live'"
|
||||
[class.is-placeholder]="!hasImage(item)"
|
||||
[class.is-placeholder-live]="
|
||||
@@ -32,7 +36,7 @@
|
||||
loading="lazy"
|
||||
(error)="markImageAsFailed(item.id)"
|
||||
/>
|
||||
} @else {
|
||||
} @else if (viewMode() === 'grid') {
|
||||
<mat-icon>{{ getFallbackIcon(item) }}</mat-icon>
|
||||
}
|
||||
</span>
|
||||
@@ -75,5 +79,15 @@
|
||||
@if (emptyHint()) {
|
||||
<p class="empty-state__hint">{{ emptyHint() }}</p>
|
||||
}
|
||||
@if (emptyActionLink() && emptyActionLabel()) {
|
||||
<a
|
||||
mat-stroked-button
|
||||
class="empty-state__action"
|
||||
[routerLink]="emptyActionLink()"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ emptyActionLabel() }}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
+95
-18
@@ -51,7 +51,7 @@
|
||||
);
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
@@ -100,8 +100,10 @@
|
||||
}
|
||||
|
||||
.activity-item__media {
|
||||
// Default: square for channels/live
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
background: var(--mat-sys-surface-container-highest);
|
||||
@@ -112,6 +114,21 @@
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
}
|
||||
|
||||
// Movie: portrait poster 2:3
|
||||
.activity-item__media--movie {
|
||||
width: 30px;
|
||||
height: 44px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
// Series: widescreen 16:9 — 64×36 keeps proportion without dominating the row
|
||||
.activity-item__media--series {
|
||||
width: 64px;
|
||||
height: 36px;
|
||||
border-radius: 7px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.activity-item__media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -131,22 +148,35 @@
|
||||
height: 1.15rem;
|
||||
}
|
||||
|
||||
// Placeholder tints use oklch() matching the stat-tile accent approach
|
||||
.is-placeholder-live {
|
||||
background: linear-gradient(135deg, rgba(56, 189, 248, 0.15), rgba(56, 189, 248, 0.02)) !important;
|
||||
color: #38bdf8 !important;
|
||||
border: 1px solid rgba(56, 189, 248, 0.2) !important;
|
||||
--_accent: oklch(66% 0.17 210); // sky-blue: live TV
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--_accent) 14%, transparent),
|
||||
color-mix(in srgb, var(--_accent) 2%, transparent)
|
||||
) !important;
|
||||
color: var(--_accent) !important;
|
||||
border-color: color-mix(in srgb, var(--_accent) 22%, var(--mat-sys-outline-variant) 78%) !important;
|
||||
}
|
||||
|
||||
.is-placeholder-movie {
|
||||
background: linear-gradient(135deg, rgba(167, 139, 250, 0.15), rgba(167, 139, 250, 0.02)) !important;
|
||||
color: #a78bfa !important;
|
||||
border: 1px solid rgba(167, 139, 250, 0.2) !important;
|
||||
--_accent: oklch(60% 0.17 295); // violet: movies
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--_accent) 14%, transparent),
|
||||
color-mix(in srgb, var(--_accent) 2%, transparent)
|
||||
) !important;
|
||||
color: var(--_accent) !important;
|
||||
border-color: color-mix(in srgb, var(--_accent) 22%, var(--mat-sys-outline-variant) 78%) !important;
|
||||
}
|
||||
|
||||
.is-placeholder-series {
|
||||
background: linear-gradient(135deg, rgba(251, 146, 60, 0.15), rgba(251, 146, 60, 0.02)) !important;
|
||||
color: #fb923c !important;
|
||||
border: 1px solid rgba(251, 146, 60, 0.2) !important;
|
||||
--_accent: oklch(68% 0.16 55); // amber: series
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in srgb, var(--_accent) 14%, transparent),
|
||||
color-mix(in srgb, var(--_accent) 2%, transparent)
|
||||
) !important;
|
||||
color: var(--_accent) !important;
|
||||
border-color: color-mix(in srgb, var(--_accent) 22%, var(--mat-sys-outline-variant) 78%) !important;
|
||||
}
|
||||
|
||||
.is-placeholder mat-icon {
|
||||
@@ -187,6 +217,40 @@
|
||||
align-items: stretch;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--mat-sys-outline) 60%, var(--mat-sys-primary) 40%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--mat-sys-surface-container-high) 0%,
|
||||
var(--mat-sys-surface-container-low) 100%
|
||||
);
|
||||
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
// In grid mode: float the remove button over the top-right corner
|
||||
.activity-items--grid .activity-item__action-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: color-mix(in srgb, var(--mat-sys-surface-container-highest) 90%, transparent);
|
||||
backdrop-filter: blur(4px);
|
||||
opacity: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 15px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.activity-items--grid .activity-item:hover .activity-item__action-btn {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.activity-items--grid .activity-item__media {
|
||||
@@ -223,13 +287,18 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 36px 16px;
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--mat-sys-outline-variant);
|
||||
border-radius: 12px;
|
||||
margin: 8px 0;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
flex: 1;
|
||||
background: radial-gradient(
|
||||
ellipse at 50% 0%,
|
||||
color-mix(in srgb, var(--mat-sys-primary) 4%, transparent),
|
||||
transparent 70%
|
||||
);
|
||||
|
||||
&.empty-state--grid {
|
||||
min-height: 180px;
|
||||
@@ -237,16 +306,20 @@
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
font-size: 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--mat-sys-outline);
|
||||
opacity: 0.7;
|
||||
font-size: 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--mat-sys-primary);
|
||||
opacity: 0.5;
|
||||
background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
|
||||
padding: 14px;
|
||||
border-radius: 50%;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.empty-state__title {
|
||||
margin: 0;
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--mat-sys-on-surface);
|
||||
@@ -260,6 +333,10 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.empty-state__action {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.activity-items--grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, EventEmitter, input, Output, signal } from '@angular/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@@ -16,7 +17,7 @@ type DashboardActivityViewMode = 'list' | 'grid';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard-activity-items',
|
||||
imports: [MatIcon, RouterLink],
|
||||
imports: [MatButton, MatIcon, RouterLink],
|
||||
templateUrl: './dashboard-activity-items.component.html',
|
||||
styleUrl: './dashboard-activity-items.component.scss',
|
||||
})
|
||||
@@ -27,6 +28,8 @@ export class DashboardActivityItemsComponent {
|
||||
readonly emptyLabel = input('No items to show.');
|
||||
readonly emptyIcon = input<string>();
|
||||
readonly emptyHint = input<string>();
|
||||
readonly emptyActionLabel = input<string>();
|
||||
readonly emptyActionLink = input<string[]>();
|
||||
readonly viewMode = input<DashboardActivityViewMode>('list');
|
||||
readonly showRemoveAction = input(false);
|
||||
readonly removeActionLabel = input('Remove item');
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
.widget-shell__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px 10px;
|
||||
@@ -33,6 +33,7 @@
|
||||
|
||||
.widget-shell__headline {
|
||||
min-width: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.widget-shell__eyebrow {
|
||||
@@ -40,7 +41,7 @@
|
||||
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 0.67rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -70,11 +71,11 @@
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
padding: 0 16px 0;
|
||||
padding: 0 16px 12px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
|
||||
scrollbar-color: color-mix(in srgb, var(--mat-sys-on-surface) 12%, transparent) transparent;
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
:host {
|
||||
// Favorites / library tint — amber-gold
|
||||
--app-eyebrow-color: oklch(60% 0.16 55);
|
||||
--app-widget-border: color-mix(in srgb, oklch(60% 0.16 55) 22%, var(--mat-sys-outline-variant) 78%);
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
border-radius: 8px;
|
||||
height: 32px;
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Component, computed, inject, input, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -40,7 +47,7 @@ const PAGE_SIZE = 20;
|
||||
templateUrl: './global-favorites-widget.component.html',
|
||||
styleUrl: './global-favorites-widget.component.scss',
|
||||
})
|
||||
export class GlobalFavoritesWidgetComponent {
|
||||
export class GlobalFavoritesWidgetComponent implements OnInit {
|
||||
readonly widget = input.required<DashboardWidgetConfig>();
|
||||
readonly data = inject(DashboardDataService);
|
||||
readonly selectedKind = signal<DashboardContentKind>(
|
||||
@@ -94,8 +101,12 @@ export class GlobalFavoritesWidgetComponent {
|
||||
}))
|
||||
);
|
||||
|
||||
constructor() {
|
||||
void this.data.reloadGlobalFavorites();
|
||||
constructor() {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.data.reloadGlobalFavorites().then(() => {
|
||||
this.visibleItemLimit.set(INITIAL_PAGE_SIZE);
|
||||
});
|
||||
}
|
||||
|
||||
onKindChange(kind: DashboardContentKind): void {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<app-dashboard-widget-shell
|
||||
[eyebrow]="'WORKSPACE.DASHBOARD.ON_AIR_EYEBROW' | translate"
|
||||
[title]="'WORKSPACE.DASHBOARD.ON_AIR_TITLE' | translate"
|
||||
>
|
||||
<ng-container widget-actions>
|
||||
<mat-button-toggle-group
|
||||
class="category-filter"
|
||||
[value]="selectedCategory()"
|
||||
hideSingleSelectionIndicator
|
||||
aria-label="Filter by category"
|
||||
(change)="setCategory($event.value)"
|
||||
>
|
||||
@for (cat of categoryKeys; track cat) {
|
||||
<mat-button-toggle
|
||||
[value]="cat"
|
||||
[attr.aria-label]="categoryTranslationKey(cat) | translate"
|
||||
>
|
||||
{{ categoryTranslationKey(cat) | translate }}
|
||||
</mat-button-toggle>
|
||||
}
|
||||
</mat-button-toggle-group>
|
||||
</ng-container>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="on-air-skeleton" aria-busy="true">
|
||||
@for (_ of [1, 2, 3, 4]; track $index) {
|
||||
<div class="skeleton-row">
|
||||
<div class="skeleton-avatar"></div>
|
||||
<div class="skeleton-body">
|
||||
<div class="skeleton-line skeleton-line--wide"></div>
|
||||
<div class="skeleton-line skeleton-line--narrow"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (filtered().length > 0) {
|
||||
<div class="on-air-list" role="list">
|
||||
@for (item of filtered(); track $index) {
|
||||
<div class="on-air-item" role="listitem">
|
||||
<button
|
||||
type="button"
|
||||
class="on-air-item__avatar"
|
||||
[class.on-air-item__avatar--img]="item.channel_icon"
|
||||
[matTooltip]="
|
||||
'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS'
|
||||
| translate
|
||||
"
|
||||
(click)="openInfo(item)"
|
||||
>
|
||||
@if (item.channel_icon) {
|
||||
<img
|
||||
[src]="item.channel_icon"
|
||||
[alt]="item.channel_name ?? ''"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
class="on-air-item__avatar-badge"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
</span>
|
||||
} @else {
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<span class="on-air-item__body">
|
||||
<span class="on-air-item__channel">
|
||||
<span
|
||||
class="on-air-item__pulse"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{{ item.channel_name ?? item.channel_id }}
|
||||
@if (item.category) {
|
||||
<span class="on-air-item__category"
|
||||
>· {{ item.category }}</span
|
||||
>
|
||||
}
|
||||
</span>
|
||||
<span
|
||||
class="on-air-item__title"
|
||||
[attr.title]="item.title"
|
||||
>{{ item.title }}</span
|
||||
>
|
||||
<span
|
||||
class="on-air-item__progress-row"
|
||||
[attr.aria-label]="
|
||||
item.minutesLeft + ' minutes left'
|
||||
"
|
||||
>
|
||||
<span class="on-air-item__bar-track">
|
||||
<span
|
||||
class="on-air-item__bar-fill"
|
||||
[style.width.%]="item.progressPercent"
|
||||
></span>
|
||||
</span>
|
||||
<span class="on-air-item__time-left"
|
||||
>{{ item.minutesLeft }} min</span
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="on-air-empty">
|
||||
<mat-icon class="on-air-empty__icon">schedule</mat-icon>
|
||||
<p class="on-air-empty__title">
|
||||
{{ 'WORKSPACE.DASHBOARD.ON_AIR_EMPTY' | translate }}
|
||||
</p>
|
||||
<p class="on-air-empty__hint">
|
||||
{{
|
||||
(items().length > 0
|
||||
? 'WORKSPACE.DASHBOARD.ON_AIR_EMPTY_FILTER'
|
||||
: 'WORKSPACE.DASHBOARD.ON_AIR_EMPTY_HINT'
|
||||
) | translate
|
||||
}}
|
||||
</p>
|
||||
@if (!items().length) {
|
||||
<a
|
||||
mat-stroked-button
|
||||
class="on-air-empty__action"
|
||||
routerLink="/settings"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'WORKSPACE.DASHBOARD.ON_AIR_ADD_EPG' | translate }}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</app-dashboard-widget-shell>
|
||||
@@ -0,0 +1,279 @@
|
||||
@use 'sass:color';
|
||||
|
||||
:host {
|
||||
--on-air-accent: oklch(62% 0.20 200);
|
||||
--on-air-accent-muted: oklch(62% 0.20 200 / 0.15);
|
||||
--on-air-pulse: oklch(62% 0.22 160);
|
||||
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ─── Category filter ───────────────────────────────────────────────── */
|
||||
.category-filter {
|
||||
::ng-deep .mat-button-toggle {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
::ng-deep .mat-button-toggle-checked {
|
||||
color: var(--on-air-accent);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Channel list ──────────────────────────────────────────────────── */
|
||||
.on-air-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* ─── Row item ──────────────────────────────────────────────────────── */
|
||||
.on-air-item {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
background: var(--on-air-accent-muted);
|
||||
border-color: color-mix(in srgb, var(--on-air-accent) 30%, var(--mat-sys-outline-variant) 70%);
|
||||
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Avatar button ─────────────────────────────────────────────────── */
|
||||
.on-air-item__avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--on-air-accent-muted);
|
||||
color: var(--on-air-accent);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
// Reset button defaults
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: filter 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
&--img img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small always-visible info badge over channel logo */
|
||||
.on-air-item__avatar-badge {
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--on-air-accent);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1.5px solid var(--mat-sys-surface-container);
|
||||
|
||||
mat-icon {
|
||||
font-size: 10px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Body ──────────────────────────────────────────────────────────── */
|
||||
.on-air-item__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.on-air-item__channel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.on-air-item__category {
|
||||
opacity: 0.75;
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.on-air-item__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ─── Progress row ──────────────────────────────────────────────────── */
|
||||
.on-air-item__progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.on-air-item__bar-track {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--on-air-accent-muted);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.on-air-item__bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--on-air-accent);
|
||||
transition: width 0.4s ease;
|
||||
min-width: 3px;
|
||||
}
|
||||
|
||||
.on-air-item__time-left {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.55;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─── Live pulse dot ────────────────────────────────────────────────── */
|
||||
.on-air-item__pulse {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--on-air-pulse);
|
||||
flex-shrink: 0;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.7); }
|
||||
}
|
||||
|
||||
/* ─── Loading skeleton ──────────────────────────────────────────────── */
|
||||
.on-air-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.skeleton-row {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.skeleton-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--mat-sys-outline-variant, rgba(128, 128, 128, 0.2));
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 11px;
|
||||
border-radius: 6px;
|
||||
background: var(--mat-sys-outline-variant, rgba(128, 128, 128, 0.2));
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
|
||||
&--wide { width: 80%; }
|
||||
&--narrow { width: 50%; }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ─── Empty state ───────────────────────────────────────────────────── */
|
||||
.on-air-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
padding: 40px 24px;
|
||||
border-radius: 14px;
|
||||
border: 1.5px dashed var(--on-air-accent-muted);
|
||||
background: radial-gradient(ellipse at 50% 0%, var(--on-air-accent-muted) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
.on-air-empty__icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
font-size: 28px;
|
||||
line-height: 52px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: var(--on-air-accent-muted);
|
||||
color: var(--on-air-accent);
|
||||
}
|
||||
|
||||
.on-air-empty__title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.on-air-empty__hint {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.6;
|
||||
max-width: 28ch;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.on-air-empty__action {
|
||||
margin-top: 6px;
|
||||
color: var(--on-air-accent);
|
||||
border-color: var(--on-air-accent);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { EpgItemDescriptionComponent } from 'components';
|
||||
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
|
||||
|
||||
export interface NowPlayingItem {
|
||||
channel_id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
start: string;
|
||||
stop: string;
|
||||
channel_name: string | null;
|
||||
channel_icon: string | null;
|
||||
}
|
||||
|
||||
/** NowPlayingItem enriched with pre-computed display values */
|
||||
export interface NowPlayingItemView extends NowPlayingItem {
|
||||
progressPercent: number;
|
||||
minutesLeft: number;
|
||||
initial: string;
|
||||
}
|
||||
|
||||
export type OnAirCategoryKey = 'all' | 'sports' | 'news' | 'movies' | 'kids';
|
||||
|
||||
@Component({
|
||||
selector: 'app-on-air-widget',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatIcon,
|
||||
MatTooltip,
|
||||
RouterLink,
|
||||
DashboardWidgetShellComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './on-air-widget.component.html',
|
||||
styleUrl: './on-air-widget.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OnAirWidgetComponent implements OnInit, OnDestroy {
|
||||
private readonly zone = inject(NgZone);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
readonly items = signal<NowPlayingItemView[]>([]);
|
||||
readonly isLoading = signal(true);
|
||||
readonly selectedCategory = signal<OnAirCategoryKey>('all');
|
||||
|
||||
readonly categoryKeys: OnAirCategoryKey[] = [
|
||||
'all',
|
||||
'sports',
|
||||
'news',
|
||||
'movies',
|
||||
'kids',
|
||||
];
|
||||
|
||||
private readonly categoryKeywords: Record<OnAirCategoryKey, string[]> = {
|
||||
all: [],
|
||||
sports: [
|
||||
'sport',
|
||||
'football',
|
||||
'soccer',
|
||||
'basketball',
|
||||
'tennis',
|
||||
'f1',
|
||||
'cricket',
|
||||
'rugby',
|
||||
],
|
||||
news: ['news', 'nachrichten', 'info', 'actualité', 'noticias', 'haber'],
|
||||
movies: ['movie', 'film', 'cinema', 'kino', 'cine', 'films'],
|
||||
kids: [
|
||||
'kids',
|
||||
'children',
|
||||
'child',
|
||||
'cartoon',
|
||||
'animation',
|
||||
'junior',
|
||||
'enfants',
|
||||
],
|
||||
};
|
||||
|
||||
readonly filtered = computed<NowPlayingItemView[]>(() => {
|
||||
const cat = this.selectedCategory();
|
||||
if (cat === 'all') return this.items();
|
||||
const keywords = this.categoryKeywords[cat];
|
||||
return this.items().filter((item) =>
|
||||
keywords.some((kw) =>
|
||||
(item.category ?? '').toLowerCase().includes(kw)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
void this.loadItems();
|
||||
// Run timer outside Angular Zone — prevents zone-triggered CD on every tick.
|
||||
// Signal updates inside loadItems() propagate to the view via Angular's
|
||||
// signal reactivity without needing zone.run().
|
||||
this.zone.runOutsideAngular(() => {
|
||||
this.refreshTimer = setInterval(
|
||||
() => void this.loadItems(),
|
||||
60_000
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.refreshTimer !== null) {
|
||||
clearInterval(this.refreshTimer);
|
||||
}
|
||||
}
|
||||
|
||||
setCategory(cat: OnAirCategoryKey): void {
|
||||
this.selectedCategory.set(cat);
|
||||
}
|
||||
|
||||
categoryTranslationKey(cat: OnAirCategoryKey): string {
|
||||
return `WORKSPACE.DASHBOARD.ON_AIR_CAT_${cat.toUpperCase()}`;
|
||||
}
|
||||
|
||||
openInfo(item: NowPlayingItemView): void {
|
||||
this.dialog.open(EpgItemDescriptionComponent, {
|
||||
data: {
|
||||
channel: item.channel_id,
|
||||
channel_name: item.channel_name,
|
||||
title: item.title,
|
||||
desc: item.description,
|
||||
category: item.category,
|
||||
start: item.start,
|
||||
stop: item.stop,
|
||||
episodeNum: null,
|
||||
rating: null,
|
||||
iconUrl: item.channel_icon ?? null,
|
||||
},
|
||||
width: '480px',
|
||||
maxWidth: '96vw',
|
||||
});
|
||||
}
|
||||
|
||||
private async loadItems(): Promise<void> {
|
||||
if (!window.electron) {
|
||||
this.isLoading.set(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await window.electron.getNowPlayingPrograms({
|
||||
limit: 25,
|
||||
});
|
||||
const now = Date.now();
|
||||
const enriched: NowPlayingItemView[] = (
|
||||
data as NowPlayingItem[]
|
||||
).map((item) => {
|
||||
const start = new Date(item.start).getTime();
|
||||
const stop = new Date(item.stop).getTime();
|
||||
const range = stop - start;
|
||||
return {
|
||||
...item,
|
||||
progressPercent:
|
||||
range > 0
|
||||
? Math.round(
|
||||
Math.min(
|
||||
100,
|
||||
Math.max(0, ((now - start) / range) * 100)
|
||||
)
|
||||
)
|
||||
: 0,
|
||||
minutesLeft: Math.max(0, Math.round((stop - now) / 60_000)),
|
||||
initial: (item.channel_name ?? item.channel_id ?? '?')
|
||||
.charAt(0)
|
||||
.toUpperCase(),
|
||||
};
|
||||
});
|
||||
this.items.set(enriched);
|
||||
} catch {
|
||||
this.items.set([]);
|
||||
} finally {
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,16 +10,18 @@
|
||||
@for (item of data.recentPlaylists(); track item._id) {
|
||||
<a class="source-row" [routerLink]="data.getPlaylistLink(item)">
|
||||
<div class="source-row__head">
|
||||
<span class="provider">
|
||||
{{ data.getPlaylistProvider(item) }}
|
||||
</span>
|
||||
<span
|
||||
class="provider-pill"
|
||||
[class]="
|
||||
'provider-pill--' +
|
||||
data.getPlaylistProvider(item).toLowerCase()
|
||||
"
|
||||
>{{ data.getPlaylistProvider(item) }}</span
|
||||
>
|
||||
@if (item.count) {
|
||||
<span class="count"
|
||||
>{{ item.count }}
|
||||
{{
|
||||
'WORKSPACE.DASHBOARD.CHANNELS' | translate
|
||||
}}</span
|
||||
>
|
||||
<span class="count-chip">{{
|
||||
item.count | number
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
<h4
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
:host {
|
||||
--_xtream-accent: oklch(55% 0.13 185);
|
||||
--_stalker-accent: oklch(68% 0.16 55);
|
||||
--_m3u-accent: oklch(55% 0.18 295);
|
||||
}
|
||||
|
||||
[widget-actions] {
|
||||
color: var(--mat-sys-primary);
|
||||
text-decoration: none;
|
||||
@@ -10,6 +16,55 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
// ── Provider pill ─────────────────────────────────────────────────────────────
|
||||
|
||||
.provider-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.67rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.09em;
|
||||
padding: 1px 7px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid currentColor;
|
||||
// Default (fallback)
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
}
|
||||
|
||||
.provider-pill--xtream {
|
||||
color: var(--_xtream-accent);
|
||||
border-color: color-mix(in srgb, var(--_xtream-accent) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--_xtream-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.provider-pill--stalker {
|
||||
color: var(--_stalker-accent);
|
||||
border-color: color-mix(in srgb, var(--_stalker-accent) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--_stalker-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.provider-pill--m3u {
|
||||
color: var(--_m3u-accent);
|
||||
border-color: color-mix(in srgb, var(--_m3u-accent) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--_m3u-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
// ── Channel count chip ────────────────────────────────────────────────────────
|
||||
|
||||
.count-chip {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
background: var(--mat-sys-surface-container-high);
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -41,18 +96,6 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.provider,
|
||||
.count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.provider {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.source-row h4 {
|
||||
margin: 8px 0 6px;
|
||||
font-size: 0.95rem;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -9,6 +10,7 @@ import { DashboardWidgetShellComponent } from './dashboard-widget-shell.componen
|
||||
@Component({
|
||||
selector: 'app-recent-sources-widget',
|
||||
imports: [
|
||||
DecimalPipe,
|
||||
MatButtonModule,
|
||||
MatIcon,
|
||||
RouterLink,
|
||||
|
||||
@@ -81,6 +81,8 @@
|
||||
[emptyLabel]="'WORKSPACE.DASHBOARD.NO_HISTORY' | translate"
|
||||
emptyIcon="history"
|
||||
[emptyHint]="'WORKSPACE.DASHBOARD.NO_HISTORY_HINT' | translate"
|
||||
[emptyActionLabel]="'WORKSPACE.DASHBOARD.ADD_SOURCE' | translate"
|
||||
[emptyActionLink]="['/workspace/sources']"
|
||||
[viewMode]="viewMode()"
|
||||
[showRemoveAction]="true"
|
||||
[removeActionLabel]="'Remove from history'"
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
:host {
|
||||
// Activity / history tint — blue-teal
|
||||
--app-eyebrow-color: oklch(54% 0.13 225);
|
||||
--app-widget-border: color-mix(in srgb, oklch(54% 0.13 225) 22%, var(--mat-sys-outline-variant) 78%);
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
border-radius: 8px;
|
||||
height: 32px;
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Component, computed, inject, input, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -40,7 +47,7 @@ const PAGE_SIZE = 20;
|
||||
templateUrl: './recently-watched-widget.component.html',
|
||||
styleUrl: './recently-watched-widget.component.scss',
|
||||
})
|
||||
export class RecentlyWatchedWidgetComponent {
|
||||
export class RecentlyWatchedWidgetComponent implements OnInit {
|
||||
readonly widget = input.required<DashboardWidgetConfig>();
|
||||
readonly data = inject(DashboardDataService);
|
||||
readonly selectedKind = signal<DashboardContentKind>(
|
||||
@@ -93,8 +100,12 @@ export class RecentlyWatchedWidgetComponent {
|
||||
}))
|
||||
);
|
||||
|
||||
constructor() {
|
||||
void this.data.reloadGlobalRecentItems();
|
||||
constructor() {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.data.reloadGlobalRecentItems().then(() => {
|
||||
this.visibleItemLimit.set(INITIAL_PAGE_SIZE);
|
||||
});
|
||||
}
|
||||
|
||||
onKindChange(kind: DashboardContentKind): void {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
<p class="value">{{ data.stats().total }}</p>
|
||||
</article>
|
||||
<article class="stat-tile">
|
||||
<article class="stat-tile stat-tile--xtream">
|
||||
<div class="stat-tile__header">
|
||||
<mat-icon>tv</mat-icon>
|
||||
<p class="label">
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
<p class="value">{{ data.stats().xtream }}</p>
|
||||
</article>
|
||||
<article class="stat-tile">
|
||||
<article class="stat-tile stat-tile--stalker">
|
||||
<div class="stat-tile__header">
|
||||
<mat-icon>router</mat-icon>
|
||||
<p class="label">
|
||||
@@ -30,7 +30,7 @@
|
||||
</div>
|
||||
<p class="value">{{ data.stats().stalker }}</p>
|
||||
</article>
|
||||
<article class="stat-tile">
|
||||
<article class="stat-tile stat-tile--m3u">
|
||||
<div class="stat-tile__header">
|
||||
<mat-icon>list_alt</mat-icon>
|
||||
<p class="label">{{ 'WORKSPACE.DASHBOARD.M3U' | translate }}</p>
|
||||
|
||||
@@ -28,6 +28,33 @@
|
||||
color: var(--mat-sys-on-primary-container);
|
||||
}
|
||||
}
|
||||
|
||||
// Xtream: teal accent
|
||||
&--xtream {
|
||||
--stat-accent: oklch(55% 0.13 185);
|
||||
border-color: color-mix(in srgb, var(--stat-accent) 35%, var(--mat-sys-outline-variant) 65%);
|
||||
background: color-mix(in srgb, var(--stat-accent) 7%, var(--mat-sys-surface-container-low) 93%);
|
||||
|
||||
.stat-tile__header mat-icon { color: var(--stat-accent); }
|
||||
}
|
||||
|
||||
// Stalker: amber accent
|
||||
&--stalker {
|
||||
--stat-accent: oklch(68% 0.16 55);
|
||||
border-color: color-mix(in srgb, var(--stat-accent) 35%, var(--mat-sys-outline-variant) 65%);
|
||||
background: color-mix(in srgb, var(--stat-accent) 7%, var(--mat-sys-surface-container-low) 93%);
|
||||
|
||||
.stat-tile__header mat-icon { color: var(--stat-accent); }
|
||||
}
|
||||
|
||||
// M3U: violet accent
|
||||
&--m3u {
|
||||
--stat-accent: oklch(55% 0.18 295);
|
||||
border-color: color-mix(in srgb, var(--stat-accent) 35%, var(--mat-sys-outline-variant) 65%);
|
||||
background: color-mix(in srgb, var(--stat-accent) 7%, var(--mat-sys-surface-container-low) 93%);
|
||||
|
||||
.stat-tile__header mat-icon { color: var(--stat-accent); }
|
||||
}
|
||||
}
|
||||
|
||||
.stat-tile__header {
|
||||
|
||||
Reference in new issue
Block a user