style: enhance grid list component with loading skeletons and type safety

- Updated GridListComponent to use a skeleton loading UI during data fetching.
- Introduced GridListItem interface for better type safety on items input.
- Improved paginator integration with proper type definitions.
- Refactored loading state handling to utilize computed properties.

feat: Improve portal rail links with accessibility features

- Added aria-current attribute to indicate active links.
- Enhanced styling for active links with new CSS variables for selection colors.

style: Refactor styles for portal rail links and context panels

- Updated SCSS for portal rail links to improve hover and active states.
- Introduced new styles for context skeletons in workspace context panel.

feat: Implement skeleton loaders in stalker main container and workspace context panel

- Replaced ngx-skeleton-loader with custom skeleton components for better control.
- Added skeleton loading states for categories in stalker main container.

style: Refine recently added component styles and interactions

- Enhanced section styling with active state indicators and transitions.
- Improved layout and visual feedback for recently added items.

fix: Correctly handle section activation in recently added component

- Added logic to determine active sections based on selected content type.

refactor: Update XtreamShellComponent to improve import overlay handling

- Changed isImporting check to showImportOverlay for better clarity.
- Refactored section handling to use signals for state management.

style: Introduce new selection colors and effects across the application

- Added new CSS variables for selection colors and effects to enhance UI consistency.
- Updated various components to utilize new selection styles for better user experience.
This commit is contained in:
4gray committed 2026-02-25 07:46:28 +01:00
1 parent ad3f6fc206
commit aa6a753aa5
27 files changed
+719 -122

No files matched your search

