feat(library): liste « À regarder plus tard » (watch later)
CI / build-and-test (push) Successful in 14m55s

Nouvelle liste dans la section Vous de la barre latérale, avec un bouton
horloge a cote du coeur sur toutes les cartes (accueil, themes, provider-
themes, recherche).

- db : les 4 fonctions de listes (like/unlike/isLiked/list) prennent un
  paramètre `tag` — la table `tags` sert déjà de mot-clés génériques, donc
  zéro migration.
- api : les 4 routes likes sont générées par registerVideoTagRoutes(prefix,
  tagName) ; /api/user/watch-later = même fabrique, même enrichissement
  yt-dlp, même middleware cookie-aware. Entrée OpenAPI ajoutée.
- front : LikesService prend un VideoTag, like-button un @Input() list
  (icône horloge, ambre quand enregistré, libellés a11y dédiés) — un seul
  composant pour les deux boutons, pas de copie.
- cartes de recherche : la grille partagée n'avait AUCUN bouton ; cœur +
  horloge ajoutés, masqués sur les lives et cartes chaîne (coin bas-gauche
  déjà occupé par le compteur de spectateurs / pas des vidéos).
- route /library/watch-later = LikedComponent via data.list (titre, icône,
  placeholder et état vide pilotés par la route), clés nav.watchLater FR/EN.

