feat(library): liste « À regarder plus tard » (watch later)
CI / build-and-test (push) Successful in 14m55s
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:
@@ -92,6 +92,7 @@ npm run test:telemetry # télémétrie minimale
|
|||||||
* 🧩 **Thèmes** : Trending, Live, Gaming, Sports, News, Finance, Tech, Science, Health, Music, Podcasts, Movies/TV, Education, Travel, Food, DIY, Auto…
|
* 🧩 **Thèmes** : Trending, Live, Gaming, Sports, News, Finance, Tech, Science, Health, Music, Podcasts, Movies/TV, Education, Travel, Food, DIY, Auto…
|
||||||
* 🎯 **Shorts** : affichage dédié (séparé des vidéos longues)
|
* 🎯 **Shorts** : affichage dédié (séparé des vidéos longues)
|
||||||
* ❤️ **Liked videos** avec recherche serveur
|
* ❤️ **Liked videos** avec recherche serveur
|
||||||
|
* ⏰ **À regarder plus tard** (Watch later) : bouton horloge à côté du cœur sur toutes les cartes (accueil, thèmes, recherche), liste **Vous → À regarder plus tard** (`/#/library/watch-later`), API `/api/user/watch-later`
|
||||||
* 🕘 **History** (recherches + visionnage)
|
* 🕘 **History** (recherches + visionnage)
|
||||||
* 📚 **Playlists** publiques/privées (CRUD, compteurs, dates MAJ)
|
* 📚 **Playlists** publiques/privées (CRUD, compteurs, dates MAJ)
|
||||||
* 🔐 **Auth légère** (JWT), **rate-limit** API
|
* 🔐 **Auth légère** (JWT), **rate-limit** API
|
||||||
@@ -239,6 +240,7 @@ Ajoutez au besoin `log-driver`, `log-opts`, `default-address-pools`, etc.
|
|||||||
* ✅ Dist statique + API Node/Express
|
* ✅ Dist statique + API Node/Express
|
||||||
* ✅ Playlists **publiques/privées** (tests de visibilité)
|
* ✅ Playlists **publiques/privées** (tests de visibilité)
|
||||||
* ✅ Barre de recherche **Liked videos** (filtrage serveur)
|
* ✅ Barre de recherche **Liked videos** (filtrage serveur)
|
||||||
|
* ✅ Liste **« À regarder plus tard »** (watch later) — même stockage « tags » que les likes (aucune migration), bouton horloge à côté du cœur sur toutes les cartes, entrée « Vous » de la barre latérale, page `/#/library/watch-later` avec recherche serveur
|
||||||
* ✅ PeerTube **multi-instances** (activer/désactiver, set active)
|
* ✅ PeerTube **multi-instances** (activer/désactiver, set active)
|
||||||
* ✅ Thème (système / dark / light / blue / black)
|
* ✅ Thème (système / dark / light / blue / black)
|
||||||
* ✅ Préférences (langue, thème, région, qualité par défaut)
|
* ✅ Préférences (langue, thème, région, qualité par défaut)
|
||||||
|
|||||||
+24
-21
@@ -903,8 +903,11 @@ function ensureTag(userId, name) {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function likeVideo({ userId, provider, videoId, title, thumbnail }) {
|
// Les listes « par tag » (like, watch-later) partagent ces 4 fonctions :
|
||||||
const tagId = ensureTag(userId, 'like');
|
// `tag` est le nom de ligne dans `tags` — la table sert déjà de mot-clés
|
||||||
|
// génériques, donc une nouvelle liste ne demande aucune migration.
|
||||||
|
export function likeVideo({ userId, provider, videoId, title, thumbnail, tag = 'like' }) {
|
||||||
|
const tagId = ensureTag(userId, tag);
|
||||||
// Upsert-like behavior; ignore if exists
|
// Upsert-like behavior; ignore if exists
|
||||||
db.prepare(`INSERT OR IGNORE INTO video_tags (user_id, provider, video_id, tag_id, created_at) VALUES (?, ?, ?, ?, ?)`)
|
db.prepare(`INSERT OR IGNORE INTO video_tags (user_id, provider, video_id, tag_id, created_at) VALUES (?, ?, ?, ?, ?)`)
|
||||||
.run(userId, provider, videoId, tagId, nowIso());
|
.run(userId, provider, videoId, tagId, nowIso());
|
||||||
@@ -922,25 +925,25 @@ export function likeVideo({ userId, provider, videoId, title, thumbnail }) {
|
|||||||
return { provider, video_id: videoId };
|
return { provider, video_id: videoId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unlikeVideo({ userId, provider, videoId }) {
|
export function unlikeVideo({ userId, provider, videoId, tag = 'like' }) {
|
||||||
const tag = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, 'like');
|
const t = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, tag);
|
||||||
if (!tag) return { removed: false };
|
if (!t) return { removed: false };
|
||||||
const info = db.prepare(`DELETE FROM video_tags WHERE user_id = ? AND provider = ? AND video_id = ? AND tag_id = ?`)
|
const info = db.prepare(`DELETE FROM video_tags WHERE user_id = ? AND provider = ? AND video_id = ? AND tag_id = ?`)
|
||||||
.run(userId, provider, videoId, tag.id);
|
.run(userId, provider, videoId, t.id);
|
||||||
return { removed: (info.changes || 0) > 0 };
|
return { removed: (info.changes || 0) > 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isVideoLiked({ userId, provider, videoId }) {
|
export function isVideoLiked({ userId, provider, videoId, tag = 'like' }) {
|
||||||
const tag = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, 'like');
|
const t = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, tag);
|
||||||
if (!tag) return false;
|
if (!t) return false;
|
||||||
const row = db.prepare(`SELECT 1 FROM video_tags WHERE user_id = ? AND provider = ? AND video_id = ? AND tag_id = ?`)
|
const row = db.prepare(`SELECT 1 FROM video_tags WHERE user_id = ? AND provider = ? AND video_id = ? AND tag_id = ?`)
|
||||||
.get(userId, provider, videoId, tag.id);
|
.get(userId, provider, videoId, t.id);
|
||||||
return Boolean(row);
|
return Boolean(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listLikedVideos({ userId, limit = 100, q }) {
|
export function listLikedVideos({ userId, limit = 100, q, tag = 'like' }) {
|
||||||
try {
|
try {
|
||||||
console.log(`[listLikedVideos] Récupération des vidéos aimées pour l'utilisateur ${userId}`);
|
console.log(`[listLikedVideos] Récupération des vidéos "${tag}" pour l'utilisateur ${userId}`);
|
||||||
|
|
||||||
// Vérifier que la table tags existe
|
// Vérifier que la table tags existe
|
||||||
const tableExists = db.prepare(`
|
const tableExists = db.prepare(`
|
||||||
@@ -953,18 +956,18 @@ export function listLikedVideos({ userId, limit = 100, q }) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer le tag 'like' de l'utilisateur
|
// Récupérer le tag de la liste demandee
|
||||||
const tag = db.prepare(`
|
const tagRow = db.prepare(`
|
||||||
SELECT id FROM tags
|
SELECT id FROM tags
|
||||||
WHERE user_id = ? AND name = ?
|
WHERE user_id = ? AND name = ?
|
||||||
`).get(userId, 'like');
|
`).get(userId, tag);
|
||||||
|
|
||||||
if (!tag) {
|
if (!tagRow) {
|
||||||
console.log(`[listLikedVideos] Aucun tag 'like' trouvé pour l'utilisateur ${userId}`);
|
console.log(`[listLikedVideos] Aucun tag "${tag}" trouvé pour l'utilisateur ${userId}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[listLikedVideos] Tag ID: ${tag.id}`);
|
console.log(`[listLikedVideos] Tag ID: ${tagRow.id}`);
|
||||||
|
|
||||||
// Vérifier que la table video_tags existe
|
// Vérifier que la table video_tags existe
|
||||||
const videoTagsExists = db.prepare(`
|
const videoTagsExists = db.prepare(`
|
||||||
@@ -1032,11 +1035,11 @@ export function listLikedVideos({ userId, limit = 100, q }) {
|
|||||||
|
|
||||||
// La requete est devenue longue (normalisation provider x 3, sous-requetes
|
// La requete est devenue longue (normalisation provider x 3, sous-requetes
|
||||||
// de repli) : la logger en entier poluait la sortie a chaque appel.
|
// de repli) : la logger en entier poluait la sortie a chaque appel.
|
||||||
console.log(`[listLikedVideos] ${hasQ ? 'recherche' : 'liste'} pour ${userId} (tag ${tag.id})`);
|
console.log(`[listLikedVideos] ${hasQ ? 'recherche' : 'liste'} pour ${userId} (tag ${tagRow.id})`);
|
||||||
|
|
||||||
const rows = hasQ
|
const rows = hasQ
|
||||||
? db.prepare(query).all(userId, tag.id, like, like, like, limit)
|
? db.prepare(query).all(userId, tagRow.id, like, like, like, limit)
|
||||||
: db.prepare(query).all(userId, tag.id, limit);
|
: db.prepare(query).all(userId, tagRow.id, limit);
|
||||||
console.log(`[listLikedVideos] ${rows.length} vidéos trouvées`);
|
console.log(`[listLikedVideos] ${rows.length} vidéos trouvées`);
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
|
|||||||
+53
-50
@@ -2713,64 +2713,63 @@ r.delete('/user/history/transcripts', authMiddleware, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Likes ---
|
// --- Listes vidéo par tag : « vidéos aimées » + « à regarder plus tard » ---
|
||||||
// NOTE : cookie-aware (et non Bearer seul) : le front s'authentifie via
|
// NOTE : cookie-aware (et non Bearer seul) : le front s'authentifie via
|
||||||
// cookies httpOnly + Bearer en mémoire (perdu au F5) ; exiger le Bearer
|
// cookies httpOnly + Bearer en mémoire (perdu au F5) ; exiger le Bearer
|
||||||
// seul renvoyait 401 « Unauthorized » même connecté.
|
// seul renvoyait 401 « Unauthorized » même connecté.
|
||||||
r.get('/user/likes', authMiddlewareCookieAware, (req, res) => {
|
// `tagName` = nom de ligne dans `tags` ; c'est le SEUL paramètre qui change
|
||||||
const limit = Math.min(500, Number(req.query.limit || 100));
|
// d'une liste à l'autre, les 4 routes sont donc enregistrées par une fabrique.
|
||||||
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
function registerVideoTagRoutes(prefix, tagName) {
|
||||||
const rows = listLikedVideos({ userId: req.user.id, limit, q });
|
r.get(prefix, authMiddlewareCookieAware, (req, res) => {
|
||||||
return res.json(rows);
|
const limit = Math.min(500, Number(req.query.limit || 100));
|
||||||
});
|
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||||||
|
const rows = listLikedVideos({ userId: req.user.id, limit, q, tag: tagName });
|
||||||
|
return res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
r.post('/user/likes', authMiddlewareCookieAware, async (req, res) => {
|
r.post(prefix, authMiddlewareCookieAware, async (req, res) => {
|
||||||
let { provider, videoId, title, thumbnail } = req.body || {};
|
let { provider, videoId, title, thumbnail } = req.body || {};
|
||||||
try {
|
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
|
||||||
console.log('[POST /user/likes] payload:', {
|
|
||||||
provider,
|
|
||||||
videoId,
|
|
||||||
titlePreview: typeof title === 'string' ? title.slice(0, 80) : title,
|
|
||||||
hasThumbnail: Boolean(thumbnail)
|
|
||||||
});
|
|
||||||
} catch {}
|
|
||||||
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
|
|
||||||
|
|
||||||
// Server-side enrichment: if title or thumbnail is missing, fetch minimal details via yt-dlp
|
// Server-side enrichment: if title or thumbnail is missing, fetch minimal details via yt-dlp
|
||||||
try {
|
try {
|
||||||
const needTitle = !(typeof title === 'string' && title.trim().length > 0);
|
const needTitle = !(typeof title === 'string' && title.trim().length > 0);
|
||||||
const needThumb = !(typeof thumbnail === 'string' && thumbnail.trim().length > 0);
|
const needThumb = !(typeof thumbnail === 'string' && thumbnail.trim().length > 0);
|
||||||
if (needTitle || needThumb) {
|
if (needTitle || needThumb) {
|
||||||
const url = providerUrlFrom(provider, videoId, { instance: req.query.instance, slug: req.query.slug, sourceUrl: req.query.sourceUrl });
|
const url = providerUrlFrom(provider, videoId, { instance: req.query.instance, slug: req.query.slug, sourceUrl: req.query.sourceUrl });
|
||||||
try {
|
try {
|
||||||
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
|
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
|
||||||
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
|
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
|
||||||
if (needTitle) title = meta?.title || title || '';
|
if (needTitle) title = meta?.title || title || '';
|
||||||
if (needThumb) thumbnail = meta?.thumbnail || (Array.isArray(meta?.thumbnails) && meta.thumbnails.length ? meta.thumbnails[0].url : thumbnail || '');
|
if (needThumb) thumbnail = meta?.thumbnail || (Array.isArray(meta?.thumbnails) && meta.thumbnails.length ? meta.thumbnails[0].url : thumbnail || '');
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const row = likeVideo({ userId: req.user.id, provider, videoId, title, thumbnail });
|
const row = likeVideo({ userId: req.user.id, provider, videoId, title, thumbnail, tag: tagName });
|
||||||
return res.status(201).json(row);
|
return res.status(201).json(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
r.delete('/user/likes', authMiddlewareCookieAware, (req, res) => {
|
r.delete(prefix, authMiddlewareCookieAware, (req, res) => {
|
||||||
const provider = req.query.provider ? String(req.query.provider) : '';
|
const provider = req.query.provider ? String(req.query.provider) : '';
|
||||||
const videoId = req.query.videoId ? String(req.query.videoId) : '';
|
const videoId = req.query.videoId ? String(req.query.videoId) : '';
|
||||||
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
|
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
|
||||||
const result = unlikeVideo({ userId: req.user.id, provider, videoId });
|
const result = unlikeVideo({ userId: req.user.id, provider, videoId, tag: tagName });
|
||||||
return res.json(result);
|
return res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Like status for a specific video
|
// Status for a specific video
|
||||||
r.get('/user/likes/status', authMiddlewareCookieAware, (req, res) => {
|
r.get(`${prefix}/status`, authMiddlewareCookieAware, (req, res) => {
|
||||||
const provider = req.query.provider ? String(req.query.provider) : '';
|
const provider = req.query.provider ? String(req.query.provider) : '';
|
||||||
const videoId = req.query.videoId ? String(req.query.videoId) : '';
|
const videoId = req.query.videoId ? String(req.query.videoId) : '';
|
||||||
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
|
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
|
||||||
const liked = isVideoLiked({ userId: req.user.id, provider, videoId });
|
const liked = isVideoLiked({ userId: req.user.id, provider, videoId, tag: tagName });
|
||||||
return res.json({ liked });
|
return res.json({ liked });
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
registerVideoTagRoutes('/user/likes', 'like');
|
||||||
|
registerVideoTagRoutes('/user/watch-later', 'watch-later');
|
||||||
|
|
||||||
// Odysee image proxy to avoid CORS/ORB issues
|
// Odysee image proxy to avoid CORS/ORB issues
|
||||||
r.get('/img/odysee', async (req, res) => {
|
r.get('/img/odysee', async (req, res) => {
|
||||||
@@ -4164,6 +4163,10 @@ r.get('/openapi.json', (_req, res) => {
|
|||||||
get: { summary: 'Vidéos likées', security: bearer, responses: { 200: { description: 'likes[]' } } },
|
get: { summary: 'Vidéos likées', security: bearer, responses: { 200: { description: 'likes[]' } } },
|
||||||
post: { summary: 'Liker', security: bearer, responses: { 201: { description: 'like' } } },
|
post: { summary: 'Liker', security: bearer, responses: { 201: { description: 'like' } } },
|
||||||
},
|
},
|
||||||
|
'/user/watch-later': {
|
||||||
|
get: { summary: 'Liste « à regarder plus tard »', security: bearer, responses: { 200: { description: 'videos[]' } } },
|
||||||
|
post: { summary: 'Ajouter à la liste', security: bearer, responses: { 201: { description: '{ provider, video_id }' } } },
|
||||||
|
},
|
||||||
'/subscriptions': {
|
'/subscriptions': {
|
||||||
get: { summary: 'Abonnements { items, ttl }', security: bearer, responses: { 200: { description: 'subs' } } },
|
get: { summary: 'Abonnements { items, ttl }', security: bearer, responses: { 200: { description: 'subs' } } },
|
||||||
post: { summary: 'S abonner (résolution chaîne)', security: bearer, responses: { 201: { description: 'sub' } } },
|
post: { summary: 'S abonner (résolution chaîne)', security: bearer, responses: { 201: { description: 'sub' } } },
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ describe('auth + espace utilisateur', () => {
|
|||||||
assert.equal(r2.status, 401);
|
assert.equal(r2.status, 401);
|
||||||
});
|
});
|
||||||
it('protégées sans token -> 401', async () => {
|
it('protégées sans token -> 401', async () => {
|
||||||
for (const u of ['/api/user/me', '/api/playlists', '/api/user/likes', '/api/subscriptions', '/api/download/jobs']) {
|
for (const u of ['/api/user/me', '/api/playlists', '/api/user/likes', '/api/user/watch-later', '/api/subscriptions', '/api/download/jobs']) {
|
||||||
const r = await J(`${base}${u}`);
|
const r = await J(`${base}${u}`);
|
||||||
assert.equal(r.status, 401, u);
|
assert.equal(r.status, 401, u);
|
||||||
}
|
}
|
||||||
@@ -180,6 +180,31 @@ describe('auth + espace utilisateur', () => {
|
|||||||
const lw = await J(`${base}/api/user/history/watch`, { headers: auth(token) });
|
const lw = await J(`${base}/api/user/history/watch`, { headers: auth(token) });
|
||||||
assert.equal(lw.status, 200);
|
assert.equal(lw.status, 200);
|
||||||
});
|
});
|
||||||
|
it('watch-later : ajout, statut, liste et retrait (indépendant des likes)', async () => {
|
||||||
|
const h = { ...auth(token), 'content-type': 'application/json' };
|
||||||
|
const add = await J(`${base}/api/user/watch-later`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'wl1', title: 'WL', thumbnail: 'http://x/wl.jpg' }) });
|
||||||
|
assert.equal(add.status, 201, JSON.stringify(add.body));
|
||||||
|
const st = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl1`, { headers: auth(token) });
|
||||||
|
assert.equal(st.status, 200);
|
||||||
|
assert.equal(st.body.liked, true, 'wl1 doit etre dans la liste watch-later');
|
||||||
|
// La meme video ajoutee aux likes ne doit PAS apparaitre dans watch-later
|
||||||
|
const like = await J(`${base}/api/user/likes`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'wl2', title: 'L', thumbnail: 'http://x/l.jpg' }) });
|
||||||
|
assert.ok([200, 201].includes(like.status));
|
||||||
|
const st2 = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl2`, { headers: auth(token) });
|
||||||
|
assert.equal(st2.body.liked, false, 'un like ne doit pas se retrouver dans watch-later');
|
||||||
|
const list = await J(`${base}/api/user/watch-later?limit=500`, { headers: auth(token) });
|
||||||
|
assert.equal(list.status, 200);
|
||||||
|
assert.ok(Array.isArray(list.body), 'watch-later repond une liste');
|
||||||
|
assert.ok(list.body.some((r) => r.video_id === 'wl1'), 'wl1 present dans la liste');
|
||||||
|
assert.ok(!list.body.some((r) => r.video_id === 'wl2'), 'wl2 absent de la liste watch-later');
|
||||||
|
const rm = await J(`${base}/api/user/watch-later?provider=youtube&videoId=wl1`, { method: 'DELETE', headers: auth(token) });
|
||||||
|
assert.equal(rm.status, 200);
|
||||||
|
assert.equal(rm.body.removed, true);
|
||||||
|
const st3 = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl1`, { headers: auth(token) });
|
||||||
|
assert.equal(st3.body.liked, false, 'wl1 retire de la liste');
|
||||||
|
const bad = await J(`${base}/api/user/watch-later`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube' }) });
|
||||||
|
assert.equal(bad.status, 400);
|
||||||
|
});
|
||||||
it('subscriptions + telemetry + download jobs', async () => {
|
it('subscriptions + telemetry + download jobs', async () => {
|
||||||
const h = { ...auth(token), 'content-type': 'application/json' };
|
const h = { ...auth(token), 'content-type': 'application/json' };
|
||||||
const sub = await J(`${base}/api/subscriptions`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'yt', externalId: 'ch1', title: 'C' }) });
|
const sub = await J(`${base}/api/subscriptions`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'yt', externalId: 'ch1', title: 'C' }) });
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ export const APP_ROUTES: Routes = [
|
|||||||
loadComponent: () => import('./components/library/liked/liked.component').then(m => m.LikedComponent),
|
loadComponent: () => import('./components/library/liked/liked.component').then(m => m.LikedComponent),
|
||||||
title: 'NewTube - Liked'
|
title: 'NewTube - Liked'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'library/watch-later',
|
||||||
|
loadComponent: () => import('./components/library/liked/liked.component').then(m => m.LikedComponent),
|
||||||
|
data: { list: 'watch-later' },
|
||||||
|
title: 'NewTube - Watch later'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'library/subscriptions',
|
path: 'library/subscriptions',
|
||||||
loadComponent: () => import('./components/library/subscriptions/subscriptions.component').then(m => m.SubscriptionsComponent),
|
loadComponent: () => import('./components/library/subscriptions/subscriptions.component').then(m => m.SubscriptionsComponent),
|
||||||
|
|||||||
@@ -42,6 +42,13 @@
|
|||||||
<span *ngIf="showClipBadge" class="rounded bg-orange-600 px-1.5 py-0.5 text-[11px] font-bold text-white">CLIP</span>
|
<span *ngIf="showClipBadge" class="rounded bg-orange-600 px-1.5 py-0.5 text-[11px] font-bold text-white">CLIP</span>
|
||||||
<span *ngIf="showChannelBadge" class="rounded bg-slate-700/90 px-1.5 py-0.5 text-[11px] font-bold text-slate-200 ring-1 ring-slate-500/50">CHAÎNE</span>
|
<span *ngIf="showChannelBadge" class="rounded bg-slate-700/90 px-1.5 py-0.5 text-[11px] font-bold text-slate-200 ring-1 ring-slate-500/50">CHAÎNE</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Like + « à regarder plus tard », sur la miniature comme sur les
|
||||||
|
cartes de thème. Masqués pour un live (badge LIVE en haut à gauche)
|
||||||
|
et une carte chaîne : ce ne sont pas des vidéos. -->
|
||||||
|
<div *ngIf="!showChannelBadge && !showLiveBadge" class="absolute bottom-1 left-1 flex items-center gap-1">
|
||||||
|
<app-like-button [videoId]="video.id" [provider]="video.provider" [title]="video.title" [thumbnail]="video.thumbnailUrl"></app-like-button>
|
||||||
|
<app-like-button [list]="'watch-later'" [videoId]="video.id" [provider]="video.provider" [title]="video.title" [thumbnail]="video.thumbnailUrl"></app-like-button>
|
||||||
|
</div>
|
||||||
<span *ngIf="showDuration" class="absolute bottom-1 right-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
|
<span *ngIf="showDuration" class="absolute bottom-1 right-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
|
||||||
{{ video.durationSec | duration }}
|
{{ video.durationSec | duration }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { DurationPipe } from '../../pipes/duration.pipe';
|
|||||||
import { ProviderBadgeComponent } from '../provider-badge/provider-badge.component';
|
import { ProviderBadgeComponent } from '../provider-badge/provider-badge.component';
|
||||||
import { SubscribeButtonComponent } from '../subscribe-button/subscribe-button.component';
|
import { SubscribeButtonComponent } from '../subscribe-button/subscribe-button.component';
|
||||||
import { ChannelIdentityComponent } from '../channel-identity/channel-identity.component';
|
import { ChannelIdentityComponent } from '../channel-identity/channel-identity.component';
|
||||||
|
import { LikeButtonComponent } from '../../../../components/shared/components/like-button/like-button.component';
|
||||||
import type { ChannelMeta, ProviderId } from '../../models/channel.model';
|
import type { ChannelMeta, ProviderId } from '../../models/channel.model';
|
||||||
import { toShortProviderId } from '../../providers/provider-ids';
|
import { toShortProviderId } from '../../providers/provider-ids';
|
||||||
import { isLiveVideoItem, isShortVideoItem, isVerticalVideoItem, videoAspectRatio, verticalFrameWidthPercent } from '../../utils/video-kind';
|
import { isLiveVideoItem, isShortVideoItem, isVerticalVideoItem, videoAspectRatio, verticalFrameWidthPercent } from '../../utils/video-kind';
|
||||||
@@ -16,7 +17,7 @@ import { capturedAgoLabel, sourceLabel } from '../../../search/adapters/provenan
|
|||||||
templateUrl: './video-card.component.html',
|
templateUrl: './video-card.component.html',
|
||||||
styleUrls: ['./video-card.component.scss'],
|
styleUrls: ['./video-card.component.scss'],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, DurationPipe, ProviderBadgeComponent, SubscribeButtonComponent, ChannelIdentityComponent],
|
imports: [CommonModule, RouterLink, DurationPipe, ProviderBadgeComponent, SubscribeButtonComponent, ChannelIdentityComponent, LikeButtonComponent],
|
||||||
})
|
})
|
||||||
export class VideoCardComponent implements OnChanges {
|
export class VideoCardComponent implements OnChanges {
|
||||||
@Input() video!: VideoItem;
|
@Input() video!: VideoItem;
|
||||||
|
|||||||
@@ -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">
|
<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 }}
|
{{ video.duration | duration }}
|
||||||
</div>
|
</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 [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>
|
</div>
|
||||||
<div class="p-4 flex-grow flex flex-col">
|
<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">
|
<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">
|
<h2 class="mb-6 flex items-center border-l-4 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">
|
[class.border-red-500]="!isWatchLater" [class.border-amber-400]="isWatchLater">
|
||||||
<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" />
|
@if (isWatchLater) {
|
||||||
</svg>
|
<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">
|
||||||
Liked videos
|
<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>
|
</h2>
|
||||||
|
|
||||||
<div class="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
|
<div class="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
|
||||||
@@ -14,7 +21,7 @@
|
|||||||
[ngModel]="searchQuery()"
|
[ngModel]="searchQuery()"
|
||||||
(ngModelChange)="onSearchChange($event)"
|
(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"
|
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">
|
<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" />
|
<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>
|
<div class="text-red-300">{{ err }}</div>
|
||||||
} @else if (items().length === 0) {
|
} @else if (items().length === 0) {
|
||||||
<div class="flex flex-col items-center gap-3 py-8 text-center">
|
<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>
|
<div class="text-slate-400">{{ emptyMessage }}</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">
|
@if (!isWatchLater) {
|
||||||
⬇ Importer mes favoris (Google / Twitch)
|
<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">
|
||||||
</a>
|
⬇ Importer mes favoris (Google / Twitch)
|
||||||
|
</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<ul class="space-y-3">
|
<ul class="space-y-3">
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { RouterLink } from '@angular/router';
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { LikesService, LikedVideoItem } from '../../../services/likes.service';
|
import { LikesService, LikedVideoItem, VideoTag } from '../../../services/likes.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-library-liked',
|
selector: 'app-library-liked',
|
||||||
@@ -13,6 +13,10 @@ import { LikesService, LikedVideoItem } from '../../../services/likes.service';
|
|||||||
})
|
})
|
||||||
export class LikedComponent {
|
export class LikedComponent {
|
||||||
private likes = inject(LikesService);
|
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);
|
loading = signal<boolean>(true);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
@@ -20,19 +24,44 @@ export class LikedComponent {
|
|||||||
searchQuery = signal<string>('');
|
searchQuery = signal<string>('');
|
||||||
|
|
||||||
constructor() {
|
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 {
|
refresh(q?: string): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
this.likes.list(200, q).subscribe({
|
this.likes.list(200, q, this.list()).subscribe({
|
||||||
next: (rows) => {
|
next: (rows) => {
|
||||||
this.items.set(rows || []);
|
this.items.set(rows || []);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
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.error.set(String(msg));
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ChangeDetectionStrategy, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, inject, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, inject, signal } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { LikesService } from '../../../../services/likes.service';
|
import { LikesService, VideoTag } from '../../../../services/likes.service';
|
||||||
import { finalize } from 'rxjs';
|
import { finalize } from 'rxjs';
|
||||||
import { AuthService } from '../../../../services/auth.service';
|
import { AuthService } from '../../../../services/auth.service';
|
||||||
|
|
||||||
@@ -14,13 +14,16 @@ import { AuthService } from '../../../../services/auth.service';
|
|||||||
@if (isAuthenticated()) {
|
@if (isAuthenticated()) {
|
||||||
<button
|
<button
|
||||||
(click)="onClick($event)"
|
(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.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()"
|
[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"
|
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.aria-pressed]="isLiked()"
|
||||||
|
[attr.title]="label"
|
||||||
>
|
>
|
||||||
@if (isLoading()) {
|
@if (isLoading()) {
|
||||||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<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"
|
fill="currentColor"
|
||||||
[attr.aria-hidden]="true"
|
[attr.aria-hidden]="true"
|
||||||
>
|
>
|
||||||
<path
|
<path [attr.d]="iconPath" />
|
||||||
[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'"
|
|
||||||
/>
|
|
||||||
</svg>
|
</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>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -83,6 +84,8 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
private likesService = inject(LikesService);
|
private likesService = inject(LikesService);
|
||||||
private auth = inject(AuthService);
|
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() provider = 'youtube';
|
||||||
@Input() title?: string;
|
@Input() title?: string;
|
||||||
@Input() thumbnail?: string;
|
@Input() thumbnail?: string;
|
||||||
@@ -100,6 +103,27 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
get videoId(): string {
|
get videoId(): string {
|
||||||
return this._videoId;
|
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);
|
isLiked = signal<boolean>(false);
|
||||||
isLoading = signal<boolean>(true);
|
isLoading = signal<boolean>(true);
|
||||||
@@ -126,7 +150,8 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
this.isLoading.set(false);
|
this.isLoading.set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (changes['videoId'] && !changes['videoId'].firstChange) {
|
if ((changes['videoId'] && !changes['videoId'].firstChange) ||
|
||||||
|
(changes['list'] && !changes['list'].firstChange)) {
|
||||||
this.checkLikeStatus();
|
this.checkLikeStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,7 +182,7 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
|
|
||||||
this.isLoading.set(true);
|
this.isLoading.set(true);
|
||||||
try {
|
try {
|
||||||
this.likesService.isLiked(this.provider, this.videoId)
|
this.likesService.isLiked(this.provider, this.videoId, this.list)
|
||||||
.pipe(
|
.pipe(
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
if (!this.destroyRef) {
|
if (!this.destroyRef) {
|
||||||
@@ -193,7 +218,7 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.isLiked()) {
|
if (this.isLiked()) {
|
||||||
this.likesService.unlike(this.provider, this.videoId)
|
this.likesService.unlike(this.provider, this.videoId, this.list)
|
||||||
.pipe(
|
.pipe(
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
if (!this.destroyRef) {
|
if (!this.destroyRef) {
|
||||||
@@ -217,8 +242,8 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
} else {
|
} else {
|
||||||
const cleanTitle = (this.title || '').trim();
|
const cleanTitle = (this.title || '').trim();
|
||||||
const cleanThumb = (this.thumbnail || '').trim();
|
const cleanThumb = (this.thumbnail || '').trim();
|
||||||
try { console.debug('[LikeButton] like payload', { provider: this.provider, videoId: this.videoId, title: cleanTitle, hasThumb: !!cleanThumb }); } catch {}
|
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.likesService.like(this.provider, this.videoId, cleanTitle || undefined, cleanThumb || undefined, this.list)
|
||||||
.pipe(
|
.pipe(
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
if (!this.destroyRef) {
|
if (!this.destroyRef) {
|
||||||
|
|||||||
@@ -64,6 +64,12 @@
|
|||||||
<span [class.hidden]="collapsed" class="truncate">{{ 'nav.liked' | t }}</span>
|
<span [class.hidden]="collapsed" class="truncate">{{ 'nav.liked' | t }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
<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">
|
<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>
|
<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">
|
<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
|
{{ v.views | number }} spectateurs
|
||||||
</div>
|
</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 [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>
|
</div>
|
||||||
<div class="p-3 pb-1">
|
<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">
|
<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 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 *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>
|
||||||
<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>
|
<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>
|
</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 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 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 *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>
|
||||||
<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>
|
<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>
|
</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">
|
<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
|
{{ v.views | number }} spectateurs
|
||||||
</div>
|
</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 [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>
|
</div>
|
||||||
<div class="p-3 pb-1">
|
<div class="p-3 pb-1">
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const EN: Record<string, string> = {
|
|||||||
'nav.history': 'History',
|
'nav.history': 'History',
|
||||||
'nav.playlists': 'Playlists',
|
'nav.playlists': 'Playlists',
|
||||||
'nav.liked': 'Liked videos',
|
'nav.liked': 'Liked videos',
|
||||||
|
'nav.watchLater': 'Watch later',
|
||||||
'nav.downloads': 'Downloads',
|
'nav.downloads': 'Downloads',
|
||||||
'nav.import': 'Import',
|
'nav.import': 'Import',
|
||||||
'nav.explore': 'Explore',
|
'nav.explore': 'Explore',
|
||||||
@@ -251,6 +252,7 @@ const FR: Record<string, string> = {
|
|||||||
'nav.history': 'Historique',
|
'nav.history': 'Historique',
|
||||||
'nav.playlists': 'Listes de lecture',
|
'nav.playlists': 'Listes de lecture',
|
||||||
'nav.liked': 'Vidéos que vous aimez',
|
'nav.liked': 'Vidéos que vous aimez',
|
||||||
|
'nav.watchLater': 'À regarder plus tard',
|
||||||
'nav.downloads': 'Téléchargements',
|
'nav.downloads': 'Téléchargements',
|
||||||
'nav.import': 'Importer',
|
'nav.import': 'Importer',
|
||||||
'nav.explore': 'Explorer',
|
'nav.explore': 'Explorer',
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ export interface LikedVideoItem {
|
|||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste vidéo par tag : 'like' (vidéos aimées) ou 'watch-later' (à regarder
|
||||||
|
* plus tard). Le segment d'API suit le tag (`likes` est le nom legacy).
|
||||||
|
*/
|
||||||
|
export type VideoTag = 'like' | 'watch-later';
|
||||||
|
|
||||||
|
const TAG_PATH: Record<VideoTag, string> = {
|
||||||
|
'like': 'likes',
|
||||||
|
'watch-later': 'watch-later',
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class LikesService {
|
export class LikesService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
@@ -24,27 +35,27 @@ export class LikesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
list(limit = 100, q?: string): Observable<LikedVideoItem[]> {
|
list(limit = 100, q?: string, tag: VideoTag = 'like'): Observable<LikedVideoItem[]> {
|
||||||
let params = new HttpParams().set('limit', String(limit));
|
let params = new HttpParams().set('limit', String(limit));
|
||||||
if (typeof q === 'string' && q.trim().length > 0) params = params.set('q', q.trim());
|
if (typeof q === 'string' && q.trim().length > 0) params = params.set('q', q.trim());
|
||||||
return this.http.get<LikedVideoItem[]>(`${this.apiBase()}/user/likes`, { params, withCredentials: true });
|
return this.http.get<LikedVideoItem[]>(`${this.apiBase()}/user/${TAG_PATH[tag]}`, { params, withCredentials: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
like(provider: string, videoId: string, title?: string, thumbnail?: string): Observable<{ provider: string; video_id: string }> {
|
like(provider: string, videoId: string, title?: string, thumbnail?: string, tag: VideoTag = 'like'): Observable<{ provider: string; video_id: string }> {
|
||||||
return this.http.post<{ provider: string; video_id: string }>(
|
return this.http.post<{ provider: string; video_id: string }>(
|
||||||
`${this.apiBase()}/user/likes`,
|
`${this.apiBase()}/user/${TAG_PATH[tag]}`,
|
||||||
{ provider, videoId, title, thumbnail },
|
{ provider, videoId, title, thumbnail },
|
||||||
{ withCredentials: true }
|
{ withCredentials: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
unlike(provider: string, videoId: string): Observable<{ removed: boolean }> {
|
unlike(provider: string, videoId: string, tag: VideoTag = 'like'): Observable<{ removed: boolean }> {
|
||||||
const params = new HttpParams().set('provider', provider).set('videoId', videoId);
|
const params = new HttpParams().set('provider', provider).set('videoId', videoId);
|
||||||
return this.http.delete<{ removed: boolean }>(`${this.apiBase()}/user/likes`, { params, withCredentials: true });
|
return this.http.delete<{ removed: boolean }>(`${this.apiBase()}/user/${TAG_PATH[tag]}`, { params, withCredentials: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
isLiked(provider: string, videoId: string): Observable<{ liked: boolean }> {
|
isLiked(provider: string, videoId: string, tag: VideoTag = 'like'): Observable<{ liked: boolean }> {
|
||||||
const params = new HttpParams().set('provider', provider).set('videoId', videoId);
|
const params = new HttpParams().set('provider', provider).set('videoId', videoId);
|
||||||
return this.http.get<{ liked: boolean }>(`${this.apiBase()}/user/likes/status`, { params, withCredentials: true });
|
return this.http.get<{ liked: boolean }>(`${this.apiBase()}/user/${TAG_PATH[tag]}/status`, { params, withCredentials: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user