chore: update Angular TypeScript build info cache
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { PlaylistsService, Playlist, PlaylistItem } from '../../../services/playlists.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-playlist-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
template: `
|
||||
<div class="container mx-auto p-4 sm:p-6">
|
||||
<div class="flex items-center justify-between gap-3 mb-4">
|
||||
<a routerLink="/library/playlists" class="text-slate-300 hover:text-slate-100">← Retour</a>
|
||||
<div class="flex items-center gap-2" *ngIf="dirty()">
|
||||
<button (click)="saveOrder()" [disabled]="saving()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white font-semibold">
|
||||
{{ saving() ? 'Enregistrement...' : 'Enregistrer l\'ordre' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="loading(); else bodyTpl">
|
||||
<div class="animate-pulse">
|
||||
<div class="h-8 bg-slate-700 w-1/2 rounded mb-4"></div>
|
||||
<div class="h-4 bg-slate-700 w-1/3 rounded mb-6"></div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="h-24 bg-slate-800 rounded"></div>
|
||||
<div class="h-24 bg-slate-800 rounded"></div>
|
||||
<div class="h-24 bg-slate-800 rounded"></div>
|
||||
<div class="h-24 bg-slate-800 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #bodyTpl>
|
||||
<ng-container *ngIf="error(); else contentTpl">
|
||||
<div class="bg-red-900/40 border border-red-700 text-red-300 p-4 rounded">{{ error() }}</div>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #contentTpl>
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div class="w-full sm:w-64">
|
||||
<div class="rounded overflow-hidden border border-slate-700 bg-slate-800">
|
||||
<img *ngIf="playlist()?.thumbnail; else noThumb" [src]="playlist()!.thumbnail!" alt="" class="w-full h-40 object-cover"/>
|
||||
<ng-template #noThumb>
|
||||
<div class="w-full h-40 bg-gradient-to-br from-slate-700 to-slate-800"></div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-bold text-slate-100">{{ playlist()?.title }}</h1>
|
||||
<div class="text-slate-400 mt-1 text-sm">
|
||||
{{ playlist()?.itemsCount || 0 }} vidéo(s) • {{ (playlist()?.updatedAt || '').slice(0,10) }}
|
||||
<span class="ml-2 text-xs px-2 py-1 rounded border" [ngClass]="playlist()?.isPrivate ? 'border-slate-600 text-slate-300' : 'border-emerald-700 text-emerald-300'">{{ playlist()?.isPrivate ? 'Privée' : 'Publique' }}</span>
|
||||
</div>
|
||||
<p class="mt-2 text-slate-300 whitespace-pre-wrap">{{ playlist()?.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div *ngFor="let it of items(); let i = index" class="flex items-center gap-3 p-2 rounded border border-slate-700 bg-slate-800/60">
|
||||
<div class="w-28 flex-shrink-0">
|
||||
<img *ngIf="it.thumbnail; else noThumb2" [src]="it.thumbnail!" alt="" class="w-28 h-16 object-cover rounded">
|
||||
<ng-template #noThumb2>
|
||||
<div class="w-28 h-16 bg-slate-700 rounded"></div>
|
||||
</ng-template>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<a [routerLink]="['/watch', it.videoId]" [queryParams]="{ p: it.provider }" class="font-semibold text-slate-100 hover:text-red-400 line-clamp-2">{{ it.title || it.videoId }}</a>
|
||||
<div class="text-xs text-slate-400 mt-1">{{ it.provider }} • Pos. {{ i + 1 }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button (click)="moveUp(i)" [disabled]="i===0" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm">↑</button>
|
||||
<button (click)="moveDown(i)" [disabled]="i===items().length-1" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm">↓</button>
|
||||
<button (click)="removeItem(it)" class="px-2 py-1 rounded bg-red-700 hover:bg-red-600 text-white text-sm">Retirer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PlaylistDetailComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
private api = inject(PlaylistsService);
|
||||
|
||||
loading = signal<boolean>(true);
|
||||
error = signal<string | null>(null);
|
||||
playlist = signal<(Playlist & { items?: PlaylistItem[] }) | null>(null);
|
||||
items = signal<PlaylistItem[]>([]);
|
||||
dirty = signal<boolean>(false);
|
||||
saving = signal<boolean>(false);
|
||||
|
||||
private id = signal<string>('');
|
||||
|
||||
constructor() {
|
||||
const pid = this.route.snapshot.paramMap.get('id') || '';
|
||||
this.id.set(pid);
|
||||
this.load(pid);
|
||||
}
|
||||
|
||||
load(id: string) {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.api.get(id, 1000, 0).subscribe({
|
||||
next: (pl) => {
|
||||
this.playlist.set(pl);
|
||||
this.items.set((pl.items || []).slice().sort((a, b) => a.position - b.position));
|
||||
this.loading.set(false);
|
||||
this.dirty.set(false);
|
||||
},
|
||||
error: (err) => { this.error.set(err?.error?.error || err?.message || 'Erreur de chargement'); this.loading.set(false); }
|
||||
});
|
||||
}
|
||||
|
||||
moveUp(i: number) {
|
||||
if (i <= 0) return;
|
||||
const arr = this.items().slice();
|
||||
const tmp = arr[i-1];
|
||||
arr[i-1] = arr[i];
|
||||
arr[i] = tmp;
|
||||
this.items.set(arr);
|
||||
this.dirty.set(true);
|
||||
}
|
||||
|
||||
moveDown(i: number) {
|
||||
const arr = this.items().slice();
|
||||
if (i >= arr.length - 1) return;
|
||||
const tmp = arr[i+1];
|
||||
arr[i+1] = arr[i];
|
||||
arr[i] = tmp;
|
||||
this.items.set(arr);
|
||||
this.dirty.set(true);
|
||||
}
|
||||
|
||||
saveOrder() {
|
||||
if (!this.dirty() || !this.id()) return;
|
||||
this.saving.set(true);
|
||||
const order = this.items().map(it => it.id);
|
||||
this.api.reorder(this.id(), order).subscribe({
|
||||
next: () => { this.saving.set(false); this.dirty.set(false); },
|
||||
error: (err) => { this.saving.set(false); this.error.set(err?.error?.error || err?.message || 'Échec de l\'enregistrement'); }
|
||||
});
|
||||
}
|
||||
|
||||
removeItem(it: PlaylistItem) {
|
||||
const ok = confirm('Retirer cette vidéo de la playlist ?');
|
||||
if (!ok) return;
|
||||
this.api.removeVideo(this.id(), it.provider, it.videoId).subscribe({
|
||||
next: () => {
|
||||
this.items.set(this.items().filter(x => !(x.provider === it.provider && x.videoId === it.videoId)));
|
||||
this.dirty.set(true);
|
||||
},
|
||||
error: (err) => this.error.set(err?.error?.error || err?.message || 'Échec du retrait')
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,107 @@
|
||||
<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">Listes de lecture</h2>
|
||||
<div class="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
|
||||
<p class="text-slate-300">Bientôt disponible: vos listes de lecture centralisées, tous providers confondus.</p>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
|
||||
<h2 class="text-3xl font-bold text-slate-100 border-l-4 border-red-500 pl-4">Listes de lecture</h2>
|
||||
<div class="flex items-center gap-3 w-full sm:w-auto">
|
||||
<input type="text" class="flex-1 sm:w-64 bg-slate-900 text-slate-100 border border-slate-700 rounded px-3 py-2"
|
||||
placeholder="Rechercher..."
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchChange($event)"
|
||||
/>
|
||||
<button (click)="openCreate()" class="bg-red-600 hover:bg-red-500 text-white font-semibold px-4 py-2 rounded">
|
||||
Nouvelle playlist
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
@for (i of [1,2,3,4,5,6,7,8]; track i) {
|
||||
<div class="animate-pulse bg-slate-800 rounded-lg overflow-hidden border border-slate-700">
|
||||
<div class="w-full h-40 bg-slate-700"></div>
|
||||
<div class="p-4 space-y-3">
|
||||
<div class="h-5 bg-slate-700 rounded w-3/4"></div>
|
||||
<div class="h-4 bg-slate-700 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="bg-red-900/40 border border-red-700 text-red-300 p-4 rounded">{{ error() }}</div>
|
||||
} @else if (playlists().length === 0) {
|
||||
<div class="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
|
||||
<p class="text-slate-300">Aucune playlist pour le moment. Créez-en une pour organiser vos vidéos.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
@for (pl of playlists(); track pl.id) {
|
||||
<div class="bg-slate-800/70 rounded-lg overflow-hidden border border-slate-700 flex flex-col">
|
||||
<a [routerLink]="['/library/playlists', pl.id]" class="block group">
|
||||
<div class="relative">
|
||||
@if (pl.thumbnail) {
|
||||
<img [src]="pl.thumbnail!" alt="" class="w-full h-40 object-cover group-hover:opacity-90 transition" />
|
||||
} @else {
|
||||
<div class="w-full h-40 bg-gradient-to-br from-slate-700 to-slate-800 flex items-center justify-center text-slate-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" viewBox="0 0 24 24" fill="currentColor"><path d="M4 5a2 2 0 00-2 2v7.5A2.5 2.5 0 004.5 17H20a2 2 0 002-2V7a2 2 0 00-2-2H4zm0 2h16v8H4.5a.5.5 0 01-.5-.5V7z"/><path d="M12 8.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z"/></svg>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
<div class="p-4 flex-1 flex flex-col">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<a [routerLink]="['/library/playlists', pl.id]" class="font-semibold text-slate-100 hover:text-red-400 transition line-clamp-2">{{ pl.title }}</a>
|
||||
<span class="text-xs px-2 py-1 rounded border" [ngClass]="pl.isPrivate ? 'border-slate-600 text-slate-300' : 'border-emerald-700 text-emerald-300'">
|
||||
{{ pl.isPrivate ? 'Privée' : 'Publique' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-slate-400 flex items-center justify-between">
|
||||
<span>{{ pl.itemsCount || 0 }} vidéo(s)</span>
|
||||
<span>MAJ: {{ (pl.updatedAt || '').slice(0,10) }}</span>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<button (click)="openEdit(pl)" class="px-3 py-1.5 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm">Modifier</button>
|
||||
<button (click)="deletePlaylist(pl)" class="px-3 py-1.5 rounded bg-red-700 hover:bg-red-600 text-white text-sm">Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (modalOpen()) {
|
||||
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" role="dialog" aria-modal="true">
|
||||
<div class="w-full max-w-lg bg-slate-900 border border-slate-700 rounded-lg shadow-xl">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-slate-100">{{ editId() ? 'Modifier la playlist' : 'Nouvelle playlist' }}</h3>
|
||||
<button (click)="closeModal()" class="text-slate-400 hover:text-slate-200">✕</button>
|
||||
</div>
|
||||
<div class="p-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-1">Titre</label>
|
||||
<input type="text" class="w-full bg-slate-950 text-slate-100 border border-slate-700 rounded px-3 py-2"
|
||||
[value]="formTitle()" (input)="onTitleChange($event)" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-1">Description</label>
|
||||
<textarea rows="3" class="w-full bg-slate-950 text-slate-100 border border-slate-700 rounded px-3 py-2"
|
||||
[value]="formDescription()" (input)="onDescriptionChange($event)"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-1">URL de l'image</label>
|
||||
<input type="text" class="w-full bg-slate-950 text-slate-100 border border-slate-700 rounded px-3 py-2"
|
||||
[value]="formThumbnail()" (input)="onThumbnailChange($event)" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="priv" type="checkbox" class="accent-red-500" [checked]="formPrivate()" (change)="onPrivateChange($event)" />
|
||||
<label for="priv" class="text-sm text-slate-300">Privée</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 border-t border-slate-700 flex items-center justify-end gap-3">
|
||||
<button (click)="closeModal()" class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" [disabled]="busySave()">Annuler</button>
|
||||
<button (click)="savePlaylist()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white font-semibold disabled:opacity-60" [disabled]="busySave() || !formTitle().trim()">
|
||||
{{ busySave() ? 'Enregistrement...' : 'Enregistrer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,132 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-playlists',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, FormsModule, RouterLink],
|
||||
templateUrl: './playlists.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PlaylistsComponent { }
|
||||
export class PlaylistsComponent {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
// UI state
|
||||
loading = signal<boolean>(true);
|
||||
error = signal<string | null>(null);
|
||||
searchQuery = signal<string>('');
|
||||
playlists = signal<Array<{ id: string; title: string; description?: string | null; thumbnail?: string | null; isPrivate: number | boolean; createdAt: string; updatedAt: string; itemsCount: number }>>([]);
|
||||
|
||||
// Modal state
|
||||
modalOpen = signal<boolean>(false);
|
||||
editId = signal<string | null>(null);
|
||||
formTitle = signal<string>('');
|
||||
formDescription = signal<string>('');
|
||||
formThumbnail = signal<string>('');
|
||||
formPrivate = signal<boolean>(true);
|
||||
busySave = signal<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
this.reload();
|
||||
}
|
||||
|
||||
private apiBase(): string {
|
||||
try {
|
||||
const port = window?.location?.port || '';
|
||||
return port && port !== '4000' ? '/proxy/api' : '/api';
|
||||
} catch { return '/api'; }
|
||||
}
|
||||
|
||||
reload(q?: string): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
const params: any = { limit: 200 };
|
||||
if (q && q.trim()) params.q = q.trim();
|
||||
this.http.get<any[]>(`${this.apiBase()}/playlists`, { params, withCredentials: true }).subscribe({
|
||||
next: (rows) => { this.playlists.set(rows || []); this.loading.set(false); },
|
||||
error: (err) => { this.error.set(err?.error?.error || err?.message || 'Erreur de chargement'); this.loading.set(false); }
|
||||
});
|
||||
}
|
||||
|
||||
onSearchChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.searchQuery.set(input.value);
|
||||
const q = input.value.trim();
|
||||
this.reload(q || undefined);
|
||||
}
|
||||
|
||||
onTitleChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.formTitle.set(input.value);
|
||||
}
|
||||
|
||||
onDescriptionChange(event: Event) {
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
this.formDescription.set(textarea.value);
|
||||
}
|
||||
|
||||
onThumbnailChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.formThumbnail.set(input.value);
|
||||
}
|
||||
|
||||
onPrivateChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.formPrivate.set(input.checked);
|
||||
}
|
||||
|
||||
openCreate() {
|
||||
this.editId.set(null);
|
||||
this.formTitle.set('');
|
||||
this.formDescription.set('');
|
||||
this.formThumbnail.set('');
|
||||
this.formPrivate.set(true);
|
||||
this.modalOpen.set(true);
|
||||
}
|
||||
|
||||
openEdit(pl: any) {
|
||||
this.editId.set(pl.id);
|
||||
this.formTitle.set(pl.title || '');
|
||||
this.formDescription.set(pl.description || '');
|
||||
this.formThumbnail.set(pl.thumbnail || '');
|
||||
this.formPrivate.set(!!pl.isPrivate);
|
||||
this.modalOpen.set(true);
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
if (this.busySave()) return;
|
||||
this.modalOpen.set(false);
|
||||
}
|
||||
|
||||
savePlaylist() {
|
||||
if (!this.formTitle().trim()) return;
|
||||
this.busySave.set(true);
|
||||
const body = {
|
||||
title: this.formTitle().trim(),
|
||||
description: this.formDescription().trim() || undefined,
|
||||
thumbnail: this.formThumbnail().trim() || undefined,
|
||||
isPrivate: !!this.formPrivate()
|
||||
};
|
||||
const id = this.editId();
|
||||
const req = id
|
||||
? this.http.put(`${this.apiBase()}/playlists/${encodeURIComponent(id)}`, body, { withCredentials: true })
|
||||
: this.http.post(`${this.apiBase()}/playlists`, body, { withCredentials: true });
|
||||
req.subscribe({
|
||||
next: () => { this.busySave.set(false); this.modalOpen.set(false); this.reload(this.searchQuery().trim() || undefined); },
|
||||
error: (err) => { this.error.set(err?.error?.error || err?.message || 'Échec de l’enregistrement'); this.busySave.set(false); }
|
||||
});
|
||||
}
|
||||
|
||||
deletePlaylist(pl: any) {
|
||||
if (!pl?.id) return;
|
||||
const ok = confirm(`Supprimer la playlist "${pl.title}" ?`);
|
||||
if (!ok) return;
|
||||
this.http.delete(`${this.apiBase()}/playlists/${encodeURIComponent(pl.id)}`, { withCredentials: true }).subscribe({
|
||||
next: () => this.reload(this.searchQuery().trim() || undefined),
|
||||
error: (err) => this.error.set(err?.error?.error || err?.message || 'Échec de la suppression')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
</div>
|
||||
<div class="absolute bottom-2 left-2">
|
||||
<app-like-button [videoId]="video.videoId" [provider]="'twitch'" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
|
||||
<app-add-to-playlist class="ml-2" [provider]="'twitch'" [videoId]="video.videoId" [title]="video.title" [thumbnail]="video.thumbnail"></app-add-to-playlist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 flex-grow flex flex-col">
|
||||
@@ -92,6 +93,7 @@
|
||||
</div>
|
||||
<div class="absolute bottom-2 left-2">
|
||||
<app-like-button [videoId]="video.videoId" [provider]="'twitch'" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
|
||||
<app-add-to-playlist class="ml-2" [provider]="'twitch'" [videoId]="video.videoId" [title]="video.title" [thumbnail]="video.thumbnail"></app-add-to-playlist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 flex-grow flex flex-col">
|
||||
@@ -128,6 +130,7 @@
|
||||
</div>
|
||||
<div class="absolute bottom-2 left-2">
|
||||
<app-like-button [videoId]="video.videoId" [provider]="selectedProviderForView()" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
|
||||
<app-add-to-playlist class="ml-2" [provider]="selectedProviderForView()" [videoId]="video.videoId" [title]="video.title" [thumbnail]="video.thumbnail"></app-add-to-playlist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 flex-grow flex flex-col">
|
||||
|
||||
@@ -10,13 +10,14 @@ import { HistoryService } from '../../services/history.service';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
|
||||
import { AddToPlaylistComponent } from '../shared/components/add-to-playlist/add-to-playlist.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-search',
|
||||
standalone: true,
|
||||
templateUrl: './search.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent]
|
||||
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent]
|
||||
})
|
||||
export class SearchComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, SimpleChanges, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
import { PlaylistsService, Playlist } from '../../../../services/playlists.service';
|
||||
import { AuthService } from '../../../../services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-add-to-playlist',
|
||||
standalone: true,
|
||||
imports: [CommonModule, HttpClientModule],
|
||||
template: `
|
||||
<div class="relative inline-block text-left" (click)="$event.stopPropagation();">
|
||||
<button (click)="toggleOpen($event)"
|
||||
[disabled]="!isAuthenticated()"
|
||||
class="transition-colors duration-200 p-1 rounded-full hover:bg-slate-700/50 flex items-center justify-center text-slate-300 disabled:opacity-60"
|
||||
[attr.aria-expanded]="open()"
|
||||
[attr.aria-haspopup]="true"
|
||||
[attr.title]="!isAuthenticated() ? 'Connectez-vous pour gérer vos playlists' : 'Ajouter à une playlist'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M4 6a2 2 0 012-2h12a1 1 0 010 2H6v10a1 1 0 11-2 0V6z" />
|
||||
<path d="M14 11a1 1 0 011-1h5a1 1 0 110 2h-5a1 1 0 01-1-1zM14 15a1 1 0 011-1h5a1 1 0 110 2h-5a1 1 0 01-1-1zM10 11a1 1 0 112 0v1h1a1 1 0 110 2h-1v1a1 1 0 11-2 0v-1H9a1 1 0 110-2h1v-1z" />
|
||||
</svg>
|
||||
<span class="sr-only">Ajouter à une playlist</span>
|
||||
</button>
|
||||
|
||||
@if (open()) {
|
||||
<div class="absolute right-0 mt-2 w-72 origin-top-right rounded-md bg-slate-900 border border-slate-700 shadow-lg focus:outline-none z-50">
|
||||
<div class="p-3 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-semibold text-slate-200">Vos playlists</div>
|
||||
<button class="text-xs text-slate-400 hover:text-slate-200" (click)="reloadPlaylists()">Rafraîchir</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="text-sm text-slate-400">Chargement...</div>
|
||||
} @else if (error()) {
|
||||
<div class="text-sm text-red-300">{{ error() }}</div>
|
||||
} @else if (playlists().length === 0) {
|
||||
<div class="text-sm text-slate-400">Aucune playlist. Créez-en une ci-dessous.</div>
|
||||
} @else {
|
||||
<div class="max-h-48 overflow-auto space-y-2">
|
||||
@for (pl of playlists(); track pl.id) {
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm text-slate-200">{{ pl.title }}</div>
|
||||
<div class="text-xs text-slate-400">{{ pl.itemsCount || 0 }} vidéo(s)</div>
|
||||
</div>
|
||||
<button class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-xs"
|
||||
[disabled]="busyAdd()"
|
||||
(click)="onAddTo(pl)">Ajouter</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="border-t border-slate-700 pt-2">
|
||||
<label class="block text-xs text-slate-400 mb-1">Nouvelle playlist</label>
|
||||
<input type="text" class="w-full bg-slate-950 text-slate-100 border border-slate-700 rounded px-2 py-1 text-sm"
|
||||
[value]="newTitle()" (input)="onTitleChange($event)" placeholder="Titre de la playlist" />
|
||||
<div class="mt-2 flex items-center justify-end gap-2">
|
||||
<label class="flex items-center gap-2 text-xs text-slate-300">
|
||||
<input type="checkbox" class="accent-red-500" [checked]="newPrivate()" (change)="onPrivateChange($event)" />
|
||||
Privée
|
||||
</label>
|
||||
<button class="px-3 py-1.5 rounded bg-red-600 hover:bg-red-500 text-white text-xs font-semibold disabled:opacity-60"
|
||||
[disabled]="!newTitle().trim() || busyAdd()"
|
||||
(click)="onQuickCreate()">Créer + Ajouter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.sr-only { position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0; }
|
||||
`
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AddToPlaylistComponent implements OnInit, OnDestroy {
|
||||
private api = inject(PlaylistsService);
|
||||
private auth = inject(AuthService);
|
||||
|
||||
@Input() provider: string = 'youtube';
|
||||
@Input() videoId: string = '';
|
||||
@Input() title?: string;
|
||||
@Input() thumbnail?: string;
|
||||
|
||||
open = signal<boolean>(false);
|
||||
loading = signal<boolean>(false);
|
||||
error = signal<string | null>(null);
|
||||
playlists = signal<Playlist[]>([]);
|
||||
busyAdd = signal<boolean>(false);
|
||||
newTitle = signal<string>('');
|
||||
newPrivate = signal<boolean>(true);
|
||||
private destroyed = false;
|
||||
|
||||
ngOnInit(): void {}
|
||||
ngOnDestroy(): void { this.destroyed = true; }
|
||||
|
||||
isAuthenticated(): boolean { return !!this.auth.currentUser(); }
|
||||
|
||||
toggleOpen(ev: Event): void {
|
||||
try { ev.preventDefault(); ev.stopPropagation(); } catch {}
|
||||
if (!this.isAuthenticated()) return;
|
||||
const next = !this.open();
|
||||
this.open.set(next);
|
||||
if (next && this.playlists().length === 0 && !this.loading()) {
|
||||
this.reloadPlaylists();
|
||||
}
|
||||
}
|
||||
|
||||
reloadPlaylists(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.api.list(200, 0).subscribe({
|
||||
next: (rows) => { if (!this.destroyed) { this.playlists.set(rows || []); this.loading.set(false); } },
|
||||
error: (err) => { if (!this.destroyed) { this.error.set(err?.error?.error || err?.message || 'Erreur'); this.loading.set(false); } }
|
||||
});
|
||||
}
|
||||
|
||||
onAddTo(pl: Playlist): void {
|
||||
if (this.busyAdd() || !pl?.id) return;
|
||||
this.busyAdd.set(true);
|
||||
this.api.addVideo(pl.id, this.provider, this.videoId, this.title, this.thumbnail).subscribe({
|
||||
next: () => { this.busyAdd.set(false); this.open.set(false); },
|
||||
error: (err) => { this.busyAdd.set(false); this.error.set(err?.error?.error || err?.message || 'Échec de l\'ajout'); }
|
||||
});
|
||||
}
|
||||
|
||||
onQuickCreate(): void {
|
||||
const t = this.newTitle().trim();
|
||||
if (!t) return;
|
||||
if (this.busyAdd()) return;
|
||||
this.busyAdd.set(true);
|
||||
this.api.create(t, undefined, undefined, this.newPrivate()).subscribe({
|
||||
next: (pl) => {
|
||||
this.api.addVideo(pl.id, this.provider, this.videoId, this.title, this.thumbnail).subscribe({
|
||||
next: () => { this.busyAdd.set(false); this.open.set(false); this.newTitle.set(''); },
|
||||
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 la création'); }
|
||||
});
|
||||
}
|
||||
|
||||
onTitleChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.newTitle.set(input.value);
|
||||
}
|
||||
|
||||
onPrivateChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.newPrivate.set(input.checked);
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,9 @@
|
||||
<span>{{ liked() ? 'Liked' : 'Like' }}</span>
|
||||
</button>
|
||||
}
|
||||
@if (isLoggedIn()) {
|
||||
<app-add-to-playlist [provider]="provider()" [videoId]="v.videoId" [title]="v.title" [thumbnail]="v.thumbnail"></app-add-to-playlist>
|
||||
}
|
||||
@if (isLoggedIn()) {
|
||||
<button (click)="openDownloadPanel()"
|
||||
class="flex items-center space-x-2 bg-red-600 hover:bg-red-500 px-4 py-2 rounded-full transition font-semibold">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { YoutubeApiService } from '../../services/youtube-api.service';
|
||||
import { Video, VideoDetail } from '../../models/video.model';
|
||||
import { VideoPlayerComponent } from '../video-player/video-player.component';
|
||||
import { AddToPlaylistComponent } from '../shared/components/add-to-playlist/add-to-playlist.component';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { GeminiService } from '../../services/gemini.service';
|
||||
import { LikesService } from '../../services/likes.service';
|
||||
@@ -21,7 +22,7 @@ import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
|
||||
templateUrl: './watch.component.html',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, VideoPlayerComponent, RouterLink]
|
||||
imports: [CommonModule, VideoPlayerComponent, RouterLink, AddToPlaylistComponent]
|
||||
})
|
||||
export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
@@ -165,7 +166,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
if (directUrl) return directUrl;
|
||||
if (id) return `https://rumble.com/${encodeURIComponent(id)}`;
|
||||
}
|
||||
return directUrl;
|
||||
return directUrl || null;
|
||||
} catch {
|
||||
return directUrl || null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user