Compare commits

...

1 Commits

Author SHA1 Message Date
Flaminel
ee44e2b5ac Rework sidebar navigation (#255) 2025-08-02 05:31:25 +03:00
19 changed files with 919 additions and 382 deletions

View File

@@ -6,11 +6,28 @@ export const routes: Routes = [
{ path: 'dashboard', loadComponent: () => import('./dashboard/dashboard-page/dashboard-page.component').then(m => m.DashboardPageComponent) },
{ path: 'logs', loadComponent: () => import('./logging/logs-viewer/logs-viewer.component').then(m => m.LogsViewerComponent) },
{ path: 'events', loadComponent: () => import('./events/events-viewer/events-viewer.component').then(m => m.EventsViewerComponent) },
{
path: 'settings',
loadComponent: () => import('./settings/settings-page/settings-page.component').then(m => m.SettingsPageComponent),
path: 'general-settings',
loadComponent: () => import('./settings/general-settings/general-settings.component').then(m => m.GeneralSettingsComponent),
canDeactivate: [pendingChangesGuard]
},
{
path: 'queue-cleaner',
loadComponent: () => import('./settings/queue-cleaner/queue-cleaner-settings.component').then(m => m.QueueCleanerSettingsComponent),
canDeactivate: [pendingChangesGuard]
},
{
path: 'content-blocker',
loadComponent: () => import('./settings/content-blocker/content-blocker-settings.component').then(m => m.ContentBlockerSettingsComponent),
canDeactivate: [pendingChangesGuard]
},
{
path: 'download-cleaner',
loadComponent: () => import('./settings/download-cleaner/download-cleaner-settings.component').then(m => m.DownloadCleanerSettingsComponent),
canDeactivate: [pendingChangesGuard]
},
{ path: 'sonarr', loadComponent: () => import('./settings/sonarr/sonarr-settings.component').then(m => m.SonarrSettingsComponent) },
{ path: 'radarr', loadComponent: () => import('./settings/radarr/radarr-settings.component').then(m => m.RadarrSettingsComponent) },
{ path: 'lidarr', loadComponent: () => import('./settings/lidarr/lidarr-settings.component').then(m => m.LidarrSettingsComponent) },

View File

@@ -10,115 +10,105 @@
</div>
<!-- Sidebar Navigation -->
<nav class="nav-menu">
<!-- Project Sponsors Link -->
<a href="https://cleanuparr.github.io/Cleanuparr/support" class="nav-item sponsor-link" target="_blank" rel="noopener noreferrer">
<!-- Show loading skeleton while determining navigation state -->
<nav class="nav-menu" *ngIf="!isNavigationReady">
<div class="nav-skeleton">
<div class="skeleton-item"
*ngFor="let item of getSkeletonItems()"
[class.sponsor-skeleton]="item.isSponsor">
</div>
</div>
</nav>
<!-- Show actual navigation when ready -->
<nav class="nav-menu"
*ngIf="isNavigationReady"
[@staggerItems]>
<!-- Project Sponsors Link (always visible) -->
<a href="https://cleanuparr.github.io/Cleanuparr/support"
class="nav-item sponsor-link"
target="_blank"
rel="noopener noreferrer">
<div class="nav-icon-wrapper heart-icon">
<i class="pi pi-heart"></i>
</div>
<span>Become A Sponsor</span>
</a>
<a [routerLink]="['/dashboard']" class="nav-item" [class.active]="router.url.includes('/dashboard')" (click)="onNavItemClick()">
<!-- Go Back button (shown when not at root level) -->
<div class="nav-item go-back-button"
*ngIf="canGoBack"
(click)="goBack()">
<div class="nav-icon-wrapper">
<i class="pi pi-home"></i>
<i class="pi pi-arrow-left"></i>
</div>
<span>Dashboard</span>
</a>
<!-- Settings Group -->
<div class="nav-group">
<div class="nav-group-title">Settings</div>
<a [routerLink]="['/sonarr']" class="nav-item" [class.active]="router.url.includes('/sonarr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-play-circle"></i>
</div>
<span>Sonarr</span>
</a>
<a [routerLink]="['/radarr']" class="nav-item" [class.active]="router.url.includes('/radarr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-play-circle"></i>
</div>
<span>Radarr</span>
</a>
<a [routerLink]="['/lidarr']" class="nav-item" [class.active]="router.url.includes('/lidarr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-bolt"></i>
</div>
<span>Lidarr</span>
</a>
<a [routerLink]="['/readarr']" class="nav-item" [class.active]="router.url.includes('/readarr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-book"></i>
</div>
<span>Readarr</span>
</a>
<a [routerLink]="['/whisparr']" class="nav-item" [class.active]="router.url.includes('/whisparr')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-lock"></i>
</div>
<span>Whisparr</span>
</a>
<a [routerLink]="['/download-clients']" class="nav-item" [class.active]="router.url.includes('/download-clients')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-download"></i>
</div>
<span>Download Clients</span>
</a>
<a [routerLink]="['/settings']" class="nav-item" [class.active]="router.url.includes('/settings')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-trash"></i>
</div>
<span>Cleanup</span>
</a>
<a [routerLink]="['/notifications']" class="nav-item" [class.active]="router.url.includes('/notifications')" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i class="pi pi-bell"></i>
</div>
<span>Notifications</span>
</a>
<span>Go Back</span>
</div>
<!-- Activity Group -->
<div class="nav-group">
<div class="nav-group-title">Activity</div>
<ng-container *ngFor="let item of menuItems">
<ng-container *ngIf="!['Dashboard', 'Settings'].includes(item.label)">
<a [routerLink]="item.route" class="nav-item" [class.active]="router.url.includes(item.route)" (click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i [class]="item.icon"></i>
</div>
<span>{{ item.label }}</span>
</a>
</ng-container>
<!-- Breadcrumb (optional, for better UX) -->
<div class="breadcrumb"
*ngIf="navigationBreadcrumb.length > 0">
<span *ngFor="let crumb of navigationBreadcrumb; let last = last; trackBy: trackByBreadcrumb">
{{ crumb.label }}
<i class="pi pi-chevron-right" *ngIf="!last"></i>
</span>
</div>
<!-- Navigation items container with container-level animation -->
<div class="navigation-items-container"
[@navigationContainer]="navigationStateKey">
<!-- Current level navigation items -->
<ng-container *ngFor="let item of currentNavigation; trackBy: trackByItemId">
<!-- Section headers for top-level sections -->
<div
class="nav-section-header"
*ngIf="item.isHeader">
<div class="nav-icon-wrapper">
<i [class]="item.icon"></i>
</div>
<span>{{ item.label }}</span>
</div>
<!-- Items with children (drill-down) - exclude top-level items -->
<div
class="nav-item nav-parent"
*ngIf="item.children && item.children.length > 0 && !item.topLevel"
(click)="navigateToLevel(item)">
<div class="nav-icon-wrapper">
<i [class]="item.icon"></i>
</div>
<span>{{ item.label }}</span>
<div class="nav-chevron">
<i class="pi pi-chevron-right"></i>
</div>
</div>
<!-- Direct navigation items -->
<a
[routerLink]="item.route"
class="nav-item"
*ngIf="!item.children && item.route && !item.isHeader"
[class.active]="router.url.includes(item.route)"
(click)="onNavItemClick()">
<div class="nav-icon-wrapper">
<i [class]="item.icon"></i>
</div>
<span>{{ item.label }}</span>
<span class="nav-badge" *ngIf="item.badge">{{ item.badge }}</span>
</a>
<!-- External links -->
<a
[href]="item.href"
class="nav-item"
*ngIf="!item.children && item.isExternal && !item.isHeader"
target="_blank"
rel="noopener noreferrer">
<div class="nav-icon-wrapper">
<i [class]="item.icon"></i>
</div>
<span>{{ item.label }}</span>
</a>
</ng-container>
</div>
<!-- Resources Group -->
<div class="nav-group">
<div class="nav-group-title">Resources</div>
<a href="https://github.com/Cleanuparr/Cleanuparr/issues" class="nav-item" target="_blank" rel="noopener noreferrer">
<div class="nav-icon-wrapper">
<i class="pi pi-github"></i>
</div>
<span>Issues and requests</span>
</a>
<a href="https://discord.gg/SCtMCgtsc4" class="nav-item" target="_blank" rel="noopener noreferrer">
<div class="nav-icon-wrapper">
<i class="pi pi-discord"></i>
</div>
<span>Discord</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group-title">Recommended apps</div>
<a href="https://github.com/plexguide/Huntarr.io" class="nav-item" target="_blank" rel="noopener noreferrer">
<div class="nav-icon-wrapper">
<i class="pi pi-github"></i>
</div>
<span>Huntarr</span>
</a>
</div>
</nav>

View File

@@ -1,3 +1,11 @@
// Main container stability
:host {
display: block;
height: 100%;
overflow: hidden; // Prevent scrolling
position: relative;
}
// Logo container
.logo-container {
display: flex;
@@ -50,7 +58,69 @@
display: flex;
flex-direction: column;
flex: 1;
gap: 1rem;
gap: 0; // Remove gap to prevent layout shifts
transition: opacity 0.2s ease;
// Prevent horizontal scrolling
overflow-x: hidden;
overflow-y: auto;
// Fixed minimum height to prevent jumping
min-height: 400px;
// Navigation items container for smooth animations
.navigation-items-container {
display: flex;
flex-direction: column;
gap: 8px; // Consistent spacing between navigation items
position: relative; // Ensure proper stacking context for animations
width: 100%; // Take full width of parent
}
// Loading skeleton
.nav-skeleton {
padding: 0;
.skeleton-item {
height: 60px; // Match actual nav-item height
padding: 10px 20px; // Match nav-item padding
margin-bottom: 8px; // Match nav-item spacing
display: flex;
align-items: center;
border-radius: 6px;
background: linear-gradient(90deg, var(--surface-200) 25%, var(--surface-300) 50%, var(--surface-200) 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
&:last-child {
margin-bottom: 0;
}
&.sponsor-skeleton {
margin-bottom: 15px;
}
// Add fake icon and text areas to match real content
&::before {
content: '';
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--surface-300);
margin-right: 15px;
flex-shrink: 0;
}
&::after {
content: '';
height: 20px;
background: var(--surface-300);
border-radius: 4px;
flex: 1;
max-width: 120px;
}
}
}
// Sponsor link
.sponsor-link {
@@ -67,20 +137,78 @@
}
}
}
// Nav groups
.nav-group {
// Go back button styling
.go-back-button {
background-color: var(--surface-200);
border: 1px solid var(--surface-300);
margin-bottom: 15px;
cursor: pointer;
.nav-group-title {
font-size: 12px;
font-weight: 700;
color: var(--text-color-secondary);
text-transform: uppercase;
letter-spacing: 2px;
padding: 0 20px 8px;
margin: 5px 0;
border-bottom: 1px solid var(--surface-border);
&:hover {
transform: translateX(2px);
background-color: var(--surface-300);
.nav-icon-wrapper i {
transform: translateX(-2px);
}
}
}
// Breadcrumb styling
.breadcrumb {
padding: 8px 20px;
font-size: 12px;
color: var(--text-color-secondary);
border-bottom: 1px solid var(--surface-border);
margin-bottom: 10px;
overflow: hidden;
transition: all 0.25s ease;
span {
transition: all 0.2s ease;
}
i {
margin: 0 8px;
font-size: 10px;
transition: all 0.2s ease;
}
}
// Section headers for top-level sections
.nav-section-header {
display: flex;
align-items: center;
padding: 8px 20px 4px;
color: var(--text-color-secondary);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
margin: 15px 0 8px 0;
border-bottom: 1px solid var(--surface-border);
.nav-icon-wrapper {
width: 24px;
height: 24px;
display: flex;
justify-content: center;
align-items: center;
margin-right: 12px;
border-radius: 4px;
background: var(--surface-200);
flex-shrink: 0;
i {
font-size: 12px;
color: var(--text-color-secondary);
}
}
span {
font-size: 11px;
font-weight: 600;
}
}
@@ -94,7 +222,12 @@
border-radius: 0 6px 6px 0;
position: relative;
overflow: hidden;
transition: all 0.2s ease;
transition: all 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
margin-bottom: 8px; // Consistent spacing instead of gap
&:last-child {
margin-bottom: 0;
}
.nav-icon-wrapper {
width: 40px;
@@ -106,17 +239,33 @@
border-radius: 8px;
background: var(--surface-card);
border: 1px solid var(--surface-border);
transition: all 0.2s ease;
transition: all 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
flex-shrink: 0; // Prevent icon from shrinking
i {
font-size: 20px;
color: var(--text-color-secondary);
transition: all 0.2s cubic-bezier(0.4, 0.0, 0.2, 1);
}
}
span {
white-space: nowrap;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
flex: 1; // Take available space
}
.nav-badge {
margin-left: auto;
background-color: var(--primary-color);
color: var(--primary-color-text);
border-radius: 12px;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
&::before {
@@ -131,7 +280,9 @@
}
&:hover {
transform: translateX(4px);
background-color: var(--surface-hover);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
.nav-icon-wrapper {
background-color: rgba(var(--primary-500-rgb), 0.1);
@@ -161,6 +312,38 @@
}
}
}
// Parent navigation items (with children)
.nav-parent {
cursor: pointer;
position: relative;
.nav-chevron {
margin-left: auto;
opacity: 0.6;
transition: transform 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);
flex-shrink: 0;
i {
font-size: 16px;
transition: transform 0.2s ease;
}
}
&:hover .nav-chevron i {
transform: translateX(3px) scale(1.1);
}
}
}
// Loading skeleton animation
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
// Animation keyframes

View File

@@ -1,7 +1,10 @@
import { Component, Input, inject, Output, EventEmitter } from '@angular/core';
import { Component, Input, inject, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { Router, RouterLink, NavigationEnd } from '@angular/router';
import { ButtonModule } from 'primeng/button';
import { filter } from 'rxjs/operators';
import { Subscription } from 'rxjs';
import { trigger, state, style, transition, animate, query, stagger } from '@angular/animations';
interface MenuItem {
label: string;
@@ -10,6 +13,24 @@ interface MenuItem {
badge?: string;
}
interface NavigationItem {
id: string;
label: string;
icon: string;
route?: string; // For direct navigation items
children?: NavigationItem[]; // For parent items with sub-menus
isExternal?: boolean; // For external links
href?: string; // For external URLs
badge?: string; // For notification badges
topLevel?: boolean; // If true, shows children directly on top level instead of drill-down
isHeader?: boolean; // If true, renders as a section header (non-clickable)
}
interface RouteMapping {
route: string;
navigationPath: string[]; // Array of navigation item IDs leading to this route
}
@Component({
selector: 'app-sidebar-content',
standalone: true,
@@ -19,9 +40,37 @@ interface MenuItem {
ButtonModule
],
templateUrl: './sidebar-content.component.html',
styleUrl: './sidebar-content.component.scss'
styleUrl: './sidebar-content.component.scss',
animations: [
trigger('staggerItems', [
transition(':enter', [
query(':enter', [
style({ transform: 'translateX(30px)', opacity: 0 }),
stagger('50ms', [
animate('300ms cubic-bezier(0.4, 0.0, 0.2, 1)', style({ transform: 'translateX(0)', opacity: 1 }))
])
], { optional: true })
])
]),
// Container-level navigation animation (replaces individual item animations)
trigger('navigationContainer', [
transition('* => *', [
style({ transform: 'translateX(100%)', opacity: 0 }),
animate('300ms cubic-bezier(0.4, 0.0, 0.2, 1)',
style({ transform: 'translateX(0)', opacity: 1 })
)
])
]),
// Simple fade in animation for initial load
trigger('fadeIn', [
transition(':enter', [
style({ opacity: 0 }),
animate('200ms ease-out', style({ opacity: 1 }))
])
])
]
})
export class SidebarContentComponent {
export class SidebarContentComponent implements OnInit, OnChanges, OnDestroy {
@Input() menuItems: MenuItem[] = [];
@Input() isMobile = false;
@Output() navItemClicked = new EventEmitter<void>();
@@ -29,6 +78,404 @@ export class SidebarContentComponent {
// Inject router for active route styling
public router = inject(Router);
// New properties for drill-down navigation
navigationData: NavigationItem[] = [];
currentNavigation: NavigationItem[] = [];
navigationBreadcrumb: NavigationItem[] = [];
canGoBack = false;
// Pre-rendering optimization properties
isNavigationReady = false;
private hasInitialized = false;
// Animation trigger property - changes to force re-render and trigger animations
navigationStateKey = 0;
// Route synchronization properties
private routerSubscription?: Subscription;
private routeMappings: RouteMapping[] = [
// Dashboard
{ route: '/dashboard', navigationPath: ['dashboard'] },
// Media Management routes
{ route: '/sonarr', navigationPath: ['media-apps', 'sonarr'] },
{ route: '/radarr', navigationPath: ['media-apps', 'radarr'] },
{ route: '/lidarr', navigationPath: ['media-apps', 'lidarr'] },
{ route: '/readarr', navigationPath: ['media-apps', 'readarr'] },
{ route: '/whisparr', navigationPath: ['media-apps', 'whisparr'] },
{ route: '/download-clients', navigationPath: ['media-apps', 'download-clients'] },
// Settings routes
{ route: '/general-settings', navigationPath: ['settings', 'general'] },
{ route: '/queue-cleaner', navigationPath: ['settings', 'queue-cleaner'] },
{ route: '/content-blocker', navigationPath: ['settings', 'content-blocker'] },
{ route: '/download-cleaner', navigationPath: ['settings', 'download-cleaner'] },
{ route: '/notifications', navigationPath: ['settings', 'notifications'] },
// Other routes will be handled dynamically
];
ngOnInit(): void {
// Start with loading state
this.isNavigationReady = false;
// Initialize navigation after showing skeleton
setTimeout(() => {
this.initializeNavigation();
}, 100);
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['menuItems']) {
this.updateActivityItems();
}
}
ngOnDestroy(): void {
this.routerSubscription?.unsubscribe();
}
/**
* Initialize navigation and determine correct level based on route
*/
private initializeNavigation(): void {
if (this.hasInitialized) return;
// 1. Initialize navigation data
this.setupNavigationData();
// 2. Update activity items if available
if (this.menuItems && this.menuItems.length > 0) {
this.updateActivityItems();
}
// 3. Determine correct navigation level based on current route
this.syncSidebarWithCurrentRoute();
// 4. Mark as ready and subscribe to route changes
this.isNavigationReady = true;
this.hasInitialized = true;
this.subscribeToRouteChanges();
}
/**
* Setup basic navigation data structure
*/
private setupNavigationData(): void {
this.navigationData = this.getNavigationData();
this.currentNavigation = this.buildTopLevelNavigation();
}
/**
* Build top-level navigation including expanded sections marked with topLevel: true
*/
private buildTopLevelNavigation(): NavigationItem[] {
const topLevelItems: NavigationItem[] = [];
for (const item of this.navigationData) {
if (item.topLevel && item.children) {
// Add section header
topLevelItems.push({
id: `${item.id}-header`,
label: item.label,
icon: item.icon,
isHeader: true
});
// Add all children directly to top level
topLevelItems.push(...item.children);
} else {
// Add item normally (drill-down behavior)
topLevelItems.push(item);
}
}
return topLevelItems;
}
/**
* Get the navigation data structure
*/
private getNavigationData(): NavigationItem[] {
return [
{
id: 'dashboard',
label: 'Dashboard',
icon: 'pi pi-home',
route: '/dashboard'
},
{
id: 'media-apps',
label: 'Media Apps',
icon: 'pi pi-play-circle',
children: [
{ id: 'sonarr', label: 'Sonarr', icon: 'pi pi-play-circle', route: '/sonarr' },
{ id: 'radarr', label: 'Radarr', icon: 'pi pi-play-circle', route: '/radarr' },
{ id: 'lidarr', label: 'Lidarr', icon: 'pi pi-bolt', route: '/lidarr' },
{ id: 'readarr', label: 'Readarr', icon: 'pi pi-book', route: '/readarr' },
{ id: 'whisparr', label: 'Whisparr', icon: 'pi pi-lock', route: '/whisparr' },
{ id: 'download-clients', label: 'Download Clients', icon: 'pi pi-download', route: '/download-clients' }
]
},
{
id: 'settings',
label: 'Settings',
icon: 'pi pi-cog',
children: [
{ id: 'general', label: 'General', icon: 'pi pi-cog', route: '/general-settings' },
{ id: 'queue-cleaner', label: 'Queue Cleaner', icon: 'pi pi-list', route: '/queue-cleaner' },
{ id: 'content-blocker', label: 'Malware Blocker', icon: 'pi pi-shield', route: '/content-blocker' },
{ id: 'download-cleaner', label: 'Download Cleaner', icon: 'pi pi-trash', route: '/download-cleaner' },
{ id: 'notifications', label: 'Notifications', icon: 'pi pi-bell', route: '/notifications' }
]
},
{
id: 'activity',
label: 'Activity',
icon: 'pi pi-chart-line',
children: [] // Will be populated dynamically from menuItems
},
{
id: 'help-support',
label: 'Help & Support',
icon: 'pi pi-question-circle',
children: [
{
id: 'issues',
label: 'Issues and Requests',
icon: 'pi pi-github',
isExternal: true,
href: 'https://github.com/Cleanuparr/Cleanuparr/issues'
},
{
id: 'discord',
label: 'Discord',
icon: 'pi pi-discord',
isExternal: true,
href: 'https://discord.gg/SCtMCgtsc4'
},
]
},
{
id: 'suggested-apps',
label: 'Suggested Apps',
topLevel: true,
icon: 'pi pi-star',
children: [
{
id: 'huntarr',
label: 'Huntarr',
icon: 'pi pi-github',
isExternal: true,
href: 'https://github.com/plexguide/Huntarr.io'
}
]
}
];
}
/**
* Navigate to route mapping synchronously without delays
*/
private navigateToRouteMappingSync(mapping: RouteMapping): void {
// No delays, no async operations - just set the state
this.navigationBreadcrumb = [];
this.currentNavigation = this.buildTopLevelNavigation();
for (let i = 0; i < mapping.navigationPath.length - 1; i++) {
const itemId = mapping.navigationPath[i];
// Find in original navigation data, not the flattened version
const item = this.navigationData.find(nav => nav.id === itemId);
if (item && item.children && !item.topLevel) {
// Only drill down if it's not a top-level section
this.navigationBreadcrumb.push(item);
this.currentNavigation = [...item.children];
}
}
this.updateNavigationState();
}
/**
* Get skeleton items based on predicted navigation state
*/
getSkeletonItems(): Array<{isSponsor: boolean}> {
const currentRoute = this.router.url;
const mapping = this.findRouteMapping(currentRoute);
if (mapping && mapping.navigationPath.length > 1) {
// We'll show sub-navigation, predict item count
return [
{ isSponsor: true },
{ isSponsor: false }, // Go back
...Array(6).fill({ isSponsor: false }) // Estimated items
];
}
// Default main navigation count
return [
{ isSponsor: true },
...Array(5).fill({ isSponsor: false })
];
}
/**
* TrackBy function for better performance
*/
trackByItemId(index: number, item: NavigationItem): string {
return item.id;
}
/**
* TrackBy function that includes navigation state for animation triggers
*/
trackByItemIdWithState(index: number, item: NavigationItem): string {
return `${item.id}-${this.navigationStateKey}`;
}
/**
* TrackBy function for breadcrumb items
*/
trackByBreadcrumb(index: number, item: NavigationItem): string {
return `${item.id}-${index}`;
}
/**
* Update activity items from menuItems input
*/
private updateActivityItems(): void {
const activityItem = this.navigationData.find(item => item.id === 'activity');
if (activityItem && this.menuItems) {
activityItem.children = this.menuItems
.filter(item => !['Dashboard', 'Settings'].includes(item.label))
.map(item => ({
id: item.label.toLowerCase().replace(/\s+/g, '-'),
label: item.label,
icon: item.icon,
route: item.route,
badge: item.badge
}));
// Update route mappings for activity items
this.updateActivityRouteMappings();
// Update current navigation if we're showing the root level
if (this.navigationBreadcrumb.length === 0) {
this.currentNavigation = this.buildTopLevelNavigation();
}
// Re-sync with current route to handle activity routes
this.syncSidebarWithCurrentRoute();
}
}
/**
* Update route mappings for activity items
*/
private updateActivityRouteMappings(): void {
// Remove old activity mappings
this.routeMappings = this.routeMappings.filter(mapping =>
!mapping.navigationPath[0] || !mapping.navigationPath[0].startsWith('activity')
);
// Add new activity mappings
const activityItem = this.navigationData.find(item => item.id === 'activity');
if (activityItem?.children) {
activityItem.children.forEach(child => {
if (child.route) {
this.routeMappings.push({
route: child.route,
navigationPath: ['activity', child.id]
});
}
});
}
}
/**
* Sync sidebar state with current route
*/
private syncSidebarWithCurrentRoute(): void {
const currentRoute = this.router.url;
const mapping = this.findRouteMapping(currentRoute);
if (mapping) {
this.navigateToRouteMapping(mapping);
}
}
/**
* Find route mapping for current route
*/
private findRouteMapping(route: string): RouteMapping | null {
// Find exact match first, or routes that start with the mapping route
const mapping = this.routeMappings.find(m =>
route === m.route || route.startsWith(m.route + '/')
);
return mapping || null;
}
/**
* Navigate sidebar to match route mapping (used by route sync)
*/
private navigateToRouteMapping(mapping: RouteMapping): void {
// Use the synchronous version
this.navigateToRouteMappingSync(mapping);
}
/**
* Subscribe to route changes for real-time synchronization
*/
private subscribeToRouteChanges(): void {
this.routerSubscription = this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() => {
this.syncSidebarWithCurrentRoute();
});
}
/**
* Navigate to a sub-level with animation trigger
*/
navigateToLevel(item: NavigationItem): void {
if (item.children && item.children.length > 0) {
this.navigationBreadcrumb.push(item);
this.currentNavigation = item.children ? [...item.children] : [];
this.navigationStateKey++; // Force animation trigger
this.updateNavigationState();
}
}
/**
* Go back to the previous level with animation trigger
*/
goBack(): void {
if (this.navigationBreadcrumb.length > 0) {
this.navigationBreadcrumb.pop();
if (this.navigationBreadcrumb.length === 0) {
// Back to root level - use top-level navigation
this.currentNavigation = this.buildTopLevelNavigation();
} else {
// Back to parent level
const parent = this.navigationBreadcrumb[this.navigationBreadcrumb.length - 1];
this.currentNavigation = parent.children ? [...parent.children] : [];
}
this.navigationStateKey++; // Force animation trigger
this.updateNavigationState();
}
}
/**
* Update navigation state
*/
private updateNavigationState(): void {
this.canGoBack = this.navigationBreadcrumb.length > 0;
}
/**
* Handle navigation item click
*/

View File

@@ -1,13 +1,16 @@
<!-- Toast notifications handled by central toast container -->
<div class="settings-container">
<div class="flex align-items-center justify-content-between mb-4">
<h1>Malware Blocker</h1>
</div>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Content Blocker Configuration</h2>
<span class="card-subtitle">Configure automatic content filtering and blocking</span>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Malware Blocker Configuration</h2>
<span class="card-subtitle">Configure automatic content filtering and blocking</span>
</div>
</div>
</div>
</ng-template>
<div class="card-content">
@@ -25,21 +28,21 @@
<!-- Main Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('enabled')"
title="Click for documentation"></i>
Enable Content Blocker
Enable Malware Blocker
</label>
<div class="field-input">
<p-checkbox formControlName="enabled" [binary]="true" inputId="cbEnabled"></p-checkbox>
<small class="form-helper-text">When enabled, the content blocker will run according to the schedule</small>
<small class="form-helper-text">When enabled, the Malware blocker will run according to the schedule</small>
</div>
</div>
<!-- Scheduling Mode Toggle -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('useAdvancedScheduling')"
title="Click for documentation"></i>
Scheduling Mode
@@ -92,7 +95,7 @@
<!-- Advanced Schedule Controls - shown when useAdvancedScheduling is true -->
<div class="field-row" *ngIf="contentBlockerForm.get('useAdvancedScheduling')?.value">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('cronExpression')"
title="Click for documentation"></i>
Cron Expression
@@ -109,7 +112,7 @@
<!-- Content Blocker Specific Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('ignorePrivate')"
title="Click for documentation"></i>
Ignore Private
@@ -122,7 +125,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('deletePrivate')"
title="Click for documentation"></i>
Delete Private
@@ -135,14 +138,14 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('deleteKnownMalware')"
title="Click for documentation"></i>
Delete Known Malware
</label>
<div class="field-input">
<p-checkbox formControlName="deleteKnownMalware" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, files matching known malware patterns will be deleted</small>
<small class="form-helper-text">When enabled, downloads matching known malware patterns will be deleted</small>
</div>
</div>
@@ -164,7 +167,7 @@
<div formGroupName="sonarr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('sonarr.enabled')"
title="Click for documentation"></i>
Enable Sonarr Blocklist
@@ -177,7 +180,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('sonarr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
@@ -193,7 +196,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('sonarr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
@@ -234,7 +237,7 @@
<div formGroupName="radarr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('radarr.enabled')"
title="Click for documentation"></i>
Enable Radarr Blocklist
@@ -247,7 +250,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('radarr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
@@ -263,7 +266,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('radarr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
@@ -304,7 +307,7 @@
<div formGroupName="lidarr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('lidarr.enabled')"
title="Click for documentation"></i>
Enable Lidarr Blocklist
@@ -317,7 +320,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('lidarr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
@@ -333,7 +336,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('lidarr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
@@ -374,7 +377,7 @@
<div formGroupName="readarr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('readarr.enabled')"
title="Click for documentation"></i>
Enable Readarr Blocklist
@@ -387,7 +390,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('readarr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
@@ -403,7 +406,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('readarr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
@@ -444,7 +447,7 @@
<div formGroupName="whisparr">
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('whisparr.enabled')"
title="Click for documentation"></i>
Enable Whisparr Blocklist
@@ -457,7 +460,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('whisparr.blocklistPath')"
title="Click for documentation"></i>
Blocklist Path
@@ -473,7 +476,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('whisparr.blocklistType')"
title="Click for documentation"></i>
Blocklist Type
@@ -522,4 +525,5 @@
</div>
</form>
</div>
</p-card>
</p-card>
</div>

View File

@@ -1,3 +1,5 @@
/* Content Blocker Settings Styles */
@use '../styles/settings-shared.scss';
@use '../styles/settings-shared.scss';
@use '../styles/arr-shared.scss';
@use '../settings-page/settings-page.component.scss';

View File

@@ -1,13 +1,16 @@
<!-- Toast notifications handled by central toast container -->
<div class="settings-container">
<div class="flex align-items-center justify-content-between mb-4">
<h1>Download Cleaner</h1>
</div>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Download Cleaner Configuration</h2>
<span class="card-subtitle">Configure automatic download cleanup</span>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Download Cleaner Configuration</h2>
<span class="card-subtitle">Configure automatic download cleanup</span>
</div>
</div>
</div>
</ng-template>
<div class="card-content">
@@ -25,7 +28,7 @@
<!-- Main Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('enabled')"
title="Click for documentation"></i>
Enable Download Cleaner
@@ -39,7 +42,7 @@
<!-- Scheduling Mode Toggle -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('useAdvancedScheduling')"
title="Click for documentation"></i>
Scheduling Mode
@@ -92,7 +95,7 @@
<!-- Advanced Schedule Controls - shown when useAdvancedScheduling is true -->
<div class="field-row" *ngIf="downloadCleanerForm.get('useAdvancedScheduling')?.value">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('cronExpression')"
title="Click for documentation"></i>
Cron Expression
@@ -124,7 +127,7 @@
<!-- Delete Private Option -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('deletePrivate')"
title="Click for documentation"></i>
Delete Private Torrents
@@ -155,7 +158,7 @@
<div class="category-title">
<i class="pi pi-tag category-icon"></i>
<input type="text" pInputText formControlName="name" placeholder="Category name" class="category-name-input" />
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('name')"
title="Click for documentation"></i>
</div>
@@ -167,7 +170,7 @@
<div class="category-content">
<div class="category-field">
<label>
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('maxRatio')"
title="Click for documentation"></i>
Max Ratio
@@ -185,7 +188,7 @@
<div class="category-field">
<label>
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('minSeedTime')"
title="Click for documentation"></i>
Min Seed Time (hours)
@@ -202,7 +205,7 @@
<div class="category-field">
<label>
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('maxSeedTime')"
title="Click for documentation"></i>
Max Seed Time (hours)
@@ -249,7 +252,7 @@
<p-accordion-content>
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('unlinkedEnabled')"
title="Click for documentation"></i>
Enable Unlinked Download Handling
@@ -263,7 +266,7 @@
<!-- Unlinked Target Category -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('unlinkedTargetCategory')"
title="Click for documentation"></i>
Target Category
@@ -281,7 +284,7 @@
<!-- Use Tag Option -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('unlinkedUseTag')"
title="Click for documentation"></i>
Use Tag
@@ -295,7 +298,7 @@
<!-- Ignored Root Directory -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('unlinkedIgnoredRootDir')"
title="Click for documentation"></i>
Ignored Root Directory
@@ -311,7 +314,7 @@
<!-- Unlinked Categories -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('unlinkedCategories')"
title="Click for documentation"></i>
Unlinked Categories
@@ -371,8 +374,9 @@
</p-card>
<!-- Confirmation Dialog -->
<p-confirmDialog
[style]="{ width: '450px' }"
[baseZIndex]="10000"
rejectButtonStyleClass="p-button-text">
</p-confirmDialog>
<p-confirmDialog
[style]="{ width: '450px' }"
[baseZIndex]="10000"
rejectButtonStyleClass="p-button-text">
</p-confirmDialog>
</div>

View File

@@ -1,6 +1,8 @@
/* Download Cleaner Settings Styles */
@use '../styles/settings-shared.scss';
@use '../styles/arr-shared.scss';
@use '../settings-page/settings-page.component.scss';
.section-header {
margin-bottom: 1rem;

View File

@@ -115,7 +115,7 @@
<form [formGroup]="clientForm" class="p-fluid instance-form">
<div class="field flex flex-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('enabled')"
pTooltip="Click for documentation"></i>
Enabled
@@ -128,7 +128,7 @@
<div class="field">
<label for="client-name">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('name')"
pTooltip="Click for documentation"></i>
Name *
@@ -146,7 +146,7 @@
<div class="field">
<label for="client-type">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('typeName')"
pTooltip="Click for documentation"></i>
Client Type *
@@ -167,7 +167,7 @@
<ng-container>
<div class="field">
<label for="client-host">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('host')"
pTooltip="Click for documentation"></i>
Host *
@@ -187,7 +187,7 @@
<div class="field">
<label for="client-urlbase">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('urlBase')"
pTooltip="Click for documentation"></i>
URL Base
@@ -204,7 +204,7 @@
<div class="field" *ngIf="shouldShowUsernameField()">
<label for="client-username">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('username')"
pTooltip="Click for documentation"></i>
Username
@@ -221,7 +221,7 @@
<div class="field">
<label for="client-password">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('password')"
pTooltip="Click for documentation"></i>
Password

View File

@@ -1,13 +1,16 @@
<!-- Toast notifications handled by central toast container -->
<div class="settings-container">
<div class="flex align-items-center justify-content-between mb-4">
<h1>General Settings</h1>
</div>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">General Configuration</h2>
<span class="card-subtitle">Configure general application settings</span>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">General Configuration</h2>
<span class="card-subtitle">Configure general application settings</span>
</div>
</div>
</div>
</ng-template>
<div class="card-content">
@@ -25,7 +28,7 @@
<!-- Display Support Banner -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('displaySupportBanner')"
title="Click for documentation">
</i>
@@ -40,7 +43,7 @@
<!-- Dry Run -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('dryRun')"
title="Click for documentation">
</i>
@@ -55,7 +58,7 @@
<!-- HTTP Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpMaxRetries')"
title="Click for documentation">
</i>
@@ -79,7 +82,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpTimeout')"
title="Click for documentation">
</i>
@@ -103,7 +106,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpCertificateValidation')"
title="Click for documentation">
</i>
@@ -124,7 +127,7 @@
<!-- Search Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('searchEnabled')"
title="Click for documentation">
</i>
@@ -138,7 +141,7 @@
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('searchDelay')"
title="Click for documentation">
</i>
@@ -163,7 +166,7 @@
<!-- Log Level -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('logLevel')"
title="Click for documentation">
</i>
@@ -184,7 +187,7 @@
<!-- Ignored Downloads -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('ignoredDownloads')"
title="Click for documentation">
</i>
@@ -241,3 +244,4 @@
[style]="{ width: '500px', maxWidth: '90vw' }"
[baseZIndex]="10000">
</p-confirmDialog>
</div>

View File

@@ -1,3 +1,5 @@
/* General Settings Styles */
@use '../styles/settings-shared.scss';
@use '../styles/settings-shared.scss';
@use '../styles/arr-shared.scss';
@use '../settings-page/settings-page.component.scss';

View File

@@ -33,7 +33,7 @@
<!-- API Key -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('notifiarr.apiKey')"
title="Click for documentation">
</i>
@@ -41,14 +41,14 @@
</label>
<div class="field-input">
<input type="text" pInputText formControlName="apiKey" inputId="notifiarrApiKey" placeholder="Enter Notifiarr API key" />
<small class="form-helper-text">Your Notifiarr API key for authentication</small>
<small class="form-helper-text">Passthrough integration must be enabled and a Passthrough key needs to be created in your profile</small>
</div>
</div>
<!-- Channel ID -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('notifiarr.channelId')"
title="Click for documentation">
</i>
@@ -56,15 +56,21 @@
</label>
<div class="field-input">
<input type="text" pInputText formControlName="channelId" inputId="notifiarrChannelId" placeholder="Enter channel ID" />
<small class="form-helper-text">The channel ID where notifications will be sent</small>
<p-inputNumber
placeholder="Enter channel ID"
formControlName="channelId"
[min]="0"
[useGrouping]="false"
>
</p-inputNumber>
<small class="form-helper-text">The Discord channel ID where notifications will be sent</small>
</div>
</div>
<!-- Event Triggers -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('eventTriggers')"
title="Click for documentation">
</i>
@@ -110,7 +116,7 @@
<!-- URL -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('apprise.fullUrl')"
title="Click for documentation">
</i>
@@ -125,7 +131,7 @@
<!-- Key -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('apprise.key')"
title="Click for documentation">
</i>
@@ -140,7 +146,7 @@
<!-- Tags -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('apprise.tags')"
title="Click for documentation">
</i>
@@ -155,7 +161,7 @@
<!-- Event Triggers -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('eventTriggers')"
title="Click for documentation">
</i>

View File

@@ -9,6 +9,7 @@ import { NotificationsConfig } from "../../shared/models/notifications-config.mo
// PrimeNG Components
import { CardModule } from "primeng/card";
import { InputTextModule } from "primeng/inputtext";
import { InputNumberModule } from "primeng/inputnumber";
import { CheckboxModule } from "primeng/checkbox";
import { ButtonModule } from "primeng/button";
import { ToastModule } from "primeng/toast";
@@ -24,6 +25,7 @@ import { LoadingErrorStateComponent } from "../../shared/components/loading-erro
ReactiveFormsModule,
CardModule,
InputTextModule,
InputNumberModule,
CheckboxModule,
ButtonModule,
ToastModule,
@@ -221,7 +223,10 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
const formValues = this.notificationForm.value;
const config: NotificationsConfig = {
notifiarr: formValues.notifiarr,
notifiarr: {
...formValues.notifiarr,
channelId: formValues.notifiarr.channelId ? formValues.notifiarr.channelId.toString() : null,
},
apprise: formValues.apprise,
};

View File

@@ -1,13 +1,16 @@
<!-- Toast notifications handled by central toast container -->
<div class="settings-container">
<div class="flex align-items-center justify-content-between mb-4">
<h1>Queue Cleaner</h1>
</div>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Queue Cleaner Configuration</h2>
<span class="card-subtitle">Configure automatic arr queue cleanup</span>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Queue Cleaner Configuration</h2>
<span class="card-subtitle">Configure automatic arr queue cleanup</span>
</div>
</div>
</div>
</ng-template>
<div class="card-content">
@@ -25,7 +28,7 @@
<!-- Main Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('enabled')"
title="Click for documentation"></i>
Enable Queue Cleaner
@@ -39,7 +42,7 @@
<!-- Scheduling Mode Toggle -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('useAdvancedScheduling')"
title="Click for documentation"></i>
Scheduling Mode
@@ -92,7 +95,7 @@
<!-- Advanced Schedule Controls - shown when useAdvancedScheduling is true -->
<div class="field-row" *ngIf="queueCleanerForm.get('useAdvancedScheduling')?.value">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('cronExpression')"
title="Click for documentation"></i>
Cron Expression
@@ -123,7 +126,7 @@
<p-accordion-content>
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('failedImport.maxStrikes')"
title="Click for documentation"></i>
Max Strikes
@@ -148,7 +151,7 @@
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('failedImport.ignorePrivate')"
title="Click for documentation"></i>
Ignore Private
@@ -161,7 +164,7 @@
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('failedImport.deletePrivate')"
title="Click for documentation"></i>
Delete Private
@@ -174,7 +177,7 @@
<div class="field-row" formGroupName="failedImport">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('failedImport.ignoredPatterns')"
title="Click for documentation"></i>
Ignored Patterns
@@ -219,7 +222,7 @@
<p-accordion-content>
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('stalled.maxStrikes')"
title="Click for documentation"></i>
Max Strikes
@@ -244,7 +247,7 @@
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('stalled.resetStrikesOnProgress')"
title="Click for documentation"></i>
Reset Strikes On Progress
@@ -257,7 +260,7 @@
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('stalled.ignorePrivate')"
title="Click for documentation"></i>
Ignore Private
@@ -270,7 +273,7 @@
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('stalled.deletePrivate')"
title="Click for documentation"></i>
Delete Private
@@ -298,7 +301,7 @@
<p-accordion-content>
<div class="field-row" formGroupName="stalled">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('stalled.downloadingMetadataMaxStrikes')"
title="Click for documentation"></i>
Max Strikes for Downloading Metadata
@@ -338,7 +341,7 @@
<p-accordion-content>
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('slow.maxStrikes')"
title="Click for documentation"></i>
Max Strikes
@@ -363,7 +366,7 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('slow.resetStrikesOnProgress')"
title="Click for documentation"></i>
Reset Strikes On Progress
@@ -376,7 +379,7 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('slow.ignorePrivate')"
title="Click for documentation"></i>
Ignore Private
@@ -389,7 +392,7 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('slow.deletePrivate')"
title="Click for documentation"></i>
Delete Private
@@ -402,7 +405,7 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('slow.minSpeed')"
title="Click for documentation"></i>
Minimum Speed
@@ -421,7 +424,7 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('slow.maxTime')"
title="Click for documentation"></i>
Maximum Time (hours)
@@ -444,7 +447,7 @@
<div class="field-row" formGroupName="slow">
<label class="field-label">
<i class="pi pi-info-circle field-info-icon"
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('slow.ignoreAboveSize')"
title="Click for documentation"></i>
Ignore Above Size
@@ -490,3 +493,4 @@
</form>
</div>
</p-card>
</div>

View File

@@ -1,3 +1,5 @@
/* Queue Cleaner Settings Styles */
@use '../styles/settings-shared.scss';
@use '../styles/settings-shared.scss';
@use '../styles/arr-shared.scss';
@use '../settings-page/settings-page.component.scss';

View File

@@ -1,28 +0,0 @@
<div class="settings-container">
<!-- Toast for notifications -->
<p-toast></p-toast>
<div class="flex align-items-center justify-content-between mb-4">
<h1>Settings</h1>
</div>
<!-- General Settings Component -->
<div class="mb-4">
<app-general-settings></app-general-settings>
</div>
<!-- Queue Cleaner Component -->
<div class="mb-4">
<app-queue-cleaner-settings></app-queue-cleaner-settings>
</div>
<!-- Content Blocker Component -->
<div class="mb-4">
<app-content-blocker-settings></app-content-blocker-settings>
</div>
<!-- Download Cleaner Component -->
<div class="mb-4">
<app-download-cleaner-settings></app-download-cleaner-settings>
</div>
</div>

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SettingsPageComponent } from './settings-page.component';
describe('SettingsPageComponent', () => {
let component: SettingsPageComponent;
let fixture: ComponentFixture<SettingsPageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SettingsPageComponent]
})
.compileComponents();
fixture = TestBed.createComponent(SettingsPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,84 +0,0 @@
import { Component, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { PanelModule } from 'primeng/panel';
import { ButtonModule } from 'primeng/button';
import { DropdownModule } from 'primeng/dropdown';
import { CanComponentDeactivate } from '../../core/guards';
// PrimeNG Components
import { CardModule } from 'primeng/card';
import { ToastModule } from 'primeng/toast';
import { MessageService, ConfirmationService } from 'primeng/api';
import { ConfirmDialogModule } from 'primeng/confirmdialog';
// Custom Components and Services
import { QueueCleanerSettingsComponent } from '../queue-cleaner/queue-cleaner-settings.component';
import { GeneralSettingsComponent } from '../general-settings/general-settings.component';
import { DownloadCleanerSettingsComponent } from '../download-cleaner/download-cleaner-settings.component';
import { SonarrSettingsComponent } from '../sonarr/sonarr-settings.component';
import { ContentBlockerSettingsComponent } from "../content-blocker/content-blocker-settings.component";
@Component({
selector: 'app-settings-page',
standalone: true,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
RouterModule,
PanelModule,
ButtonModule,
DropdownModule,
CardModule,
ToastModule,
ConfirmDialogModule,
QueueCleanerSettingsComponent,
GeneralSettingsComponent,
DownloadCleanerSettingsComponent,
ContentBlockerSettingsComponent
],
providers: [MessageService, ConfirmationService],
templateUrl: './settings-page.component.html',
styleUrl: './settings-page.component.scss'
})
export class SettingsPageComponent implements CanComponentDeactivate {
// Reference to the settings components
@ViewChild(QueueCleanerSettingsComponent) queueCleanerSettings!: QueueCleanerSettingsComponent;
@ViewChild(GeneralSettingsComponent) generalSettings!: GeneralSettingsComponent;
@ViewChild(DownloadCleanerSettingsComponent) downloadCleanerSettings!: DownloadCleanerSettingsComponent;
@ViewChild(SonarrSettingsComponent) sonarrSettings!: SonarrSettingsComponent;
ngOnInit(): void {
// Future implementation for other settings sections
}
/**
* Implements CanComponentDeactivate interface
* Check if any settings components have unsaved changes
*/
canDeactivate(): boolean {
// Check if queue cleaner settings has unsaved changes
if (this.queueCleanerSettings?.canDeactivate() === false) {
return false;
}
// Check if general settings has unsaved changes
if (this.generalSettings?.canDeactivate() === false) {
return false;
}
// Check if download cleaner settings has unsaved changes
if (this.downloadCleanerSettings?.canDeactivate() === false) {
return false;
}
// Check if sonarr settings has unsaved changes
if (this.sonarrSettings?.canDeactivate() === false) {
return false;
}
return true;
}
}

View File

@@ -9,9 +9,9 @@
margin-right: 0.5rem;
color: var(--primary-color, #3b82f6);
cursor: pointer;
opacity: 0.7;
opacity: 0.8;
transition: opacity 0.2s ease, color 0.2s ease;
font-size: 0.9em;
font-size: 1em;
&:hover {
opacity: 1;