Tests : npm run test:api 41/41 (nouveau test watch-later : ajout, statut,
indépendance vis-à-vis des likes, liste, retrait, 400, 401) + 21 autres
suites vertes. Build OK, instance locale 4200 rebuildée. Sondage Playwright
desktop+mobile : 12/14 (2 faux positifs du sondage : accordéon replié sur
une route hors /library, 3×401 console pendant la phase déconnectée).
This commit is contained in:
2026-10-01 14:13:43 -04:00
parent 88a2d21133
commit 735255b7d8
16 changed files with 246 additions and 114 deletions
+2 -1
View File
@@ -30,8 +30,9 @@
<div *ngIf="video.duration && video.duration > 0" class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded">
{{ video.duration | duration }}
</div>
<div class="absolute bottom-2 left-2">
<div class="absolute bottom-2 left-2 flex items-center gap-1">
<app-like-button [videoId]="video.videoId" [provider]="instances.selectedProvider()" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
<app-like-button [list]="'watch-later'" [videoId]="video.videoId" [provider]="instances.selectedProvider()" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
@@ -1,9 +1,16 @@
<div class="container mx-auto p-4 sm:p-6 max-w-6xl">
<h2 class="mb-6 flex items-center border-l-4 border-red-500 pl-4 text-2xl font-bold text-slate-100 sm:text-3xl">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-7 w-7 mr-2 text-red-400">
<path d="M12 21s-6.716-4.248-9.193-6.725A6 6 0 0 1 12 5.414 6 6 0 0 1 21.193 14.275C18.716 16.752 12 21 12 21z" />
</svg>
Liked videos
<h2 class="mb-6 flex items-center border-l-4 pl-4 text-2xl font-bold text-slate-100 sm:text-3xl"
[class.border-red-500]="!isWatchLater" [class.border-amber-400]="isWatchLater">
@if (isWatchLater) {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-7 w-7 mr-2 text-amber-400">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.23.2.8-1.3-4.5-2.7z" />
</svg>
} @else {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-7 w-7 mr-2 text-red-400">
<path d="M12 21s-6.716-4.248-9.193-6.725A6 6 0 0 1 12 5.414 6 6 0 0 1 21.193 14.275C18.716 16.752 12 21 12 21z" />
</svg>
}
{{ heading }}
</h2>
<div class="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
@@ -14,7 +21,7 @@
[ngModel]="searchQuery()"
(ngModelChange)="onSearchChange($event)"
class="block w-full pl-10 pr-10 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-150"
placeholder="Rechercher dans les vidéos aimées..."
[placeholder]="searchPlaceholder"
/>
<svg class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
@@ -34,10 +41,12 @@
<div class="text-red-300">{{ err }}</div>
} @else if (items().length === 0) {
<div class="flex flex-col items-center gap-3 py-8 text-center">
<div class="text-slate-400">Aucune vidéo aimée pour le moment.</div>
<a [routerLink]="['/library/import']" class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-500">
⬇ Importer mes favoris (Google / Twitch)
</a>
<div class="text-slate-400">{{ emptyMessage }}</div>
@if (!isWatchLater) {
<a [routerLink]="['/library/import']" class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-500">
⬇ Importer mes favoris (Google / Twitch)
</a>
}
</div>
} @else {
<ul class="space-y-3">
@@ -1,8 +1,8 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LikesService, LikedVideoItem } from '../../../services/likes.service';
import { LikesService, LikedVideoItem, VideoTag } from '../../../services/likes.service';
@Component({
selector: 'app-library-liked',
@@ -13,6 +13,10 @@ import { LikesService, LikedVideoItem } from '../../../services/likes.service';
})
export class LikedComponent {
private likes = inject(LikesService);
private route = inject(ActivatedRoute);
/** Liste affichée : 'like' (défaut) ou 'watch-later', portée par la route. */
readonly list = signal<VideoTag>('like');
loading = signal<boolean>(true);
error = signal<string | null>(null);
@@ -20,19 +24,44 @@ export class LikedComponent {
searchQuery = signal<string>('');
constructor() {
this.refresh();
// `data` émet la valeur courante puis à chaque réactivation de route :
// le composant reste unique pour les deux listes, pas de double chargement.
this.route.data.subscribe((d) => {
this.list.set(d?.['list'] === 'watch-later' ? 'watch-later' : 'like');
this.refresh();
});
}
get isWatchLater(): boolean {
return this.list() === 'watch-later';
}
get heading(): string {
return this.isWatchLater ? 'À regarder plus tard' : 'Liked videos';
}
get searchPlaceholder(): string {
return this.isWatchLater
? 'Rechercher dans « À regarder plus tard »...'
: 'Rechercher dans les vidéos aimées...';
}
get emptyMessage(): string {
return this.isWatchLater
? 'Aucune vidéo à regarder plus tard.'
: 'Aucune vidéo aimée pour le moment.';
}
refresh(q?: string): void {
this.loading.set(true);
this.error.set(null);
this.likes.list(200, q).subscribe({
this.likes.list(200, q, this.list()).subscribe({
next: (rows) => {
this.items.set(rows || []);
this.loading.set(false);
},
error: (err) => {
const msg = err?.error?.error || err?.message || 'Erreur lors du chargement des vidéos aimées';
const msg = err?.error?.error || err?.message || 'Erreur lors du chargement de la liste';
this.error.set(String(msg));
this.loading.set(false);
}
@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LikesService } from '../../../../services/likes.service';
import { LikesService, VideoTag } from '../../../../services/likes.service';
import { finalize } from 'rxjs';
import { AuthService } from '../../../../services/auth.service';
@@ -14,13 +14,16 @@ import { AuthService } from '../../../../services/auth.service';
@if (isAuthenticated()) {
<button
(click)="onClick($event)"
[class.text-red-500]="isLiked()"
[class.text-red-500]="isLiked() && list === 'like'"
[class.text-amber-400]="isLiked() && list === 'watch-later'"
[class.text-slate-400]="!isLiked()"
[class.hover\:text-red-500]="!isLiked()"
[class.hover\:text-red-500]="!isLiked() && list === 'like'"
[class.hover\:text-amber-400]="!isLiked() && list === 'watch-later'"
[disabled]="isLoading()"
class="flex h-11 w-11 items-center justify-center rounded-full transition-colors duration-200 hover:bg-slate-700/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60"
[attr.aria-label]="isLiked() ? 'Retirer des vidéos aimées' : 'Ajouter aux vidéos aimées'"
[attr.aria-label]="label"
[attr.aria-pressed]="isLiked()"
[attr.title]="label"
>
@if (isLoading()) {
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -36,11 +39,9 @@ import { AuthService } from '../../../../services/auth.service';
fill="currentColor"
[attr.aria-hidden]="true"
>
<path
[attr.d]="isLiked() ? 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z' : 'M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55l-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z'"
/>
<path [attr.d]="iconPath" />
</svg>
<span class="sr-only">{{ isLiked() ? 'Retirer des vidéos aimées' : 'Ajouter aux vidéos aimées' }}</span>
<span class="sr-only">{{ label }}</span>
}
</button>
}
@@ -83,6 +84,8 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges {
private likesService = inject(LikesService);
private auth = inject(AuthService);
/** Liste pilotée par ce bouton : 'like' (cœur) ou 'watch-later' (horloge). */
@Input() list: VideoTag = 'like';
@Input() provider = 'youtube';
@Input() title?: string;
@Input() thumbnail?: string;
@@ -100,6 +103,27 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges {
get videoId(): string {
return this._videoId;
}
/** Libellé a11y (aria-label / title / sr-only) selon la liste et l'état. */
get label(): string {
if (this.list === 'watch-later') {
return this.isLiked()
? 'Retirer de « À regarder plus tard »'
: 'Ajouter à « À regarder plus tard »';
}
return this.isLiked() ? 'Retirer des vidéos aimées' : 'Ajouter aux vidéos aimées';
}
get iconPath(): string {
if (this.list === 'watch-later') {
// Horloge (Material « schedule ») : identique dans les deux états,
// l'état se lit à la couleur (ambre quand la vidéo est enregistrée).
return 'M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.23.2.8-1.3-4.5-2.7z';
}
return this.isLiked()
? 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z'
: 'M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55l-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z';
}
isLiked = signal<boolean>(false);
isLoading = signal<boolean>(true);
@@ -126,7 +150,8 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges {
this.isLoading.set(false);
return;
}
if (changes['videoId'] && !changes['videoId'].firstChange) {
if ((changes['videoId'] && !changes['videoId'].firstChange) ||
(changes['list'] && !changes['list'].firstChange)) {
this.checkLikeStatus();
}
}
@@ -157,7 +182,7 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges {
this.isLoading.set(true);
try {
this.likesService.isLiked(this.provider, this.videoId)
this.likesService.isLiked(this.provider, this.videoId, this.list)
.pipe(
finalize(() => {
if (!this.destroyRef) {
@@ -193,7 +218,7 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges {
try {
if (this.isLiked()) {
this.likesService.unlike(this.provider, this.videoId)
this.likesService.unlike(this.provider, this.videoId, this.list)
.pipe(
finalize(() => {
if (!this.destroyRef) {
@@ -217,8 +242,8 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges {
} else {
const cleanTitle = (this.title || '').trim();
const cleanThumb = (this.thumbnail || '').trim();
try { console.debug('[LikeButton] like payload', { provider: this.provider, videoId: this.videoId, title: cleanTitle, hasThumb: !!cleanThumb }); } catch {}
this.likesService.like(this.provider, this.videoId, cleanTitle || undefined, cleanThumb || undefined)
try { console.debug('[LikeButton] tag payload', { list: this.list, provider: this.provider, videoId: this.videoId, title: cleanTitle, hasThumb: !!cleanThumb }); } catch {}
this.likesService.like(this.provider, this.videoId, cleanTitle || undefined, cleanThumb || undefined, this.list)
.pipe(
finalize(() => {
if (!this.destroyRef) {
@@ -64,6 +64,12 @@
<span [class.hidden]="collapsed" class="truncate">{{ 'nav.liked' | t }}</span>
</a>
</li>
<li>
<a routerLink="/library/watch-later" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.watchLater' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.23.2.8-1.3-4.5-2.7z"/></svg>
<span [class.hidden]="collapsed" class="truncate">{{ 'nav.watchLater' | t }}</span>
</a>
</li>
<li>
<a routerLink="/library/downloads" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.downloads' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.17l3.59-3.58L17 11l-5 5-5-5 1.41-1.41L12 13.17V3h0zm-7 15h14v2H5v-2z"/></svg>
@@ -181,8 +181,9 @@
<div *ngIf="isLiveCard(v) && v.views > 0" class="absolute bottom-2 left-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md">
{{ v.views | number }} spectateurs
</div>
<div *ngIf="!isLiveCard(v)" class="absolute bottom-2 left-2">
<div *ngIf="!isLiveCard(v)" class="absolute bottom-2 left-2 flex items-center gap-1">
<app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
<app-like-button [list]="'watch-later'" [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
</div>
</div>
<div class="p-3 pb-1">
@@ -243,7 +244,7 @@
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute top-2 right-2"><app-provider-badge [provider]="provider()"></app-provider-badge></div>
<div *ngIf="v.duration && v.duration > 0" class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md font-mono">{{ v.duration | duration }}</div>
<div class="absolute bottom-2 left-2"><app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button></div>
<div class="absolute bottom-2 left-2 flex items-center gap-1"><app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button><app-like-button [list]="'watch-later'" [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button></div>
</div>
<div class="p-3 pb-1"><h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 text-sm line-clamp-2 pr-2">{{ v.title }}</h3></div>
</a>
@@ -268,7 +269,7 @@
<div class="absolute top-2 right-2"><app-provider-badge [provider]="provider()"></app-provider-badge></div>
<div class="absolute top-2 left-2"><span class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">CLIP</span></div>
<div *ngIf="v.duration && v.duration > 0" class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md font-mono">{{ v.duration | duration }}</div>
<div class="absolute bottom-2 left-2"><app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button></div>
<div class="absolute bottom-2 left-2 flex items-center gap-1"><app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button><app-like-button [list]="'watch-later'" [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button></div>
</div>
<div class="p-3 pb-1"><h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 text-sm line-clamp-2 pr-2">{{ v.title }}</h3></div>
</a>
@@ -67,8 +67,9 @@
<div *ngIf="isLiveCard(v) && v.views > 0" class="absolute bottom-2 left-2 bg-black/70 text-white text-xs px-2 py-0.5 rounded">
{{ v.views | number }} spectateurs
</div>
<div class="absolute bottom-2 left-2" *ngIf="!isLiveCard(v)">
<div class="absolute bottom-2 left-2 flex items-center gap-1" *ngIf="!isLiveCard(v)">
<app-like-button [videoId]="v.videoId" [provider]="b.provider" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
<app-like-button [list]="'watch-later'" [videoId]="v.videoId" [provider]="b.provider" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
</div>
</div>
<div class="p-3 pb-1">