chore: update TypeScript build info cache

This commit is contained in:
Bruno Charest 2025-09-19 12:52:02 -04:00
parent d6da699c54
commit 9c03f5f5fe
6 changed files with 165 additions and 59 deletions

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -158,9 +158,20 @@ export class PlaylistsComponent {
if (!pl?.id || !user?.id || pl.userId !== user.id) return; if (!pl?.id || !user?.id || pl.userId !== user.id) return;
const ok = confirm(`Supprimer la playlist "${pl.title}" ?`); const ok = confirm(`Supprimer la playlist "${pl.title}" ?`);
if (!ok) return; if (!ok) return;
this.http.delete(`${this.apiBase()}/playlists/${encodeURIComponent(pl.id)}`, { withCredentials: true }).subscribe({
next: () => this.reload(this.searchQuery().trim() || undefined), this.http.delete(`${this.apiBase()}/playlists/${encodeURIComponent(pl.id)}`, {
error: (err) => this.error.set(err?.error?.error || err?.message || 'Échec de la suppression') withCredentials: true,
responseType: 'text' // Handle empty response
}).subscribe({
next: () => {
// Remove the playlist from the local list immediately for better UX
this.playlists.set(this.playlists().filter(p => p.id !== pl.id));
},
error: (err) => {
this.error.set(err?.error?.error || err?.message || 'Échec de la suppression');
// Reload to ensure consistency with the server
this.reload(this.searchQuery().trim() || undefined);
}
}); });
} }
} }

View File

