first commit
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
<div class="container mx-auto p-4 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2 text-slate-200">
|
||||
<a [routerLink]="['/t', theme()]" class="px-3 py-1 rounded bg-slate-800 hover:bg-slate-700 border border-slate-700">← {{ 'themes.backToThemes' | t }}</a>
|
||||
<h2 class="text-2xl font-bold">
|
||||
{{ themes.bySlug(theme())?.emoji }} {{ themes.bySlug(theme())?.label }}
|
||||
<span class="text-slate-400 text-base">— {{ provider() | titlecase }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-slate-400">{{ 'themes.changeProvider' | t }}</label>
|
||||
<select [ngModel]="provider()" (ngModelChange)="changeProvider($event)" class="bg-gray-800 text-white rounded px-2 py-1">
|
||||
<option *ngFor="let p of providersList()" [value]="p.id">{{ p.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="grid sm:grid-cols-4 gap-3 mb-6 bg-slate-800/50 p-3 rounded-lg border border-slate-700">
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.sort' | t }}</label>
|
||||
<select [ngModel]="sort()" (ngModelChange)="onSortChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
|
||||
<option value="recent">{{ 'filter.sort.recent' | t }}</option>
|
||||
<option value="viewed">{{ 'filter.sort.viewed' | t }}</option>
|
||||
<option value="longest">{{ 'filter.sort.longest' | t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.duration' | t }}</label>
|
||||
<select [ngModel]="duration()" (ngModelChange)="onDurationChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
|
||||
<option value="any">{{ 'filter.duration.any' | t }}</option>
|
||||
<option value="short">{{ 'filter.duration.short' | t }}</option>
|
||||
<option value="medium">{{ 'filter.duration.medium' | t }}</option>
|
||||
<option value="long">{{ 'filter.duration.long' | t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.language' | t }}</label>
|
||||
<select [ngModel]="language()" (ngModelChange)="onLanguageChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
|
||||
<option value="any">Any</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.date' | t }}</label>
|
||||
<select [ngModel]="date()" (ngModelChange)="onDateChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
|
||||
<option value="any">Any</option>
|
||||
<option value="day">Last 24h</option>
|
||||
<option value="week">Last week</option>
|
||||
<option value="month">Last month</option>
|
||||
<option value="year">Last year</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sections Tabs mimic (simple headings) -->
|
||||
<div class="space-y-10">
|
||||
<!-- Recorded Streams -->
|
||||
<section>
|
||||
<h3 class="text-xl font-semibold mb-3">{{ 'themes.recorded' | t }}</h3>
|
||||
<div *ngIf="recorded.loading && !recorded.items.length" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<div *ngFor="let i of [1,2,3,4,5,6,7,8]" class="animate-pulse bg-slate-800/50 rounded-lg overflow-hidden border border-slate-800">
|
||||
<div class="w-full h-32 bg-slate-800"></div>
|
||||
<div class="p-2">
|
||||
<div class="h-3 bg-slate-700 rounded w-11/12 mb-2"></div>
|
||||
<div class="h-3 bg-slate-700 rounded w-8/12"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="!recorded.loading && recorded.items.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
|
||||
<div *ngIf="recorded.error" class="mt-2 p-3 bg-red-900/30 border border-red-700 text-red-200 rounded">
|
||||
{{ recorded.error }}
|
||||
<button class="ml-2 underline hover:text-red-100" (click)="retry(recorded)">{{ 'action.retry' | t }}</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4" *ngIf="recorded.items.length">
|
||||
<a *ngFor="let v of (recorded.items | slice:0:recorded.visibleCount); trackBy: trackByVideo"
|
||||
[routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }"
|
||||
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50">
|
||||
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-32 object-cover">
|
||||
<div class="p-2 text-sm line-clamp-2">{{ v.title }}</div>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Infinite scroll anchor -->
|
||||
<app-infinite-anchor
|
||||
*ngIf="recorded.items.length && (recorded.items.length >= recorded.visibleCount)"
|
||||
[busy]="recorded.loading"
|
||||
[disabled]="!recorded.nextCursor && recorded.items.length <= recorded.visibleCount"
|
||||
[rootMargin]="'1000px 0px 1000px 0px'"
|
||||
(loadMore)="showMore(recorded)">
|
||||
</app-infinite-anchor>
|
||||
<div class="mt-2 flex items-center justify-center text-slate-400 text-sm" *ngIf="recorded.loading">
|
||||
<span class="inline-block h-4 w-4 border-2 border-slate-500 border-t-transparent rounded-full animate-spin mr-2"></span>
|
||||
{{ 'loading.more' | t }}
|
||||
</div>
|
||||
<div class="mt-3" *ngIf="recorded.items.length && (recorded.items.length > recorded.visibleCount || recorded.nextCursor)">
|
||||
<button class="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-700 text-slate-200 disabled:opacity-60"
|
||||
[disabled]="recorded.loading"
|
||||
(click)="showMore(recorded)">
|
||||
{{ 'loading.more' | t }} (20)
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Videos -->
|
||||
<section>
|
||||
<h3 class="text-xl font-semibold mb-3">{{ 'themes.videos' | t }}</h3>
|
||||
<div *ngIf="videos.loading && !videos.items.length" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<div *ngFor="let i of [1,2,3,4,5,6,7,8]" class="animate-pulse bg-slate-800/50 rounded-lg overflow-hidden border border-slate-800">
|
||||
<div class="w-full h-32 bg-slate-800"></div>
|
||||
<div class="p-2">
|
||||
<div class="h-3 bg-slate-700 rounded w-11/12 mb-2"></div>
|
||||
<div class="h-3 bg-slate-700 rounded w-8/12"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="!videos.loading && videos.items.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
|
||||
<div *ngIf="videos.error" class="mt-2 p-3 bg-red-900/30 border border-red-700 text-red-200 rounded">
|
||||
{{ videos.error }}
|
||||
<button class="ml-2 underline hover:text-red-100" (click)="retry(videos)">{{ 'action.retry' | t }}</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4" *ngIf="videos.items.length">
|
||||
<a *ngFor="let v of (videos.items | slice:0:videos.visibleCount); trackBy: trackByVideo"
|
||||
[routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }"
|
||||
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50">
|
||||
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-32 object-cover">
|
||||
<div class="p-2 text-sm line-clamp-2">{{ v.title }}</div>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Infinite scroll anchor -->
|
||||
<app-infinite-anchor
|
||||
*ngIf="videos.items.length && (videos.items.length >= videos.visibleCount)"
|
||||
[busy]="videos.loading"
|
||||
[disabled]="!videos.nextCursor && videos.items.length <= videos.visibleCount"
|
||||
[rootMargin]="'1000px 0px 1000px 0px'"
|
||||
(loadMore)="showMore(videos)">
|
||||
</app-infinite-anchor>
|
||||
<div class="mt-2 flex items-center justify-center text-slate-400 text-sm" *ngIf="videos.loading">
|
||||
<span class="inline-block h-4 w-4 border-2 border-slate-500 border-t-transparent rounded-full animate-spin mr-2"></span>
|
||||
{{ 'loading.more' | t }}
|
||||
</div>
|
||||
<div class="mt-3" *ngIf="videos.items.length && (videos.items.length > videos.visibleCount || videos.nextCursor)">
|
||||
<button class="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-700 text-slate-200 disabled:opacity-60"
|
||||
[disabled]="videos.loading"
|
||||
(click)="showMore(videos)">
|
||||
{{ 'loading.more' | t }} (20)
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Categories (static recommended) -->
|
||||
<section>
|
||||
<h3 class="text-xl font-semibold mb-3">{{ 'themes.categories' | t }}</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<div *ngFor="let c of categories()" class="bg-slate-800/60 rounded-xl p-4 border border-slate-700">
|
||||
<div class="text-4xl">{{ c.emoji }}</div>
|
||||
<div class="mt-2 font-semibold">{{ c.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,375 @@
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, effect, inject, signal, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { InstanceService, Provider } from '../../services/instance.service';
|
||||
import { ThemesService } from '../../services/themes.service';
|
||||
import { YoutubeApiService } from '../../services/youtube-api.service';
|
||||
import { Video } from '../../models/video.model';
|
||||
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
|
||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
|
||||
interface SectionState {
|
||||
key: 'recorded' | 'videos' | 'categories';
|
||||
titleKey: string;
|
||||
items: Video[];
|
||||
nextCursor: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
visibleCount: number; // how many items are currently shown
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-provider-theme',
|
||||
standalone: true,
|
||||
templateUrl: './provider-theme-page.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, FormsModule, RouterLink, InfiniteAnchorComponent, TranslatePipe]
|
||||
})
|
||||
export class ProviderThemePageComponent implements OnDestroy {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private instances = inject(InstanceService);
|
||||
themes = inject(ThemesService);
|
||||
private api = inject(YoutubeApiService);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
|
||||
provider = signal<Provider>('youtube');
|
||||
theme = signal<string>('trending');
|
||||
|
||||
// Guard to ignore stale async responses after route/theme/provider changes
|
||||
private version = 0;
|
||||
|
||||
// Filters state
|
||||
sort = signal<'recent' | 'viewed' | 'longest'>('recent');
|
||||
duration = signal<'any' | 'short' | 'medium' | 'long'>('any');
|
||||
language = signal<'any' | 'en' | 'fr'>('any');
|
||||
date = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
|
||||
|
||||
// UI
|
||||
providersList = computed(() => this.instances.providers());
|
||||
categories = computed(() => this.themes.categoriesFor(this.theme()));
|
||||
|
||||
// Sections (Live removed on provider pages)
|
||||
recorded: SectionState = { key: 'recorded', titleKey: 'themes.recorded', items: [], nextCursor: null, loading: false, error: null, visibleCount: 20 };
|
||||
videos: SectionState = { key: 'videos', titleKey: 'themes.videos', items: [], nextCursor: null, loading: false, error: null, visibleCount: 20 };
|
||||
|
||||
constructor() {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const provider = (pm.get('provider') as Provider) || 'youtube';
|
||||
const theme = pm.get('theme') || 'trending';
|
||||
this.provider.set(provider);
|
||||
this.theme.set(theme);
|
||||
// Keep global provider in sync so header search uses the current context
|
||||
try { this.instances.setSelectedProvider(provider); } catch {}
|
||||
this.resetAll();
|
||||
// Increment version so in-flight requests from previous state are ignored
|
||||
this.version++;
|
||||
this.loadInitial();
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
|
||||
// Prefetch recalculation on resize (affects columns/rows thresholds)
|
||||
this.handleResize = () => {
|
||||
this.checkPrefetch(this.recorded);
|
||||
this.checkPrefetch(this.videos);
|
||||
};
|
||||
try { window.addEventListener('resize', this.handleResize); } catch {}
|
||||
|
||||
// Re-apply filters on change (Live removed) with a tiny debounce to avoid thrashing
|
||||
effect(() => {
|
||||
const _s = this.sort();
|
||||
const _d = this.duration();
|
||||
const _l = this.language();
|
||||
const _dt = this.date();
|
||||
const apply = () => {
|
||||
// Reset visible window to ensure top results reflect new sort/filter instantly
|
||||
this.recorded.visibleCount = Math.max(20, this.recorded.visibleCount);
|
||||
this.videos.visibleCount = Math.max(20, this.videos.visibleCount);
|
||||
this.recorded.items = this.applyFilters(this.recorded.items);
|
||||
this.videos.items = this.applyFilters(this.videos.items);
|
||||
// After filtering, re-evaluate if we need to prefetch more
|
||||
this.checkPrefetch(this.recorded);
|
||||
this.checkPrefetch(this.videos);
|
||||
this.cdr.markForCheck();
|
||||
try { this.cdr.detectChanges(); } catch {}
|
||||
};
|
||||
try {
|
||||
clearTimeout((this as any).__fltTimer);
|
||||
} catch {}
|
||||
(this as any).__fltTimer = setTimeout(apply, 150);
|
||||
}, { allowSignalWrites: true });
|
||||
}
|
||||
|
||||
private resetAll() {
|
||||
for (const s of [this.recorded, this.videos]) {
|
||||
s.items = []; s.nextCursor = null; s.loading = false; s.error = null; s.visibleCount = 20;
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
private baseTokens(): string[] {
|
||||
const t = this.theme();
|
||||
if (t === 'trending') return [];
|
||||
if (t === 'live') return ['live'];
|
||||
return this.themes.tokensFor(t);
|
||||
}
|
||||
|
||||
private buildQueryFor(section: SectionState): string {
|
||||
const tokens = [...this.baseTokens()];
|
||||
if (section.key === 'recorded') {
|
||||
const p = this.provider();
|
||||
// Only providers that benefit from a 'replay' search hint
|
||||
if (p === 'youtube' || p === 'twitch') tokens.push('replay');
|
||||
// For other providers, prefer trending (empty query) to avoid bad/no results
|
||||
}
|
||||
return tokens.join(' ').trim();
|
||||
}
|
||||
|
||||
private applyFilters(list: Video[]): Video[] {
|
||||
let arr = list.slice();
|
||||
const d = this.duration();
|
||||
if (d !== 'any') {
|
||||
arr = arr.filter(v => {
|
||||
const dur = v.duration || 0;
|
||||
if (d === 'short') return dur > 0 && dur < 600;
|
||||
if (d === 'medium') return dur >= 600 && dur < 1800;
|
||||
if (d === 'long') return dur >= 1800;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Language heuristic (very lightweight)
|
||||
const lang = this.language();
|
||||
if (lang !== 'any') {
|
||||
const isFrWord = (s: string) => /\b(le|la|les|des|un|une|et|ou|pour|avec|sans|sur|dans|est|cette|vidéo)\b/i.test(s);
|
||||
const isEnWord = (s: string) => /\b(the|a|an|and|or|with|without|for|this|video|live)\b/i.test(s);
|
||||
arr = arr.filter(v => {
|
||||
const text = `${v.title} ${v.uploaderName}`;
|
||||
const fr = isFrWord(text) || /[éèêàùçîïô]/i.test(text);
|
||||
const en = isEnWord(text);
|
||||
return lang === 'fr' ? fr && !en : en && !fr;
|
||||
});
|
||||
}
|
||||
|
||||
// Date filter based on uploaded timestamp if available
|
||||
const dateOpt = this.date();
|
||||
if (dateOpt !== 'any') {
|
||||
const now = Date.now();
|
||||
const cutoff = (() => {
|
||||
if (dateOpt === 'day') return now - 24 * 3600_000;
|
||||
if (dateOpt === 'week') return now - 7 * 24 * 3600_000;
|
||||
if (dateOpt === 'month') return now - 30 * 24 * 3600_000;
|
||||
if (dateOpt === 'year') return now - 365 * 24 * 3600_000;
|
||||
return 0;
|
||||
})();
|
||||
if (cutoff > 0) {
|
||||
arr = arr.filter(v => (v.uploaded || 0) >= cutoff);
|
||||
}
|
||||
}
|
||||
const s = this.sort();
|
||||
if (s === 'recent') arr.sort((a, b) => (b.uploaded || 0) - (a.uploaded || 0));
|
||||
if (s === 'viewed') arr.sort((a, b) => (b.views || 0) - (a.views || 0));
|
||||
if (s === 'longest') arr.sort((a, b) => (b.duration || 0) - (a.duration || 0));
|
||||
return arr;
|
||||
}
|
||||
|
||||
changeProvider(p: any) {
|
||||
const provider = String(p) as Provider;
|
||||
// Sync the global provider so header search reflects the current context immediately
|
||||
try { this.instances.setSelectedProvider(provider); } catch {}
|
||||
this.router.navigate(['/p', provider, 't', this.theme()]);
|
||||
}
|
||||
|
||||
onSortChange(val: any) { this.sort.set(val as any); }
|
||||
onDurationChange(val: any) { this.duration.set(val as any); }
|
||||
onLanguageChange(val: any) { this.language.set(val as any); }
|
||||
onDateChange(val: any) { this.date.set(val as any); }
|
||||
|
||||
loadInitial() {
|
||||
const readiness = this.instances.getProviderReadiness(this.provider());
|
||||
if (!readiness.ready) {
|
||||
const msg = readiness.reason || 'Provider not ready';
|
||||
console.warn('[ProviderTheme] Provider not ready', { provider: this.provider(), reason: msg, theme: this.theme() });
|
||||
this.recorded.error = msg; this.recorded.loading = false;
|
||||
this.videos.error = msg; this.videos.loading = false;
|
||||
return;
|
||||
}
|
||||
this.loadMore(this.recorded);
|
||||
this.loadMore(this.videos);
|
||||
}
|
||||
|
||||
loadMore(section: SectionState) {
|
||||
if (section.loading) return;
|
||||
const readiness = this.instances.getProviderReadiness(this.provider());
|
||||
if (!readiness.ready) {
|
||||
section.error = readiness.reason || 'Provider not ready';
|
||||
console.warn('[ProviderTheme] Skip load: provider not ready', { provider: this.provider(), section: section.key, reason: section.error, theme: this.theme() });
|
||||
section.loading = false;
|
||||
return;
|
||||
}
|
||||
section.loading = true;
|
||||
this.cdr.markForCheck();
|
||||
const provider = this.provider();
|
||||
|
||||
const query = this.buildQueryFor(section);
|
||||
const snapshotVersion = this.version;
|
||||
|
||||
// If YouTube quota has already been flagged exceeded, surface it and avoid requests
|
||||
if (provider === 'youtube' && this.api.quotaExceeded$.getValue()) {
|
||||
section.error = 'YouTube quota exceeded. Try again later or switch provider.';
|
||||
section.loading = false;
|
||||
console.warn('[ProviderTheme] Skip load: YouTube quota exceeded', { section: section.key, theme: this.theme() });
|
||||
return;
|
||||
}
|
||||
|
||||
const next = (res: { items: Video[]; nextCursor?: string | null }) => {
|
||||
// Ignore responses from a previous state
|
||||
if (snapshotVersion !== this.version) return;
|
||||
// Deduplicate by videoId or URL before applying filters
|
||||
const existing = new Set(section.items.map(v => v.videoId || v.url || ''));
|
||||
const beforeLen = section.items.length;
|
||||
const merged: Video[] = [...section.items];
|
||||
const incoming = res.items || [];
|
||||
const beforeKeys = existing.size;
|
||||
for (const v of res.items || []) {
|
||||
const key = v.videoId || v.url || '';
|
||||
if (!existing.has(key)) { existing.add(key); merged.push(v); }
|
||||
}
|
||||
const afterKeys = existing.size;
|
||||
const added = Math.max(0, afterKeys - beforeKeys);
|
||||
const duplicates = Math.max(0, (incoming.length || 0) - added);
|
||||
section.items = this.applyFilters(merged);
|
||||
section.nextCursor = res.nextCursor || null;
|
||||
section.loading = false;
|
||||
if (incoming.length === 0) {
|
||||
console.info('[ProviderTheme] Empty page', { provider, section: section.key, theme: this.theme(), query, cursor: section.nextCursor });
|
||||
}
|
||||
if (!res.nextCursor) {
|
||||
console.info('[ProviderTheme] Reached end of pagination', { provider, section: section.key, theme: this.theme(), total: section.items.length });
|
||||
}
|
||||
if (duplicates > 0) {
|
||||
console.debug('[ProviderTheme] Deduplicated incoming items', { provider, section: section.key, duplicates, added, before: beforeLen, after: section.items.length });
|
||||
}
|
||||
// Post-process for YouTube: fetch durations for items lacking them to make duration filters/sorts work
|
||||
if (provider === 'youtube') {
|
||||
try {
|
||||
const wantIds = merged.filter(v => (v.duration || 0) === 0 && !!v.videoId).slice(0, 50).map(v => v.videoId as string);
|
||||
const unique = Array.from(new Set(wantIds));
|
||||
if (unique.length) {
|
||||
this.api.getYouTubeDurations(unique).subscribe({
|
||||
next: (mapDur) => {
|
||||
if (snapshotVersion !== this.version) return;
|
||||
if (!mapDur) return;
|
||||
// Update durations in-place
|
||||
for (const v of section.items) {
|
||||
const id = v.videoId || '';
|
||||
if (id && mapDur[id] != null) v.duration = Number(mapDur[id]) || 0;
|
||||
}
|
||||
// Re-apply filters/sorts that depend on duration
|
||||
section.items = this.applyFilters(section.items);
|
||||
this.checkPrefetch(section);
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// Logic-based prefetch: if we are within ~2 rows of the end, prefetch next page
|
||||
this.checkPrefetch(section);
|
||||
this.cdr.markForCheck();
|
||||
try { this.cdr.detectChanges(); } catch {}
|
||||
};
|
||||
|
||||
const onErr = (e?: any) => {
|
||||
// Specific message for quota exceeded on YouTube
|
||||
if (provider === 'youtube' && e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
|
||||
this.api.quotaExceeded$.next(true);
|
||||
section.error = 'YouTube quota exceeded. Try again later or switch provider.';
|
||||
} else if (provider === 'peertube') {
|
||||
const inst = this.instances.activePeerTubeInstance();
|
||||
const status = e?.status;
|
||||
if (status === 404) section.error = `PeerTube (${inst}) introuvable. Essayez une autre instance.`;
|
||||
else if (status === 429) section.error = `PeerTube (${inst}) limite atteinte. Réessayez plus tard.`;
|
||||
else section.error = `PeerTube (${inst}) indisponible. Réessayez plus tard.`;
|
||||
} else {
|
||||
section.error = 'Erreur de chargement';
|
||||
}
|
||||
section.loading = false;
|
||||
console.error('[ProviderTheme] Load error', { provider, section: section.key, theme: this.theme(), query, cursor: section.nextCursor, error: String(e?.message || e) });
|
||||
this.cdr.markForCheck();
|
||||
try { this.cdr.detectChanges(); } catch {}
|
||||
};
|
||||
|
||||
if (query) {
|
||||
this.api.searchVideosPage(query, section.nextCursor, provider).subscribe({ next, error: onErr });
|
||||
} else {
|
||||
this.api.getTrendingPage(section.nextCursor, provider).subscribe({ next, error: onErr });
|
||||
}
|
||||
}
|
||||
|
||||
// Increase visible items by 20; fetch the next page if we need more data
|
||||
showMore(section: SectionState) {
|
||||
const target = section.visibleCount + 20;
|
||||
section.visibleCount = target;
|
||||
// If we don't have enough items yet and there is a next page, fetch it
|
||||
if (section.items.length < target && section.nextCursor && !section.loading) {
|
||||
console.debug('[ProviderTheme] showMore triggers fetch', { section: section.key, target, have: section.items.length, nextCursor: section.nextCursor });
|
||||
this.loadMore(section);
|
||||
}
|
||||
// Additionally, run logic-based prefetch check in case of edge conditions
|
||||
this.checkPrefetch(section);
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
// Helpers for watch params
|
||||
watchQueryParams(v: Video): Record<string, any> | null {
|
||||
const p = this.provider();
|
||||
const qp: any = { p };
|
||||
if (p === 'odysee' && v.url?.startsWith('https://odysee.com/')) {
|
||||
let slug = v.url.substring('https://odysee.com/'.length);
|
||||
if (slug.startsWith('/')) slug = slug.slice(1);
|
||||
qp.slug = slug;
|
||||
}
|
||||
if (p === 'twitch' && v.type === 'channel') qp.channel = v.videoId;
|
||||
return qp;
|
||||
}
|
||||
|
||||
// trackBy to reduce DOM churn
|
||||
trackByVideo(_idx: number, v: Video) { return v.videoId || v.url || _idx; }
|
||||
|
||||
// Manual retry helper
|
||||
retry(section: SectionState) {
|
||||
section.error = null;
|
||||
this.loadMore(section);
|
||||
}
|
||||
|
||||
// --- Prefetch helpers ---
|
||||
private handleResize: (() => void) | null = null;
|
||||
|
||||
private currentCols(): number {
|
||||
try {
|
||||
// Tailwind breakpoints: md>=768px -> 4 cols, lg>=1024px -> 6 cols, else 2 cols
|
||||
if (window.matchMedia && window.matchMedia('(min-width: 1024px)').matches) return 6;
|
||||
if (window.matchMedia && window.matchMedia('(min-width: 768px)').matches) return 4;
|
||||
return 2;
|
||||
} catch {
|
||||
return 4; // safe default
|
||||
}
|
||||
}
|
||||
|
||||
private checkPrefetch(section: SectionState) {
|
||||
const cols = this.currentCols();
|
||||
const threshold = cols * 2; // ~2 rows
|
||||
const remaining = section.items.length - section.visibleCount;
|
||||
if (remaining <= threshold && section.nextCursor && !section.loading) {
|
||||
console.debug('[ProviderTheme] Logic prefetch triggered', { section: section.key, remaining, threshold, nextCursor: section.nextCursor });
|
||||
this.loadMore(section);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
try { if (this.handleResize) window.removeEventListener('resize', this.handleResize); } catch {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<div class="container mx-auto p-4 sm:p-6">
|
||||
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center gap-2">
|
||||
<span>{{ themes.bySlug(themeSlug())?.emoji }}</span>
|
||||
<span>{{ themeLabel() }}</span>
|
||||
</h2>
|
||||
|
||||
<div class="space-y-10">
|
||||
<section *ngFor="let b of blocks()">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-xl font-semibold">{{ b.label }}</h3>
|
||||
<a [routerLink]="['/p', b.provider, 't', themeSlug()]" class="text-sm px-3 py-1 rounded bg-slate-800 hover:bg-slate-700 border border-slate-700">{{ 'themes.viewAll' | t }}</a>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div *ngIf="b.loading" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<div *ngFor="let i of [1,2,3,4,5,6,7,8,9,10]" class="animate-pulse bg-slate-800 rounded-lg h-40"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error / empty -->
|
||||
<div *ngIf="!b.loading && b.error" class="text-amber-300">{{ b.error }}</div>
|
||||
<div *ngIf="!b.loading && !b.error && b.items.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div *ngIf="!b.loading && !b.error && b.items.length" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<a *ngFor="let v of b.items"
|
||||
[routerLink]="['/watch', v.videoId]"
|
||||
[queryParams]="watchQueryParams(b.provider, v)"
|
||||
[state]="{ video: v }"
|
||||
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300">
|
||||
<div class="relative">
|
||||
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-40 object-cover">
|
||||
<div class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-0.5 rounded" *ngIf="v.duration">
|
||||
{{ v.duration / 60 | number:'1.0-0' }}:{{ (v.duration % 60) | number:'2.0-0' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<h4 class="font-semibold line-clamp-2">{{ v.title }}</h4>
|
||||
<div class="text-sm text-slate-400 mt-1">{{ v.uploaderName }}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,171 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ThemesService } from '../../services/themes.service';
|
||||
import { YoutubeApiService } from '../../services/youtube-api.service';
|
||||
import { Provider, InstanceService } from '../../services/instance.service';
|
||||
import { Video } from '../../models/video.model';
|
||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
|
||||
interface ProviderBlock {
|
||||
provider: Provider;
|
||||
label: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
items: Video[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-theme-page',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './theme-page.component.html',
|
||||
imports: [CommonModule, RouterLink, TranslatePipe]
|
||||
})
|
||||
export class ThemePageComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
themes = inject(ThemesService);
|
||||
private api = inject(YoutubeApiService);
|
||||
private instances = inject(InstanceService);
|
||||
|
||||
// Show all supported providers on Tendances
|
||||
private providersToShow: Provider[] = ['youtube', 'twitch', 'dailymotion', 'peertube', 'rumble', 'odysee'];
|
||||
|
||||
readonly themeSlug = signal<string>('trending');
|
||||
readonly themeLabel = computed(() => this.themes.bySlug(this.themeSlug())?.label || this.themeSlug());
|
||||
|
||||
blocks = signal<ProviderBlock[]>([]);
|
||||
|
||||
constructor() {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const slug = pm.get('theme') || pm.get('slug') || 'trending';
|
||||
this.themeSlug.set(slug);
|
||||
this.loadAllBlocks();
|
||||
});
|
||||
}
|
||||
|
||||
private loadAllBlocks() {
|
||||
const slug = this.themeSlug();
|
||||
const blocks: ProviderBlock[] = this.providersToShow.map(p => ({ provider: p, label: this.labelFor(p), loading: true, error: null, items: [] }));
|
||||
this.blocks.set(blocks);
|
||||
for (const b of blocks) this.fetchForProvider(b, slug);
|
||||
}
|
||||
|
||||
private tokensForTheme(slug: string): string[] {
|
||||
const tokens = this.themes.tokensFor(slug);
|
||||
// Special cases
|
||||
if (slug === 'trending') return [];
|
||||
if (slug === 'live') return ['live'];
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private labelFor(p: Provider): string {
|
||||
switch (p) {
|
||||
case 'youtube': return 'YouTube';
|
||||
case 'twitch': return 'Twitch';
|
||||
case 'peertube': return 'PeerTube';
|
||||
case 'rumble': return 'Rumble';
|
||||
case 'odysee': return 'Odysee';
|
||||
case 'dailymotion': return 'Dailymotion';
|
||||
}
|
||||
}
|
||||
|
||||
private fetchForProvider(block: ProviderBlock, slug: string) {
|
||||
// Provider readiness (e.g., YouTube key, Twitch creds). If not ready, surface reason and skip network calls.
|
||||
const ready = this.instances.getProviderReadiness(block.provider);
|
||||
if (!ready.ready) {
|
||||
block.loading = false;
|
||||
block.error = ready.reason || 'Provider not ready';
|
||||
console.warn('[ThemePage] Provider not ready', { provider: block.provider, theme: slug, reason: block.error });
|
||||
this.blocks.update(a => [...a]);
|
||||
return;
|
||||
}
|
||||
// If YouTube quota already exceeded, surface and skip
|
||||
if (block.provider === 'youtube' && this.api.quotaExceeded$.getValue()) {
|
||||
block.loading = false;
|
||||
block.error = 'YouTube quota exceeded. Try again later or switch provider.';
|
||||
console.warn('[ThemePage] Skip fetch: YouTube quota exceeded', { theme: slug });
|
||||
this.blocks.update(a => [...a]);
|
||||
return;
|
||||
}
|
||||
const tokens = this.tokensForTheme(slug);
|
||||
const max = 10;
|
||||
block.loading = true;
|
||||
block.error = null;
|
||||
if (slug === 'trending') {
|
||||
this.api.getTrendingPage(null, block.provider).subscribe({
|
||||
next: res => {
|
||||
block.items = (res.items || []).slice(0, max);
|
||||
block.loading = false;
|
||||
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug });
|
||||
this.blocks.update(a => [...a]);
|
||||
},
|
||||
error: (e) => {
|
||||
if (block.provider === 'youtube' && e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
|
||||
this.api.quotaExceeded$.next(true);
|
||||
block.error = 'YouTube quota exceeded. Try again later or switch provider.';
|
||||
} else {
|
||||
block.error = 'Erreur de chargement';
|
||||
}
|
||||
block.loading = false; this.blocks.update(a => [...a]);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const q = tokens.join(' ');
|
||||
if (!q) {
|
||||
// fallback to trending
|
||||
this.api.getTrendingPage(null, block.provider).subscribe({
|
||||
next: res => {
|
||||
block.items = (res.items || []).slice(0, max);
|
||||
block.loading = false;
|
||||
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode: 'fallback_trending' });
|
||||
this.blocks.update(a => [...a]);
|
||||
},
|
||||
error: (e) => {
|
||||
if (block.provider === 'youtube' && e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
|
||||
this.api.quotaExceeded$.next(true);
|
||||
block.error = 'YouTube quota exceeded. Try again later or switch provider.';
|
||||
} else {
|
||||
block.error = 'Erreur de chargement';
|
||||
}
|
||||
block.loading = false; this.blocks.update(a => [...a]);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.api.searchVideosPage(q, null, block.provider).subscribe({
|
||||
next: res => {
|
||||
block.items = (res.items || []).slice(0, max);
|
||||
block.loading = false;
|
||||
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, query: q });
|
||||
this.blocks.update(a => [...a]);
|
||||
},
|
||||
error: (e) => {
|
||||
if (block.provider === 'youtube' && e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
|
||||
this.api.quotaExceeded$.next(true);
|
||||
block.error = 'YouTube quota exceeded. Try again later or switch provider.';
|
||||
} else {
|
||||
block.error = 'Erreur de chargement';
|
||||
}
|
||||
block.loading = false; this.blocks.update(a => [...a]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
watchQueryParams(provider: Provider, v: Video): Record<string, any> {
|
||||
const qp: any = { p: provider };
|
||||
if (provider === 'odysee' && v.url?.startsWith('https://odysee.com/')) {
|
||||
try {
|
||||
let slug = v.url.substring('https://odysee.com/'.length);
|
||||
if (slug.startsWith('/')) slug = slug.slice(1);
|
||||
qp.slug = slug;
|
||||
} catch {}
|
||||
}
|
||||
if (provider === 'twitch' && v.type === 'channel') {
|
||||
// Try to use channel name
|
||||
if (v.videoId) qp.channel = v.videoId;
|
||||
}
|
||||
return qp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<nav class="sticky top-16 left-0 right-0 z-40 w-full relative"
|
||||
[ngClass]="{
|
||||
'bg-slate-100 border-b border-slate-200': isLightTheme(),
|
||||
'bg-slate-900 border-b border-slate-800': isDarkTheme(),
|
||||
'bg-black border-b border-slate-900': isBlackTheme(),
|
||||
'bg-blue-950 border-b border-blue-900': isBlueTheme()
|
||||
}">
|
||||
<!-- Left scroll button -->
|
||||
<button type="button" (click)="scrollBy(-320)" aria-label="Scroll left"
|
||||
class="hidden md:flex items-center justify-center absolute left-2 top-1/2 -translate-y-1/2 h-9 w-9 rounded-full shadow z-10"
|
||||
[ngClass]="{
|
||||
'bg-white/90 border border-slate-300 text-slate-700 hover:bg-slate-100': isLightTheme(),
|
||||
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': !isLightTheme()
|
||||
}">
|
||||
«
|
||||
</button>
|
||||
|
||||
<!-- Right scroll button -->
|
||||
<button type="button" (click)="scrollBy(320)" aria-label="Scroll right"
|
||||
class="hidden md:flex items-center justify-center absolute right-2 top-1/2 -translate-y-1/2 h-9 w-9 rounded-full shadow z-10"
|
||||
[ngClass]="{
|
||||
'bg-white/90 border border-slate-300 text-slate-700 hover:bg-slate-100': isLightTheme(),
|
||||
'bg-slate-800/90 border border-slate-700 text-slate-200 hover:bg-slate-700': !isLightTheme()
|
||||
}">
|
||||
»
|
||||
</button>
|
||||
|
||||
<div class="w-full overflow-x-auto no-scrollbar" #scroll (wheel)="onWheel($event)">
|
||||
<ul role="tablist" aria-label="Themes" class="flex gap-2 px-12 py-2 min-w-max items-center">
|
||||
<li *ngFor="let t of displayedThemes(); let i = index" class="shrink-0">
|
||||
<button
|
||||
#pill
|
||||
role="tab"
|
||||
type="button"
|
||||
class="px-3 py-1.5 rounded-full text-sm font-medium border transition-colors whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
[class.bg-slate-100/10]="activeSlug() === t.slug"
|
||||
[class.text-white]="activeSlug() === t.slug"
|
||||
[class.border-red-500]="activeSlug() === t.slug"
|
||||
[class.text-slate-300]="activeSlug() !== t.slug"
|
||||
[class.border-slate-700]="activeSlug() !== t.slug"
|
||||
[attr.aria-selected]="activeSlug() === t.slug"
|
||||
(click)="goToTheme(t.slug)"
|
||||
(keydown)="onKeydown($event, i)"
|
||||
>
|
||||
<span class="mr-1 select-none">{{ t.emoji }}</span>
|
||||
<span>{{ themesSvc.i18nLabel(t) }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ChangeDetectionStrategy, Component, ElementRef, QueryList, ViewChild, ViewChildren, inject, signal, effect } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, NavigationEnd, ActivatedRoute } from '@angular/router';
|
||||
import { ThemesService } from '../../services/themes.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-themes-nav',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './themes-nav.component.html'
|
||||
})
|
||||
export class ThemesNavComponent {
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
themesSvc = inject(ThemesService);
|
||||
|
||||
themes = () => this.themesSvc.themes();
|
||||
activeSlug = this.themesSvc.activeSlug;
|
||||
displayedThemes = () => this.themes();
|
||||
|
||||
@ViewChildren('pill', { read: ElementRef }) pills!: QueryList<ElementRef<HTMLButtonElement>>;
|
||||
@ViewChild('scroll', { static: false }) scrollRef?: ElementRef<HTMLDivElement>;
|
||||
|
||||
constructor() {
|
||||
// Reflect current URL into active slug
|
||||
this.router.events.subscribe((e) => {
|
||||
if (e instanceof NavigationEnd) this.syncFromUrl();
|
||||
});
|
||||
// Also on init
|
||||
setTimeout(() => this.syncFromUrl(), 0);
|
||||
}
|
||||
|
||||
private syncFromUrl() {
|
||||
const url = this.router.url || '';
|
||||
const m = url.match(/#?\/?(?:p\/[^/]+\/)?t\/([^/?#]+)/i);
|
||||
if (m && m[1]) this.activeSlug.set(m[1]);
|
||||
}
|
||||
|
||||
goToTheme(slug: string) {
|
||||
this.activeSlug.set(slug);
|
||||
this.router.navigate(['/t', slug]);
|
||||
}
|
||||
|
||||
onKeydown(ev: KeyboardEvent, index: number) {
|
||||
const list = this.pills?.toArray() || [];
|
||||
if (!list.length) return;
|
||||
if (ev.key === 'ArrowRight') {
|
||||
ev.preventDefault();
|
||||
const next = list[(index + 1) % list.length];
|
||||
next?.nativeElement?.focus();
|
||||
next?.nativeElement?.scrollIntoView({ inline: 'center', behavior: 'smooth', block: 'nearest' });
|
||||
} else if (ev.key === 'ArrowLeft') {
|
||||
ev.preventDefault();
|
||||
const prev = list[(index - 1 + list.length) % list.length];
|
||||
prev?.nativeElement?.focus();
|
||||
prev?.nativeElement?.scrollIntoView({ inline: 'center', behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
scrollBy(px: number) {
|
||||
const el = this.scrollRef?.nativeElement;
|
||||
if (!el) return;
|
||||
el.scrollBy({ left: px, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
onWheel(ev: WheelEvent) {
|
||||
const el = this.scrollRef?.nativeElement;
|
||||
if (!el) return;
|
||||
// Horizontal scroll with mouse wheel while hovering the bar
|
||||
if (Math.abs(ev.deltaY) > Math.abs(ev.deltaX)) {
|
||||
ev.preventDefault();
|
||||
el.scrollLeft += ev.deltaY;
|
||||
}
|
||||
}
|
||||
|
||||
// Theme helpers (read from document attribute/localStorage)
|
||||
private getCurrentTheme(): string {
|
||||
try {
|
||||
const fromAttr = document.documentElement.getAttribute('data-theme');
|
||||
if (fromAttr) return fromAttr;
|
||||
const fromStorage = localStorage.getItem('newtube.theme');
|
||||
const val = fromStorage || 'system';
|
||||
if (val === 'system') {
|
||||
// Resolve to light/dark using media query so styles can be deterministic
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
return prefersDark ? 'dark' : 'light';
|
||||
}
|
||||
return val;
|
||||
} catch {
|
||||
// Safe fallback: prefer dark for safety in most UIs
|
||||
const prefersDark = typeof window !== 'undefined' && (window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false);
|
||||
return prefersDark ? 'dark' : 'light';
|
||||
}
|
||||
}
|
||||
|
||||
isLightTheme(): boolean {
|
||||
const t = this.getCurrentTheme();
|
||||
return t === 'light' || t === 'system';
|
||||
}
|
||||
isDarkTheme(): boolean { return this.getCurrentTheme() === 'dark'; }
|
||||
isBlackTheme(): boolean { return this.getCurrentTheme() === 'black'; }
|
||||
isBlueTheme(): boolean { return this.getCurrentTheme() === 'blue'; }
|
||||
}
|
||||
Reference in New Issue
Block a user