@@ -14,7 +14,11 @@
novalidate
>
<div class="settings-content">
<section class="settings-group" id="general">
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'general'"
id="general"
>
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>settings</mat-icon>
@@ -105,7 +109,11 @@
</div>
</section>
<section class="settings-group" id="playback">
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'playback'"
id="playback"
>
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>play_circle</mat-icon>
@@ -269,7 +277,11 @@
</section>
@if (isDesktop) {
<section class="settings-group" id="epg">
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'epg'"
id="epg"
>
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>list_alt</mat-icon>
@@ -369,7 +381,13 @@
}
@if (isDesktop) {
<section class="settings-group" id="remote-control">
<section
class="settings-group"
[class.settings-group--active]="
activeSection() === 'remote-control'
"
id="remote-control"
>
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>settings_remote</mat-icon>
@@ -546,7 +564,11 @@
</section>
}
<section class="settings-group" id="data">
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'data'"
id="data"
>
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>sync_alt</mat-icon>
@@ -631,7 +653,11 @@
}
</section>
<section class="settings-group" id="about">
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'about'"
id="about"
>
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>info</mat-icon>
@@ -33,17 +33,45 @@
}
.settings-group {
position: relative;
background: transparent;
overflow: hidden;
scroll-margin-top: 24px;
padding-bottom: 24px;
border-bottom: 1px solid var(--mat-sys-outline-variant);
border-radius: 18px;
transition:
background 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
&:last-child {
border-bottom: none;
}
}
.settings-group--active {
background: linear-gradient(
145deg,
var(--app-selection-surface),
color-mix(in srgb, var(--app-selection-color) 6%, transparent)
);
border-color: var(--app-selection-border);
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 16px 28px -24px var(--app-selection-glow);
.settings-group__header-icon {
background: var(--app-selection-surface-strong);
color: var(--app-selection-color);
box-shadow: inset 0 0 0 1px var(--app-selection-border);
}
.settings-group__header-text h3 {
color: var(--app-selection-color);
}
}
.settings-group__header {
display: flex;
align-items: center;
@@ -25,12 +25,8 @@
<div class="main-content">
<div class="content-header">
<h2>
{{
(titleTranslationString() | translate) +
' (' +
selectedCategoryId() +
')'
}}
{{ titleTranslationString() | translate }}
<span class="selected-category-pill">{{ selectedCategoryId() }}</span>
</h2>
@if (showHeaderAction() && !isWorkspaceLayout) {
<button
@@ -33,6 +33,24 @@
align-items: center;
gap: 10px;
}
.selected-category-pill {
padding: 4px 10px;
border-radius: 999px;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--app-selection-color);
background: linear-gradient(
145deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
);
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 10px 16px -18px var(--app-selection-glow);
}
}
.scrollable-content {
@@ -17,7 +17,6 @@ mat-card:hover {
// ─── Grid layout ─────────────────────────────────────────────────────────────
.grid {
mat-card {
cursor: pointer;
width: 100%;
@@ -108,3 +107,97 @@ mat-card:hover {
width: 14px;
}
}
.grid-skeleton-card {
width: 100%;
}
.grid-skeleton-thumb {
position: relative;
width: 100%;
aspect-ratio: 2 / 3;
border-radius: 10px;
overflow: hidden;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 84%,
var(--app-widget-bg, var(--mat-sys-surface-container)) 16%
);
border: 1px solid
color-mix(
in srgb,
var(--app-widget-header-border, var(--mat-sys-outline-variant)) 94%,
transparent
);
}
.grid-skeleton-badge {
position: absolute;
top: 8px;
right: 8px;
width: 34px;
height: 14px;
border-radius: 999px;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 56%,
var(--app-body-color, var(--mat-sys-on-surface-variant)) 44%
);
}
.grid-skeleton-title {
margin-top: 10px;
padding: 0 3px;
display: grid;
gap: 6px;
}
.grid-skeleton-line {
height: 9px;
border-radius: 999px;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 52%,
var(--app-body-color, var(--mat-sys-on-surface-variant)) 48%
);
&--primary {
width: 92%;
}
&--secondary {
width: 68%;
}
}
.grid-skeleton-thumb,
.grid-skeleton-badge,
.grid-skeleton-line {
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 10%,
color-mix(
in srgb,
var(--app-heading-color, var(--mat-sys-on-surface)) 20%,
transparent
)
46%,
transparent 76%
);
transform: translateX(-120%);
animation: grid-skeleton-shimmer 1.2s ease-in-out infinite;
}
}
@keyframes grid-skeleton-shimmer {
100% {
transform: translateX(120%);
}
}
@@ -1,23 +1,54 @@
import { Component, input, output } from '@angular/core';
import { Component, computed, input, output } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatIcon } from '@angular/material/icon';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistErrorViewComponent } from '../../../xtream-electron/playlist-error-view/playlist-error-view.component';
import { ProgressCapsuleComponent } from '../../../xtream-electron/shared/progress-capsule/progress-capsule.component';
import { WatchedBadgeComponent } from '../../../xtream-electron/shared/watched-badge/watched-badge.component';
interface GridListItem {
id?: number | string;
is_series?: number | string | boolean;
xtream_id?: number | string;
series_id?: number | string;
stream_id?: number | string;
category_id?: number | string;
poster_url?: string;
cover?: string;
title?: string;
o_name?: string;
name?: string;
rating?: string | number;
rating_imdb?: string | number;
progress?: number;
isWatched?: boolean;
hasSeriesProgress?: boolean;
[key: string]: unknown;
}
@Component({
selector: 'app-grid-list',
template: `<div
class="grid grid-cols-[repeat(auto-fill,minmax(148px,1fr))] gap-4"
>
@if (isLoading()) {
<div class="flex justify-center items-center p-8">
<mat-spinner diameter="50"></mat-spinner>
</div>
@for (row of skeletonRows(); track row) {
<div class="grid-skeleton-card" aria-hidden="true">
<div class="grid-skeleton-thumb">
<span class="grid-skeleton-badge"></span>
</div>
<div class="grid-skeleton-title">
<span
class="grid-skeleton-line grid-skeleton-line--primary"
></span>
<span
class="grid-skeleton-line grid-skeleton-line--secondary"
></span>
</div>
</div>
}
} @else {
@for (item of items(); track $index) {
@let i = $any(item);
@@ -100,7 +131,6 @@ import { WatchedBadgeComponent } from '../../../xtream-electron/shared/watched-b
PlaylistErrorViewComponent,
MatCardModule,
MatIcon,
MatProgressSpinner,
MatTooltip,
MatPaginatorModule,
ProgressCapsuleComponent,
@@ -108,14 +138,20 @@ import { WatchedBadgeComponent } from '../../../xtream-electron/shared/watched-b
],
})
export class GridListComponent {
readonly items = input<any[]>();
readonly items = input<GridListItem[]>();
readonly isLoading = input<boolean>();
readonly showPaginator = input(true);
readonly itemClicked = output<any>();
readonly pageChange = output<any>();
readonly itemClicked = output<GridListItem>();
readonly pageChange = output<PageEvent>();
readonly pageIndex = input<number>();
readonly totalPages = input<number>();
readonly limit = input<number>();
readonly pageSizeOptions = input<number[]>();
readonly skeletonRows = computed(() => {
const preferredCount = this.limit() ?? 12;
const count = Math.max(8, Math.min(18, preferredCount));
return Array.from({ length: count }, (_, index) => index);
});
}
@@ -2,6 +2,7 @@
<mat-nav-list class="p-0 m-0">
@for (link of links(); track link.path.join('/')) {
<a
class="portal-rail-link"
mat-list-item
[routerLink]="link.path"
[routerLinkActive]="activeClass()"
@@ -9,6 +10,7 @@
[matTooltip]="link.tooltip"
matTooltipPosition="right"
[attr.aria-label]="link.tooltip"
[attr.aria-current]="isActive(link) ? 'page' : null"
[class.active]="activeClass() === 'active' && isActive(link)"
[class.is-active]="
activeClass() === 'is-active' && isActive(link)
@@ -22,12 +24,14 @@
<nav class="flex flex-col items-center gap-2 w-full">
@for (link of links(); track link.path.join('/')) {
<a
class="portal-rail-link"
[routerLink]="link.path"
[routerLinkActive]="activeClass()"
[routerLinkActiveOptions]="{ exact: link.exact ?? false }"
[matTooltip]="link.tooltip"
[matTooltipPosition]="'right'"
[attr.aria-label]="link.tooltip"
[attr.aria-current]="isActive(link) ? 'page' : null"
[class.active]="activeClass() === 'active' && isActive(link)"
[class.is-active]="
activeClass() === 'is-active' && isActive(link)
@@ -4,25 +4,29 @@
--rail-link-color: var(--mat-sys-on-surface-variant);
--rail-link-hover-color: var(--mat-sys-on-surface);
--rail-link-hover-bg: var(--mat-sys-surface-container);
/* Keep consistent with category-item.selected tone */
--rail-link-active-bg: color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
--rail-link-active-color: var(--mat-sys-primary);
--rail-link-active-icon: var(--mat-sys-primary);
--rail-link-active-border: color-mix(in srgb, var(--mat-sys-primary) 42%, transparent);
--rail-link-active-glow: color-mix(in srgb, var(--mat-sys-primary) 24%, transparent);
--rail-link-active-bg: var(--app-selection-surface);
--rail-link-active-bg-strong: var(--app-selection-surface-strong);
--rail-link-active-color: var(--app-selection-color);
--rail-link-active-icon: var(--app-selection-color);
--rail-link-active-border: var(--app-selection-border);
--rail-link-active-glow: var(--app-selection-glow);
}
.p-0.m-0 {
.mdc-list-item {
min-height: 28px;
height: 28px;
padding: 0 !important;
border-radius: 4px;
border-radius: 10px;
margin: 2px 0;
display: flex;
justify-content: center;
color: var(--rail-link-color);
transition:
background 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
.mdc-list-item__start {
margin-inline-end: 0 !important;
@@ -43,11 +47,16 @@
.mdc-list-item.active,
.mdc-list-item.is-active {
background: var(--rail-link-active-bg);
background: linear-gradient(
145deg,
var(--rail-link-active-bg-strong),
var(--rail-link-active-bg)
);
color: var(--rail-link-active-color);
box-shadow:
inset 0 0 0 1px var(--rail-link-active-border),
0 8px 18px -14px var(--rail-link-active-glow);
transform: translateY(-1px);
mat-icon {
color: var(--rail-link-active-icon);
@@ -55,7 +64,7 @@
}
}
nav.flex a {
nav.flex .portal-rail-link {
width: 44px;
height: 44px;
border-radius: 14px;
@@ -65,38 +74,47 @@ nav.flex a {
justify-content: center;
color: var(--rail-link-color);
text-decoration: none;
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
transition:
background-color 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
app-region: no-drag;
}
nav.flex a:hover {
nav.flex .portal-rail-link:hover {
color: var(--rail-link-hover-color);
background: var(--rail-link-hover-bg);
}
nav.flex a.is-active,
nav.flex a.active {
nav.flex .portal-rail-link.is-active,
nav.flex .portal-rail-link.active {
color: var(--rail-link-active-color);
background: var(--rail-link-active-bg);
background: linear-gradient(
145deg,
var(--rail-link-active-bg-strong),
var(--rail-link-active-bg)
);
box-shadow:
inset 0 0 0 1px var(--rail-link-active-border),
0 10px 20px -16px var(--rail-link-active-glow);
transform: translateY(-1px);
}
nav.flex a.is-active mat-icon,
nav.flex a.active mat-icon {
nav.flex .portal-rail-link.is-active mat-icon,
nav.flex .portal-rail-link.active mat-icon {
color: var(--rail-link-active-icon);
}
nav.flex a.is-active::after,
nav.flex a.active::after {
nav.flex .portal-rail-link.is-active::after,
nav.flex .portal-rail-link.active::after {
content: '';
position: absolute;
right: -4px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 18px;
width: 4px;
height: 20px;
border-radius: 999px;
background: var(--rail-link-active-icon);
box-shadow: 0 0 10px var(--rail-link-active-glow);
@@ -17,14 +17,18 @@
</div>
}
@if (isCategoryLoading()) {
<ngx-skeleton-loader
count="20"
[theme]="{
width: '95%',
height: '48px',
borderRadius: '40px',
}"
/>
<div class="category-skeleton" aria-hidden="true">
@for (row of skeletonRows; track row) {
<div class="category-skeleton__row">
<span class="category-skeleton__dot"></span>
<span
class="category-skeleton__label"
[style.width.%]="skeletonLabelWidths[row]"
></span>
<span class="category-skeleton__count"></span>
</div>
}
</div>
} @else if (isCategoryFailed()) {
<app-playlist-error-view
[title]="'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate"
@@ -45,3 +45,85 @@
flex-direction: column;
overflow: hidden;
}
.category-skeleton {
display: grid;
gap: 10px;
padding: 12px 8px 10px;
}
.category-skeleton__row {
display: grid;
grid-template-columns: 14px minmax(0, 1fr) 34px;
align-items: center;
gap: 12px;
min-height: 40px;
padding: 0 12px;
border-radius: 14px;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 84%,
var(--app-widget-bg, var(--mat-sys-surface-container)) 16%
);
border: 1px solid
color-mix(
in srgb,
var(--app-widget-header-border, var(--mat-sys-outline-variant)) 92%,
transparent
);
}
.category-skeleton__dot,
.category-skeleton__label,
.category-skeleton__count {
position: relative;
overflow: hidden;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 55%,
var(--app-body-color, var(--mat-sys-on-surface-variant)) 45%
);
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 8%,
color-mix(
in srgb,
var(--app-heading-color, var(--mat-sys-on-surface)) 22%,
transparent
)
44%,
transparent 74%
);
transform: translateX(-120%);
animation: category-skeleton-shimmer 1.25s ease-in-out infinite;
}
}
.category-skeleton__dot {
width: 14px;
height: 14px;
border-radius: 999px;
}
.category-skeleton__label {
height: 10px;
border-radius: 999px;
}
.category-skeleton__count {
justify-self: end;
width: 26px;
height: 10px;
border-radius: 999px;
}
@keyframes category-skeleton-shimmer {
100% {
transform: translateX(120%);
}
}
@@ -4,7 +4,6 @@ import { MatIcon } from '@angular/material/icon';
import { MatPaginatorModule } from '@angular/material/paginator';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { CategoryViewComponent } from '../xtream-electron/category-view/category-view.component';
import { PlaylistErrorViewComponent } from '../xtream-electron/playlist-error-view/playlist-error-view.component';
@@ -23,7 +22,6 @@ import { StalkerStore } from './stalker.store';
MatIcon,
MatIconButton,
MatPaginatorModule,
NgxSkeletonLoaderModule,
PlaylistErrorViewComponent,
PlaylistSwitcherComponent,
ResizableDirective,
@@ -56,6 +54,10 @@ export class StalkerMainContainerComponent {
/** content items */
readonly contentItems = this.stalkerStore.getPaginatedContent;
readonly isContentLoading = this.stalkerStore.isPaginatedContentLoading;
readonly skeletonRows = Array.from({ length: 12 }, (_, index) => index);
readonly skeletonLabelWidths = [
82, 70, 77, 66, 86, 73, 79, 62, 84, 68, 76, 71,
];
constructor() {
// reset category title after changing content type
@@ -98,7 +100,8 @@ export class StalkerMainContainerComponent {
const selectedTypeLabel =
typeLabelByContentType[selectedContentType] ?? '';
const selectedCategoryName = this.stalkerStore.getSelectedCategoryName();
const selectedCategoryName =
this.stalkerStore.getSelectedCategoryName();
let categoryName = selectedCategoryName;
// For implicit "All" view, show a type-specific title so switching
@@ -30,14 +30,18 @@
/>
} @else if (isStalkerCategories()) {
@if (isStalkerCategoryLoading()) {
<ngx-skeleton-loader
count="18"
[theme]="{
width: '95%',
height: '36px',
borderRadius: '8px',
}"
/>
<div class="context-skeleton" aria-hidden="true">
@for (row of skeletonRows; track row) {
<div class="context-skeleton__row">
<span class="context-skeleton__dot"></span>
<span
class="context-skeleton__label"
[style.width.%]="skeletonLabelWidths[row]"
></span>
<span class="context-skeleton__count"></span>
</div>
}
</div>
} @else if (isStalkerCategoryFailed()) {
<app-playlist-error-view
[title]="'Unable to load categories'"
@@ -41,7 +41,9 @@
line-height: 32px;
border-radius: 8px;
color: var(--mat-sys-on-surface-variant);
transition: background 0.15s ease, color 0.15s ease;
transition:
background 0.15s ease,
color 0.15s ease;
&:hover {
background: var(--mat-sys-surface-container);
@@ -61,7 +63,7 @@
align-items: center;
gap: 4px;
margin-top: 5px;
padding: 2px 8px;
padding: 2px 0;
border-radius: 999px;
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface-variant);
@@ -98,3 +100,86 @@ app-category-view {
scrollbar-width: thin;
scrollbar-color: var(--mat-sys-outline-variant) transparent;
}
.context-skeleton {
display: grid;
gap: 8px;
padding: 4px 6px 2px;
overflow: hidden;
}
.context-skeleton__row {
display: grid;
grid-template-columns: 12px minmax(0, 1fr) 28px;
align-items: center;
gap: 10px;
min-height: 36px;
padding: 0 10px;
border-radius: 12px;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 88%,
var(--app-widget-bg, var(--mat-sys-surface-container)) 12%
);
border: 1px solid
color-mix(
in srgb,
var(--app-widget-header-border, var(--mat-sys-outline-variant)) 96%,
transparent
);
}
.context-skeleton__dot,
.context-skeleton__label,
.context-skeleton__count {
position: relative;
overflow: hidden;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 52%,
var(--app-body-color, var(--mat-sys-on-surface-variant)) 48%
);
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 8%,
color-mix(
in srgb,
var(--app-heading-color, var(--mat-sys-on-surface)) 18%,
transparent
)
44%,
transparent 74%
);
transform: translateX(-120%);
animation: context-skeleton-shimmer 1.15s ease-in-out infinite;
}
}
.context-skeleton__dot {
width: 12px;
height: 12px;
border-radius: 999px;
}
.context-skeleton__label {
height: 9px;
border-radius: 999px;
}
.context-skeleton__count {
justify-self: end;
width: 22px;
height: 9px;
border-radius: 999px;
}
@keyframes context-skeleton-shimmer {
100% {
transform: translateX(120%);
}
}
@@ -4,7 +4,6 @@ import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PlaylistErrorViewComponent } from '../xtream-electron/playlist-error-view/playlist-error-view.component';
import {
CategoryManagementDialogComponent,
@@ -33,7 +32,6 @@ interface XtreamCategoryLike {
MatIcon,
MatIconButton,
MatTooltip,
NgxSkeletonLoaderModule,
PlaylistErrorViewComponent,
],
templateUrl: './workspace-context-panel.component.html',
@@ -69,8 +67,14 @@ export class WorkspaceContextPanelComponent {
readonly stalkerCategories = this.stalkerStore.getCategoryResource;
readonly stalkerSelectedCategoryId = this.stalkerStore.selectedCategoryId;
readonly isStalkerCategoryLoading = this.stalkerStore.isCategoryResourceLoading;
readonly isStalkerCategoryFailed = this.stalkerStore.isCategoryResourceFailed;
readonly isStalkerCategoryLoading =
this.stalkerStore.isCategoryResourceLoading;
readonly isStalkerCategoryFailed =
this.stalkerStore.isCategoryResourceFailed;
readonly skeletonRows = Array.from({ length: 14 }, (_, index) => index);
readonly skeletonLabelWidths = [
78, 66, 74, 59, 83, 69, 76, 62, 81, 64, 72, 67, 79, 61,
];
readonly title = computed(() => {
if (this.isXtreamCategories()) {
@@ -38,6 +38,7 @@
<div class="rail-footer">
<a
class="rail-shortcut"
routerLink="/workspace/settings"
[class.is-active]="isSettingsRoute()"
matTooltip="Settings"
@@ -108,6 +109,7 @@
@if (downloadsContext()) {
<button
type="button"
class="header-shortcut"
mat-icon-button
[class.is-active]="isDownloadsView()"
(click)="openDownloadsShortcut()"
@@ -131,6 +133,7 @@
}
<button
type="button"
class="header-shortcut"
mat-icon-button
[class.is-active]="isFavoritesView()"
(click)="toggleFavoritesShortcut()"
@@ -47,11 +47,12 @@
--rail-link-color: var(--mat-sys-on-surface-variant);
--rail-link-hover-color: var(--mat-sys-on-surface);
--rail-link-hover-bg: var(--mat-sys-surface-container);
--rail-link-active-bg: color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
--rail-link-active-color: var(--mat-sys-primary);
--rail-link-active-icon: var(--mat-sys-primary);
--rail-link-active-border: color-mix(in srgb, var(--mat-sys-primary) 42%, transparent);
--rail-link-active-glow: color-mix(in srgb, var(--mat-sys-primary) 24%, transparent);
--rail-link-active-bg: var(--app-selection-surface);
--rail-link-active-bg-strong: var(--app-selection-surface-strong);
--rail-link-active-color: var(--app-selection-color);
--rail-link-active-icon: var(--app-selection-color);
--rail-link-active-border: var(--app-selection-border);
--rail-link-active-glow: var(--app-selection-glow);
}
.app-rail a {
@@ -64,7 +65,11 @@
justify-content: center;
color: var(--rail-link-color);
text-decoration: none;
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
transition:
background-color 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
app-region: no-drag;
mat-icon {
@@ -82,10 +87,15 @@
.app-rail a.is-active,
.app-rail a.active {
color: var(--rail-link-active-color);
background: var(--rail-link-active-bg);
background: linear-gradient(
145deg,
var(--rail-link-active-bg-strong),
var(--rail-link-active-bg)
);
box-shadow:
inset 0 0 0 1px var(--rail-link-active-border),
0 10px 20px -16px var(--rail-link-active-glow);
transform: translateY(-1px);
mat-icon {
color: var(--rail-link-active-icon);
@@ -237,8 +247,15 @@
}
&.is-active {
color: var(--mat-sys-on-primary-container);
background: var(--mat-sys-primary-container);
color: var(--app-selection-color);
background: linear-gradient(
145deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
);
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 10px 20px -16px var(--app-selection-glow);
}
}
}
@@ -336,9 +353,14 @@
}
&.is-active {
border-color: transparent;
color: var(--mat-sys-on-primary-container);
background: var(--mat-sys-primary-container);
border-color: var(--app-selection-border);
color: var(--app-selection-color);
background: linear-gradient(
145deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
);
box-shadow: 0 10px 20px -18px var(--app-selection-glow);
font-weight: 500;
}
}
@@ -209,7 +209,9 @@ export class WorkspaceShellComponent {
return (
(context.provider === 'xtreams' ||
context.provider === 'stalker') &&
(section === 'favorites' || section === 'recent')
(section === 'favorites' ||
section === 'recent' ||
section === 'downloads')
);
});
readonly showContextPanel = computed(
@@ -57,12 +57,39 @@
min-height: 0;
}
.option-list {
overflow-y: auto;
min-height: 0;
}
.option-row {
border-radius: 12px;
transition:
box-shadow 0.18s ease,
transform 0.18s ease;
.option-label,
.option-meta,
mat-icon {
transition: color 0.18s ease, opacity 0.18s ease;
}
&.selected {
box-shadow: 0 10px 20px -18px var(--app-selection-glow);
transform: translateY(-1px);
.option-label,
.option-meta,
mat-icon {
color: var(--app-selection-color) !important;
}
.option-meta {
opacity: 0.95;
}
}
}
.panel-section-title {
margin: 0;
padding: 10px 10px 6px;
@@ -5,6 +5,8 @@
class="nav-item category-item"
[attr.data-category-id]="$any(item).category_id ?? item.id"
[class.selected]="isSelected(item)"
[class.category-item--selected]="isSelected(item)"
[attr.aria-current]="isSelected(item) ? 'true' : null"
(click)="categoryClicked.emit(item)"
>
<span class="nav-item-label">
@@ -20,6 +20,11 @@
}
}
.category-item--selected .item-count {
color: var(--app-selection-color) !important;
opacity: 0.95;
}
mat-card-content {
overflow-wrap: break-word;
padding: 12px;
@@ -2,7 +2,7 @@
<h2>{{ 'PORTALS.SIDEBAR.RECENTLY_ADDED' | translate }}</h2>
@if (recentlyAddedVod().length > 0) {
<div class="section">
<div class="section" [class.section--active]="isSectionActive('vod')">
<h3>{{ 'PORTALS.SIDEBAR.MOVIES' | translate }}</h3>
<div class="carousel">
@for (item of recentlyAddedVod(); track item.xtream_id) {
@@ -20,7 +20,10 @@
}
@if (recentlyAddedSeries().length > 0) {
<div class="section">
<div
class="section"
[class.section--active]="isSectionActive('series')"
>
<h3>{{ 'PORTALS.SIDEBAR.SERIES' | translate }}</h3>
<div class="carousel">
@for (item of recentlyAddedSeries(); track item.xtream_id) {
@@ -38,7 +41,7 @@
}
@if (recentlyAddedLive().length > 0) {
<div class="section">
<div class="section" [class.section--active]="isSectionActive('live')">
<h3>{{ 'PORTALS.SIDEBAR.LIVE_TV' | translate }}</h3>
<div class="carousel">
@for (item of recentlyAddedLive(); track item.xtream_id) {
@@ -13,22 +13,58 @@ h2 {
}
.section {
padding: 12px 14px 0;
border: 1px solid transparent;
border-radius: 16px;
margin-bottom: 32px;
transition:
background 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
h3 {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
opacity: 0.8;
}
}
.section.section--active {
border-color: var(--app-selection-border);
background: linear-gradient(
145deg,
var(--app-selection-surface),
color-mix(in srgb, var(--app-selection-color) 6%, transparent)
);
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 14px 24px -22px var(--app-selection-glow);
h3 {
color: var(--app-selection-color);
opacity: 1;
&::after {
content: '';
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--app-selection-color);
box-shadow: 0 0 10px var(--app-selection-glow);
}
}
}
.carousel {
display: flex;
overflow-x: auto;
gap: 16px;
padding-bottom: 16px;
scrollbar-width: thin;
.carousel-item {
min-width: 160px;
width: 160px;
@@ -25,6 +25,7 @@ export class RecentlyAddedComponent {
readonly recentlyAddedSeries = computed(() =>
this.getRecentlyAdded(this.xtreamStore.serialStreams(), true)
);
readonly selectedContentType = this.xtreamStore.selectedContentType;
private getRecentlyAdded(items: any[], isSeries = false) {
return [...items]
@@ -43,6 +44,10 @@ export class RecentlyAddedComponent {
return parseInt(timestamp) * 1000;
}
isSectionActive(type: ContentType): boolean {
return this.selectedContentType() === type;
}
openItem(item: any, type: ContentType) {
this.xtreamStore.setSelectedContentType(type);
@@ -1,4 +1,4 @@
@if (isImporting()) {
@if (showImportOverlay()) {
<app-loading-overlay
[current]="getImportCount()"
[total]="itemsToImport()"
@@ -1,4 +1,4 @@
import { Component, effect, inject } from '@angular/core';
import { Component, computed, effect, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
ActivatedRoute,
@@ -36,40 +36,49 @@ export class XtreamShellComponent {
readonly portalStatus = this.xtreamStore.portalStatus;
readonly isWorkspaceLayout =
this.route.snapshot.data['layout'] === 'workspace';
readonly showImportOverlay = computed(() => {
const section = this.currentSection();
return this.isImporting() && this.isContentSection(section);
});
private currentPlaylistId: string | null = null;
private currentSection: string | null = null;
private readonly currentSection = signal<string | null>(null);
constructor() {
// Subscribe to route params to handle switching between playlists
this.route.params.pipe(takeUntilDestroyed()).subscribe(async (params) => {
const newPlaylistId = params['id'];
this.route.params
.pipe(takeUntilDestroyed())
.subscribe(async (params) => {
const newPlaylistId = params['id'];
// Skip if playlist ID hasn't changed
if (this.currentPlaylistId === newPlaylistId) {
return;
}
// Skip if playlist ID hasn't changed
if (this.currentPlaylistId === newPlaylistId) {
return;
}
// Always reset the store when playlist changes to prevent stale data
// from previous sessions showing up (the store is a singleton)
this.xtreamStore.resetStore(newPlaylistId);
this.currentPlaylistId = newPlaylistId;
// Always reset the store when playlist changes to prevent stale data
// from previous sessions showing up (the store is a singleton)
this.xtreamStore.resetStore(newPlaylistId);
this.currentPlaylistId = newPlaylistId;
this.store.dispatch(
PlaylistActions.setActivePlaylist({
playlistId: newPlaylistId,
})
);
this.store.dispatch(
PlaylistActions.setActivePlaylist({
playlistId: newPlaylistId,
})
);
// Must await fetchXtreamPlaylist before checking status
// because checkPortalStatus needs currentPlaylist to be set
await this.xtreamStore.fetchXtreamPlaylist();
await this.xtreamStore.checkPortalStatus();
});
// Must await fetchXtreamPlaylist before checking status
// because checkPortalStatus needs currentPlaylist to be set
await this.xtreamStore.fetchXtreamPlaylist();
await this.xtreamStore.checkPortalStatus();
});
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
filter(
(event): event is NavigationEnd =>
event instanceof NavigationEnd
),
takeUntilDestroyed()
)
.subscribe(() => {
@@ -81,11 +90,16 @@ export class XtreamShellComponent {
effect(() => {
const playlist = this.xtreamStore.currentPlaylist();
const playlistId = this.xtreamStore.playlistId();
const section = this.currentSection();
if (!this.isContentSection(section)) {
return;
}
// Only initialize content when playlist is loaded AND matches the current playlistId
// This prevents stale data from a previous session from being used
if (playlist !== null && playlist.id === playlistId) {
this.xtreamStore.initializeContent();
void this.xtreamStore.initializeContent();
}
});
}
@@ -96,11 +110,11 @@ export class XtreamShellComponent {
const sectionFromUrl = this.getSectionFromUrl(this.router.url);
const section = sectionFromSnapshot ?? sectionFromUrl;
if (!section || section === this.currentSection) {
if (!section || section === this.currentSection()) {
return;
}
this.currentSection = section;
this.currentSection.set(section);
if (section === 'vod' || section === 'live' || section === 'series') {
this.xtreamStore.setSelectedContentType(section);
@@ -116,4 +130,14 @@ export class XtreamShellComponent {
);
return match?.[1] ?? null;
}
private isContentSection(section: string | null): boolean {
return (
section === 'vod' ||
section === 'live' ||
section === 'series' ||
section === 'search' ||
section === 'recently-added'
);
}
}
+44
View File
@@ -52,6 +52,28 @@ html {
--app-rail-border: rgba(0, 0, 0, 0.08);
--app-search-bg: rgba(255, 255, 255, 0.7);
--app-search-border: rgba(0, 0, 0, 0.1);
--app-selection-color: #2f7bff;
--app-selection-on-color: #eef4ff;
--app-selection-surface: color-mix(
in srgb,
var(--app-selection-color) 14%,
transparent
);
--app-selection-surface-strong: color-mix(
in srgb,
var(--app-selection-color) 22%,
transparent
);
--app-selection-border: color-mix(
in srgb,
var(--app-selection-color) 52%,
transparent
);
--app-selection-glow: color-mix(
in srgb,
var(--app-selection-color) 34%,
transparent
);
// Widget surfaces — no borders, layered backgrounds
--app-widget-bg: #ffffff;
@@ -96,6 +118,28 @@ html {
--app-rail-border: rgba(255, 255, 255, 0.05);
--app-search-bg: rgba(255, 255, 255, 0.05);
--app-search-border: rgba(255, 255, 255, 0.09);
--app-selection-color: #78adff;
--app-selection-on-color: #071225;
--app-selection-surface: color-mix(
in srgb,
var(--app-selection-color) 18%,
transparent
);
--app-selection-surface-strong: color-mix(
in srgb,
var(--app-selection-color) 28%,
transparent
);
--app-selection-border: color-mix(
in srgb,
var(--app-selection-color) 62%,
transparent
);
--app-selection-glow: color-mix(
in srgb,
var(--app-selection-color) 44%,
transparent
);
// Widget surfaces — NO border, surface contrast does the work
// Widgets sit on content-bg (#161a22), so widget-bg is notably lighter
+30 -7
View File
@@ -9,6 +9,7 @@
}
.nav-item {
position: relative;
width: 100%;
display: flex;
align-items: center;
@@ -27,7 +28,9 @@
transition:
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease;
border-color 0.15s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
mat-icon {
font-size: 18px;
@@ -62,18 +65,38 @@
&.selected,
&.active {
background: var(--mat-sys-primary-container) !important;
color: var(--mat-sys-on-primary-container) !important;
border-color: transparent !important;
background: linear-gradient(
135deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
) !important;
color: var(--app-selection-color) !important;
border-color: var(--app-selection-border) !important;
font-weight: 600;
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 12px 20px -18px var(--app-selection-glow);
transform: translateY(-1px);
* {
color: var(--mat-sys-on-primary-container) !important;
color: var(--app-selection-color) !important;
}
.nav-item-meta {
color: var(--mat-sys-on-primary-container) !important;
opacity: 0.85;
color: var(--app-selection-color) !important;
opacity: 0.92;
}
&::before {
content: '';
position: absolute;
inset-inline-start: 6px;
top: 7px;
bottom: 7px;
width: 3px;
border-radius: 999px;
background: var(--app-selection-color);
box-shadow: 0 0 10px var(--app-selection-glow);
}
}
}