@ -1,8 +1,9 @@
import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, SimpleChanges, inject, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, inject, signal, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http'; import { HttpClientModule } from '@angular/common/http';
import { PlaylistsService, Playlist } from '../../../../services/playlists.service'; import { PlaylistsService, Playlist } from '../../../../services/playlists.service';
import { AuthService } from '../../../../services/auth.service'; import { AuthService } from '../../../../services/auth.service';
import { forkJoin } from 'rxjs';
@Component({ @Component({
selector: 'app-add-to-playlist', selector: 'app-add-to-playlist',
@ -12,7 +13,7 @@ import { AuthService } from '../../../../services/auth.service';
<div class="relative inline-block text-left" (click)="$event.stopPropagation();"> <div class="relative inline-block text-left" (click)="$event.stopPropagation();">
<button (click)="toggleOpen($event)" <button (click)="toggleOpen($event)"
[disabled]="!isAuthenticated()" [disabled]="!isAuthenticated()"
class="flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 disabled:opacity-60 disabled:cursor-not-allowed px-4 py-2 rounded-full transition text-slate-200 font-semibold" class="flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 disabled:opacity-60 disabled:cursor-not-allowed px-4 py-2 rounded-full transition text-slate-200 font-semibold"
[attr.aria-expanded]="open()" [attr.aria-expanded]="open()"
[attr.aria-haspopup]="true" [attr.aria-haspopup]="true"
[attr.title]="!isAuthenticated() ? 'Connectez-vous pour gérer vos playlists' : 'Ajouter à une playlist'"> [attr.title]="!isAuthenticated() ? 'Connectez-vous pour gérer vos playlists' : 'Ajouter à une playlist'">
@ -24,47 +25,72 @@ import { AuthService } from '../../../../services/auth.service';
</button> </button>
@if (open()) { @if (open()) {
<div class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-80 rounded-md bg-slate-900 border border-slate-700 shadow-2xl focus:outline-none z-50"> <!-- Backdrop -->
<div class="p-3 space-y-3"> <div class="fixed inset-0 z-40 bg-black/60 backdrop-blur-[2px]" (click)="close()"></div>
<div class="flex items-center justify-between">
<div class="text-sm font-semibold text-slate-200">Vos playlists</div> <!-- Modal -->
<button class="text-xs text-slate-400 hover:text-slate-200" (click)="reloadPlaylists()">Rafraîchir</button> <div class="fixed inset-0 z-50 grid place-items-center" aria-modal="true" role="dialog" [attr.aria-labelledby]="'addToPlaylistTitle'">
<div class="w-[min(92vw,720px)] max-h-[min(80vh,700px)] rounded-xl bg-slate-900 border border-slate-700 shadow-2xl focus:outline-none overflow-hidden" (click)="$event.stopPropagation()">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-slate-700">
<div id="addToPlaylistTitle" class="text-base font-semibold text-slate-100">Vos playlists</div>
<div class="flex items-center gap-3">
<button class="text-xs text-slate-400 hover:text-slate-200" (click)="reloadPlaylists()">Rafraîchir</button>
<button class="h-8 w-8 grid place-items-center rounded-md text-slate-300 hover:text-white hover:bg-slate-700/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60" (click)="close()" aria-label="Fermer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5"><path d="M6.225 4.811a1 1 0 011.414 0L12 9.172l4.361-4.361a1 1 0 011.415 1.415L13.415 10.586l4.361 4.361a1 1 0 01-1.415 1.415L12 12l-4.361 4.361a1 1 0 01-1.414-1.415l4.36-4.36-4.36-4.36a1 1 0 010-1.415z"/></svg>
</button>
</div>
</div> </div>
@if (loading()) { <!-- Body -->
<div class="text-sm text-slate-400">Chargement...</div> <div class="px-5 py-4 space-y-4">
} @else if (error()) { @if (loading()) {
<div class="text-sm text-red-300">{{ error() }}</div> <div class="text-sm text-slate-400">Chargement...</div>
} @else if (playlists().length === 0) { } @else if (error()) {
<div class="text-sm text-slate-400">Aucune playlist. Créez-en une ci-dessous.</div> <div class="text-sm text-red-300">{{ error() }}</div>
} @else { } @else if (playlists().length === 0) {
<div class="max-h-48 overflow-auto space-y-2"> <div class="text-sm text-slate-400">Aucune playlist. Créez-en une ci-dessous.</div>
@for (pl of playlists(); track pl.id) { } @else {
<div class="flex items-center justify-between gap-2"> <div class="max-h-[42vh] overflow-auto divide-y divide-slate-800 rounded-md border border-slate-800">
<div class="min-w-0"> @for (pl of playlists(); track pl.id) {
<div class="truncate text-sm text-slate-200">{{ pl.title }}</div> <label class="flex items-center gap-4 px-4 py-3 hover:bg-slate-800/60 cursor-pointer">
<div class="text-xs text-slate-400">{{ pl.itemsCount || 0 }} vidéo(s)</div> <input type="checkbox" class="accent-red-500 h-4 w-4" [checked]="isSelected(pl.id)" (change)="toggleSelect(pl.id, $event)" />
</div> <div class="min-w-0 flex-1">
<button class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-xs" <div class="truncate text-sm text-slate-200">{{ pl.title }}</div>
[disabled]="busyAdd()" <div class="text-xs text-slate-400">{{ pl.itemsCount || 0 }} vidéo(s)</div>
(click)="onAddTo(pl)">Ajouter</button> </div>
</div> </label>
} }
</div> </div>
} }
<div class="border-t border-slate-700 pt-2"> <!-- Create new playlist -->
<label class="block text-xs text-slate-400 mb-1">Nouvelle playlist</label> <div class="border-t border-slate-700 pt-4">
<input type="text" class="w-full bg-slate-950 text-slate-100 border border-slate-700 rounded px-2 py-1 text-sm" <label class="block text-xs text-slate-400 mb-2">Nouvelle playlist</label>
[value]="newTitle()" (input)="onTitleChange($event)" placeholder="Titre de la playlist" /> <div class="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end">
<div class="mt-2 flex items-center justify-end gap-2"> <div class="flex-1">
<label class="flex items-center gap-2 text-xs text-slate-300"> <input type="text" class="w-full bg-slate-950 text-slate-100 border border-slate-700 rounded px-3 py-2 text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-red-500/60"
<input type="checkbox" class="accent-red-500" [checked]="newPrivate()" (change)="onPrivateChange($event)" /> [value]="newTitle()" (input)="onTitleChange($event)" placeholder="Titre de la playlist" />
Privée </div>
</label> <label class="flex items-center gap-2 text-xs text-slate-300">
<button class="px-3 py-1.5 rounded bg-red-600 hover:bg-red-500 text-white text-xs font-semibold disabled:opacity-60" <input type="checkbox" class="accent-red-500" [checked]="newPrivate()" (change)="onPrivateChange($event)" />
[disabled]="!newTitle().trim() || busyAdd()" Privée
(click)="onQuickCreate()">Créer + Ajouter</button> </label>
<button class="px-4 py-2 rounded-md bg-red-600 hover:bg-red-500 text-white text-sm font-semibold disabled:opacity-60 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60"
[disabled]="!newTitle().trim() || busyAdd()"
(click)="onQuickCreate()">Créer + Ajouter</button>
</div>
</div>
</div>
<!-- Footer -->
<div class="px-5 py-4 border-t border-slate-700 bg-slate-900/60 flex items-center justify-between">
<div class="text-xs text-slate-400">{{ selectedCount() }} sélectionnée(s)</div>
<div class="flex items-center gap-3">
<button class="px-4 py-2 rounded-md bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400/60" (click)="close()">Annuler</button>
<button class="px-4 py-2 rounded-md bg-red-600 hover:bg-red-500 text-white text-sm font-semibold disabled:opacity-60 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60"
[disabled]="busyAdd() || selectedCount() === 0"
(click)="onAddSelected()">Ajouter</button>
</div> </div>
</div> </div>
</div> </div>
@ -95,6 +121,7 @@ export class AddToPlaylistComponent implements OnInit, OnDestroy {
busyAdd = signal<boolean>(false); busyAdd = signal<boolean>(false);
newTitle = signal<string>(''); newTitle = signal<string>('');
newPrivate = signal<boolean>(true); newPrivate = signal<boolean>(true);
selected = signal<Set<string>>(new Set());
private destroyed = false; private destroyed = false;
ngOnInit(): void {} ngOnInit(): void {}
@ -110,6 +137,14 @@ export class AddToPlaylistComponent implements OnInit, OnDestroy {
if (next && this.playlists().length === 0 && !this.loading()) { if (next && this.playlists().length === 0 && !this.loading()) {
this.reloadPlaylists(); this.reloadPlaylists();
} }
if (!next) {
this.selected.set(new Set());
}
}
close(): void {
this.open.set(false);
this.selected.set(new Set());
} }
reloadPlaylists(): void { reloadPlaylists(): void {
@ -121,11 +156,25 @@ export class AddToPlaylistComponent implements OnInit, OnDestroy {
}); });
} }
onAddTo(pl: Playlist): void { isSelected(id: string): boolean { return this.selected().has(id); }
if (this.busyAdd() || !pl?.id) return;
selectedCount(): number { return this.selected().size; }
toggleSelect(id: string, event: Event): void {
const input = event.target as HTMLInputElement;
const next = new Set(this.selected());
if (input.checked) next.add(id); else next.delete(id);
this.selected.set(next);
}
onAddSelected(): void {
if (this.busyAdd() || this.selected().size === 0) return;
this.busyAdd.set(true); this.busyAdd.set(true);
this.api.addVideo(pl.id, this.provider, this.videoId, this.title, this.thumbnail).subscribe({ const requests = Array.from(this.selected()).map((playlistId) =>
next: () => { this.busyAdd.set(false); this.open.set(false); }, this.api.addVideo(playlistId, this.provider, this.videoId, this.title, this.thumbnail)
);
forkJoin(requests).subscribe({
next: () => { this.busyAdd.set(false); this.close(); },
error: (err) => { this.busyAdd.set(false); this.error.set(err?.error?.error || err?.message || 'Échec de l\'ajout'); } error: (err) => { this.busyAdd.set(false); this.error.set(err?.error?.error || err?.message || 'Échec de l\'ajout'); }
}); });
} }
@ -155,4 +204,10 @@ export class AddToPlaylistComponent implements OnInit, OnDestroy {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
this.newPrivate.set(input.checked); this.newPrivate.set(input.checked);
} }
// Close on ESC key
@HostListener('window:keydown.escape')
onEsc() {
if (this.open()) this.close();
}
} }

