598 lines
22 KiB
TypeScript
598 lines
22 KiB
TypeScript
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'
|
|
};
|
|
}
|
|
|
|
// Brand colors for provider badge (inline styles to match History badges)
|
|
getProviderColors(providerId: string | null | undefined): { [key: string]: string } {
|
|
const id = (providerId || '').toLowerCase();
|
|
switch (id) {
|
|
case 'youtube':
|
|
return { backgroundColor: 'rgba(220, 38, 38, 0.15)', color: 'rgb(248, 113, 113)', borderColor: 'rgba(239, 68, 68, 0.3)' };
|
|
case 'vimeo':
|
|
return { backgroundColor: 'rgba(2, 132, 199, 0.15)', color: 'rgb(125, 211, 252)', borderColor: 'rgba(14, 165, 233, 0.3)' };
|
|
case 'dailymotion':
|
|
return { backgroundColor: 'rgba(37, 99, 235, 0.15)', color: 'rgb(147, 197, 253)', borderColor: 'rgba(59, 130, 246, 0.3)' };
|
|
case 'peertube':
|
|
return { backgroundColor: 'rgba(245, 158, 11, 0.15)', color: 'rgb(252, 211, 77)', borderColor: 'rgba(245, 158, 11, 0.3)' };
|
|
case 'rumble':
|
|
return { backgroundColor: 'rgba(22, 163, 74, 0.15)', color: 'rgb(134, 239, 172)', borderColor: 'rgba(34, 197, 94, 0.3)' };
|
|
case 'twitch':
|
|
return { backgroundColor: 'rgba(168, 85, 247, 0.15)', color: 'rgb(216, 180, 254)', borderColor: 'rgba(168, 85, 247, 0.3)' };
|
|
case 'odysee':
|
|
return { backgroundColor: 'rgba(236, 72, 153, 0.15)', color: 'rgb(251, 207, 232)', borderColor: 'rgba(236, 72, 153, 0.3)' };
|
|
default:
|
|
return { backgroundColor: 'rgba(30, 41, 59, 0.8)', color: 'rgb(203, 213, 225)', borderColor: 'rgb(51, 65, 85)' };
|
|
}
|
|
}
|
|
|
|
// 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 {}
|
|
}
|
|
|
|
private updateProviderContext(provider: Provider) {
|
|
if (this.instances.providers().some(p => p.id === provider)) {
|
|
this.providerContext.set(provider);
|
|
}
|
|
}
|
|
|
|
ngOnInit() {
|
|
// Initialize and keep provider context in sync with URL
|
|
this.updateProviderContextFromUrl(this.router.url);
|
|
|
|
// Écouter les mises à jour de provider depuis d'autres composants
|
|
document.addEventListener('updateProviderContext', (event: Event) => {
|
|
const customEvent = event as CustomEvent<Provider>;
|
|
if (customEvent.detail) {
|
|
this.updateProviderContext(customEvent.detail);
|
|
}
|
|
});
|
|
|
|
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);
|
|
// Support both ?provider= and legacy/alt ?p=
|
|
let provider = ((params.get('provider') || params.get('p') || '').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/Watch, 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;
|
|
}
|
|
}
|
|
// If current path looks like '/watch', also fall back to selected provider to show a consistent badge
|
|
if (segs.length > 0 && segs[0] === 'watch') {
|
|
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 {}
|
|
}
|
|
}
|