refactor: rework dashboard view

- Removed global favorites related properties and methods from WorkspaceShellHeaderComponent.
- Updated WorkspaceShellFacade to streamline command palette commands and navigation.
- Refactored command palette to include type-aware xtream import progress labels.
- Enhanced WorkspaceShellComponent to display import progress with improved styling.
- Updated tests to reflect changes in command palette and import progress handling.
This commit is contained in:
4gray committed 2026-04-22 22:19:42 +02:00
1 parent fc3db71e85
commit 0e77f7b98a
69 files changed
+2603 -4939

No files matched your search

@@ -1 +1,2 @@
export * from './lib/workspace-dashboard.component';
export * from './lib/rails/workspace-dashboard-rails.component';
export * from './lib/rails/dashboard-rail.component';
@@ -0,0 +1,96 @@
<section class="rail" [attr.data-test-id]="testId()">
<header class="rail__header">
<h2 class="rail__title">
<span class="rail__label">{{ label() }}</span>
@if ((totalCount() ?? items().length) > 0) {
<span class="rail__count" aria-hidden="true">{{
totalCount() ?? items().length
}}</span>
}
</h2>
@if (seeAllLink(); as link) {
<a
class="rail__see-all"
[routerLink]="link"
[attr.data-test-id]="
testId() ? testId() + '-manage-all' : null
"
>
<span class="rail__see-all-label">{{
'WORKSPACE.DASHBOARD.MANAGE_ALL' | translate
}}</span>
<mat-icon>chevron_right</mat-icon>
</a>
}
</header>
<div class="rail__viewport">
<button
type="button"
class="rail__chev rail__chev--left"
[class.rail__chev--visible]="canScrollLeft()"
(click)="scrollBy(-1)"
aria-label="Scroll left"
tabindex="-1"
>
<mat-icon>chevron_left</mat-icon>
</button>
<div
#track
class="rail__track"
(scroll)="onScroll()"
role="list"
>
@for (card of items(); track card.id) {
<a
class="rail__card"
role="listitem"
[routerLink]="card.link"
[state]="card.state"
[attr.title]="card.title"
[attr.data-test-id]="testId() ? testId() + '-card' : null"
>
<div
class="rail__art"
[style.aspect-ratio]="aspectRatio()"
>
@if (card.imageUrl && !failedImages()[card.id]) {
<img
[src]="card.imageUrl"
[alt]="card.title"
loading="lazy"
decoding="async"
(error)="markFailed(card.id)"
/>
} @else {
<div class="rail__art-fallback">
<mat-icon>{{ card.icon }}</mat-icon>
</div>
}
</div>
<div class="rail__meta">
<span class="rail__card-title">{{ card.title }}</span>
@if (card.subtitle) {
<span class="rail__card-subtitle">{{
card.subtitle
}}</span>
}
</div>
</a>
}
</div>
<button
type="button"
class="rail__chev rail__chev--right"
[class.rail__chev--visible]="canScrollRight()"
(click)="scrollBy(1)"
aria-label="Scroll right"
tabindex="-1"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
</section>
@@ -0,0 +1,361 @@
:host {
display: block;
--rail-card-width: 172px;
--rail-gap: 14px;
--rail-radius: 12px;
}
.rail {
display: flex;
flex-direction: column;
gap: 12px;
}
.rail__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 2px;
// Tighter height cap — keeps rail chapter markers compact
min-height: 24px;
}
.rail__title {
margin: 0;
display: inline-flex;
align-items: baseline;
gap: 8px;
font-size: 1.125rem; // 18px — matches Netflix rail-title scale
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.012em;
color: var(--app-heading-color, #1a1d23);
}
// Muted count badge, tabular numerals for alignment, sits just after label.
.rail__count {
font-size: 0.78rem;
font-weight: 500;
line-height: 1;
color: var(--app-muted-color, #8891a4);
font-variant-numeric: tabular-nums;
// Tiny capsule to read as a "count chip", not a number-in-text
padding: 2px 8px;
border-radius: 999px;
background: color-mix(
in srgb,
var(--app-muted-color, #8891a4) 18%,
transparent
);
letter-spacing: 0;
}
.rail__see-all {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 4px 8px 4px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 500;
line-height: 1;
letter-spacing: 0.005em;
color: var(--app-muted-color, #8891a4);
text-decoration: none;
transition:
color 0.15s ease,
background-color 0.15s ease;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
transition: transform 0.15s ease;
}
&:hover {
color: var(--app-selection-color, #2f7bff);
background: color-mix(
in srgb,
var(--app-selection-color, #2f7bff) 12%,
transparent
);
mat-icon {
transform: translateX(2px);
}
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #2f7bff);
outline-offset: 2px;
}
}
.rail__viewport {
position: relative;
min-width: 0;
// Soft edge gradients hint at horizontal scroll without any interaction.
// Anchored to the viewport so they appear/disappear in sync with the
// chevrons' `.rail__chev--visible` state.
&::before,
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 32px;
pointer-events: none;
z-index: 1;
opacity: 0;
transition: opacity 0.2s ease;
}
&::before {
left: 0;
background: linear-gradient(
to right,
var(--app-content-bg, #161a22) 0%,
transparent 100%
);
}
&::after {
right: 0;
background: linear-gradient(
to left,
var(--app-content-bg, #161a22) 0%,
transparent 100%
);
}
// Show the correct edge gradient when scroll is possible in that direction
&:has(.rail__chev--left.rail__chev--visible)::before {
opacity: 0.9;
}
&:has(.rail__chev--right.rail__chev--visible)::after {
opacity: 0.9;
}
&:hover .rail__chev--visible {
opacity: 1;
transform: translateY(-50%) scale(1);
}
}
.rail__track {
display: flex;
align-items: flex-start;
gap: var(--rail-gap);
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scroll-padding-inline-start: 4px;
scrollbar-width: none;
padding: 4px;
margin: -4px;
&::-webkit-scrollbar {
display: none;
}
}
.rail__card {
// Pin the card size so long titles never expand the flex item.
// `min-width: 0` is needed because flex items default to `min-width: auto`
// (their intrinsic content size), which would otherwise override the
// flex-basis when the title wraps.
flex: 0 0 var(--rail-card-width);
width: var(--rail-card-width);
max-width: var(--rail-card-width);
min-width: 0;
scroll-snap-align: start;
display: flex;
flex-direction: column;
gap: 8px;
text-decoration: none;
color: inherit;
border-radius: var(--rail-radius);
transition:
transform 0.2s ease,
filter 0.2s ease;
&:hover {
transform: translateY(-2px);
.rail__art {
box-shadow:
0 12px 22px -12px rgba(0, 0, 0, 0.45),
0 0 0 1px var(--app-selection-border, rgba(47, 123, 255, 0.4));
}
.rail__card-title {
color: var(--app-selection-color, #2f7bff);
}
}
}
.rail__art {
position: relative;
overflow: hidden;
border-radius: var(--rail-radius);
background: var(--app-widget-bg, #1e2330);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
transition: box-shadow 0.2s ease;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.rail__art-fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(
circle at 30% 20%,
var(--app-selection-surface, rgba(120, 173, 255, 0.18)) 0%,
transparent 55%
),
var(--app-widget-bg, #1e2330);
mat-icon {
font-size: 44px;
width: 44px;
height: 44px;
color: var(--app-selection-color, #78adff);
opacity: 0.55;
}
}
.rail__meta {
display: flex;
flex-direction: column;
gap: 3px;
padding: 0 2px;
min-width: 0;
}
.rail__card-title {
font-size: 0.85rem;
font-weight: 600;
line-height: 1.3;
color: var(--app-heading-color, #1a1d23);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -0.005em;
transition: color 0.15s ease;
}
.rail__card-subtitle {
font-size: 0.72rem;
font-weight: 500;
line-height: 1.4;
color: var(--app-muted-color, #8891a4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.005em;
}
// Circular chevron — streaming-app pattern (Netflix/Apple TV+). High-contrast
// dark glass works against any card color, including the muted fallback tiles
// which previously camouflaged the buttons.
.rail__chev {
position: absolute;
top: 50%;
transform: translateY(-50%) scale(0.96);
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 50%;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: #fff;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition:
opacity 0.18s ease,
background-color 0.18s ease,
transform 0.18s ease,
box-shadow 0.18s ease;
z-index: 2;
box-shadow:
0 4px 14px -4px rgba(0, 0, 0, 0.55),
0 0 0 0 rgba(255, 255, 255, 0); // slot for hover ring
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
line-height: 24px;
// Keep icon crisp over bright content
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
}
// Peek at rest when scroll is possible — hints the affordance exists
// without requiring the user to hover first.
&--visible {
opacity: 0.5;
pointer-events: auto;
}
&:hover {
background: rgba(0, 0, 0, 0.88);
transform: translateY(-50%) scale(1.08);
box-shadow:
0 8px 22px -6px rgba(0, 0, 0, 0.65),
0 0 0 4px rgba(255, 255, 255, 0.06);
}
&:active {
transform: translateY(-50%) scale(0.96);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 3px;
}
&--left {
left: 6px;
}
&--right {
right: 6px;
}
}
@media (hover: none) {
.rail__chev {
display: none;
}
.rail__viewport::before,
.rail__viewport::after {
display: none;
}
}
@media (max-width: 720px) {
:host {
--rail-card-width: 140px;
}
}
@@ -0,0 +1,126 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
OnDestroy,
effect,
signal,
viewChild,
input,
} 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';
export interface DashboardRailCard {
id: string;
title: string;
subtitle?: string;
imageUrl?: string;
icon: string;
link: string[];
state?: Record<string, unknown>;
}
@Component({
selector: 'lib-dashboard-rail',
imports: [MatButtonModule, MatIcon, RouterLink, TranslatePipe],
templateUrl: './dashboard-rail.component.html',
styleUrl: './dashboard-rail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardRailComponent implements AfterViewInit, OnDestroy {
readonly label = input.required<string>();
readonly items = input.required<DashboardRailCard[]>();
readonly seeAllLink = input<string[] | null>(null);
readonly aspectRatio = input<string>('2 / 3');
readonly testId = input<string | null>(null);
/**
* True total in the underlying dataset. Shown as a count badge next to
* the rail label. Falls back to `items().length` when not supplied.
*/
readonly totalCount = input<number | null>(null);
private readonly track = viewChild.required<ElementRef<HTMLDivElement>>('track');
readonly canScrollLeft = signal(false);
readonly canScrollRight = signal(false);
readonly failedImages = signal<Record<string, true>>({});
private readonly viewReady = signal(false);
private resizeObserver?: ResizeObserver;
private resetFrameId: number | null = null;
private settleFrameId: number | null = null;
constructor() {
effect(() => {
this.items();
if (!this.viewReady()) return;
this.scheduleResetToStart();
});
}
ngAfterViewInit(): void {
this.viewReady.set(true);
this.updateScrollState();
this.resizeObserver = new ResizeObserver(() => this.updateScrollState());
this.resizeObserver.observe(this.track().nativeElement);
}
ngOnDestroy(): void {
this.resizeObserver?.disconnect();
this.cancelPendingReset();
}
onScroll(): void {
this.updateScrollState();
}
scrollBy(direction: 1 | -1): void {
const el = this.track().nativeElement;
el.scrollBy({
left: direction * el.clientWidth * 0.85,
behavior: 'smooth',
});
}
markFailed(id: string): void {
this.failedImages.update((state) =>
state[id] ? state : { ...state, [id]: true }
);
}
private updateScrollState(): void {
const el = this.track().nativeElement;
this.canScrollLeft.set(el.scrollLeft > 4);
this.canScrollRight.set(
el.scrollLeft + el.clientWidth < el.scrollWidth - 4
);
}
private scheduleResetToStart(): void {
this.cancelPendingReset();
this.resetFrameId = requestAnimationFrame(() => {
this.resetFrameId = null;
this.settleFrameId = requestAnimationFrame(() => {
this.settleFrameId = null;
const el = this.track().nativeElement;
el.scrollTo({ left: 0, behavior: 'auto' });
this.updateScrollState();
});
});
}
private cancelPendingReset(): void {
if (this.resetFrameId !== null) {
cancelAnimationFrame(this.resetFrameId);
this.resetFrameId = null;
}
if (this.settleFrameId !== null) {
cancelAnimationFrame(this.settleFrameId);
this.settleFrameId = null;
}
}
}
@@ -0,0 +1,130 @@
<section class="rails-page" data-test-id="dashboard-page">
@if (!ready()) {
<!-- Precise ghosts that mirror the real hero + rails so the layout
doesn't reflow when data arrives. -->
<section
class="rails-page__skeleton-hero"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-hero-poster"></div>
<div class="rails-page__skeleton-hero-body">
<div class="rails-page__skeleton-hero-title"></div>
<div
class="rails-page__skeleton-hero-title rails-page__skeleton-hero-title--short"
></div>
<div class="rails-page__skeleton-hero-subtitle"></div>
<div class="rails-page__skeleton-hero-cta"></div>
</div>
</section>
@for (_ of skeletonRails; track $index) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
}
} @else if (!hasPlaylists()) {
<app-empty-state
[type]="'welcome'"
(addPlaylistClicked)="onAddPlaylist()"
/>
} @else {
@if (hero(); as item) {
<a
class="hero"
data-test-id="dashboard-hero"
[class.hero--with-image]="!!item.posterUrl || !!item.backdropUrl"
[class.hero--has-backdrop]="item.hasBackdrop"
[routerLink]="item.link"
[state]="item.state ?? {}"
>
@if (item.backdropUrl) {
<span
class="hero__backdrop"
aria-hidden="true"
[style.backgroundImage]="'url(' + item.backdropUrl + ')'"
></span>
}
<span class="hero__vignette" aria-hidden="true"></span>
<span class="hero__poster">
@if (item.posterUrl) {
<img
[src]="item.posterUrl"
[alt]="item.title"
loading="eager"
decoding="async"
/>
} @else {
<span class="hero__poster-fallback">
<mat-icon>{{ item.icon }}</mat-icon>
</span>
}
</span>
<span class="hero__body">
<h1 class="hero__title">{{ item.title }}</h1>
<p class="hero__subtitle">{{ item.subtitle }}</p>
<span class="hero__cta">
<mat-icon>play_arrow</mat-icon>
<span>{{
'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate
}}</span>
</span>
</span>
</a>
}
@if (recentlyWatchedCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.RECENTLY_WATCHED' | translate"
[items]="recentlyWatchedCards()"
[totalCount]="data.globalRecentItems().length"
[seeAllLink]="['/workspace/global-recent']"
[testId]="'dashboard-recently-watched-rail'"
/>
}
@if (favoriteCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES' | translate"
[items]="favoriteCards()"
[totalCount]="data.globalFavoriteItems().length"
[seeAllLink]="['/workspace/global-favorites']"
[testId]="'dashboard-global-favorites-rail'"
/>
}
@if (sourceCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.RECENT_SOURCES' | translate"
[items]="sourceCards()"
[totalCount]="data.playlists().length"
[seeAllLink]="['/workspace/sources']"
aspectRatio="16 / 9"
[testId]="'dashboard-recent-sources-rail'"
/>
}
@if (xtreamRecentlyAddedCards().length > 0) {
<lib-dashboard-rail
[label]="
'WORKSPACE.DASHBOARD.XTREAM_RECENTLY_ADDED' | translate
"
[items]="xtreamRecentlyAddedCards()"
[totalCount]="data.xtreamRecentlyAddedItems().length"
[testId]="'dashboard-xtream-recently-added-rail'"
/>
}
}
</section>
@@ -0,0 +1,427 @@
:host {
display: block;
padding: 24px 28px 40px;
box-sizing: border-box;
min-height: 100%;
}
.rails-page {
max-width: 1440px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 32px;
min-width: 0;
}
// ── Hero ────────────────────────────────────────────────────────────────────
// Netflix/Apple TV-style hero: blurred poster backdrop + vignette + sharp 2:3
// poster card + text block. Mirrors content-hero fallback (no dedicated
// backdrop asset available, so we reuse the poster blurred + scaled).
.hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, var(--hero-poster-width)) minmax(0, 1fr);
gap: 24px;
padding: 20px;
text-decoration: none;
color: inherit;
border-radius: 18px;
overflow: hidden;
isolation: isolate;
background: var(--app-widget-header-bg, #232840);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.08));
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.2),
0 12px 32px -18px var(--app-selection-glow, rgba(120, 173, 255, 0.4));
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
--hero-poster-width: 160px;
&:hover {
transform: translateY(-2px);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.2),
0 20px 40px -20px var(--app-selection-glow, rgba(120, 173, 255, 0.55));
}
// No-image fallback keeps the accent gradient so the hero is never flat.
&:not(.hero--with-image) {
background:
var(
--app-accent-gradient,
linear-gradient(
135deg,
rgba(99, 102, 241, 0.12) 0%,
rgba(168, 85, 247, 0.07) 100%
)
),
var(--app-widget-header-bg, #232840);
}
}
// Layer 0: backdrop. Two modes:
// (1) Real backdrop (`.hero--has-backdrop`): sharp wide image with no blur.
// (2) Blurred-poster fallback: heavy blur + scale so the poster's colours
// wash across the full panel without the portrait edges showing.
.hero__backdrop {
position: absolute;
inset: 0;
background-size: cover;
background-position: center 25%;
background-repeat: no-repeat;
z-index: 0;
pointer-events: none;
// Fallback: blurred poster.
filter: blur(40px) saturate(1.5);
transform: scale(1.5);
opacity: 0.9;
}
.hero--has-backdrop .hero__backdrop {
filter: none;
transform: none;
opacity: 1;
}
// Layer 1: vignette. Scrim behind the poster card (contrast) and on the
// right edge (text legibility). Middle stays mostly transparent so the
// backdrop colours show through. Darkens further when a sharp backdrop is
// present, since real photography has more focal variation than a blur wash.
.hero__vignette {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background:
linear-gradient(
90deg,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 55%,
transparent
)
0%,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 10%,
transparent
)
45%,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 65%,
transparent
)
100%
),
linear-gradient(
to bottom,
transparent 55%,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 35%,
transparent
)
100%
);
}
.hero--has-backdrop .hero__vignette {
background:
linear-gradient(
90deg,
rgba(0, 0, 0, 0.55) 0%,
rgba(0, 0, 0, 0.2) 35%,
rgba(0, 0, 0, 0.78) 100%
),
linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 40%,
rgba(0, 0, 0, 0.45) 100%
);
}
// Layer 2: sharp poster in native 2:3 aspect (no more 16:9 crop).
.hero__poster {
position: relative;
z-index: 2;
width: var(--hero-poster-width);
aspect-ratio: 2 / 3;
border-radius: 12px;
overflow: hidden;
background: var(--app-widget-bg, #1e2330);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.1));
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.55);
align-self: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.hero__poster-fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(
circle at 50% 40%,
var(--app-selection-surface, rgba(120, 173, 255, 0.2)) 0%,
transparent 60%
);
mat-icon {
font-size: 56px;
width: 56px;
height: 56px;
color: var(--app-selection-color, #78adff);
opacity: 0.75;
}
}
.hero__body {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
min-width: 0;
}
.hero__title {
margin: 6px 0 4px;
font-size: 1.75rem;
font-weight: 600;
line-height: 1.15;
// Display-weight tracking: looser than -0.02em which crushes glyphs at
// this size (~28px). Matches Apple TV+ "Up Next" card scale.
letter-spacing: -0.015em;
color: var(--app-heading-color, #d8dce8);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
.hero--with-image & {
color: #fff;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
}
}
.hero__subtitle {
margin: 0;
font-size: 0.88rem;
font-weight: 500;
line-height: 1.45;
letter-spacing: 0.002em;
color: var(--app-body-color, #8b93a8);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.hero--with-image & {
color: rgba(255, 255, 255, 0.82);
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
}
}
.hero__cta {
margin-top: 16px;
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px 10px 14px;
border-radius: 999px;
background: var(--app-selection-color, #78adff);
color: var(--app-selection-on-color, #071225);
font-size: 0.875rem;
font-weight: 600;
line-height: 1;
letter-spacing: 0.005em;
// Explicit min-height anchors the text baseline regardless of font-smoothing
min-height: 36px;
box-sizing: border-box;
box-shadow: 0 4px 12px -4px var(--app-selection-glow, rgba(120, 173, 255, 0.5));
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
line-height: 20px;
}
}
// ── Loading skeleton ────────────────────────────────────────────────────────
// Geometry mirrors the real hero + rails so the layout doesn't shift when
// data arrives. All shimmer targets share the `%skeleton-shimmer` placeholder
// defined below.
%skeleton-shimmer {
background: linear-gradient(
90deg,
var(--app-widget-bg, #1e2330) 0%,
var(--app-widget-header-bg, #232840) 50%,
var(--app-widget-bg, #1e2330) 100%
);
background-size: 200% 100%;
animation: dashboard-skeleton-shimmer 1.6s linear infinite;
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
}
@keyframes dashboard-skeleton-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
// ── Hero ghost ──────────────────────────────────────────────────────────────
.rails-page__skeleton-hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, 160px) minmax(0, 1fr);
gap: 24px;
padding: 20px;
border-radius: 18px;
background: var(--app-widget-header-bg, #232840);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.08));
}
.rails-page__skeleton-hero-poster {
@extend %skeleton-shimmer;
width: 160px;
aspect-ratio: 2 / 3;
border-radius: 12px;
align-self: center;
}
.rails-page__skeleton-hero-body {
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
min-width: 0;
}
.rails-page__skeleton-hero-title {
@extend %skeleton-shimmer;
width: 60%;
height: 28px;
border-radius: 6px;
}
.rails-page__skeleton-hero-title--short {
width: 35%;
}
.rails-page__skeleton-hero-subtitle {
@extend %skeleton-shimmer;
width: 45%;
height: 14px;
border-radius: 4px;
margin-top: 2px;
}
.rails-page__skeleton-hero-cta {
@extend %skeleton-shimmer;
width: 160px;
height: 36px;
border-radius: 999px;
margin-top: 14px;
}
// ── Rail ghost ──────────────────────────────────────────────────────────────
.rails-page__skeleton-rail {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 0 0;
}
.rails-page__skeleton-header {
@extend %skeleton-shimmer;
width: 180px;
height: 22px;
border-radius: 6px;
}
.rails-page__skeleton-track {
display: flex;
gap: 14px;
padding: 4px 0 16px;
overflow: hidden;
}
.rails-page__skeleton-card {
@extend %skeleton-shimmer;
// Matches the real .rail__card width (--rail-card-width: 172px) and
// .rail__art aspect-ratio 2/3, so when cards arrive the geometry is
// identical and there's no layout shift.
flex: 0 0 172px;
width: 172px;
aspect-ratio: 2 / 3;
border-radius: 12px;
}
@media (prefers-reduced-motion: reduce) {
.rails-page__skeleton-hero-poster,
.rails-page__skeleton-hero-title,
.rails-page__skeleton-hero-subtitle,
.rails-page__skeleton-hero-cta,
.rails-page__skeleton-header,
.rails-page__skeleton-card {
animation: none;
background-position: 0 0;
}
}
// ── Responsive ──────────────────────────────────────────────────────────────
@media (max-width: 720px) {
:host {
padding: 16px 16px 32px;
}
.rails-page {
gap: 24px;
}
.hero {
--hero-poster-width: 120px;
grid-template-columns: var(--hero-poster-width) minmax(0, 1fr);
gap: 14px;
padding: 14px;
}
// Skeleton hero mirrors real hero at this breakpoint.
.rails-page__skeleton-hero {
grid-template-columns: 120px minmax(0, 1fr);
gap: 14px;
padding: 14px;
}
.rails-page__skeleton-hero-poster {
width: 120px;
}
.rails-page__skeleton-card {
flex: 0 0 140px;
width: 140px;
}
.hero__title {
font-size: 1.3rem;
}
}
@@ -0,0 +1,210 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
import {
DashboardDataService,
DashboardFavoriteItem,
DashboardRecentlyAddedItem,
GlobalRecentItem,
} from 'workspace-dashboard-data-access';
import {
DashboardRailCard,
DashboardRailComponent,
} from './dashboard-rail.component';
// Cap dashboard rails at 20 items. Users get ~3× what's visible at once,
// the DOM stays cheap, and the "Manage all" link is one click away for the
// full list. Matches the single-rail density of Netflix / Apple TV+.
const RAIL_ITEM_LIMIT = 20;
// Six placeholder slots per skeleton rail — fills a typical viewport without
// taking the whole page. Mirrors the recently-added skeleton density.
const SKELETON_CARDS_PER_RAIL = [1, 2, 3, 4, 5, 6] as const;
const SKELETON_RAILS = [1, 2, 3] as const;
interface DashboardHeroModel {
readonly backdropUrl?: string;
readonly hasBackdrop: boolean;
readonly icon: string;
readonly link: string[];
readonly posterUrl?: string;
readonly state?: Record<string, unknown>;
readonly subtitle: string;
readonly title: string;
}
@Component({
selector: 'lib-workspace-dashboard-rails',
imports: [
DashboardRailComponent,
EmptyStateComponent,
MatButtonModule,
MatIcon,
RouterLink,
TranslatePipe,
],
templateUrl: './workspace-dashboard-rails.component.html',
styleUrl: './workspace-dashboard-rails.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkspaceDashboardRailsComponent {
readonly data = inject(DashboardDataService);
private readonly translate = inject(TranslateService);
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
readonly hasPlaylists = computed(() => this.data.playlists().length > 0);
readonly ready = this.data.dashboardReady;
readonly xtreamPlaylistCount = this.data.xtreamPlaylistCount;
readonly skeletonSlots = SKELETON_CARDS_PER_RAIL;
readonly skeletonRails = SKELETON_RAILS;
readonly hero = computed<DashboardHeroModel | null>(() => {
const item = this.data.globalRecentItems()[0] ?? null;
if (!item) {
return null;
}
return {
backdropUrl: item.backdrop_url || item.poster_url,
hasBackdrop: Boolean(item.backdrop_url),
icon: this.typeIcon(item.type),
link: this.data.getRecentItemLink(item),
posterUrl: item.poster_url,
state: this.data.getRecentItemNavigationState(item),
subtitle: this.buildHeroSubtitle(item),
title: item.title,
};
});
readonly recentlyWatchedCards = computed<DashboardRailCard[]>(() =>
this.data
.globalRecentItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toRecentCard(item))
);
readonly favoriteCards = computed<DashboardRailCard[]>(() =>
this.data
.globalFavoriteItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toFavoriteCard(item))
);
readonly xtreamRecentlyAddedCards = computed<DashboardRailCard[]>(() =>
this.data
.xtreamRecentlyAddedItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toRecentlyAddedCard(item))
);
readonly sourceCards = computed<DashboardRailCard[]>(() =>
this.data.recentPlaylists().map((playlist) => ({
id: playlist._id,
title:
playlist.title ||
playlist.filename ||
this.t('WORKSPACE.DASHBOARD.UNTITLED_SOURCE'),
subtitle: this.data.getPlaylistProvider(playlist),
icon: playlist.serverUrl
? 'cloud'
: playlist.macAddress
? 'cast'
: 'folder_open',
link: this.data.getPlaylistLink(playlist),
}))
);
constructor() {
// Re-entering the dashboard should pick up any DB-backed recent/favorite
// changes made while viewing details, including newly backfilled
// backdrops that do not change recency ordering.
void this.data.reloadGlobalRecentItems();
void this.data.reloadGlobalFavorites();
// Refresh when Xtream playlist count changes so a newly added provider
// populates the rail without a manual dashboard reload.
effect(() => {
if (this.xtreamPlaylistCount() === 0) {
return;
}
void this.data.reloadXtreamRecentlyAddedItems(RAIL_ITEM_LIMIT);
});
}
onAddPlaylist(): void {
this.shellActions.openAddPlaylistDialog();
}
private buildHeroSubtitle(item: GlobalRecentItem): string {
const parts = [
item.playlist_name,
this.data.getRecentItemProviderLabel(item),
this.data.getRecentItemTypeLabel(item),
].filter((value): value is string => Boolean(value));
return parts.join(' · ');
}
private toRecentCard(item: GlobalRecentItem): DashboardRailCard {
return {
id: `recent-${item.id}-${item.playlist_id}-${item.viewed_at}`,
title: item.title,
subtitle: `${this.data.getRecentItemProviderLabel(item)} · ${this.data.getRecentItemTypeLabel(item)}`,
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
link: this.data.getRecentItemLink(item),
state: this.data.getRecentItemNavigationState(item),
};
}
private toFavoriteCard(item: DashboardFavoriteItem): DashboardRailCard {
return {
id: `fav-${item.id}-${item.playlist_id}-${item.added_at}`,
title: item.title,
subtitle: `${this.data.getFavoriteItemProviderLabel(item)} · ${this.data.getFavoriteItemTypeLabel(item)}`,
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
link: this.data.getGlobalFavoriteLink(item),
state: this.data.getGlobalFavoriteNavigationState(item),
};
}
private toRecentlyAddedCard(
item: DashboardRecentlyAddedItem
): DashboardRailCard {
const typeLabel = this.data.getRecentlyAddedItemTypeLabel(item);
const subtitleParts = [item.playlist_name, typeLabel].filter(
(value): value is string => Boolean(value)
);
return {
id: `added-${item.id}-${item.playlist_id}-${item.added_at}`,
title: item.title,
subtitle: subtitleParts.join(' · '),
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
link: this.data.getRecentlyAddedLink(item),
state: this.data.getRecentlyAddedNavigationState(item),
};
}
private typeIcon(type: 'live' | 'movie' | 'series'): string {
if (type === 'live') return 'live_tv';
if (type === 'movie') return 'movie';
return 'video_library';
}
private t(key: string): string {
return this.translate.instant(key);
}
}
@@ -1,198 +0,0 @@
<section class="dashboard-page">
<section
class="dashboard-topbar"
[class.dashboard-topbar--editing]="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()"
>
{{ 'WORKSPACE.DASHBOARD.TOOLBAR_RESET' | translate }}
</button>
<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
"
[attr.aria-label]="
'WORKSPACE.DASHBOARD.CUSTOMIZE_LAYOUT' | translate
"
>
<mat-icon>tune</mat-icon>
</button>
}
</section>
<section
class="dashboard-editor-shell"
[class.dashboard-editor-shell--editing]="editMode()"
>
<section class="dashboard-canvas">
<section
class="widget-grid"
cdkDropList
[class.widget-grid--editing]="editMode()"
[cdkDropListData]="visibleWidgets()"
[cdkDropListDisabled]="!editMode()"
(cdkDropListDropped)="onWidgetDrop($event)"
>
@for (widget of visibleWidgets(); track widget.id) {
<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--half]="widget.size === 'half'"
[class.widget-slot--two-thirds]="
widget.size === 'two-thirds'
"
[class.widget-slot--full]="widget.size === 'full'"
[class.widget-slot--editing]="editMode()"
>
@if (editMode()) {
<section class="widget-inline-editor">
<button
type="button"
mat-icon-button
class="drag-handle"
cdkDragHandle
[attr.aria-label]="
getDragWidgetLabel(widget)
"
>
<mat-icon>drag_indicator</mat-icon>
</button>
<div class="size-stepper">
<button
type="button"
mat-icon-button
class="size-stepper__btn"
[disabled]="isFirstSize(widget.size)"
(click)="cycleWidgetSize(widget.id, -1)"
[attr.aria-label]="
'WORKSPACE.DASHBOARD.MAKE_WIDGET_NARROWER'
| translate
"
>
<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)"
[attr.aria-label]="
'WORKSPACE.DASHBOARD.MAKE_WIDGET_WIDER'
| translate
"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<button
type="button"
mat-icon-button
class="widget-inline-editor__hide"
[attr.aria-label]="
getHideWidgetLabel(widget)
"
(click)="hideWidget(widget.id)"
>
<mat-icon>visibility_off</mat-icon>
</button>
</section>
}
<app-dashboard-widget-host [widget]="widget" />
</article>
} @empty {
<article class="empty-state">
<h3>
{{
'WORKSPACE.DASHBOARD.EMPTY_WIDGETS' | translate
}}
</h3>
<p>
{{
'WORKSPACE.DASHBOARD.EMPTY_WIDGETS_HINT'
| translate
}}
</p>
</article>
}
</section>
</section>
@if (editMode()) {
<aside class="widget-library">
<section class="layout-editor">
<h2>
{{
'WORKSPACE.DASHBOARD.WIDGET_LIBRARY_TITLE'
| translate
}}
</h2>
<p class="layout-editor__hint">
{{
'WORKSPACE.DASHBOARD.WIDGET_LIBRARY_HINT'
| translate
}}
</p>
<div class="editor-list">
@for (widget of allWidgets(); track widget.id) {
<article class="editor-item">
<div class="editor-item__meta">
<h3>{{ getWidgetTitle(widget) }}</h3>
<p>{{ getWidgetDescription(widget) }}</p>
</div>
<div class="editor-item__actions">
<p class="editor-item__action-label">
{{
'WORKSPACE.DASHBOARD.WIDGET_VISIBILITY'
| translate
}}
</p>
<mat-slide-toggle
[checked]="widget.enabled"
(change)="
layoutService.toggleWidget(
widget.id
)
"
></mat-slide-toggle>
</div>
</article>
}
</div>
</section>
</aside>
}
</section>
</section>
@@ -1,471 +0,0 @@
:host {
display: block;
min-height: 100%;
box-sizing: border-box;
padding: 20px;
--dashboard-accent: color-mix(in srgb, var(--mat-sys-primary) 84%, #1c75ff 16%);
--dashboard-frame-border: color-mix(
in srgb,
var(--mat-sys-outline-variant) 72%,
var(--dashboard-accent) 28%
);
--dashboard-frame-glow: color-mix(in srgb, var(--dashboard-accent) 34%, transparent);
--dashboard-frame-tint: color-mix(
in srgb,
var(--dashboard-accent) 12%,
var(--mat-sys-surface-container-high) 88%
);
--widget-grid-gap: 12px;
--dashboard-widget-height: 400px;
}
.dashboard-page {
max-width: 1440px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
overflow-x: clip;
}
// ─── 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-topbar--editing {
position: sticky;
top: 8px;
z-index: 20;
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);
}
.topbar-label {
margin: 0;
font-size: 0.72rem;
font-weight: 600;
color: var(--mat-sys-on-surface-variant);
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.6;
}
.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: 6px;
flex-shrink: 0;
}
.dashboard-editor-shell {
border-radius: 18px;
border: 1px solid color-mix(in srgb, var(--dashboard-frame-border) 72%, transparent);
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 84%,
var(--dashboard-frame-tint) 16%
);
overflow: clip;
min-width: 0;
}
.dashboard-editor-shell--editing {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 0;
align-items: start;
}
.dashboard-canvas {
min-width: 0;
overflow-x: clip;
}
.widget-library {
border-left: 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: -18px 0 30px -22px var(--dashboard-frame-glow);
max-height: calc(100vh - 96px);
overflow: auto;
overflow-x: hidden;
position: sticky;
top: 76px;
min-width: 0;
}
.layout-editor {
padding: 14px 14px 24px;
}
.layout-editor h2 {
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 {
margin: 6px 0 12px;
color: var(--mat-sys-on-surface-variant);
font-size: 0.8rem;
}
.editor-list {
display: grid;
gap: 8px;
}
.editor-item {
border: 1px solid var(--dashboard-frame-border);
border-radius: 14px;
background: color-mix(
in srgb,
var(--mat-sys-surface-container-low) 84%,
var(--dashboard-frame-tint) 16%
);
padding: 10px 12px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
overflow: hidden;
}
.editor-item__meta h3 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--mat-sys-on-surface);
}
.editor-item__meta p {
margin: 4px 0 0;
font-size: 0.79rem;
color: var(--mat-sys-on-surface-variant);
}
.editor-item__actions {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
justify-content: space-between;
padding-top: 6px;
border-top: 1px solid color-mix(in srgb, var(--dashboard-frame-border) 50%, transparent);
margin-top: 6px;
}
.editor-item__action-label {
margin: 0;
font-size: 0.74rem;
color: var(--mat-sys-on-surface-variant);
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
.widget-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: var(--widget-grid-gap);
align-items: stretch;
position: relative;
//padding: 12px;
min-width: 0;
overflow-x: clip;
}
.widget-grid--editing::before {
content: '';
position: absolute;
inset: 12px;
pointer-events: none;
background-image: repeating-linear-gradient(
to right,
color-mix(in srgb, var(--dashboard-frame-border) 60%, transparent) 0,
color-mix(in srgb, var(--dashboard-frame-border) 60%, transparent) 1px,
transparent 1px,
transparent calc(100% / 12)
);
opacity: 0.5;
z-index: 0;
}
.widget-slot {
min-width: 0;
min-height: 0;
border: 1px solid var(--dashboard-frame-border);
border-radius: 20px;
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 80%,
var(--dashboard-frame-tint) 20%
);
padding: 3px;
box-sizing: border-box;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--dashboard-frame-border) 64%, transparent),
0 14px 24px -22px var(--dashboard-frame-glow);
overflow: hidden;
position: relative;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
contain: layout style;
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 {
//transform: translateY(-1px);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--dashboard-frame-border) 52%, transparent),
0 18px 28px -20px var(--dashboard-frame-glow);
}
// 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 {
cursor: grabbing;
}
.widget-slot--editing app-dashboard-widget-host {
pointer-events: none;
user-select: none;
}
.widget-inline-editor {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
z-index: 4;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--dashboard-frame-border);
border-radius: 12px;
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 92%,
var(--dashboard-accent) 8%
);
padding: 4px 6px;
}
.drag-handle {
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
}
// ── 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;
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 {
display: block;
height: 100%;
}
.widget-slot--one-third {
grid-column: span 4;
}
.widget-slot--half {
grid-column: span 6;
}
.widget-slot--two-thirds {
grid-column: span 8;
}
.widget-slot--full {
grid-column: span 12;
}
.empty-state {
grid-column: 1 / -1;
border: 1px dashed var(--mat-sys-outline);
border-radius: 14px;
background: var(--mat-sys-surface-container-low);
padding: 20px;
z-index: 1;
}
.empty-state h3 {
margin: 0 0 8px;
}
.empty-state p {
margin: 0;
color: var(--mat-sys-on-surface-variant);
}
:host ::ng-deep .cdk-drag-preview {
border-radius: 20px;
border: 1px solid var(--dashboard-frame-border);
box-shadow: 0 24px 40px -20px var(--dashboard-frame-glow);
background: var(--mat-sys-surface-container-high);
}
:host ::ng-deep .cdk-drop-list-dragging .widget-slot:not(.cdk-drag-placeholder) {
transition: transform 0.2s ease;
}
:host ::ng-deep .widget-slot.cdk-drag-placeholder {
border: 1px dashed var(--dashboard-frame-border);
background: color-mix(in srgb, var(--dashboard-frame-tint) 72%, transparent);
}
@media (max-width: 1180px) {
.dashboard-editor-shell--editing {
grid-template-columns: minmax(0, 1fr) 340px;
}
.widget-slot--one-third,
.widget-slot--half,
.widget-slot--two-thirds {
grid-column: span 6;
}
}
@media (max-width: 860px) {
:host {
padding: 14px;
}
.dashboard-topbar--editing {
flex-wrap: wrap;
height: auto;
padding: 8px 8px;
}
.dashboard-editor-shell--editing {
grid-template-columns: 1fr;
}
.widget-library {
border-left: 0;
border-top: 1px solid var(--dashboard-frame-border);
max-height: none;
position: static;
box-shadow: none;
overflow: visible;
}
.widget-inline-editor {
gap: 4px;
}
.size-stepper__label {
display: none;
}
.widget-grid {
grid-template-columns: 1fr;
}
.widget-slot {
height: 480px;
}
.widget-slot--one-third,
.widget-slot--half,
.widget-slot--two-thirds,
.widget-slot--full {
grid-column: span 1;
}
}
@@ -1,150 +0,0 @@
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop';
import { Component, computed, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import {
DashboardLayoutService,
DashboardWidgetConfig,
DashboardWidgetSize,
} from 'workspace-dashboard-data-access';
import { DashboardWidgetHostComponent } from 'workspace-dashboard-ui';
@Component({
selector: 'app-workspace-dashboard',
imports: [
DashboardWidgetHostComponent,
DragDropModule,
MatButtonModule,
MatIcon,
MatSlideToggleModule,
MatTooltip,
TranslatePipe,
],
templateUrl: './workspace-dashboard.component.html',
styleUrl: './workspace-dashboard.component.scss',
})
export class WorkspaceDashboardComponent {
private readonly translate = inject(TranslateService);
readonly editMode = signal(false);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
private readonly sizeOrder: DashboardWidgetSize[] = [
'one-third',
'half',
'two-thirds',
'full',
];
readonly allWidgets = computed(() =>
[...this.layoutService.state().widgets].sort(
(a, b) => a.order - b.order
)
);
readonly visibleWidgets = computed(() =>
this.allWidgets().filter((widget) => widget.enabled)
);
constructor(readonly layoutService: DashboardLayoutService) {
// Data reload is handled reactively by DashboardDataService
// via Router NavigationEnd events
}
toggleEditMode(): void {
this.editMode.update((value) => !value);
}
onWidgetDrop(event: CdkDragDrop<DashboardWidgetConfig[]>): void {
if (event.previousIndex === event.currentIndex) {
return;
}
const reordered = [...this.visibleWidgets()];
moveItemInArray(reordered, event.previousIndex, event.currentIndex);
this.layoutService.reorderVisibleWidgets(
reordered.map((widget) => widget.id)
);
}
setWidgetSize(widgetId: string, size: unknown): void {
if (
size === 'one-third' ||
size === 'half' ||
size === 'two-thirds' ||
size === 'full'
) {
this.layoutService.setWidgetSize(widgetId, size);
}
}
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 {
this.languageTick();
const labels: Record<DashboardWidgetSize, string> = {
'one-third': '1/3',
half: '1/2',
'two-thirds': '2/3',
full: this.translateText('WORKSPACE.DASHBOARD.SIZE_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);
}
getWidgetTitle(widget: DashboardWidgetConfig): string {
this.languageTick();
return this.translateText(widget.title);
}
getWidgetDescription(widget: DashboardWidgetConfig): string {
this.languageTick();
return this.translateText(widget.description);
}
getDragWidgetLabel(widget: DashboardWidgetConfig): string {
return this.translateText('WORKSPACE.DASHBOARD.DRAG_WIDGET', {
title: this.getWidgetTitle(widget),
});
}
getHideWidgetLabel(widget: DashboardWidgetConfig): string {
return this.translateText('WORKSPACE.DASHBOARD.HIDE_WIDGET', {
title: this.getWidgetTitle(widget),
});
}
private translateText(
key: string,
params?: Record<string, string | number>
): string {
return this.translate.instant(key, params);
}
}