View File

@ -143,12 +143,18 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
return null; return null;
} }
// Generic "open on provider" URL // Méthode simplifiée pour obtenir l'ID Rumble (toujours l'ID de la route)
private getRumbleRouteId(): string {
return this.route.snapshot.params['id'] || '';
}
// Generic "open on provider" URL (returns string for external links)
openOnProviderUrl(): string | null { openOnProviderUrl(): string | null {
const p = this.provider(); const p = this.provider();
const id = this.videoId(); const id = this.videoId();
const v = this.video(); const v = this.video();
const directUrl = v?.url && /^https?:\/\//i.test(v.url) ? v.url : null; const directUrl = v?.url && /^https?:\/\//i.test(v.url) ? v.url : null;
try { try {
if (p === 'youtube') return `https://www.youtube.com/watch?v=${encodeURIComponent(id)}`; if (p === 'youtube') return `https://www.youtube.com/watch?v=${encodeURIComponent(id)}`;
if (p === 'dailymotion') return `https://www.dailymotion.com/video/${encodeURIComponent(id)}`; if (p === 'dailymotion') return `https://www.dailymotion.com/video/${encodeURIComponent(id)}`;
@ -163,14 +169,30 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
if (directUrl) return directUrl; if (directUrl) return directUrl;
} }
if (p === 'rumble') { if (p === 'rumble') {
if (directUrl) return directUrl; const rumbleId = this.getRumbleRouteId();
if (id) return `https://rumble.com/${encodeURIComponent(id)}`; return rumbleId ? `https://rumble.com/${rumbleId}` : null;
} }
return directUrl || null; return directUrl;
} catch { } catch {
return directUrl || null; return directUrl;
} }
} }
// Get secure embed URL (returns SafeResourceUrl for iframe src)
getEmbedUrl(): SafeResourceUrl | null {
const p = this.provider();
const id = this.videoId();
if (p === 'rumble') {
const rumbleId = this.getRumbleRouteId();
if (rumbleId) {
return this.sanitizer.bypassSecurityTrustResourceUrl(
`https://rumble.com/embed/${rumbleId}/?pub=4&autoplay=1`
);
}
}
return null;
}
availableQualities = computed(() => this.sortedStreams().map(s => s.quality).filter((q, i, arr) => q && arr.indexOf(q) === i) as string[]); availableQualities = computed(() => this.sortedStreams().map(s => s.quality).filter((q, i, arr) => q && arr.indexOf(q) === i) as string[]);
videoSource = computed(() => { videoSource = computed(() => {
const qual = this.selectedQuality(); const qual = this.selectedQuality();
@ -264,13 +286,30 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
return this.sanitizer.bypassSecurityTrustResourceUrl(u); return this.sanitizer.bypassSecurityTrustResourceUrl(u);
} }
if (p === 'rumble') { if (p === 'rumble') {
// Prefer backend-provided embed URL (includes required pub/query params) const currentVideo = this.video();
const fromBackend = this.rumbleEmbed(); const videoUrl = currentVideo?.url;
const u = fromBackend && /^https?:\/\//i.test(fromBackend)
? fromBackend // Extraire l'ID de la vidéo Rumble
: `https://rumble.com/embed/${encodeURIComponent(id)}/?autoplay=2&muted=1`; let videoId = '';
return this.sanitizer.bypassSecurityTrustResourceUrl(u); if (videoUrl) {
const match = videoUrl.match(/\/(v[\w-]+)(?:\.html|$)/i);
if (match?.[1]) {
videoId = match[1];
}
}
// Si on n'a pas pu extraire l'ID de l'URL, on utilise l'ID de la route
if (!videoId && id) {
videoId = id.replace(/^v/, '');
}
// Construire l'URL d'embed officielle de Rumble
if (videoId) {
const embedUrl = `https://rumble.com/embed/${videoId}/?pub=4&autoplay=1`;
return this.sanitizer.bypassSecurityTrustResourceUrl(embedUrl);
}
} }
return null;
} catch {} } catch {}
return null; return null;
}); });

View File

@ -13,6 +13,7 @@ export interface Video {
views: number; views: number;
uploaded: number; uploaded: number;
videoId: string; videoId: string;
providerUrl?: string; // URL complète de la vidéo sur le site du fournisseur
} }
export interface VideoDetail extends Video { export interface VideoDetail extends Video {