mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: refactor dashboard widgets, and remove the home component.
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
+16
-1
@@ -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 }}
|
||||
|
||||
+34
-3
@@ -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 {
|
||||
|
||||
+21
-1
@@ -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>
|
||||
}
|
||||
+76
-6
@@ -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',
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user