first commit
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
|
||||
<header class="bg-slate-800/50 backdrop-blur-sm fixed top-0 left-0 right-0 z-50 shadow-lg w-full border-b border-slate-700/60">
|
||||
<div class="w-full px-0 py-3 grid items-center gap-4 relative grid-cols-[auto_1fr_auto]">
|
||||
<!-- Left: burger + logo -->
|
||||
<div class="flex items-center gap-3 shrink-0 pl-3 md:pl-4">
|
||||
<button (click)="menuToggle.emit()" class="p-2 rounded-full hover:bg-slate-700 focus:outline-none" aria-label="Toggle menu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-200" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 6h18v2H3V6zm0 5h18v2H3v-2zm0 5h18v2H3v-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<a routerLink="/" class="flex items-center space-x-2 shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-500" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10,15.6l5.3-3.3L10,9V15.6z M21.8,8.1c-0.2-0.8-0.9-1.4-1.7-1.7C18.2,6,12,6,12,6s-6.2,0-8.1,0.5 c-0.8,0.2-1.4,0.9-1.7,1.7C2,9.9,2,12,2,12s0,2.1,0.5,3.9c0.2,0.8,0.9,1.4,1.7,1.7C6.2,18,12,18,12,18s6.2,0,8.1-0.5 c0.8-0.2,1.4-0.9,1.7-1.7c0.5-1.8,0.5-3.9,0.5-3.9S22.2,9.9,21.8,8.1z"/>
|
||||
</svg>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-white">NewTube</h1>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Center: search (grid-centered, avoids overlap) -->
|
||||
<form class="justify-self-center mx-auto w-full max-w-2xl md:max-w-3xl lg:max-w-4xl px-4 relative group" (submit)="onSubmitSearch($event, searchInput)">
|
||||
<input #searchInput type="search" [placeholder]="('search.placeholder' | t)" aria-label="Search"
|
||||
(focus)="onSearchFocus()" (input)="onSearchInput(searchInput)" (blur)="onSearchBlur()" (keydown)="onSearchKeydown($event, searchInput)"
|
||||
(search)="onSearchCleared(searchInput)"
|
||||
[ngClass]="{
|
||||
'bg-white text-slate-900 placeholder-slate-500 ring-slate-300 hover:bg-slate-50': isLightTheme(),
|
||||
'bg-slate-700/80 text-slate-200 placeholder-slate-400 ring-slate-600/50 hover:bg-slate-700/70': isDarkTheme(),
|
||||
'bg-black text-slate-200 placeholder-slate-400 ring-slate-800/50 hover:bg-slate-900': isBlackTheme(),
|
||||
'bg-blue-950/80 text-blue-100 placeholder-blue-300 ring-blue-900/60 hover:bg-blue-900/70': isBlueTheme()
|
||||
}"
|
||||
class="w-full rounded-lg py-2.5 px-4 pl-10 pr-16 focus:outline-none ring-1 focus:ring-2 focus:ring-red-500/70 transition-all duration-200 shadow-sm">
|
||||
<svg class="w-5 h-5 absolute left-6 md:left-5 top-1/2 -translate-y-1/2"
|
||||
[ngClass]="{
|
||||
'text-slate-500': isLightTheme(),
|
||||
'text-slate-400': isDarkTheme(),
|
||||
'text-slate-300': isBlackTheme(),
|
||||
'text-blue-200': isBlueTheme()
|
||||
}"
|
||||
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<!-- Keyboard shortcut hint (only when no provider indicator) -->
|
||||
<div *ngIf="!providerContextLabel()" class="absolute right-6 top-1/2 -translate-y-1/2 hidden md:flex items-center gap-1 text-xs select-none"
|
||||
[ngClass]="{
|
||||
'text-slate-700/80': isLightTheme(),
|
||||
'text-slate-300/80': isDarkTheme() || isBlackTheme(),
|
||||
'text-blue-200/80': isBlueTheme()
|
||||
}">
|
||||
<kbd [ngClass]="{
|
||||
'bg-slate-200/80 border-slate-300 text-slate-700': isLightTheme(),
|
||||
'bg-slate-700/80 border-slate-600/60': isDarkTheme(),
|
||||
'bg-slate-900/80 border-slate-800/60 text-slate-300': isBlackTheme(),
|
||||
'bg-blue-800/80 border-blue-700/60 text-blue-200': isBlueTheme()
|
||||
}" class="px-1.5 py-0.5 rounded border">Ctrl</kbd>
|
||||
<span>+</span>
|
||||
<kbd [ngClass]="{
|
||||
'bg-slate-200/80 border-slate-300 text-slate-700': isLightTheme(),
|
||||
'bg-slate-700/80 border-slate-600/60 text-slate-300': isDarkTheme(),
|
||||
'bg-slate-900/80 border-slate-800/60 text-slate-300': isBlackTheme(),
|
||||
'bg-blue-800/80 border-blue-700/60 text-blue-200': isBlueTheme()
|
||||
}" class="px-1.5 py-0.5 rounded border">K</kbd>
|
||||
</div>
|
||||
<!-- Suggestions dropdown -->
|
||||
@if (suggestionsOpen() && suggestionItems().length > 0) {
|
||||
<div class="absolute z-50 mt-2 left-1/2 -translate-x-1/2 w-full max-w-2xl md:max-w-3xl lg:max-w-4xl rounded-xl shadow-2xl overflow-hidden"
|
||||
[ngClass]="{
|
||||
'bg-white border border-slate-200 text-slate-900': isLightTheme(),
|
||||
'bg-slate-800/95 border border-slate-700 text-slate-200': isDarkTheme(),
|
||||
'bg-black/95 border border-slate-900 text-slate-200': isBlackTheme(),
|
||||
'bg-blue-950/90 border border-blue-800 text-blue-100': isBlueTheme(),
|
||||
'backdrop-blur-md': !isLightTheme()
|
||||
}">
|
||||
<ul class="max-h-[70vh] overflow-y-auto py-1">
|
||||
@for (item of suggestionItems(); track $index; let i = $index) {
|
||||
<!-- Divider before first generated suggestion -->
|
||||
@if (isFirstGenerated(i)) {
|
||||
<li class="px-4 py-2 text-xs uppercase tracking-wide border-t border-b"
|
||||
[ngClass]="{
|
||||
'text-slate-500 border-slate-100': isLightTheme(),
|
||||
'text-slate-400 border-slate-700': isDarkTheme(),
|
||||
'text-slate-400 border-slate-800': isBlackTheme(),
|
||||
'text-blue-300 border-blue-800': isBlueTheme()
|
||||
}">Suggestions</li>
|
||||
}
|
||||
<li (mouseenter)="highlightedIndex.set(i)">
|
||||
<button type="button" (mousedown)="pickSuggestion(item.text, searchInput)"
|
||||
class="w-full text-left px-4 py-2 flex items-center gap-3 transition-colors duration-150"
|
||||
[ngClass]="{
|
||||
'hover:bg-slate-100': isLightTheme(),
|
||||
'hover:bg-slate-700/70': isDarkTheme(),
|
||||
'hover:bg-slate-900': isBlackTheme(),
|
||||
'hover:bg-blue-800/60': isBlueTheme(),
|
||||
'bg-slate-200': isLightTheme() && highlightedIndex() === i,
|
||||
'bg-slate-700/50': isDarkTheme() && highlightedIndex() === i,
|
||||
'bg-slate-900/70': isBlackTheme() && highlightedIndex() === i,
|
||||
'bg-blue-800/50': isBlueTheme() && highlightedIndex() === i,
|
||||
'text-slate-900': isLightTheme(),
|
||||
'text-slate-200': isDarkTheme() || isBlackTheme(),
|
||||
'text-blue-100': isBlueTheme()
|
||||
}">
|
||||
<!-- Clock for history, magnifier for generated -->
|
||||
<svg *ngIf="item.source === 'history'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5"
|
||||
[ngClass]="{
|
||||
'text-slate-500': isLightTheme(),
|
||||
'text-slate-400': isDarkTheme(),
|
||||
'text-slate-300': isBlackTheme(),
|
||||
'text-blue-200': isBlueTheme()
|
||||
}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg *ngIf="item.source === 'generated'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5"
|
||||
[ngClass]="{
|
||||
'text-slate-500': isLightTheme(),
|
||||
'text-slate-400': isDarkTheme(),
|
||||
'text-slate-300': isBlackTheme(),
|
||||
'text-blue-200': isBlueTheme()
|
||||
}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span class="truncate">{{ item.text }}</span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
|
||||
<!-- Right: user -->
|
||||
<div class="justify-self-end flex items-center gap-3 pr-3 md:pr-4">
|
||||
<!-- Provider context indicator placed between search and user area -->
|
||||
<div *ngIf="providerContextLabel()" class="flex items-center gap-1 text-xs">
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-yellow-400/30 border border-yellow-400/40 shadow-sm"
|
||||
[ngClass]="{ 'text-black': isLightTheme(), 'text-yellow-200': !isLightTheme() }">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-yellow-300 animate-pulse"></span>
|
||||
<span>{{ providerContextLabel() }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-6 w-px bg-slate-700/60 mx-2"></div>
|
||||
|
||||
<!-- Auth / User Menu Area -->
|
||||
<div *ngIf="!user(); else userMenu" class="flex items-center gap-2">
|
||||
<a routerLink="/auth/login" class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-white">{{ 'btn.login' | t }}</a>
|
||||
<a routerLink="/auth/register" class="px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white">{{ 'btn.register' | t }}</a>
|
||||
</div>
|
||||
<ng-template #userMenu>
|
||||
<div class="relative" #userMenuContainer>
|
||||
<!-- Avatar Button -->
|
||||
<button (click)="toggleUserMenu()" class="flex items-center gap-2 p-1 rounded-full hover:bg-slate-700">
|
||||
<div class="h-8 w-8 rounded-full flex items-center justify-center text-sm font-semibold border"
|
||||
[ngClass]="{
|
||||
'bg-slate-200 text-slate-800 border-slate-300': isLightTheme(),
|
||||
'bg-slate-600 text-white border-slate-500': isDarkTheme(),
|
||||
'bg-slate-800 text-slate-200 border-slate-700': isBlackTheme(),
|
||||
'bg-blue-900 text-blue-100 border-blue-800': isBlueTheme()
|
||||
}">
|
||||
{{ (user()?.username || user()?.email || 'U') | slice:0:1 | uppercase }}
|
||||
</div>
|
||||
</button>
|
||||
<!-- Dropdown Menu -->
|
||||
@if (userMenuOpen()) {
|
||||
<div class="absolute right-0 mt-2 w-64 bg-slate-900 border border-slate-700 rounded-xl shadow-xl overflow-hidden z-50">
|
||||
<div class="px-4 py-3 border-b border-slate-700">
|
||||
<div class="font-semibold text-slate-100">{{ user()?.username || user()?.email }}</div>
|
||||
<div class="text-xs text-slate-400 truncate">{{ user()?.email }}</div>
|
||||
</div>
|
||||
<div class="py-1">
|
||||
<a routerLink="/account/preferences" class="block px-4 py-2 text-sm text-slate-200 hover:bg-slate-800">{{ 'menu.preferences' | t }}</a>
|
||||
<div class="px-4 py-2 text-xs uppercase tracking-wide text-slate-500">{{ 'menu.appearance' | t }}</div>
|
||||
<div class="px-3 pb-2 grid grid-cols-4 gap-2">
|
||||
<!-- Black -->
|
||||
<button (click)="setTheme('black')"
|
||||
[ngClass]="{ 'ring-2': currentTheme() === 'black', 'ring-red-500': currentTheme() === 'black' }"
|
||||
class="px-2 py-2 text-xs rounded bg-slate-800 hover:bg-slate-700 flex items-center justify-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="9"/></svg>
|
||||
<span>{{ 'theme.black' | t }}</span>
|
||||
</button>
|
||||
<!-- Dark -->
|
||||
<button (click)="setTheme('dark')"
|
||||
[ngClass]="{ 'ring-2': currentTheme() === 'dark', 'ring-red-500': currentTheme() === 'dark' }"
|
||||
class="px-2 py-2 text-xs rounded bg-slate-800 hover:bg-slate-700 flex items-center justify-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 1 0 9.79 9.79Z"/></svg>
|
||||
<span>{{ 'theme.dark' | t }}</span>
|
||||
</button>
|
||||
<!-- Blue -->
|
||||
<button (click)="setTheme('blue')"
|
||||
[ngClass]="{ 'ring-2': currentTheme() === 'blue', 'ring-red-500': currentTheme() === 'blue' }"
|
||||
class="px-2 py-2 text-xs rounded bg-slate-800 hover:bg-slate-700 flex items-center justify-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2s7 7.58 7 12a7 7 0 1 1-14 0C5 9.58 12 2 12 2Z"/></svg>
|
||||
<span>{{ 'theme.blue' | t }}</span>
|
||||
</button>
|
||||
<!-- Light -->
|
||||
<button (click)="setTheme('light')"
|
||||
[ngClass]="{ 'ring-2': currentTheme() === 'light', 'ring-red-500': currentTheme() === 'light' }"
|
||||
class="px-2 py-2 text-xs rounded bg-slate-800 hover:bg-slate-700 flex items-center justify-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 18a6 6 0 1 0 0-12 6 6 0 0 0 0 12Zm0-16a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V3a1 1 0 0 1 1-1Zm0 18a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1Zm9-9a1 1 0 0 1 1 1h-2a1 1 0 1 1 0-2h2a1 1 0 0 1-1 1ZM4 12a1 1 0 0 1 1-1H3a1 1 0 1 1 0 2h2a1 1 0 0 1-1-1Zm12.95 6.364a1 1 0 0 1 1.414 1.414l-1.415 1.415a1 1 0 1 1-1.414-1.415l1.415-1.414ZM7.05 4.222a1 1 0 1 1 1.414-1.415L9.88 4.222A1 1 0 0 1 8.465 5.636L7.05 4.222zm9.9-1.415a1 1 0 1 1 1.414 1.415L17.95 5.636A1 1 0 0 1 16.536 4.22l1.414-1.414ZM7.05 19.778 5.636 21.19a1 1 0 0 1-1.414-1.414L5.636 18.364A1 1 0 0 1 7.05 19.78Z"/></svg>
|
||||
<span>{{ 'theme.light' | t }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button (click)="onLogout()" class="w-full text-left px-4 py-2 text-sm text-red-300 hover:bg-red-900/30">{{ 'menu.logout' | t }}</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- PeerTube Instance Editor Modal -->
|
||||
<div *ngIf="editingPeerTubeInstances()" class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div class="bg-gray-800 rounded-lg p-6 w-full max-w-md">
|
||||
<h2 class="text-xl font-bold mb-4">Edit PeerTube Instances</h2>
|
||||
<p class="text-sm text-gray-400 mb-4">Enter one instance domain per line.</p>
|
||||
<textarea [(ngModel)]="peerTubeInstancesInput" class="w-full h-48 bg-gray-900 text-white rounded p-2 focus:outline-none focus:ring-2 focus:ring-red-500"></textarea>
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
<button (click)="editingPeerTubeInstances.set(false)" class="px-4 py-2 rounded bg-gray-600 hover:bg-gray-500">Cancel</button>
|
||||
<button (click)="savePeerTubeInstances()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal, Output, EventEmitter, HostListener, ViewChild, ElementRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterLink, NavigationEnd } from '@angular/router';
|
||||
import { InstanceService, Provider } from '../../services/instance.service';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { UserService } from '../../services/user.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
import { I18nService } from '../../services/i18n.service';
|
||||
import { ThemesService } from '../../services/themes.service';
|
||||
import { HistoryService, SearchHistoryItem } from '../../services/history.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
templateUrl: './header.component.html',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterLink,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
TranslatePipe
|
||||
]
|
||||
})
|
||||
export class HeaderComponent {
|
||||
private router = inject(Router);
|
||||
instances = inject(InstanceService);
|
||||
private auth = inject(AuthService);
|
||||
private userService = inject(UserService);
|
||||
private i18n = inject(I18nService);
|
||||
private themes = inject(ThemesService);
|
||||
private history = inject(HistoryService);
|
||||
|
||||
@ViewChild('searchInput', { static: false }) searchInputRef?: ElementRef<HTMLInputElement>;
|
||||
@ViewChild('userMenuContainer', { static: false }) userMenuContainerRef?: ElementRef<HTMLElement>;
|
||||
|
||||
searchQuery = '';
|
||||
editingPeerTubeInstances = signal(false);
|
||||
peerTubeInstancesInput = '';
|
||||
|
||||
// Search suggestions dropdown state
|
||||
suggestionsOpen = signal(false);
|
||||
recentSearches = signal<SearchHistoryItem[]>([]);
|
||||
highlightedIndex = signal<number>(-1);
|
||||
// Suggestion item shape: history vs generated
|
||||
readonly suggestionItems = computed<{ text: string; source: 'history' | 'generated' }[]>(() => {
|
||||
type Item = { text: string; source: 'history' | 'generated' };
|
||||
const q = (this.searchQuery || '').trim();
|
||||
const provider = this.selectedProvider();
|
||||
const fromHistory = (this.recentSearches() || [])
|
||||
.map(it => (it.query || '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// 1) Base list from history
|
||||
let historyList: string[];
|
||||
if (!q) {
|
||||
historyList = fromHistory.slice(0, 15);
|
||||
} else {
|
||||
const lower = q.toLowerCase();
|
||||
historyList = fromHistory.filter(txt => txt.toLowerCase().includes(lower)).slice(0, 15);
|
||||
}
|
||||
|
||||
const items: Item[] = historyList.map(text => ({ text, source: 'history' }));
|
||||
|
||||
// 2) If we have fewer than 15, fill with generated suggestions
|
||||
if (items.length < 15) {
|
||||
const need = 15 - items.length;
|
||||
const generated = this.generateQuerySuggestions(q, provider);
|
||||
// Avoid duplicates with history
|
||||
const existing = new Set(items.map(i => i.text.toLowerCase()));
|
||||
for (const g of generated) {
|
||||
const t = (g || '').trim();
|
||||
if (!t) continue;
|
||||
if (existing.has(t.toLowerCase())) continue;
|
||||
items.push({ text: t, source: 'generated' });
|
||||
if (items.length >= 15) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset highlight if list size changes or becomes empty
|
||||
const prev = this.highlightedIndex();
|
||||
if (prev >= items.length) this.highlightedIndex.set(items.length - 1);
|
||||
if (items.length === 0) this.highlightedIndex.set(-1);
|
||||
return items;
|
||||
});
|
||||
|
||||
// Generate query suggestions based on the current text and provider
|
||||
private generateQuerySuggestions(q: string, provider: string | null): string[] {
|
||||
const base = (q || '').trim();
|
||||
const suggestions: string[] = [];
|
||||
|
||||
// If nothing typed yet, propose popular generic queries per provider context
|
||||
if (!base) {
|
||||
// Lightweight provider-tailored seeds
|
||||
const common = [
|
||||
'live', 'news', 'best of', 'highlights', 'playlist', 'remix', 'cover', 'tutorial', 'review'
|
||||
];
|
||||
if (provider === 'youtube') {
|
||||
suggestions.push('trending', 'documentary', 'mix', 'lyrics', 'official video');
|
||||
} else if (provider === 'peertube') {
|
||||
suggestions.push('open source', 'conference', 'self-hosted');
|
||||
}
|
||||
suggestions.push(...common);
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
// Heuristics: variants that typically help discovery
|
||||
const quoted = `"${base}"`;
|
||||
const year = new Date().getFullYear();
|
||||
const tokens = [
|
||||
quoted,
|
||||
`${base} official`,
|
||||
`${base} live`,
|
||||
`${base} lyrics`,
|
||||
`${base} remix`,
|
||||
`${base} cover`,
|
||||
`${base} tutorial`,
|
||||
`${base} review`,
|
||||
`${base} full album`,
|
||||
`${base} best of`,
|
||||
`${base} ${year}`,
|
||||
`${base} ${year - 1}`
|
||||
];
|
||||
|
||||
if (provider === 'youtube') {
|
||||
tokens.push(`${base} playlist`, `${base} 4k`, `${base} short`);
|
||||
}
|
||||
if (provider === 'rumble') {
|
||||
tokens.push(`${base} highlights`, `${base} podcast`);
|
||||
}
|
||||
|
||||
// Keep unique and meaningful
|
||||
const seen = new Set<string>();
|
||||
for (const t of tokens) {
|
||||
const key = t.toLowerCase();
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
suggestions.push(t);
|
||||
}
|
||||
if (suggestions.length >= 20) break;
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
readonly providers = computed(() => this.instances.providers());
|
||||
readonly selectedProvider = computed(() => this.instances.selectedProvider());
|
||||
readonly user = computed(() => this.auth.currentUser());
|
||||
|
||||
// Provider context read from current URL query param (?provider=...)
|
||||
providerContext = signal<Provider | null>(null);
|
||||
providerContextLabel = computed(() => {
|
||||
const ctx = this.providerContext();
|
||||
if (!ctx) return null;
|
||||
const p = this.providers().find(x => x.id === ctx);
|
||||
return p?.label || ctx;
|
||||
});
|
||||
|
||||
// Theme management (global/local)
|
||||
themeOptions = ['system', 'light', 'dark', 'black', 'blue'];
|
||||
currentTheme = signal<string>((() => {
|
||||
try { return localStorage.getItem('newtube.theme') || 'system'; } catch { return 'system'; }
|
||||
})());
|
||||
|
||||
// Login/Register modals state
|
||||
loginOpen = signal(false);
|
||||
registerOpen = signal(false);
|
||||
loginUsername = signal('');
|
||||
loginPassword = signal('');
|
||||
rememberMe = signal(true);
|
||||
registerUsername = signal('');
|
||||
registerPassword = signal('');
|
||||
registerEmail = signal('');
|
||||
authError = signal<string | null>(null);
|
||||
userMenuOpen = signal(false);
|
||||
|
||||
// Sidebar toggle (emits to parent AppComponent)
|
||||
@Output() menuToggle = new EventEmitter<void>();
|
||||
|
||||
onSubmitSearch(ev: Event, input: HTMLInputElement) {
|
||||
ev.preventDefault();
|
||||
const q = input.value.trim();
|
||||
if (!q) return;
|
||||
const provider = this.selectedProvider();
|
||||
const theme = this.themes.activeSlug();
|
||||
const qp: any = { q, provider };
|
||||
if (theme) qp.theme = theme;
|
||||
this.router.navigate(['/search'], { queryParams: qp });
|
||||
}
|
||||
|
||||
// Open suggestions and load last 15 searches when focusing the input
|
||||
onSearchFocus() {
|
||||
this.suggestionsOpen.set(true);
|
||||
this.loadRecentSearches();
|
||||
this.highlightedIndex.set(-1);
|
||||
}
|
||||
|
||||
// Keep suggestions open and update searchQuery as user types
|
||||
onSearchInput(input: HTMLInputElement) {
|
||||
this.searchQuery = input.value;
|
||||
if (!this.suggestionsOpen()) {
|
||||
this.suggestionsOpen.set(true);
|
||||
}
|
||||
// Force refresh of suggestions by updating the signal
|
||||
this.recentSearches.update(searches => [...searches]);
|
||||
this.highlightedIndex.set(-1);
|
||||
}
|
||||
|
||||
// Delay closing to allow click on a suggestion
|
||||
onSearchBlur() {
|
||||
setTimeout(() => this.suggestionsOpen.set(false), 150);
|
||||
}
|
||||
|
||||
// Pick a suggestion: fill input and submit navigation
|
||||
pickSuggestion(text: string, input: HTMLInputElement) {
|
||||
input.value = text;
|
||||
this.searchQuery = text;
|
||||
// Submit same as pressing Enter
|
||||
const provider = this.selectedProvider();
|
||||
const theme = this.themes.activeSlug();
|
||||
const qp: any = { q: text, provider };
|
||||
if (theme) qp.theme = theme;
|
||||
this.router.navigate(['/search'], { queryParams: qp });
|
||||
this.suggestionsOpen.set(false);
|
||||
}
|
||||
|
||||
// Keyboard navigation for suggestions
|
||||
onSearchKeydown(ev: KeyboardEvent, input: HTMLInputElement) {
|
||||
const items = this.suggestionItems();
|
||||
if (!items || items.length === 0) return;
|
||||
const max = items.length - 1;
|
||||
const current = this.highlightedIndex();
|
||||
if (ev.key === 'ArrowDown') {
|
||||
ev.preventDefault();
|
||||
const next = Math.min(max, current + 1);
|
||||
this.highlightedIndex.set(next < 0 ? 0 : next);
|
||||
} else if (ev.key === 'ArrowUp') {
|
||||
ev.preventDefault();
|
||||
const next = Math.max(-1, current - 1);
|
||||
this.highlightedIndex.set(next);
|
||||
} else if (ev.key === 'Enter') {
|
||||
if (current >= 0 && current <= max) {
|
||||
ev.preventDefault();
|
||||
const chosen = items[current]?.text;
|
||||
if (chosen) this.pickSuggestion(chosen, input);
|
||||
}
|
||||
} else if (ev.key === 'Escape') {
|
||||
this.suggestionsOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Theme helpers for styling based on current theme
|
||||
getCurrentTheme(): string {
|
||||
return this.currentTheme() || 'system';
|
||||
}
|
||||
|
||||
isLightTheme(): boolean {
|
||||
const t = this.getCurrentTheme();
|
||||
return t === 'light' || t === 'system';
|
||||
}
|
||||
|
||||
isDarkTheme(): boolean {
|
||||
const t = this.getCurrentTheme();
|
||||
return t === 'dark';
|
||||
}
|
||||
|
||||
isBlackTheme(): boolean {
|
||||
const t = this.getCurrentTheme();
|
||||
return t === 'black';
|
||||
}
|
||||
|
||||
isBlueTheme(): boolean {
|
||||
const t = this.getCurrentTheme();
|
||||
return t === 'blue';
|
||||
}
|
||||
|
||||
getThemeClasses(): { [key: string]: boolean } {
|
||||
const theme = this.getCurrentTheme();
|
||||
return {
|
||||
'theme-light': theme === 'light' || theme === 'system',
|
||||
'theme-dark': theme === 'dark',
|
||||
'theme-black': theme === 'black',
|
||||
'theme-blue': theme === 'blue'
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to show the divider before the first generated item
|
||||
isFirstGenerated(index: number): boolean {
|
||||
const arr = this.suggestionItems();
|
||||
if (!arr || index < 0 || index >= arr.length) return false;
|
||||
if (arr[index]?.source !== 'generated') return false;
|
||||
return index === 0 || arr[index - 1]?.source !== 'generated';
|
||||
}
|
||||
|
||||
private loadRecentSearches() {
|
||||
try {
|
||||
this.history.getSearchHistory(15).subscribe({
|
||||
next: (items) => {
|
||||
// API likely returns newest first; ensure we keep order and dedupe by query
|
||||
this.recentSearches.set(items || []);
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
// Initialize and keep provider context in sync with URL
|
||||
this.updateProviderContextFromUrl(this.router.url);
|
||||
this.router.events.subscribe(evt => {
|
||||
if (evt instanceof NavigationEnd) {
|
||||
this.updateProviderContextFromUrl(evt.urlAfterRedirects || evt.url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updateProviderContextFromUrl(url: string) {
|
||||
try {
|
||||
// Normalize to portion after '#', if present (hash routing)
|
||||
const hashIdx = url.indexOf('#');
|
||||
const afterHash = hashIdx >= 0 ? url.substring(hashIdx + 1) : url;
|
||||
// Prefer query param ?provider=...
|
||||
const qIdx = afterHash.indexOf('?');
|
||||
const query = qIdx >= 0 ? afterHash.substring(qIdx + 1) : '';
|
||||
const params = new URLSearchParams(query);
|
||||
let provider = (params.get('provider') || '').trim() as Provider;
|
||||
|
||||
// If not provided via query, try path format /p/:provider/... (supports hash and non-hash routing)
|
||||
if (!provider) {
|
||||
// Normalize to path without query/hash prefix symbols
|
||||
const pathOnly = (qIdx >= 0 ? afterHash.substring(0, qIdx) : afterHash) || '';
|
||||
const noHash = pathOnly; // already removed above
|
||||
const segs = noHash.split('/').filter(s => s.length > 0);
|
||||
// Look for pattern ['p', ':provider', ...]
|
||||
const pIdx = segs.indexOf('p');
|
||||
if (pIdx >= 0 && segs.length > pIdx + 1) {
|
||||
provider = segs[pIdx + 1] as Provider;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate against known providers list; if none and we're on Shorts, fall back to selected provider
|
||||
const known = this.providers().map(p => p.id);
|
||||
if (provider && known.includes(provider)) {
|
||||
this.providerContext.set(provider);
|
||||
} else {
|
||||
// If current path looks like '/shorts' (hash routing already normalized above)
|
||||
const pathOnly = (qIdx >= 0 ? afterHash.substring(0, qIdx) : afterHash) || '';
|
||||
const segs = pathOnly.split('/').filter(s => s.length > 0);
|
||||
if (segs.length > 0 && segs[0] === 'shorts') {
|
||||
const fallback = this.selectedProvider();
|
||||
if (fallback && known.includes(fallback)) {
|
||||
this.providerContext.set(fallback);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.providerContext.set(null);
|
||||
}
|
||||
} catch {
|
||||
this.providerContext.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
// When the native clear (X) on a search input is clicked, browsers fire a 'search' event.
|
||||
// If the field becomes empty, navigate back to Home.
|
||||
onSearchCleared(input: HTMLInputElement) {
|
||||
const q = (input?.value || '').trim();
|
||||
if (!q) {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
|
||||
focusSearch() {
|
||||
try {
|
||||
const el = this.searchInputRef?.nativeElement;
|
||||
if (el) { el.focus(); el.select(); }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
handleGlobalKeydown(ev: KeyboardEvent) {
|
||||
const isInput = (ev.target as HTMLElement)?.closest('input, textarea, [contenteditable="true"]');
|
||||
// Ctrl/Cmd+K focuses search
|
||||
if ((ev.key === 'k' || ev.key === 'K') && (ev.ctrlKey || ev.metaKey)) {
|
||||
ev.preventDefault();
|
||||
this.focusSearch();
|
||||
return;
|
||||
}
|
||||
// '/' focuses search when not typing in another input
|
||||
if (ev.key === '/' && !isInput) {
|
||||
ev.preventDefault();
|
||||
this.focusSearch();
|
||||
return;
|
||||
}
|
||||
// Escape blurs search
|
||||
if (ev.key === 'Escape') {
|
||||
const el = this.searchInputRef?.nativeElement;
|
||||
if (el && document.activeElement === el) {
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onProviderChange(event: Event) {
|
||||
const selectedProvider = (event.target as HTMLSelectElement).value as Provider;
|
||||
this.instances.setSelectedProvider(selectedProvider);
|
||||
}
|
||||
|
||||
toggleUserMenu() {
|
||||
this.userMenuOpen.update(v => !v);
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onDocumentClick(ev: MouseEvent) {
|
||||
if (!this.userMenuOpen()) return;
|
||||
const container = this.userMenuContainerRef?.nativeElement;
|
||||
const target = ev.target as Node | null;
|
||||
if (container && target && !container.contains(target)) {
|
||||
this.userMenuOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
onPeerTubeInstanceChange(event: Event) {
|
||||
const selectedInstance = (event.target as HTMLSelectElement).value;
|
||||
this.instances.setActivePeerTubeInstance(selectedInstance);
|
||||
}
|
||||
|
||||
editPeerTubeInstances() {
|
||||
this.peerTubeInstancesInput = this.instances.peerTubeInstances().join('\n');
|
||||
this.editingPeerTubeInstances.set(true);
|
||||
}
|
||||
|
||||
savePeerTubeInstances() {
|
||||
const instances = this.peerTubeInstancesInput.split('\n').map(i => i.trim()).filter(i => i.length > 0);
|
||||
this.instances.setPeerTubeInstances(instances);
|
||||
if (!instances.includes(this.instances.activePeerTubeInstance())) {
|
||||
this.instances.setActivePeerTubeInstance(instances[0] || '');
|
||||
}
|
||||
this.editingPeerTubeInstances.set(false);
|
||||
}
|
||||
|
||||
onThemeChange(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement | null)?.value || 'system';
|
||||
this.setTheme(value);
|
||||
}
|
||||
|
||||
setTheme(value: string) {
|
||||
this.currentTheme.set(value);
|
||||
try {
|
||||
document.documentElement.setAttribute('data-theme', value);
|
||||
localStorage.setItem('newtube.theme', value);
|
||||
} catch {}
|
||||
// If logged in, persist to backend preferences (fire-and-forget)
|
||||
if (this.user()) {
|
||||
try { this.userService.updatePreferences({ theme: value }).subscribe({ next: () => {}, error: () => {} }); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
cycleTheme() {
|
||||
const order = this.themeOptions;
|
||||
const cur = this.currentTheme();
|
||||
const idx = Math.max(0, order.indexOf(cur));
|
||||
const nextVal = order[(idx + 1) % order.length];
|
||||
this.setTheme(nextVal);
|
||||
}
|
||||
|
||||
// Auth actions
|
||||
openLogin() { this.clearAuthForms(); this.loginOpen.set(true); }
|
||||
openRegister() { this.clearAuthForms(); this.registerOpen.set(true); }
|
||||
closeModals() { this.loginOpen.set(false); this.registerOpen.set(false); this.authError.set(null); }
|
||||
|
||||
private clearAuthForms() {
|
||||
this.loginUsername.set('');
|
||||
this.loginPassword.set('');
|
||||
this.rememberMe.set(true);
|
||||
this.registerUsername.set('');
|
||||
this.registerPassword.set('');
|
||||
this.registerEmail.set('');
|
||||
this.authError.set(null);
|
||||
}
|
||||
|
||||
async onSubmitLogin(ev: Event, u?: HTMLInputElement, p?: HTMLInputElement, rm?: HTMLInputElement) {
|
||||
ev.preventDefault();
|
||||
this.authError.set(null);
|
||||
try {
|
||||
const username = (u?.value ?? this.loginUsername()).trim();
|
||||
const password = p?.value ?? this.loginPassword();
|
||||
const remember = (rm?.checked != null) ? rm.checked : this.rememberMe();
|
||||
await firstValueFrom(this.auth.login(username, password, remember));
|
||||
// Load preferences and apply immediately
|
||||
const prefs = await firstValueFrom(this.userService.loadPreferences());
|
||||
if (prefs) {
|
||||
if (prefs.defaultProvider) this.instances.setSelectedProvider(prefs.defaultProvider as any);
|
||||
if (prefs.region) this.instances.setRegion(prefs.region);
|
||||
try {
|
||||
const t = prefs.theme || 'system';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
this.currentTheme.set(t);
|
||||
localStorage.setItem('newtube.theme', t);
|
||||
} catch {}
|
||||
if (prefs.language) this.i18n.setLanguage(prefs.language);
|
||||
}
|
||||
this.closeModals();
|
||||
} catch (e: any) {
|
||||
const status = (e && typeof e.status === 'number') ? e.status : 0;
|
||||
const serverMsg = (e && e.error && (e.error.error || e.error.message)) || null;
|
||||
if (status === 401) this.authError.set(serverMsg || 'Invalid credentials.');
|
||||
else if (status === 429) this.authError.set('Too many attempts. Please wait a minute and try again.');
|
||||
else this.authError.set(serverMsg || 'Login failed.');
|
||||
}
|
||||
}
|
||||
|
||||
async onSubmitRegister(ev: Event, ru?: HTMLInputElement, re?: HTMLInputElement, rp?: HTMLInputElement) {
|
||||
ev.preventDefault();
|
||||
this.authError.set(null);
|
||||
try {
|
||||
const usernameRaw = (ru?.value ?? this.registerUsername()).trim();
|
||||
const emailRaw = (re?.value ?? this.registerEmail()).trim();
|
||||
const password = (rp?.value ?? this.registerPassword());
|
||||
const username = usernameRaw || emailRaw; // fallback to email when username empty
|
||||
if (!username || !password) {
|
||||
this.authError.set('Username (or Email) and password are required.');
|
||||
return;
|
||||
}
|
||||
await firstValueFrom(this.auth.register(username, password, emailRaw || undefined));
|
||||
const prefs = await firstValueFrom(this.userService.loadPreferences());
|
||||
if (prefs) {
|
||||
if (prefs.defaultProvider) this.instances.setSelectedProvider(prefs.defaultProvider as any);
|
||||
if (prefs.region) this.instances.setRegion(prefs.region);
|
||||
try {
|
||||
const t = prefs.theme || 'system';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
this.currentTheme.set(t);
|
||||
localStorage.setItem('newtube.theme', t);
|
||||
} catch {}
|
||||
}
|
||||
this.closeModals();
|
||||
} catch (e: any) {
|
||||
const status = (e && typeof e.status === 'number') ? e.status : 0;
|
||||
const serverMsg = (e && e.error && (e.error.error || e.error.message)) || null;
|
||||
if (status === 409) this.authError.set(serverMsg || 'Username already exists. Choose another.');
|
||||
else if (status === 400) this.authError.set(serverMsg || 'Username and password are required.');
|
||||
else if (status === 429) this.authError.set('Too many attempts. Please wait a minute and try again.');
|
||||
else this.authError.set(serverMsg || 'Registration failed.');
|
||||
}
|
||||
}
|
||||
|
||||
async onLogout() {
|
||||
try { await firstValueFrom(this.auth.logout()); } catch {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user