feat: refactor dashboard widgets, and remove the home component.

This commit is contained in:
4gray committed 2026-03-02 22:30:16 +01:00
1 parent 992b996ecd
commit 20485972c3
19 files changed
+562 -75

No files matched your search

@@ -1,6 +1,10 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectActivePlaylist, selectAllPlaylistsMeta } from 'm3u-state';
import {
PlaylistActions,
selectActivePlaylist,
selectAllPlaylistsMeta,
} from 'm3u-state';
import {
DatabaseService,
GlobalFavoriteItem as DbGlobalFavoriteItem,
@@ -17,7 +21,7 @@ type RecentActivityType = 'live' | 'movie' | 'series';
export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series';
interface GlobalRecentItem {
export interface GlobalRecentItem {
id: string | number;
title: string;
type: RecentActivityType;
@@ -259,6 +263,51 @@ export class DashboardDataService {
return this.getRecentItemNavigation(item).state;
}
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
if (item.source === 'xtream') {
await this.dbService.removeRecentItem(
item.id as number,
item.playlist_id
);
await this.reloadGlobalRecentItems();
return;
}
if (item.source === 'stalker') {
const playlist = this.playlists().find(
(p) => p._id === item.playlist_id
);
if (!playlist) return;
const currentRecent = Array.isArray(
(playlist as any).recentlyViewed
)
? [...(playlist as any).recentlyViewed]
: [];
const itemMatchStr = String(item.id);
const filteredRecent = currentRecent.filter((raw, index) => {
const rawId = String(
(raw as any)?.id ??
(raw as any)?.stream_id ??
(raw as any)?.series_id ??
(raw as any)?.movie_id ??
`${playlist._id}-${index}`
);
return rawId !== itemMatchStr && rawId !== '';
});
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: item.playlist_id,
recentlyViewed: filteredRecent,
} as unknown as PlaylistMeta,
}) as any
);
}
}
getFavoriteItemProviderLabel(item: DashboardFavoriteItem): string {
if (item.source === 'stalker') {
return 'Stalker';
@@ -283,6 +332,50 @@ export class DashboardDataService {
return this.getGlobalFavoriteNavigation(item).state;
}
async removeGlobalFavorite(item: DashboardFavoriteItem): Promise<void> {
if (item.source === 'xtream') {
await this.dbService.removeFromFavorites(
item.id as number,
item.playlist_id
);
await this.reloadGlobalFavorites();
return;
}
if (item.source === 'stalker') {
const playlist = this.playlists().find(
(p) => p._id === item.playlist_id
);
if (!playlist) return;
const currentFavorites = Array.isArray(playlist.favorites)
? [...playlist.favorites]
: [];
const itemMatchStr = String(item.id);
// Stalker favorite objects are usually stored entirely in the favorites array
const filteredFavorites = currentFavorites.filter((raw, index) => {
const rawId = String(
(raw as any)?.id ??
(raw as any)?.stream_id ??
(raw as any)?.series_id ??
(raw as any)?.movie_id ??
`${playlist._id}-${index}`
);
return rawId !== itemMatchStr && rawId !== '';
});
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: item.playlist_id,
favorites: filteredFavorites,
} as unknown as PlaylistMeta,
}) as any
);
}
}
formatTimestamp(value?: string | number): string {
const timestamp = this.toTimestamp(value);
if (!timestamp) {
@@ -33,7 +33,7 @@ export interface DashboardLayoutState {
widgets: DashboardWidgetConfig[];
}
export const DASHBOARD_LAYOUT_VERSION = 5;
export const DASHBOARD_LAYOUT_VERSION = 6;
export const DASHBOARD_WIDGET_SIZE_OPTIONS: DashboardWidgetSize[] = [
'one-third',
'half',
@@ -90,7 +90,7 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
type: 'recently-watched',
title: 'Recently Watched',
description: 'Global watch history across channels, VOD and series.',
size: 'one-third',
size: 'two-thirds',
enabled: true,
order: 3,
settings: {
@@ -102,7 +102,7 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
type: 'global-favorites',
title: 'Global Favorites',
description: 'Pinned items across all connected providers.',
size: 'two-thirds',
size: 'one-third',
enabled: true,
order: 4,
settings: {
@@ -4,23 +4,32 @@
<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.
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()">
<section
class="dashboard-toolbar"
[class.dashboard-toolbar--editing]="editMode()"
>
<p class="toolbar-hint">
@if (editMode()) {
Drag cards by the handle. Resize inline. Use the sidebar for visibility and scope.
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()) {
<button type="button" mat-button (click)="layoutService.reset()">
<button
type="button"
mat-button
(click)="layoutService.reset()"
>
Reset Layout
</button>
}
@@ -31,7 +40,10 @@
</div>
</section>
<section class="dashboard-editor-shell" [class.dashboard-editor-shell--editing]="editMode()">
<section
class="dashboard-editor-shell"
[class.dashboard-editor-shell--editing]="editMode()"
>
<section class="dashboard-canvas">
<section
class="widget-grid"
@@ -45,10 +57,15 @@
<article
class="widget-slot"
cdkDrag
[attr.data-widget-type]="widget.type"
[cdkDragDisabled]="!editMode()"
[class.widget-slot--one-third]="widget.size === 'one-third'"
[class.widget-slot--one-third]="
widget.size === 'one-third'
"
[class.widget-slot--half]="widget.size === 'half'"
[class.widget-slot--two-thirds]="widget.size === 'two-thirds'"
[class.widget-slot--two-thirds]="
widget.size === 'two-thirds'
"
[class.widget-slot--full]="widget.size === 'full'"
[class.widget-slot--editing]="editMode()"
>
@@ -63,14 +80,21 @@
>
<mat-icon>drag_indicator</mat-icon>
</button>
<p class="widget-inline-editor__title">{{ widget.title }}</p>
<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)"
(change)="
setWidgetSize(widget.id, $event.value)
"
aria-label="Widget width"
>
@for (size of sizeOptions; track size.value) {
@for (
size of sizeOptions;
track size.value
) {
<mat-button-toggle [value]="size.value">
{{ size.label }}
</mat-button-toggle>
@@ -103,7 +127,8 @@
<section class="layout-editor">
<h2>Widget Library</h2>
<p class="layout-editor__hint">
Show/hide widgets and configure provider scope without leaving the canvas.
Show/hide widgets and configure provider scope without
leaving the canvas.
</p>
<div class="editor-list">
@for (widget of allWidgets(); track widget.id) {
@@ -113,10 +138,16 @@
<p>{{ widget.description }}</p>
</div>
<div class="editor-item__actions">
<p class="editor-item__action-label">Visibility</p>
<p class="editor-item__action-label">
Visibility
</p>
<mat-slide-toggle
[checked]="widget.enabled"
(change)="layoutService.toggleWidget(widget.id)"
(change)="
layoutService.toggleWidget(
widget.id
)
"
color="primary"
>
Visible
@@ -126,52 +157,105 @@
@if (supportsScope(widget)) {
<mat-expansion-panel class="advanced-panel">
<mat-expansion-panel-header>
<mat-panel-title>Advanced settings</mat-panel-title>
<mat-panel-title
>Advanced
settings</mat-panel-title
>
<mat-panel-description>
Scope ({{ getScopeProviderSummary(widget) }})
Scope ({{
getScopeProviderSummary(
widget
)
}})
</mat-panel-description>
</mat-expansion-panel-header>
<section class="scope-editor">
<p class="scope-editor__title">Scope</p>
<p class="scope-editor__title">
Scope
</p>
<p class="scope-editor__subtitle">
Provider filters: {{ getScopeProviderSummary(widget) }}
Provider filters:
{{
getScopeProviderSummary(
widget
)
}}
</p>
@if (isAllProvidersScope(widget)) {
<p class="scope-editor__subtitle">
No providers selected means all providers are included.
<p
class="scope-editor__subtitle"
>
No providers selected means
all providers are included.
</p>
}
<mat-chip-listbox class="scope-providers" multiple>
@for (provider of providerOptions; track provider) {
<mat-chip-listbox
class="scope-providers"
multiple
>
@for (
provider of providerOptions;
track provider
) {
<mat-chip-option
[selected]="isProviderSelected(widget, provider)"
[selected]="
isProviderSelected(
widget,
provider
)
"
(click)="
toggleScopeProvider(widget.id, provider)
toggleScopeProvider(
widget.id,
provider
)
"
>
{{ getProviderLabel(provider) }}
{{
getProviderLabel(
provider
)
}}
</mat-chip-option>
}
</mat-chip-listbox>
<p class="scope-editor__subtitle">
Playlists (leave empty to use all playlists in current provider scope)
Playlists (leave empty to use
all playlists in current
provider scope)
</p>
<div class="scope-playlists">
@for (playlist of getScopePlaylists(widget); track playlist._id) {
@for (
playlist of getScopePlaylists(
widget
);
track playlist._id
) {
<mat-checkbox
[checked]="
isPlaylistSelected(widget, playlist._id)
isPlaylistSelected(
widget,
playlist._id
)
"
(change)="
toggleScopePlaylist(widget.id, playlist._id)
toggleScopePlaylist(
widget.id,
playlist._id
)
"
>
{{ getPlaylistTitle(playlist) }}
{{
getPlaylistTitle(
playlist
)
}}
</mat-checkbox>
} @empty {
<p class="scope-empty">
No playlists available for selected providers.
No playlists available
for selected providers.
</p>
}
</div>
@@ -336,6 +336,11 @@ h1 {
0 18px 28px -20px var(--dashboard-frame-glow);
}
.widget-slot[data-widget-type="source-stats"] {
height: auto;
align-self: start;
}
.widget-slot--editing {
cursor: grab;
}
@@ -34,7 +34,22 @@
</article>
} @else {
<article class="focus-empty">
<p>{{ 'WORKSPACE.DASHBOARD.NO_ACTIVE_SOURCE' | translate }}</p>
<div class="focus-empty__content">
<mat-icon class="focus-empty__icon"
>play_circle_outline</mat-icon
>
<div class="focus-empty__text">
<p class="focus-empty__title">
{{ 'WORKSPACE.DASHBOARD.NO_ACTIVE_SOURCE' | translate }}
</p>
<p class="focus-empty__hint">
{{
'WORKSPACE.DASHBOARD.NO_ACTIVE_SOURCE_HINT'
| translate
}}
</p>
</div>
</div>
<a mat-flat-button routerLink="/workspace/sources">
<mat-icon>add</mat-icon>
{{ 'WORKSPACE.DASHBOARD.ADD_SOURCE' | translate }}
@@ -39,16 +39,47 @@
border: 1px dashed var(--mat-sys-outline);
background: var(--mat-sys-surface-container-low);
border-radius: 12px;
padding: 12px;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 16px;
flex-wrap: wrap;
}
.focus-empty p {
.focus-empty__content {
display: flex;
align-items: center;
gap: 16px;
}
.focus-empty__icon {
font-size: 32px;
width: 32px;
height: 32px;
color: var(--mat-sys-outline);
opacity: 0.7;
}
.focus-empty__text {
display: flex;
flex-direction: column;
gap: 4px;
}
.focus-empty__title {
margin: 0;
font-weight: 500;
font-size: 0.9rem;
color: var(--mat-sys-on-surface);
}
.focus-empty__hint {
margin: 0;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
max-width: 320px;
line-height: 1.4;
}
.quick-list {
@@ -51,9 +51,29 @@
{{ item.subtitle }}
</span>
</span>
@if (showRemoveAction()) {
<button
type="button"
class="activity-item__action-btn"
(click)="onActionClick($event, item)"
[attr.aria-label]="removeActionLabel()"
[title]="removeActionLabel()"
>
<mat-icon>close</mat-icon>
</button>
}
</a>
}
</div>
} @else {
<p class="empty">{{ emptyLabel() }}</p>
<div class="empty-state" [class.empty-state--grid]="viewMode() === 'grid'">
@if (emptyIcon()) {
<mat-icon class="empty-state__icon">{{ emptyIcon() }}</mat-icon>
}
<p class="empty-state__title">{{ emptyLabel() }}</p>
@if (emptyHint()) {
<p class="empty-state__hint">{{ emptyHint() }}</p>
}
</div>
}
@@ -51,18 +51,52 @@
);
padding: 8px;
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
grid-template-columns: 44px 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;
}
.activity-item__action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--mat-sys-on-surface-variant);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease, background-color 0.2s ease, color 0.2s ease;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.activity-item:hover .activity-item__action-btn {
opacity: 0.6;
}
.activity-item__action-btn:hover {
opacity: 1 !important;
background: var(--mat-sys-surface-container-highest);
color: var(--mat-sys-on-surface);
}
.activity-item:hover {
border-color: color-mix(in srgb, var(--mat-sys-outline) 72%, var(--mat-sys-primary) 28%);
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) 100%
var(--mat-sys-surface-container-low) 100%
);
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.15);
}
.activity-item__media {
@@ -184,10 +218,46 @@
font-size: 0.68rem;
}
.empty {
margin: 0;
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 36px 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);
font-size: 0.82rem;
flex: 1;
&.empty-state--grid {
min-height: 180px;
}
}
.empty-state__icon {
font-size: 36px;
width: 36px;
height: 36px;
margin-bottom: 12px;
color: var(--mat-sys-outline);
opacity: 0.7;
}
.empty-state__title {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
}
.empty-state__hint {
margin: 6px 0 0;
font-size: 0.78rem;
color: var(--mat-sys-on-surface-variant);
max-width: 280px;
line-height: 1.45;
}
@media (max-width: 920px) {
@@ -25,7 +25,14 @@ export class DashboardActivityItemsComponent {
readonly items = input.required<DashboardActivityItemViewModel[]>();
readonly emptyLabel = input('No items to show.');
readonly emptyIcon = input<string>();
readonly emptyHint = input<string>();
readonly viewMode = input<DashboardActivityViewMode>('list');
readonly showRemoveAction = input(false);
readonly removeActionLabel = input('Remove item');
@Output() readonly itemAction =
new EventEmitter<DashboardActivityItemViewModel>();
readonly failedImages = signal<Record<string, true>>({});
@@ -68,9 +75,16 @@ export class DashboardActivityItemsComponent {
return;
}
const remaining = target.scrollHeight - target.scrollTop - target.clientHeight;
const remaining =
target.scrollHeight - target.scrollTop - target.clientHeight;
if (remaining <= 120) {
this.scrolledToEnd.emit();
}
}
onActionClick(event: Event, item: DashboardActivityItemViewModel): void {
event.preventDefault();
event.stopPropagation();
this.itemAction.emit(item);
}
}
@@ -79,7 +79,12 @@
<app-dashboard-activity-items
[items]="activityItems()"
[emptyLabel]="'WORKSPACE.DASHBOARD.NO_FAVORITES' | translate"
emptyIcon="favorite_border"
[emptyHint]="'WORKSPACE.DASHBOARD.NO_FAVORITES_HINT' | translate"
[viewMode]="viewMode()"
[showRemoveAction]="true"
[removeActionLabel]="'Remove favorite'"
(itemAction)="onRemoveItem($event)"
(scrolledToEnd)="onItemsScrolledToEnd()"
/>
</app-dashboard-widget-shell>
@@ -117,6 +117,28 @@ export class GlobalFavoritesWidgetComponent {
this.visibleItemLimit.update((value) => value + PAGE_SIZE);
}
onRemoveItem(item: DashboardActivityItemViewModel): void {
const globalItem = this.data
.globalFavoriteItems()
.find((i) => `${i.id}-${i.playlist_id}-${i.added_at}` === item.id);
if (globalItem) {
void this.data.removeGlobalFavorite(globalItem);
} else {
// Fallback strategy
const fallbackItem = this.data
.globalFavoriteItems()
.find(
(i) =>
item.link.includes(String(i.xtream_id)) ||
item.link.includes(i.playlist_id)
);
if (fallbackItem) {
void this.data.removeGlobalFavorite(fallbackItem);
}
}
}
private setKind(value: unknown): void {
if (
value === 'all' ||
@@ -48,8 +48,16 @@
</div>
} @else {
<article class="empty-state">
<p>{{ 'WORKSPACE.DASHBOARD.NO_SOURCES' | translate }}</p>
<a routerLink="/workspace/sources">{{
<mat-icon class="empty-state__icon">library_add</mat-icon>
<div class="empty-content">
<p class="empty-title">
{{ 'WORKSPACE.DASHBOARD.NO_SOURCES' | translate }}
</p>
<p class="empty-hint">
{{ 'WORKSPACE.DASHBOARD.NO_SOURCES_HINT' | translate }}
</p>
</div>
<a mat-stroked-button routerLink="/workspace/sources">{{
'WORKSPACE.DASHBOARD.OPEN_SOURCES' | translate
}}</a>
</article>
@@ -24,11 +24,14 @@
text-decoration: none;
color: inherit;
min-width: 0;
transition: transform 0.2s ease, border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
}
.source-row:hover {
border-color: var(--mat-sys-outline);
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--mat-sys-outline) 60%, var(--mat-sys-primary) 40%);
background: var(--mat-sys-surface-container-high);
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.15);
}
.source-row__head {
@@ -70,18 +73,43 @@
border: 1px dashed var(--mat-sys-outline);
border-radius: 12px;
background: var(--mat-sys-surface-container-low);
padding: 14px;
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 16px;
font-size: 0.82rem;
}
.empty-state p {
margin: 0 0 8px;
.empty-state__icon {
font-size: 32px;
width: 32px;
height: 32px;
color: var(--mat-sys-outline);
opacity: 0.7;
}
.empty-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.empty-title {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
}
.empty-hint {
margin: 0;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
}
.empty-state a {
color: var(--mat-sys-primary);
text-decoration: none;
font-weight: 600;
max-width: 240px;
line-height: 1.4;
}
@media (max-width: 920px) {
@@ -1,4 +1,6 @@
import { Component, 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';
@@ -6,7 +8,13 @@ import { DashboardWidgetShellComponent } from './dashboard-widget-shell.componen
@Component({
selector: 'app-recent-sources-widget',
imports: [RouterLink, DashboardWidgetShellComponent, TranslatePipe],
imports: [
MatButtonModule,
MatIcon,
RouterLink,
DashboardWidgetShellComponent,
TranslatePipe,
],
templateUrl: './recent-sources-widget.component.html',
styleUrl: './recent-sources-widget.component.scss',
})
@@ -79,7 +79,12 @@
<app-dashboard-activity-items
[items]="activityItems()"
[emptyLabel]="'WORKSPACE.DASHBOARD.NO_HISTORY' | translate"
emptyIcon="history"
[emptyHint]="'WORKSPACE.DASHBOARD.NO_HISTORY_HINT' | translate"
[viewMode]="viewMode()"
[showRemoveAction]="true"
[removeActionLabel]="'Remove from history'"
(itemAction)="onRemoveItem($event)"
(scrolledToEnd)="onItemsScrolledToEnd()"
/>
</app-dashboard-widget-shell>
@@ -116,6 +116,28 @@ export class RecentlyWatchedWidgetComponent {
this.visibleItemLimit.update((value) => value + PAGE_SIZE);
}
onRemoveItem(item: DashboardActivityItemViewModel): void {
const globalItem = this.data
.globalRecentItems()
.find((i) => `${i.id}-${i.playlist_id}-${i.viewed_at}` === item.id);
if (globalItem) {
void this.data.removeGlobalRecentItem(globalItem);
} else {
// Fallback strategy if custom ID mapping changed
const fallbackItem = this.data
.globalRecentItems()
.find(
(i) =>
item.link.includes(String(i.xtream_id)) ||
item.link.includes(i.playlist_id)
);
if (fallbackItem) {
void this.data.removeGlobalRecentItem(fallbackItem);
}
}
}
private setKind(value: unknown): void {
if (
value === 'all' ||
@@ -3,20 +3,38 @@
[title]="'WORKSPACE.DASHBOARD.SOURCE_STATS' | translate"
>
<div class="stats-grid">
<article class="stat-tile">
<p class="label">{{ 'WORKSPACE.DASHBOARD.TOTAL' | translate }}</p>
<article class="stat-tile stat-tile--total">
<div class="stat-tile__header">
<mat-icon>dns</mat-icon>
<p class="label">
{{ 'WORKSPACE.DASHBOARD.TOTAL' | translate }}
</p>
</div>
<p class="value">{{ data.stats().total }}</p>
</article>
<article class="stat-tile">
<p class="label">{{ 'WORKSPACE.DASHBOARD.XTREAM' | translate }}</p>
<div class="stat-tile__header">
<mat-icon>tv</mat-icon>
<p class="label">
{{ 'WORKSPACE.DASHBOARD.XTREAM' | translate }}
</p>
</div>
<p class="value">{{ data.stats().xtream }}</p>
</article>
<article class="stat-tile">
<p class="label">{{ 'WORKSPACE.DASHBOARD.STALKER' | translate }}</p>
<div class="stat-tile__header">
<mat-icon>router</mat-icon>
<p class="label">
{{ 'WORKSPACE.DASHBOARD.STALKER' | translate }}
</p>
</div>
<p class="value">{{ data.stats().stalker }}</p>
</article>
<article class="stat-tile">
<p class="label">{{ 'WORKSPACE.DASHBOARD.M3U' | translate }}</p>
<div class="stat-tile__header">
<mat-icon>list_alt</mat-icon>
<p class="label">{{ 'WORKSPACE.DASHBOARD.M3U' | translate }}</p>
</div>
<p class="value">{{ data.stats().m3u }}</p>
</article>
</div>
@@ -1,26 +1,64 @@
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.stat-tile {
flex: 1 1 120px;
border-radius: 12px;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container-low);
padding: 10px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
&--total {
background: linear-gradient(135deg, var(--mat-sys-primary-container) 0%, transparent 100%);
border-color: var(--mat-sys-primary);
.stat-tile__header mat-icon,
.label {
color: var(--mat-sys-on-primary-container);
}
.value {
color: var(--mat-sys-on-primary-container);
}
}
}
.stat-tile__header {
display: flex;
align-items: center;
gap: 6px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: var(--mat-sys-on-surface-variant);
}
}
.label {
margin: 0;
color: var(--mat-sys-on-surface-variant);
font-size: 0.76rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
letter-spacing: 0.08em;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.value {
margin: 6px 0 0;
font-size: 1.25rem;
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--mat-sys-on-surface);
line-height: 1;
}
@@ -1,11 +1,12 @@
import { Component, inject } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { DashboardDataService } from 'workspace-dashboard-data-access';
import { DashboardWidgetShellComponent } from './dashboard-widget-shell.component';
@Component({
selector: 'app-source-stats-widget',
imports: [DashboardWidgetShellComponent, TranslatePipe],
imports: [DashboardWidgetShellComponent, TranslatePipe, MatIcon],
templateUrl: './source-stats-widget.component.html',
styleUrl: './source-stats-widget.component.scss',
})