mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
feat(header): add home styling, search output and host binding
Enhance the header component to support a distinct "home" appearance and emit search updates. Add a HostBinding that toggles a home-header class from the isHome flag so styles can target the home state. Expose a searchQuery output and wire an onSearchQueryUpdate method to emit the search text, enabling parent components to react to user searches. Refactor imports to include HostBinding and output. Tidy toolbar metrics and adjust element sizing for a compact brand block and pulsing logo. Introduce comprehensive SCSS for the home header: - gradient background, animated glow overlay, and edge accent - brand block styling with blur, border, hover states and box-shadow - logo sizing, pulse animation and responsive tweaks - reduced toolbar height and overall visual polish These changes separate home-specific styling from the default header appearance and provide a programmatic hook for search interactions.
This commit is contained in:
1 parent
a08a4e4122
commit
53cbd64575
8 files changed
+379
-88
No files matched your search
@@ -1,5 +1,9 @@
|
||||
<app-header
|
||||
[title]="'HOME.TITLE' | translate"
|
||||
[subtitle]="'HOME.SUBTITLE' | translate"
|
||||
(searchQuery)="onSearchQueryChange($event)"
|
||||
/>
|
||||
<app-recent-playlists
|
||||
class="recent-playlists"
|
||||
[searchQueryInput]="searchQuery()"
|
||||
/>
|
||||
<app-recent-playlists class="recent-playlists" />
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { RecentPlaylistsComponent } from 'components';
|
||||
import { HeaderComponent } from '../shared/components/header/header.component';
|
||||
@@ -9,4 +9,10 @@ import { HeaderComponent } from '../shared/components/header/header.component';
|
||||
styleUrls: ['./home.component.scss'],
|
||||
imports: [HeaderComponent, RecentPlaylistsComponent, TranslatePipe],
|
||||
})
|
||||
export class HomeComponent {}
|
||||
export class HomeComponent {
|
||||
searchQuery = signal<string>('');
|
||||
|
||||
onSearchQueryChange(query: string): void {
|
||||
this.searchQuery.set(query);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,29 @@
|
||||
<mat-toolbar>
|
||||
<div class="logo">
|
||||
<img
|
||||
src="./assets/icons/icon-tv-256.png"
|
||||
height="100"
|
||||
alt="iptvnator logo"
|
||||
/>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="title">{{ title() }}</div>
|
||||
<div class="subtitle">{{ subtitle() }}</div>
|
||||
<mat-toolbar class="modern-header">
|
||||
<div class="brand-block">
|
||||
<div class="logo-pulse">
|
||||
<img
|
||||
src="./assets/icons/icon-tv-256.png"
|
||||
alt="iptvnator logo"
|
||||
/>
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<div class="title">{{ title() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isHome) {
|
||||
<div class="search-container">
|
||||
<mat-icon class="search-icon">search</mat-icon>
|
||||
<input
|
||||
#searchInput
|
||||
type="search"
|
||||
class="search-input"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
|
||||
(input)="onSearchQueryUpdate(searchInput.value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="menu">
|
||||
<button
|
||||
mat-icon-button
|
||||
|
||||
@@ -1,44 +1,298 @@
|
||||
:host {
|
||||
background: #1b1c1c;
|
||||
background: linear-gradient(135deg, #0a0a0b 0%, #1b1c1c 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
--mat-toolbar-standard-height: 100px;
|
||||
--mat-toolbar-standard-height: 70px;
|
||||
//padding-left: 80px; // Space for macOS traffic lights
|
||||
app-region: drag;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logo {
|
||||
flex: 1 1 100px;
|
||||
// Premium home page gradient - dark theme
|
||||
:host.home-header {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#0f0c1d 0%,
|
||||
#1a1428 25%,
|
||||
#1e1535 50%,
|
||||
#151121 75%,
|
||||
#0d0a18 100%
|
||||
);
|
||||
|
||||
// Subtle animated glow overlay
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(
|
||||
circle at 30% 50%,
|
||||
rgba(139, 92, 246, 0.08) 0%,
|
||||
rgba(99, 102, 241, 0.05) 25%,
|
||||
transparent 50%
|
||||
);
|
||||
animation: headerGlow 15s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Edge accent
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(139, 92, 246, 0.3) 20%,
|
||||
rgba(99, 102, 241, 0.4) 50%,
|
||||
rgba(139, 92, 246, 0.3) 80%,
|
||||
transparent 100%
|
||||
);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes headerGlow {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
33% {
|
||||
transform: translate(-10%, -5%) rotate(120deg);
|
||||
opacity: 0.8;
|
||||
}
|
||||
66% {
|
||||
transform: translate(5%, 10%) rotate(240deg);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
// Compact brand block with pulsing logo
|
||||
.brand-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 16px 0 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 35px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
app-region: no-drag; // Allow interaction with brand block
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced brand block on home page - dark theme
|
||||
:host.home-header .brand-block {
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
border-color: rgba(139, 92, 246, 0.15);
|
||||
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.1);
|
||||
|
||||
&:hover {
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
border-color: rgba(139, 92, 246, 0.25);
|
||||
box-shadow: 0 6px 20px rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.logo-pulse {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
height: 100px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
object-fit: contain;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
filter: drop-shadow(0 2px 8px rgba(255, 59, 48, 0.3));
|
||||
}
|
||||
|
||||
// Pulsing effect - like a live broadcast indicator
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(255, 59, 48, 0.4) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(255, 59, 48, 0.2) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite 0.5s;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
place-content: center flex-start;
|
||||
align-self: center;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.title {
|
||||
font-size: 1em;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #e0e0e0 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
}
|
||||
|
||||
// Modern centered search field
|
||||
.search-container {
|
||||
flex: 1;
|
||||
max-width: 500px;
|
||||
margin: 0 24px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 24px;
|
||||
padding: 0 20px;
|
||||
height: 48px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
app-region: no-drag; // Allow search interaction
|
||||
z-index: 1;
|
||||
|
||||
&:focus-within {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 59, 48, 0.4);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(255, 59, 48, 0.1),
|
||||
0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #999;
|
||||
font-size: 0.7em;
|
||||
margin-top: 5px;
|
||||
text-wrap: pretty;
|
||||
&:hover:not(:focus-within) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced search field on home page
|
||||
:host.home-header .search-container {
|
||||
background: rgba(139, 92, 246, 0.06);
|
||||
border-color: rgba(139, 92, 246, 0.12);
|
||||
|
||||
&:focus-within {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
border-color: rgba(139, 92, 246, 0.4);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(139, 92, 246, 0.15),
|
||||
0 4px 20px rgba(139, 92, 246, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:hover:not(:focus-within) {
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
border-color: rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
:host.home-header .search-icon {
|
||||
color: rgba(139, 92, 246, 0.6);
|
||||
|
||||
.search-container:focus-within & {
|
||||
color: rgba(139, 92, 246, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
margin-right: 12px;
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
.search-container:focus-within & {
|
||||
color: rgba(255, 59, 48, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.01em;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='rgba(255,255,255,0.4)'%3E%3Cpath d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E");
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
place-content: center;
|
||||
align-self: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
app-region: no-drag; // Allow button interactions
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
@@ -55,6 +309,7 @@
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.active-sort {
|
||||
@@ -65,12 +320,50 @@
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logo > img {
|
||||
height: 48px;
|
||||
// Responsive design
|
||||
@media (max-width: 899px) {
|
||||
.search-container {
|
||||
max-width: 300px;
|
||||
margin: 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
:host {
|
||||
--mat-toolbar-standard-height: 64px;
|
||||
padding-left: 70px; // Reduced padding for smaller screens
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
padding: 6px 12px 6px 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logo-pulse {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-text .title {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin: 0 12px;
|
||||
height: 42px;
|
||||
padding: 0 16px;
|
||||
|
||||
.search-input {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, effect, inject, input } from '@angular/core';
|
||||
import { Component, HostBinding, OnInit, effect, inject, input, output } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
@@ -39,6 +39,9 @@ import {
|
||||
],
|
||||
})
|
||||
export class HeaderComponent implements OnInit {
|
||||
@HostBinding('class.home-header') get isHomeHeader() {
|
||||
return this.isHome;
|
||||
}
|
||||
private activatedRoute = inject(ActivatedRoute);
|
||||
private dialog = inject(MatDialog);
|
||||
private dataService = inject(DataService);
|
||||
@@ -49,6 +52,7 @@ export class HeaderComponent implements OnInit {
|
||||
readonly isDesktop = !!window.electron;
|
||||
readonly title = input.required<string>();
|
||||
readonly subtitle = input.required<string>();
|
||||
readonly searchQuery = output<string>();
|
||||
|
||||
isHome = true;
|
||||
|
||||
@@ -160,4 +164,8 @@ export class HeaderComponent implements OnInit {
|
||||
this.currentSortOptions?.order === order
|
||||
);
|
||||
}
|
||||
|
||||
onSearchQueryUpdate(query: string): void {
|
||||
this.searchQuery.emit(query);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,4 @@
|
||||
@if (allPlaylistsLoaded()) {
|
||||
<mat-form-field class="search-field" subscriptSizing="dynamic">
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
#searchQuery
|
||||
type="search"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
|
||||
(input)="onSearchQueryUpdate(searchQuery.value)"
|
||||
/>
|
||||
</mat-form-field>
|
||||
@defer {
|
||||
@if (playlists$ | async; as playlists) {
|
||||
<mat-nav-list class="playlists-list">
|
||||
|
||||
@@ -15,25 +15,16 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
width: 96%;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 12px !important;
|
||||
color: #666;
|
||||
margin-top: 2px !important;
|
||||
}
|
||||
|
||||
|
||||
::ng-deep {
|
||||
div[matformfieldlineripple] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-field > .mat-mdc-text-field-wrapper {
|
||||
border-radius: 30px;
|
||||
// Light theme
|
||||
@media (prefers-color-scheme: light) {
|
||||
.meta {
|
||||
color: #757575;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +53,4 @@
|
||||
padding-left: 0 !important;
|
||||
display: none
|
||||
}
|
||||
|
||||
.search-field {
|
||||
@include mat.form-field-density(-2);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,10 @@ import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -60,19 +59,24 @@ export class RecentPlaylistsComponent {
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly sidebarMode = input(false);
|
||||
readonly searchQueryInput = input<string>('');
|
||||
readonly playlistClicked = output<string>();
|
||||
|
||||
readonly allPlaylistsLoaded = this.store.selectSignal(
|
||||
selectPlaylistsLoadingFlag
|
||||
);
|
||||
|
||||
readonly searchQueryInput =
|
||||
viewChild<ElementRef<HTMLInputElement>>('searchQuery');
|
||||
|
||||
readonly searchQuery = new BehaviorSubject<string>('');
|
||||
|
||||
readonly ghostElements = new Array(10);
|
||||
|
||||
constructor() {
|
||||
// Update searchQuery when input changes
|
||||
effect(() => {
|
||||
this.searchQuery.next(this.searchQueryInput());
|
||||
});
|
||||
}
|
||||
|
||||
readonly playlists$ = combineLatest([
|
||||
this.store.select(selectAllPlaylistsMeta),
|
||||
this.searchQuery,
|
||||
@@ -278,16 +282,4 @@ export class RecentPlaylistsComponent {
|
||||
});
|
||||
}
|
||||
|
||||
onSearchQueryUpdate(searchQuery: string) {
|
||||
this.searchQuery.next(searchQuery);
|
||||
}
|
||||
|
||||
/* @HostListener('window:keydown.control.f', ['$event'])
|
||||
@HostListener('window:keydown.meta.f', ['$event']) */
|
||||
onSearchHotkey(event: KeyboardEvent) {
|
||||
// Prevent default browser search behavior
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.searchQueryInput()?.nativeElement?.focus();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user