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:
4gray committed 2026-03-08 11:23:34 +01:00
1 parent a8fd16b16d
commit 268a710bf2
27 files changed
+1487 -352

No files matched your search

@@ -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(