chore: update Angular build cache and TypeScript definitions
This commit is contained in:
parent
23c9ec539e
commit
49f9018e77
2
.angular/cache/20.2.2/app/.tsbuildinfo
vendored
2
.angular/cache/20.2.2/app/.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"root": "C:\\Users\\bruno\\Downloads\\newtube",
|
"root": "C:\\dev\\git\\web\\NewTube",
|
||||||
"uuid": "969ba79d-ef50-49c8-8372-7418cabbcb6f"
|
"uuid": "e7314654-8200-487a-b9e0-56296fc8b99f"
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE watch_history ADD COLUMN thumbnail TEXT;
|
BIN
db/newtube.db
BIN
db/newtube.db
Binary file not shown.
203
server/db.mjs
203
server/db.mjs
@ -148,11 +148,40 @@ export function insertSearchHistory({ userId, query, filters }) {
|
|||||||
return { id, created_at };
|
return { id, created_at };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listSearchHistory({ userId, limit = 50, before }) {
|
export function listSearchHistory({ userId, limit = 50, before, q }) {
|
||||||
const rows = before
|
const hasBefore = Boolean(before);
|
||||||
? db.prepare(`SELECT * FROM search_history WHERE user_id = ? AND created_at < ? ORDER BY created_at DESC LIMIT ?`).all(userId, before, limit)
|
const hasQ = typeof q === 'string' && q.trim().length > 0;
|
||||||
: db.prepare(`SELECT * FROM search_history WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`).all(userId, limit);
|
const like = `%${(q || '').trim()}%`;
|
||||||
return rows;
|
if (hasBefore && hasQ) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT * FROM search_history
|
||||||
|
WHERE user_id = ? AND created_at < ? AND (query LIKE ? OR COALESCE(filters_json,'') LIKE ?)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(userId, before, like, like, limit);
|
||||||
|
}
|
||||||
|
if (hasBefore) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT * FROM search_history
|
||||||
|
WHERE user_id = ? AND created_at < ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(userId, before, limit);
|
||||||
|
}
|
||||||
|
if (hasQ) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT * FROM search_history
|
||||||
|
WHERE user_id = ? AND (query LIKE ? OR COALESCE(filters_json,'') LIKE ?)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(userId, like, like, limit);
|
||||||
|
}
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT * FROM search_history
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(userId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteSearchHistoryById(userId, id) {
|
export function deleteSearchHistoryById(userId, id) {
|
||||||
@ -164,19 +193,20 @@ export function deleteAllSearchHistory(userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- Watch History --------------------
|
// -------------------- Watch History --------------------
|
||||||
export function upsertWatchHistory({ userId, provider, videoId, title, watchedAt, progressSeconds = 0, durationSeconds = 0, lastPositionSeconds = 0 }) {
|
export function upsertWatchHistory({ userId, provider, videoId, title, thumbnail, watchedAt, progressSeconds = 0, durationSeconds = 0, lastPositionSeconds = 0 }) {
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
const watched_at = watchedAt || now;
|
const watched_at = watchedAt || now;
|
||||||
// Insert or update on unique (user_id, provider, video_id)
|
// Insert or update on unique (user_id, provider, video_id)
|
||||||
db.prepare(`INSERT INTO watch_history (id, user_id, provider, video_id, title, watched_at, progress_seconds, duration_seconds, last_position_seconds, last_watched_at)
|
db.prepare(`INSERT INTO watch_history (id, user_id, provider, video_id, title, thumbnail, watched_at, progress_seconds, duration_seconds, last_position_seconds, last_watched_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(user_id, provider, video_id) DO UPDATE SET
|
ON CONFLICT(user_id, provider, video_id) DO UPDATE SET
|
||||||
title=COALESCE(excluded.title, title),
|
title=COALESCE(excluded.title, title),
|
||||||
|
thumbnail=COALESCE(excluded.thumbnail, watch_history.thumbnail),
|
||||||
progress_seconds=MAX(excluded.progress_seconds, watch_history.progress_seconds),
|
progress_seconds=MAX(excluded.progress_seconds, watch_history.progress_seconds),
|
||||||
duration_seconds=MAX(excluded.duration_seconds, watch_history.duration_seconds),
|
duration_seconds=MAX(excluded.duration_seconds, watch_history.duration_seconds),
|
||||||
last_position_seconds=excluded.last_position_seconds,
|
last_position_seconds=excluded.last_position_seconds,
|
||||||
last_watched_at=excluded.last_watched_at`).run(
|
last_watched_at=excluded.last_watched_at`).run(
|
||||||
cryptoRandomId(), userId, provider, videoId, title || null, watched_at, progressSeconds, durationSeconds, lastPositionSeconds, now
|
cryptoRandomId(), userId, provider, videoId, title || null, thumbnail || null, watched_at, progressSeconds, durationSeconds, lastPositionSeconds, now
|
||||||
);
|
);
|
||||||
// Return the row id
|
// Return the row id
|
||||||
const row = db.prepare(`SELECT * FROM watch_history WHERE user_id = ? AND provider = ? AND video_id = ?`).get(userId, provider, videoId);
|
const row = db.prepare(`SELECT * FROM watch_history WHERE user_id = ? AND provider = ? AND video_id = ?`).get(userId, provider, videoId);
|
||||||
@ -194,11 +224,41 @@ export function updateWatchHistoryById(id, { progressSeconds, lastPositionSecond
|
|||||||
return db.prepare(`SELECT * FROM watch_history WHERE id = ?`).get(id);
|
return db.prepare(`SELECT * FROM watch_history WHERE id = ?`).get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listWatchHistory({ userId, limit = 50, before }) {
|
export function listWatchHistory({ userId, limit = 50, before, q }) {
|
||||||
const rows = before
|
const hasBefore = Boolean(before);
|
||||||
? db.prepare(`SELECT * FROM watch_history WHERE user_id = ? AND watched_at < ? ORDER BY watched_at DESC LIMIT ?`).all(userId, before, limit)
|
const hasQ = typeof q === 'string' && q.trim().length > 0;
|
||||||
: db.prepare(`SELECT * FROM watch_history WHERE user_id = ? ORDER BY watched_at DESC LIMIT ?`).all(userId, limit);
|
const like = `%${(q || '').trim()}%`;
|
||||||
return rows;
|
if (hasBefore && hasQ) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT * FROM watch_history
|
||||||
|
WHERE user_id = ? AND watched_at < ?
|
||||||
|
AND (COALESCE(title,'') LIKE ? OR provider LIKE ? OR video_id LIKE ?)
|
||||||
|
ORDER BY watched_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(userId, before, like, like, like, limit);
|
||||||
|
}
|
||||||
|
if (hasBefore) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT * FROM watch_history
|
||||||
|
WHERE user_id = ? AND watched_at < ?
|
||||||
|
ORDER BY watched_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(userId, before, limit);
|
||||||
|
}
|
||||||
|
if (hasQ) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT * FROM watch_history
|
||||||
|
WHERE user_id = ? AND (COALESCE(title,'') LIKE ? OR provider LIKE ? OR video_id LIKE ?)
|
||||||
|
ORDER BY watched_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(userId, like, like, like, limit);
|
||||||
|
}
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT * FROM watch_history
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY watched_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(userId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteWatchHistoryById(userId, id) {
|
export function deleteWatchHistoryById(userId, id) {
|
||||||
@ -208,3 +268,118 @@ export function deleteWatchHistoryById(userId, id) {
|
|||||||
export function deleteAllWatchHistory(userId) {
|
export function deleteAllWatchHistory(userId) {
|
||||||
db.prepare(`DELETE FROM watch_history WHERE user_id = ?`).run(userId);
|
db.prepare(`DELETE FROM watch_history WHERE user_id = ?`).run(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------- Likes (via tags/video_tags) --------------------
|
||||||
|
function ensureTag(userId, name) {
|
||||||
|
const tag = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, name);
|
||||||
|
if (tag && tag.id) return tag.id;
|
||||||
|
const id = cryptoRandomId();
|
||||||
|
db.prepare(`INSERT INTO tags (id, user_id, name, created_at) VALUES (?, ?, ?, ?)`)
|
||||||
|
.run(id, userId, name, nowIso());
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function likeVideo({ userId, provider, videoId }) {
|
||||||
|
const tagId = ensureTag(userId, 'like');
|
||||||
|
// Upsert-like behavior; ignore if exists
|
||||||
|
db.prepare(`INSERT OR IGNORE INTO video_tags (user_id, provider, video_id, tag_id) VALUES (?, ?, ?, ?)`)
|
||||||
|
.run(userId, provider, videoId, tagId);
|
||||||
|
return { provider, video_id: videoId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unlikeVideo({ userId, provider, videoId }) {
|
||||||
|
const tag = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, 'like');
|
||||||
|
if (!tag) return { removed: false };
|
||||||
|
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);
|
||||||
|
return { removed: (info.changes || 0) > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVideoLiked({ userId, provider, videoId }) {
|
||||||
|
const tag = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, 'like');
|
||||||
|
if (!tag) return false;
|
||||||
|
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);
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listLikedVideos({ userId, limit = 100, q }) {
|
||||||
|
try {
|
||||||
|
console.log(`[listLikedVideos] Récupération des vidéos aimées pour l'utilisateur ${userId}`);
|
||||||
|
|
||||||
|
// Vérifier que la table tags existe
|
||||||
|
const tableExists = db.prepare(`
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name='tags'
|
||||||
|
`).get();
|
||||||
|
|
||||||
|
if (!tableExists) {
|
||||||
|
console.error('[listLikedVideos] La table tags n\'existe pas');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer le tag 'like' de l'utilisateur
|
||||||
|
const tag = db.prepare(`
|
||||||
|
SELECT id FROM tags
|
||||||
|
WHERE user_id = ? AND name = ?
|
||||||
|
`).get(userId, 'like');
|
||||||
|
|
||||||
|
if (!tag) {
|
||||||
|
console.log(`[listLikedVideos] Aucun tag 'like' trouvé pour l'utilisateur ${userId}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[listLikedVideos] Tag ID: ${tag.id}`);
|
||||||
|
|
||||||
|
// Vérifier que la table video_tags existe
|
||||||
|
const videoTagsExists = db.prepare(`
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name='video_tags'
|
||||||
|
`).get();
|
||||||
|
|
||||||
|
if (!videoTagsExists) {
|
||||||
|
console.error('[listLikedVideos] La table video_tags n\'existe pas');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer les vidéos aimées avec les métadonnées de l'historique
|
||||||
|
// Note: La colonne thumbnail n'existe pas dans la table watch_history
|
||||||
|
const hasQ = typeof q === 'string' && q.trim().length > 0;
|
||||||
|
const like = `%${(q || '').trim()}%`;
|
||||||
|
const base = `
|
||||||
|
SELECT
|
||||||
|
vt.provider,
|
||||||
|
vt.video_id,
|
||||||
|
COALESCE(wh.title, '') AS title,
|
||||||
|
COALESCE(wh.thumbnail, '') AS thumbnail,
|
||||||
|
wh.last_watched_at AS last_watched_at
|
||||||
|
FROM video_tags vt
|
||||||
|
LEFT JOIN watch_history wh
|
||||||
|
ON wh.user_id = vt.user_id
|
||||||
|
AND wh.provider = vt.provider
|
||||||
|
AND wh.video_id = vt.video_id
|
||||||
|
WHERE vt.user_id = ? AND vt.tag_id = ?
|
||||||
|
`;
|
||||||
|
const orderLimit = `
|
||||||
|
ORDER BY COALESCE(wh.last_watched_at, wh.watched_at) DESC
|
||||||
|
LIMIT ?
|
||||||
|
`;
|
||||||
|
const query = hasQ
|
||||||
|
? `${base} AND (COALESCE(wh.title,'') LIKE ? OR vt.provider LIKE ? OR vt.video_id LIKE ?)
|
||||||
|
${orderLimit}`
|
||||||
|
: `${base} ${orderLimit}`;
|
||||||
|
|
||||||
|
console.log('[listLikedVideos] Exécution de la requête:', query.replace(/\s+/g, ' ').trim());
|
||||||
|
|
||||||
|
const rows = hasQ
|
||||||
|
? db.prepare(query).all(userId, tag.id, like, like, like, limit)
|
||||||
|
: db.prepare(query).all(userId, tag.id, limit);
|
||||||
|
console.log(`[listLikedVideos] ${rows.length} vidéos trouvées`);
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[listLikedVideos] Erreur:', error.message);
|
||||||
|
console.error(error.stack);
|
||||||
|
throw error; // Renvoyer l'erreur pour qu'elle soit gérée par le routeur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -37,6 +37,10 @@ import {
|
|||||||
updateWatchHistoryById,
|
updateWatchHistoryById,
|
||||||
deleteWatchHistoryById,
|
deleteWatchHistoryById,
|
||||||
deleteAllWatchHistory,
|
deleteAllWatchHistory,
|
||||||
|
likeVideo,
|
||||||
|
unlikeVideo,
|
||||||
|
listLikedVideos,
|
||||||
|
isVideoLiked,
|
||||||
} from './db.mjs';
|
} from './db.mjs';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@ -977,7 +981,8 @@ r.post('/user/history/search', authMiddleware, (req, res) => {
|
|||||||
r.get('/user/history/search', authMiddleware, (req, res) => {
|
r.get('/user/history/search', authMiddleware, (req, res) => {
|
||||||
const limit = Math.min(200, Number(req.query.limit || 50));
|
const limit = Math.min(200, Number(req.query.limit || 50));
|
||||||
const before = req.query.before ? String(req.query.before) : undefined;
|
const before = req.query.before ? String(req.query.before) : undefined;
|
||||||
const rows = listSearchHistory({ userId: req.user.id, limit, before });
|
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||||||
|
const rows = listSearchHistory({ userId: req.user.id, limit, before, q });
|
||||||
return res.json(rows);
|
return res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -992,21 +997,46 @@ r.delete('/user/history/search', authMiddleware, (req, res) => {
|
|||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delete a single search history item
|
||||||
|
r.delete('/user/history/search/:id', authMiddleware, (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
if (!id) return res.status(400).json({ error: 'id is required' });
|
||||||
|
try {
|
||||||
|
deleteSearchHistoryById(req.user.id, id);
|
||||||
|
return res.status(204).end();
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- History: Watch ---
|
// --- History: Watch ---
|
||||||
r.post('/user/history/watch', authMiddleware, (req, res) => {
|
r.post('/user/history/watch', authMiddleware, (req, res) => {
|
||||||
const { provider, videoId, title, watchedAt, progressSeconds, durationSeconds, lastPositionSeconds } = req.body || {};
|
const { provider, videoId, title, thumbnail, watchedAt, progressSeconds, durationSeconds, lastPositionSeconds } = req.body || {};
|
||||||
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 row = upsertWatchHistory({ userId: req.user.id, provider, videoId, title, watchedAt, progressSeconds, durationSeconds, lastPositionSeconds });
|
const row = upsertWatchHistory({ userId: req.user.id, provider, videoId, title, thumbnail, watchedAt, progressSeconds, durationSeconds, lastPositionSeconds });
|
||||||
return res.status(201).json(row);
|
return res.status(201).json(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
r.get('/user/history/watch', authMiddleware, (req, res) => {
|
r.get('/user/history/watch', authMiddleware, (req, res) => {
|
||||||
const limit = Math.min(200, Number(req.query.limit || 50));
|
const limit = Math.min(200, Number(req.query.limit || 50));
|
||||||
const before = req.query.before ? String(req.query.before) : undefined;
|
const before = req.query.before ? String(req.query.before) : undefined;
|
||||||
const rows = listWatchHistory({ userId: req.user.id, limit, before });
|
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||||||
|
const rows = listWatchHistory({ userId: req.user.id, limit, before, q });
|
||||||
return res.json(rows);
|
return res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delete a single watch history item
|
||||||
|
r.delete('/user/history/watch/:id', authMiddleware, (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
if (!id) return res.status(400).json({ error: 'id is required' });
|
||||||
|
try {
|
||||||
|
deleteWatchHistoryById(req.user.id, id);
|
||||||
|
return res.status(204).end();
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
r.patch('/user/history/watch/:id', authMiddleware, (req, res) => {
|
r.patch('/user/history/watch/:id', authMiddleware, (req, res) => {
|
||||||
const { progressSeconds, lastPositionSeconds } = req.body || {};
|
const { progressSeconds, lastPositionSeconds } = req.body || {};
|
||||||
const row = updateWatchHistoryById(req.params.id, { progressSeconds, lastPositionSeconds });
|
const row = updateWatchHistoryById(req.params.id, { progressSeconds, lastPositionSeconds });
|
||||||
@ -1014,17 +1044,43 @@ r.patch('/user/history/watch/:id', authMiddleware, (req, res) => {
|
|||||||
return res.json(row);
|
return res.json(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
r.delete('/user/history/watch/:id', authMiddleware, (req, res) => {
|
|
||||||
deleteWatchHistoryById(req.user.id, req.params.id);
|
|
||||||
return res.status(204).end();
|
|
||||||
});
|
|
||||||
|
|
||||||
r.delete('/user/history/watch', authMiddleware, (req, res) => {
|
r.delete('/user/history/watch', authMiddleware, (req, res) => {
|
||||||
if (String(req.query.all || '') !== '1') return res.status(400).json({ error: 'set all=1' });
|
if (String(req.query.all || '') !== '1') return res.status(400).json({ error: 'set all=1' });
|
||||||
deleteAllWatchHistory(req.user.id);
|
deleteAllWatchHistory(req.user.id);
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Likes ---
|
||||||
|
r.get('/user/likes', authMiddleware, (req, res) => {
|
||||||
|
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 });
|
||||||
|
return res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
r.post('/user/likes', authMiddleware, (req, res) => {
|
||||||
|
const { provider, videoId } = req.body || {};
|
||||||
|
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
|
||||||
|
const row = likeVideo({ userId: req.user.id, provider, videoId });
|
||||||
|
return res.status(201).json(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
r.delete('/user/likes', authMiddleware, (req, res) => {
|
||||||
|
const provider = req.query.provider ? String(req.query.provider) : '';
|
||||||
|
const videoId = req.query.videoId ? String(req.query.videoId) : '';
|
||||||
|
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
|
||||||
|
const result = unlikeVideo({ userId: req.user.id, provider, videoId });
|
||||||
|
return res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
r.get('/user/likes/status', authMiddleware, (req, res) => {
|
||||||
|
const provider = req.query.provider ? String(req.query.provider) : '';
|
||||||
|
const videoId = req.query.videoId ? String(req.query.videoId) : '';
|
||||||
|
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
|
||||||
|
const liked = isVideoLiked({ userId: req.user.id, provider, videoId });
|
||||||
|
return res.json({ liked });
|
||||||
|
});
|
||||||
|
|
||||||
app.use('/api', r);
|
app.use('/api', r);
|
||||||
// Mount dedicated Rumble router (browse, search, video)
|
// Mount dedicated Rumble router (browse, search, video)
|
||||||
app.use('/api/rumble', rumbleRouter);
|
app.use('/api/rumble', rumbleRouter);
|
||||||
|
@ -1,10 +1,36 @@
|
|||||||
<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="text-3xl font-bold mb-8 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center">
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
|
||||||
|
<h2 class="text-3xl font-bold text-slate-100 border-l-4 border-red-500 pl-4 flex items-center">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
Historique
|
Historique
|
||||||
</h2>
|
</h2>
|
||||||
|
<div class="relative w-full md:w-96">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<svg class="h-5 w-5 text-slate-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[ngModel]="searchQuery()"
|
||||||
|
(ngModelChange)="searchQuery.set($event)"
|
||||||
|
class="block w-full pl-10 pr-3 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 l'historique..."
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
*ngIf="searchQuery()"
|
||||||
|
(click)="searchQuery.set('')"
|
||||||
|
class="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-400 hover:text-slate-300 transition-colors"
|
||||||
|
aria-label="Effacer la recherche"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<!-- Search History Section -->
|
<!-- Search History Section -->
|
||||||
@ -16,27 +42,53 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Historique des recherches
|
Historique des recherches
|
||||||
</h3>
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xs bg-slate-700 text-slate-300 px-2 py-1 rounded-full">
|
<span class="text-xs bg-slate-700 text-slate-300 px-2 py-1 rounded-full">
|
||||||
{{ searchHistory().length }} recherche(s)
|
{{ searchHistory().length }} recherche(s)
|
||||||
|
<span *ngIf="searchQuery()">
|
||||||
|
• {{ filteredSearchHistory().length }} résultat(s)
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="searchHistory().length === 0" class="text-slate-400 text-center py-4">
|
|
||||||
Aucune recherche récente
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="space-y-3">
|
<ng-container *ngIf="searchQuery(); else normalSearchHistory">
|
||||||
<li *ngFor="let s of searchHistory()"
|
<!-- Mode recherche -->
|
||||||
class="group bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
|
<div *ngIf="isLoading()" class="text-center py-6">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-500 border-t-transparent"></div>
|
||||||
|
<p class="mt-2 text-slate-400">Recherche en cours...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="!isLoading() && filteredSearchHistory().length === 0" class="text-slate-400 text-center py-6">
|
||||||
|
Aucun résultat trouvé dans l'historique des recherches pour "{{ searchQuery() }}"
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul *ngIf="!isLoading() && filteredSearchHistory().length > 0" class="space-y-3">
|
||||||
|
<li *ngFor="let s of filteredSearchHistory()"
|
||||||
|
class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
|
||||||
<a [routerLink]="['/search']" [queryParams]="{ q: s.query, provider: getSearchProvider(s).id }"
|
<a [routerLink]="['/search']" [queryParams]="{ q: s.query, provider: getSearchProvider(s).id }"
|
||||||
class="block p-4 hover:no-underline">
|
class="block p-4 pr-16 hover:no-underline">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-slate-400 mt-0.5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="text-slate-100 font-medium group-hover:text-blue-400 transition-colors">
|
<div class="text-slate-100 font-medium group-hover:text-blue-400 transition-colors">
|
||||||
{{ s.query }}
|
{{ s.query }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center mt-1 text-xs text-slate-400">
|
<div class="flex items-center mt-1 text-xs text-slate-400">
|
||||||
<span class="inline-flex items-center bg-slate-800/80 px-2 py-0.5 rounded mr-2">
|
<span class="inline-flex items-center px-2 py-0.5 rounded mr-2 border"
|
||||||
|
[ngStyle]="getProviderColors(getSearchProvider(s).id)"
|
||||||
|
[ngClass]="{
|
||||||
|
'bg-red-600/15 text-red-400 border-red-500/30': getSearchProvider(s).id === 'youtube',
|
||||||
|
'bg-sky-600/15 text-sky-300 border-sky-500/30': getSearchProvider(s).id === 'vimeo',
|
||||||
|
'bg-blue-600/15 text-blue-300 border-blue-500/30': getSearchProvider(s).id === 'dailymotion',
|
||||||
|
'bg-amber-600/15 text-amber-300 border-amber-500/30': getSearchProvider(s).id === 'peertube',
|
||||||
|
'bg-green-600/15 text-green-300 border-green-500/30': getSearchProvider(s).id === 'rumble',
|
||||||
|
'bg-slate-800/80 text-slate-300 border-slate-700': !['youtube', 'vimeo', 'dailymotion', 'peertube', 'rumble'].includes(getSearchProvider(s).id)
|
||||||
|
}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -47,15 +99,75 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="text-slate-400 hover:text-blue-400 p-1 -mr-1">
|
</div>
|
||||||
|
</a>
|
||||||
|
<button (click)="onDeleteSearch(s); $event.preventDefault(); $event.stopPropagation();"
|
||||||
|
[disabled]="deletingSearchId() === s.id"
|
||||||
|
class="absolute bottom-2 right-2 inline-flex items-center gap-1 px-2 py-1 rounded-md bg-red-600/90 hover:bg-red-500/90 text-white text-xs disabled:opacity-60 disabled:cursor-not-allowed transition-colors">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-3 w-3">
|
||||||
|
<path d="M9 3a1 1 0 00-1 1v1H5a1 1 0 100 2h14a1 1 0 100-2h-3V4a1 1 0 00-1-1H9zM6 9a1 1 0 011 1v8a2 2 0 002 2h6a2 2 0 002-2v-8a1 1 0 112 0v8a4 4 0 01-4 4H9a4 4 0 01-4-4v-8a1 1 0 011-1z" />
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Supprimer</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #normalSearchHistory>
|
||||||
|
<!-- Mode affichage normal -->
|
||||||
|
<div *ngIf="searchHistory().length === 0" class="text-slate-400 text-center py-4">
|
||||||
|
Aucune recherche récente
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul *ngIf="searchHistory().length > 0" class="space-y-3">
|
||||||
|
<li *ngFor="let s of searchHistory()"
|
||||||
|
class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
|
||||||
|
<a [routerLink]="['/search']" [queryParams]="{ q: s.query, provider: getSearchProvider(s).id }"
|
||||||
|
class="block p-4 pr-16 hover:no-underline">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="text-slate-400 mt-0.5">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-slate-100 font-medium group-hover:text-blue-400 transition-colors">
|
||||||
|
{{ s.query }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mt-1 text-xs text-slate-400">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded mr-2 border"
|
||||||
|
[ngStyle]="getProviderColors(getSearchProvider(s).id)"
|
||||||
|
[ngClass]="{
|
||||||
|
'bg-red-600/15 text-red-400 border-red-500/30': getSearchProvider(s).id === 'youtube',
|
||||||
|
'bg-sky-600/15 text-sky-300 border-sky-500/30': getSearchProvider(s).id === 'vimeo',
|
||||||
|
'bg-blue-600/15 text-blue-300 border-blue-500/30': getSearchProvider(s).id === 'dailymotion',
|
||||||
|
'bg-amber-600/15 text-amber-300 border-amber-500/30': getSearchProvider(s).id === 'peertube',
|
||||||
|
'bg-green-600/15 text-green-300 border-green-500/30': getSearchProvider(s).id === 'rumble',
|
||||||
|
'bg-slate-800/80 text-slate-300 border-slate-700': !['youtube', 'vimeo', 'dailymotion', 'peertube', 'rumble'].includes(getSearchProvider(s).id)
|
||||||
|
}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||||
|
</svg>
|
||||||
|
{{ getSearchProvider(s).name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-slate-500">
|
||||||
|
{{ formatDate(s.created_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
<button (click)="onDeleteSearch(s); $event.preventDefault(); $event.stopPropagation();"
|
||||||
|
[disabled]="deletingSearchId() === s.id"
|
||||||
|
class="absolute bottom-2 right-2 inline-flex items-center gap-1 px-2 py-1 rounded-md bg-red-600/90 hover:bg-red-500/90 text-white text-xs disabled:opacity-60 disabled:cursor-not-allowed transition-colors">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-3 w-3">
|
||||||
|
<path d="M9 3a1 1 0 00-1 1v1H5a1 1 0 100 2h14a1 1 0 100-2h-3V4a1 1 0 00-1-1H9zM6 9a1 1 0 011 1v8a2 2 0 002 2h6a2 2 0 002-2v-8a1 1 0 112 0v8a4 4 0 01-4 4H9a4 4 0 01-4-4v-8a1 1 0 011-1z" />
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Supprimer</span>
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
</ng-template>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Watch History Section -->
|
<!-- Watch History Section -->
|
||||||
@ -67,27 +179,43 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Historique de visionnage
|
Historique de visionnage
|
||||||
</h3>
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xs bg-slate-700 text-slate-300 px-2 py-1 rounded-full">
|
<span class="text-xs bg-slate-700 text-slate-300 px-2 py-1 rounded-full">
|
||||||
{{ watchHistory().length }} vidéo(s)
|
{{ watchHistory().length }} vidéo(s)
|
||||||
|
<span *ngIf="searchQuery()">
|
||||||
|
• {{ filteredWatchHistory().length }} résultat(s)
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="watchHistory().length === 0" class="text-slate-400 text-center py-4">
|
|
||||||
Aucune vidéo récemment regardée
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="space-y-3">
|
<ng-container *ngIf="searchQuery(); else normalWatchHistory">
|
||||||
<li *ngFor="let w of watchHistory()"
|
<!-- Mode recherche -->
|
||||||
class="group bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
|
<div *ngIf="isLoading()" class="text-center py-6">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-500 border-t-transparent"></div>
|
||||||
|
<p class="mt-2 text-slate-400">Recherche en cours...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="!isLoading() && filteredWatchHistory().length === 0" class="text-slate-400 text-center py-6">
|
||||||
|
Aucun résultat trouvé dans l'historique de visionnage pour "{{ searchQuery() }}"
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul *ngIf="!isLoading() && filteredWatchHistory().length > 0" class="space-y-3">
|
||||||
|
<li *ngFor="let w of filteredWatchHistory()"
|
||||||
|
class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
|
||||||
<a [routerLink]="['/watch', w.video_id]" [queryParams]="{ p: w.provider }"
|
<a [routerLink]="['/watch', w.video_id]" [queryParams]="{ p: w.provider }"
|
||||||
class="block p-4 hover:no-underline">
|
class="block p-4 pr-16 hover:no-underline">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="relative flex-shrink-0 w-24 h-16 bg-slate-700 rounded overflow-hidden flex items-center justify-center">
|
<div class="relative flex-shrink-0 w-24 h-16 bg-slate-700 rounded overflow-hidden flex items-center justify-center">
|
||||||
|
@if (w.thumbnail) {
|
||||||
|
<img [src]="w.thumbnail" alt="" class="w-full h-full object-cover" />
|
||||||
|
} @else {
|
||||||
<div class="text-slate-400">
|
<div class="text-slate-400">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-slate-600">
|
<div class="absolute bottom-0 left-0 right-0 h-1 bg-slate-600">
|
||||||
<div class="h-full bg-red-500" [style.width.%]="getProgressPercentage(w)"></div>
|
<div class="h-full bg-red-500" [style.width.%]="getProgressPercentage(w)"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -96,29 +224,109 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4 flex-1 min-w-0">
|
<div class="ml-4 flex-1 min-w-0">
|
||||||
<h4 class="text-sm font-medium text-slate-100 group-hover:text-blue-400 transition-colors truncate">
|
<h4 class="flex items-center gap-2 text-sm font-medium text-slate-100 group-hover:text-blue-400 transition-colors truncate">
|
||||||
{{ w.title || 'Sans titre' }}
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
<span class="truncate">{{ w.title || 'Sans titre' }}</span>
|
||||||
</h4>
|
</h4>
|
||||||
<div class="mt-1 text-xs text-slate-400">
|
<div class="mt-1 text-xs text-slate-400">
|
||||||
<span class="inline-flex items-center bg-slate-800/80 px-2 py-0.5 rounded mr-2">
|
<span class="inline-flex items-center px-2 py-0.5 rounded mr-2 border"
|
||||||
{{ w.provider }}
|
[ngStyle]="getProviderColors(w.provider)">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||||
|
</svg>
|
||||||
|
{{ getProviderDisplayName(w.provider) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 text-2xs text-slate-500">
|
<p class="text-xs text-slate-400 mt-1">
|
||||||
<span>Regardé le {{ formatDate(w.watched_at) }}</span>
|
Visionné le {{ formatDate(w.watched_at) }}
|
||||||
<span class="mx-2">•</span>
|
</p>
|
||||||
|
<div class="mt-1 text-xs text-slate-500">
|
||||||
<span>Progression: {{ formatDuration(w.progress_seconds || 0) }} / {{ formatDuration(w.duration_seconds || 0) }}</span>
|
<span>Progression: {{ formatDuration(w.progress_seconds || 0) }} / {{ formatDuration(w.duration_seconds || 0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="text-slate-400 hover:text-blue-400 p-1 -mr-1 self-center">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
<button (click)="onDeleteWatch(w); $event.preventDefault(); $event.stopPropagation();"
|
||||||
|
[disabled]="deletingWatchId() === w.id"
|
||||||
|
class="absolute bottom-2 right-2 inline-flex items-center gap-1 px-2 py-1 rounded-md bg-red-600/90 hover:bg-red-500/90 text-white text-xs disabled:opacity-60 disabled:cursor-not-allowed transition-colors">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-3 w-3">
|
||||||
|
<path d="M9 3a1 1 0 00-1 1v1H5a1 1 0 100 2h14a1 1 0 100-2h-3V4a1 1 0 00-1-1H9zM6 9a1 1 0 011 1v8a2 2 0 002 2h6a2 2 0 002-2v-8a1 1 0 112 0v8a4 4 0 01-4 4H9a4 4 0 01-4-4v-8a1 1 0 011-1z" />
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Supprimer</span>
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #normalWatchHistory>
|
||||||
|
<!-- Mode affichage normal -->
|
||||||
|
<div *ngIf="watchHistory().length === 0" class="text-slate-400 text-center py-4">
|
||||||
|
Aucune vidéo récemment regardée
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul *ngIf="watchHistory().length > 0" class="space-y-3">
|
||||||
|
<li *ngFor="let w of watchHistory()"
|
||||||
|
class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
|
||||||
|
<a [routerLink]="['/watch', w.video_id]" [queryParams]="{ p: w.provider }"
|
||||||
|
class="block p-4 pr-16 hover:no-underline">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="relative flex-shrink-0 w-24 h-16 bg-slate-700 rounded overflow-hidden flex items-center justify-center">
|
||||||
|
@if (w.thumbnail) {
|
||||||
|
<img [src]="w.thumbnail" alt="" class="w-full h-full object-cover" />
|
||||||
|
} @else {
|
||||||
|
<div class="text-slate-400">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 h-1 bg-slate-600">
|
||||||
|
<div class="h-full bg-red-500" [style.width.%]="getProgressPercentage(w)"></div>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="w.duration_seconds" class="absolute bottom-1 right-1 bg-black/80 text-white text-2xs px-1 rounded">
|
||||||
|
{{ formatDuration(w.duration_seconds) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 flex-1 min-w-0">
|
||||||
|
<h4 class="flex items-center gap-2 text-sm font-medium text-slate-100 group-hover:text-blue-400 transition-colors truncate">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
<span class="truncate">{{ w.title || 'Sans titre' }}</span>
|
||||||
|
</h4>
|
||||||
|
<div class="mt-1 text-xs text-slate-400">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded mr-2 border"
|
||||||
|
[ngStyle]="getProviderColors(w.provider)">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||||
|
</svg>
|
||||||
|
{{ getProviderDisplayName(w.provider) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400 mt-1">
|
||||||
|
Visionné le {{ formatDate(w.watched_at) }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-1 text-xs text-slate-500">
|
||||||
|
<span>Progression: {{ formatDuration(w.progress_seconds || 0) }} / {{ formatDuration(w.duration_seconds || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<button (click)="onDeleteWatch(w); $event.preventDefault(); $event.stopPropagation();"
|
||||||
|
[disabled]="deletingWatchId() === w.id"
|
||||||
|
class="absolute bottom-2 right-2 inline-flex items-center gap-1 px-2 py-1 rounded-md bg-red-600/90 hover:bg-red-500/90 text-white text-xs disabled:opacity-60 disabled:cursor-not-allowed transition-colors">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-3 w-3">
|
||||||
|
<path d="M9 3a1 1 0 00-1 1v1H5a1 1 0 100 2h14a1 1 0 100-2h-3V4a1 1 0 00-1-1H9zM6 9a1 1 0 011 1v8a2 2 0 002 2h6a2 2 0 002-2v-8a1 1 0 112 0v8a4 4 0 01-4 4H9a4 4 0 01-4-4v-8a1 1 0 011-1z" />
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Supprimer</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</ng-template>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject, signal, computed, effect } from '@angular/core';
|
||||||
|
import { NgClass } from '@angular/common';
|
||||||
import { CommonModule, DatePipe } from '@angular/common';
|
import { CommonModule, DatePipe } from '@angular/common';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
|
||||||
|
import { Subject, of } from 'rxjs';
|
||||||
import { HistoryService, SearchHistoryItem, WatchHistoryItem } from '../../../services/history.service';
|
import { HistoryService, SearchHistoryItem, WatchHistoryItem } from '../../../services/history.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -8,7 +12,7 @@ import { HistoryService, SearchHistoryItem, WatchHistoryItem } from '../../../se
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
templateUrl: './history.component.html',
|
templateUrl: './history.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [CommonModule, RouterModule]
|
imports: [CommonModule, RouterModule, FormsModule, NgClass]
|
||||||
})
|
})
|
||||||
export class HistoryComponent {
|
export class HistoryComponent {
|
||||||
private history = inject(HistoryService);
|
private history = inject(HistoryService);
|
||||||
@ -16,22 +20,122 @@ export class HistoryComponent {
|
|||||||
loading = signal<boolean>(false);
|
loading = signal<boolean>(false);
|
||||||
searchHistory = signal<SearchHistoryItem[]>([]);
|
searchHistory = signal<SearchHistoryItem[]>([]);
|
||||||
watchHistory = signal<WatchHistoryItem[]>([]);
|
watchHistory = signal<WatchHistoryItem[]>([]);
|
||||||
|
searchQuery = signal<string>('');
|
||||||
|
isLoading = signal<boolean>(false);
|
||||||
|
deletingSearchId = signal<string | null>(null);
|
||||||
|
deletingWatchId = signal<string | null>(null);
|
||||||
|
|
||||||
|
// Historiques complets
|
||||||
|
filteredSearchHistory = signal<SearchHistoryItem[]>([]);
|
||||||
|
filteredWatchHistory = signal<WatchHistoryItem[]>([]);
|
||||||
|
|
||||||
|
// Sujet pour la recherche avec debounce
|
||||||
|
private searchTerms = new Subject<string>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
// Chargement initial des données
|
||||||
this.reload();
|
this.reload();
|
||||||
|
|
||||||
|
// Configuration de la recherche avec debounce
|
||||||
|
this.searchTerms.pipe(
|
||||||
|
debounceTime(300),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
switchMap((term: string) => {
|
||||||
|
if (!term.trim()) {
|
||||||
|
// Si la recherche est vide, on recharge les 50 premiers éléments
|
||||||
|
return of({ search: [], watch: [] });
|
||||||
|
}
|
||||||
|
this.isLoading.set(true);
|
||||||
|
return this.history.searchInSearchHistory(term).pipe(
|
||||||
|
switchMap(searchResults =>
|
||||||
|
this.history.searchInWatchHistory(term).pipe(
|
||||||
|
switchMap(watchResults =>
|
||||||
|
of({ search: searchResults, watch: watchResults })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
).subscribe({
|
||||||
|
next: ({ search, watch }) => {
|
||||||
|
this.filteredSearchHistory.set(search);
|
||||||
|
this.filteredWatchHistory.set(watch);
|
||||||
|
this.isLoading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.isLoading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Réagir aux changements de la requête de recherche
|
||||||
|
effect(() => {
|
||||||
|
const query = this.searchQuery();
|
||||||
|
if (query === '') {
|
||||||
|
// Si la recherche est vide, on recharge les 50 premiers éléments
|
||||||
|
this.reload();
|
||||||
|
} else {
|
||||||
|
// Sinon on lance la recherche
|
||||||
|
this.searchTerms.next(query);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
reload() {
|
reload() {
|
||||||
this.loading.set(true);
|
this.isLoading.set(true);
|
||||||
this.history.getSearchHistory(50).subscribe({
|
this.history.getSearchHistory(50).subscribe({
|
||||||
next: (items) => this.searchHistory.set(items || []),
|
next: (items) => {
|
||||||
error: () => {},
|
this.searchHistory.set(items || []);
|
||||||
|
if (!this.searchQuery()) {
|
||||||
|
this.filteredSearchHistory.set(items || []);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.isLoading.set(false);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
this.history.getWatchHistory(50).subscribe({
|
this.history.getWatchHistory(50).subscribe({
|
||||||
next: (items) => this.watchHistory.set(items || []),
|
next: (items) => {
|
||||||
error: () => {},
|
this.watchHistory.set(items || []);
|
||||||
|
if (!this.searchQuery()) {
|
||||||
|
this.filteredWatchHistory.set(items || []);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.isLoading.set(false);
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
this.isLoading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delete a single search history item
|
||||||
|
onDeleteSearch(item: SearchHistoryItem): void {
|
||||||
|
if (!item?.id || this.deletingSearchId()) return;
|
||||||
|
this.deletingSearchId.set(item.id);
|
||||||
|
this.history.deleteSearchItem(item.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
// Remove from both full and filtered lists
|
||||||
|
this.searchHistory.update(list => (list || []).filter(x => x.id !== item.id));
|
||||||
|
this.filteredSearchHistory.update(list => (list || []).filter(x => x.id !== item.id));
|
||||||
|
},
|
||||||
|
error: () => {},
|
||||||
|
complete: () => this.deletingSearchId.set(null)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delete a single watch history item
|
||||||
|
onDeleteWatch(item: WatchHistoryItem): void {
|
||||||
|
if (!item?.id || this.deletingWatchId()) return;
|
||||||
|
this.deletingWatchId.set(item.id);
|
||||||
|
this.history.deleteWatchItem(item.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.watchHistory.update(list => (list || []).filter(x => x.id !== item.id));
|
||||||
|
this.filteredWatchHistory.update(list => (list || []).filter(x => x.id !== item.id));
|
||||||
|
},
|
||||||
|
error: () => {},
|
||||||
|
complete: () => this.deletingWatchId.set(null)
|
||||||
});
|
});
|
||||||
this.loading.set(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format a date to a readable string
|
// Format a date to a readable string
|
||||||
@ -55,7 +159,7 @@ export class HistoryComponent {
|
|||||||
const filters = JSON.parse(item.filters_json);
|
const filters = JSON.parse(item.filters_json);
|
||||||
if (filters.provider) {
|
if (filters.provider) {
|
||||||
const providerName = this.formatProviderName(filters.provider);
|
const providerName = this.formatProviderName(filters.provider);
|
||||||
return { name: providerName, id: filters.provider };
|
return { name: providerName, id: String(filters.provider).toLowerCase() };
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error parsing filters_json:', e);
|
console.error('Error parsing filters_json:', e);
|
||||||
@ -68,7 +172,7 @@ export class HistoryComponent {
|
|||||||
const provider = url.searchParams.get('provider');
|
const provider = url.searchParams.get('provider');
|
||||||
if (provider) {
|
if (provider) {
|
||||||
const providerName = this.formatProviderName(provider);
|
const providerName = this.formatProviderName(provider);
|
||||||
return { name: providerName, id: provider };
|
return { name: providerName, id: provider.toLowerCase() };
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Not a valid URL or no provider in query params
|
// Not a valid URL or no provider in query params
|
||||||
@ -77,7 +181,7 @@ export class HistoryComponent {
|
|||||||
// Default to the selected provider if available
|
// Default to the selected provider if available
|
||||||
const defaultProvider = this.getDefaultProvider();
|
const defaultProvider = this.getDefaultProvider();
|
||||||
if (defaultProvider) {
|
if (defaultProvider) {
|
||||||
return { name: this.formatProviderName(defaultProvider), id: defaultProvider };
|
return { name: this.formatProviderName(defaultProvider), id: defaultProvider.toLowerCase() };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to 'all' if no provider can be determined
|
// Fallback to 'all' if no provider can be determined
|
||||||
@ -90,11 +194,11 @@ export class HistoryComponent {
|
|||||||
// Try to get from current URL
|
// Try to get from current URL
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
const provider = url.searchParams.get('provider');
|
const provider = url.searchParams.get('provider');
|
||||||
if (provider) return provider;
|
if (provider) return provider.toLowerCase();
|
||||||
|
|
||||||
// Or get from localStorage if available
|
// Or get from localStorage if available
|
||||||
const savedProvider = localStorage.getItem('selectedProvider');
|
const savedProvider = localStorage.getItem('selectedProvider');
|
||||||
if (savedProvider) return savedProvider;
|
if (savedProvider) return savedProvider.toLowerCase();
|
||||||
|
|
||||||
// Default to youtube if nothing else
|
// Default to youtube if nothing else
|
||||||
return 'youtube';
|
return 'youtube';
|
||||||
@ -115,6 +219,35 @@ export class HistoryComponent {
|
|||||||
return providerMap[providerId.toLowerCase()] || providerId;
|
return providerMap[providerId.toLowerCase()] || providerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expose a public-friendly name for provider IDs for templates
|
||||||
|
getProviderDisplayName(providerId: string | null | undefined): string {
|
||||||
|
if (!providerId) return '';
|
||||||
|
return this.formatProviderName(providerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brand colors for provider badges (inline to avoid Tailwind purge issues)
|
||||||
|
getProviderColors(providerId: string): { [key: string]: string } {
|
||||||
|
const id = (providerId || '').toLowerCase();
|
||||||
|
switch (id) {
|
||||||
|
case 'youtube':
|
||||||
|
return { backgroundColor: 'rgba(220, 38, 38, 0.15)', color: 'rgb(248, 113, 113)', borderColor: 'rgba(239, 68, 68, 0.3)' };
|
||||||
|
case 'vimeo':
|
||||||
|
return { backgroundColor: 'rgba(2, 132, 199, 0.15)', color: 'rgb(125, 211, 252)', borderColor: 'rgba(14, 165, 233, 0.3)' };
|
||||||
|
case 'dailymotion':
|
||||||
|
return { backgroundColor: 'rgba(37, 99, 235, 0.15)', color: 'rgb(147, 197, 253)', borderColor: 'rgba(59, 130, 246, 0.3)' };
|
||||||
|
case 'peertube':
|
||||||
|
return { backgroundColor: 'rgba(245, 158, 11, 0.15)', color: 'rgb(252, 211, 77)', borderColor: 'rgba(245, 158, 11, 0.3)' };
|
||||||
|
case 'rumble':
|
||||||
|
return { backgroundColor: 'rgba(22, 163, 74, 0.15)', color: 'rgb(134, 239, 172)', borderColor: 'rgba(34, 197, 94, 0.3)' };
|
||||||
|
case 'twitch':
|
||||||
|
return { backgroundColor: 'rgba(168, 85, 247, 0.15)', color: 'rgb(216, 180, 254)', borderColor: 'rgba(168, 85, 247, 0.3)' };
|
||||||
|
case 'odysee':
|
||||||
|
return { backgroundColor: 'rgba(236, 72, 153, 0.15)', color: 'rgb(251, 207, 232)', borderColor: 'rgba(236, 72, 153, 0.3)' };
|
||||||
|
default:
|
||||||
|
return { backgroundColor: 'rgba(30, 41, 59, 0.8)', color: 'rgb(203, 213, 225)', borderColor: 'rgb(51, 65, 85)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Format seconds to HH:MM:SS or MM:SS
|
// Format seconds to HH:MM:SS or MM:SS
|
||||||
formatDuration(seconds: number): string {
|
formatDuration(seconds: number): string {
|
||||||
if (!seconds && seconds !== 0) return '00:00';
|
if (!seconds && seconds !== 0) return '00:00';
|
||||||
|
@ -129,9 +129,11 @@
|
|||||||
<div class="justify-self-end flex items-center gap-3 pr-3 md:pr-4">
|
<div class="justify-self-end flex items-center gap-3 pr-3 md:pr-4">
|
||||||
<!-- Provider context indicator placed between search and user area -->
|
<!-- Provider context indicator placed between search and user area -->
|
||||||
<div *ngIf="providerContextLabel()" class="flex items-center gap-1 text-xs">
|
<div *ngIf="providerContextLabel()" class="flex items-center gap-1 text-xs">
|
||||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-yellow-400/30 border border-yellow-400/40 shadow-sm"
|
<span class="inline-flex items-center px-2 py-0.5 rounded border shadow-sm gap-1"
|
||||||
[ngClass]="{ 'text-black': isLightTheme(), 'text-yellow-200': !isLightTheme() }">
|
[ngStyle]="getProviderColors(providerContext())">
|
||||||
<span class="inline-block w-2 h-2 rounded-full bg-yellow-300 animate-pulse"></span>
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||||
|
</svg>
|
||||||
<span>{{ providerContextLabel() }}</span>
|
<span>{{ providerContextLabel() }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -284,6 +284,29 @@ export class HeaderComponent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Brand colors for provider badge (inline styles to match History badges)
|
||||||
|
getProviderColors(providerId: string | null | undefined): { [key: string]: string } {
|
||||||
|
const id = (providerId || '').toLowerCase();
|
||||||
|
switch (id) {
|
||||||
|
case 'youtube':
|
||||||
|
return { backgroundColor: 'rgba(220, 38, 38, 0.15)', color: 'rgb(248, 113, 113)', borderColor: 'rgba(239, 68, 68, 0.3)' };
|
||||||
|
case 'vimeo':
|
||||||
|
return { backgroundColor: 'rgba(2, 132, 199, 0.15)', color: 'rgb(125, 211, 252)', borderColor: 'rgba(14, 165, 233, 0.3)' };
|
||||||
|
case 'dailymotion':
|
||||||
|
return { backgroundColor: 'rgba(37, 99, 235, 0.15)', color: 'rgb(147, 197, 253)', borderColor: 'rgba(59, 130, 246, 0.3)' };
|
||||||
|
case 'peertube':
|
||||||
|
return { backgroundColor: 'rgba(245, 158, 11, 0.15)', color: 'rgb(252, 211, 77)', borderColor: 'rgba(245, 158, 11, 0.3)' };
|
||||||
|
case 'rumble':
|
||||||
|
return { backgroundColor: 'rgba(22, 163, 74, 0.15)', color: 'rgb(134, 239, 172)', borderColor: 'rgba(34, 197, 94, 0.3)' };
|
||||||
|
case 'twitch':
|
||||||
|
return { backgroundColor: 'rgba(168, 85, 247, 0.15)', color: 'rgb(216, 180, 254)', borderColor: 'rgba(168, 85, 247, 0.3)' };
|
||||||
|
case 'odysee':
|
||||||
|
return { backgroundColor: 'rgba(236, 72, 153, 0.15)', color: 'rgb(251, 207, 232)', borderColor: 'rgba(236, 72, 153, 0.3)' };
|
||||||
|
default:
|
||||||
|
return { backgroundColor: 'rgba(30, 41, 59, 0.8)', color: 'rgb(203, 213, 225)', borderColor: 'rgb(51, 65, 85)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to show the divider before the first generated item
|
// Helper to show the divider before the first generated item
|
||||||
isFirstGenerated(index: number): boolean {
|
isFirstGenerated(index: number): boolean {
|
||||||
const arr = this.suggestionItems();
|
const arr = this.suggestionItems();
|
||||||
@ -304,9 +327,24 @@ export class HeaderComponent {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updateProviderContext(provider: Provider) {
|
||||||
|
if (this.instances.providers().some(p => p.id === provider)) {
|
||||||
|
this.providerContext.set(provider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
// Initialize and keep provider context in sync with URL
|
// Initialize and keep provider context in sync with URL
|
||||||
this.updateProviderContextFromUrl(this.router.url);
|
this.updateProviderContextFromUrl(this.router.url);
|
||||||
|
|
||||||
|
// Écouter les mises à jour de provider depuis d'autres composants
|
||||||
|
document.addEventListener('updateProviderContext', (event: Event) => {
|
||||||
|
const customEvent = event as CustomEvent<Provider>;
|
||||||
|
if (customEvent.detail) {
|
||||||
|
this.updateProviderContext(customEvent.detail);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.router.events.subscribe(evt => {
|
this.router.events.subscribe(evt => {
|
||||||
if (evt instanceof NavigationEnd) {
|
if (evt instanceof NavigationEnd) {
|
||||||
this.updateProviderContextFromUrl(evt.urlAfterRedirects || evt.url);
|
this.updateProviderContextFromUrl(evt.urlAfterRedirects || evt.url);
|
||||||
@ -323,7 +361,8 @@ export class HeaderComponent {
|
|||||||
const qIdx = afterHash.indexOf('?');
|
const qIdx = afterHash.indexOf('?');
|
||||||
const query = qIdx >= 0 ? afterHash.substring(qIdx + 1) : '';
|
const query = qIdx >= 0 ? afterHash.substring(qIdx + 1) : '';
|
||||||
const params = new URLSearchParams(query);
|
const params = new URLSearchParams(query);
|
||||||
let provider = (params.get('provider') || '').trim() as Provider;
|
// Support both ?provider= and legacy/alt ?p=
|
||||||
|
let provider = ((params.get('provider') || params.get('p') || '').trim()) as Provider;
|
||||||
|
|
||||||
// If not provided via query, try path format /p/:provider/... (supports hash and non-hash routing)
|
// If not provided via query, try path format /p/:provider/... (supports hash and non-hash routing)
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
@ -338,7 +377,7 @@ export class HeaderComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate against known providers list; if none and we're on Shorts, fall back to selected provider
|
// Validate against known providers list; if none and we're on Shorts/Watch, fall back to selected provider
|
||||||
const known = this.providers().map(p => p.id);
|
const known = this.providers().map(p => p.id);
|
||||||
if (provider && known.includes(provider)) {
|
if (provider && known.includes(provider)) {
|
||||||
this.providerContext.set(provider);
|
this.providerContext.set(provider);
|
||||||
@ -353,6 +392,14 @@ export class HeaderComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// If current path looks like '/watch', also fall back to selected provider to show a consistent badge
|
||||||
|
if (segs.length > 0 && segs[0] === 'watch') {
|
||||||
|
const fallback = this.selectedProvider();
|
||||||
|
if (fallback && known.includes(fallback)) {
|
||||||
|
this.providerContext.set(fallback);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
this.providerContext.set(null);
|
this.providerContext.set(null);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -1,6 +1,92 @@
|
|||||||
<div class="container mx-auto p-4 sm:p-6">
|
<div class="container mx-auto p-4 sm:p-6 max-w-6xl">
|
||||||
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">Vidéos que vous aimez</h2>
|
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4 flex items-center">
|
||||||
|
<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>
|
||||||
|
|
||||||
<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">
|
||||||
<p class="text-slate-300">Ici apparaîtront vos vidéos aimées (tous providers). Fonctionnalité à venir.</p>
|
<!-- Barre de recherche -->
|
||||||
|
<div class="mb-4 relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[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..."
|
||||||
|
/>
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
<button
|
||||||
|
*ngIf="searchQuery()"
|
||||||
|
(click)="clearSearch()"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 px-2 py-1 text-xs rounded bg-slate-600 hover:bg-slate-500 text-white"
|
||||||
|
aria-label="Effacer la recherche"
|
||||||
|
>
|
||||||
|
Effacer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="text-slate-400">Chargement…</div>
|
||||||
|
} @else if (error(); as err) {
|
||||||
|
<div class="text-red-300">{{ err }}</div>
|
||||||
|
} @else if (items().length === 0) {
|
||||||
|
<div class="text-slate-400">Aucune vidéo aimée pour le moment.</div>
|
||||||
|
} @else {
|
||||||
|
<ul class="space-y-3">
|
||||||
|
@for (it of items(); track it.provider + ':' + it.video_id) {
|
||||||
|
<li class="group bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all">
|
||||||
|
<a [routerLink]="['/watch', it.video_id]" [queryParams]="{ p: it.provider }" class="block p-4 hover:no-underline">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<!-- Miniature de la vidéo -->
|
||||||
|
<div class="relative flex-shrink-0 w-24 h-16 bg-slate-800 rounded overflow-hidden mr-4">
|
||||||
|
@if (it.thumbnail) {
|
||||||
|
<img [src]="it.thumbnail" alt="" class="w-full h-full object-cover" />
|
||||||
|
} @else {
|
||||||
|
<div class="w-full h-full flex items-center justify-center bg-gradient-to-br from-slate-700 to-slate-800">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded border text-xs"
|
||||||
|
[ngStyle]="getProviderColors(it.provider)">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||||
|
</svg>
|
||||||
|
{{ getProviderDisplayName(it.provider) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Détails de la vidéo -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-slate-100 font-medium group-hover:text-red-400 truncate">
|
||||||
|
{{ it.title || (it.provider + ':' + it.video_id) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-400 mt-1">
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded border"
|
||||||
|
[ngStyle]="getProviderColors(it.provider)">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||||
|
</svg>
|
||||||
|
{{ getProviderDisplayName(it.provider) }}
|
||||||
|
</span>
|
||||||
|
@if (it.last_watched_at) {
|
||||||
|
<span>Vu le {{ it.last_watched_at | date:'short' }}</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flèche -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 flex-shrink-0 text-slate-400 group-hover:text-slate-200 ml-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,11 +1,93 @@
|
|||||||
import { ChangeDetectionStrategy, Component } 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 { FormsModule } from '@angular/forms';
|
||||||
|
import { LikesService, LikedVideoItem } from '../../../services/likes.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-library-liked',
|
selector: 'app-library-liked',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule, RouterLink, FormsModule],
|
||||||
templateUrl: './liked.component.html',
|
templateUrl: './liked.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class LikedComponent { }
|
export class LikedComponent {
|
||||||
|
private likes = inject(LikesService);
|
||||||
|
|
||||||
|
loading = signal<boolean>(true);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
items = signal<LikedVideoItem[]>([]);
|
||||||
|
searchQuery = signal<string>('');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(q?: string): void {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
|
this.likes.list(200, q).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';
|
||||||
|
this.error.set(String(msg));
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearchChange(value: string) {
|
||||||
|
const q = (value || '').trim();
|
||||||
|
this.searchQuery.set(value);
|
||||||
|
this.refresh(q || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSearch() {
|
||||||
|
this.searchQuery.set('');
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Provider helpers (match History/Header badges)
|
||||||
|
private formatProviderName(providerId: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
youtube: 'YouTube',
|
||||||
|
vimeo: 'Vimeo',
|
||||||
|
dailymotion: 'Dailymotion',
|
||||||
|
peertube: 'PeerTube',
|
||||||
|
rumble: 'Rumble',
|
||||||
|
twitch: 'Twitch',
|
||||||
|
odysee: 'Odysee',
|
||||||
|
};
|
||||||
|
return map[(providerId || '').toLowerCase()] || providerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProviderDisplayName(providerId: string | null | undefined): string {
|
||||||
|
if (!providerId) return '';
|
||||||
|
return this.formatProviderName(providerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getProviderColors(providerId: string | null | undefined): { [key: string]: string } {
|
||||||
|
const id = (providerId || '').toLowerCase();
|
||||||
|
switch (id) {
|
||||||
|
case 'youtube':
|
||||||
|
return { backgroundColor: 'rgba(220, 38, 38, 0.15)', color: 'rgb(248, 113, 113)', borderColor: 'rgba(239, 68, 68, 0.3)' };
|
||||||
|
case 'vimeo':
|
||||||
|
return { backgroundColor: 'rgba(2, 132, 199, 0.15)', color: 'rgb(125, 211, 252)', borderColor: 'rgba(14, 165, 233, 0.3)' };
|
||||||
|
case 'dailymotion':
|
||||||
|
return { backgroundColor: 'rgba(37, 99, 235, 0.15)', color: 'rgb(147, 197, 253)', borderColor: 'rgba(59, 130, 246, 0.3)' };
|
||||||
|
case 'peertube':
|
||||||
|
return { backgroundColor: 'rgba(245, 158, 11, 0.15)', color: 'rgb(252, 211, 77)', borderColor: 'rgba(245, 158, 11, 0.3)' };
|
||||||
|
case 'rumble':
|
||||||
|
return { backgroundColor: 'rgba(22, 163, 74, 0.15)', color: 'rgb(134, 239, 172)', borderColor: 'rgba(34, 197, 94, 0.3)' };
|
||||||
|
case 'twitch':
|
||||||
|
return { backgroundColor: 'rgba(168, 85, 247, 0.15)', color: 'rgb(216, 180, 254)', borderColor: 'rgba(168, 85, 247, 0.3)' };
|
||||||
|
case 'odysee':
|
||||||
|
return { backgroundColor: 'rgba(236, 72, 153, 0.15)', color: 'rgb(251, 207, 232)', borderColor: 'rgba(236, 72, 153, 0.3)' };
|
||||||
|
default:
|
||||||
|
return { backgroundColor: 'rgba(30, 41, 59, 0.8)', color: 'rgb(203, 213, 225)', borderColor: 'rgb(51, 65, 85)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -36,14 +36,6 @@
|
|||||||
<app-video-player [videoSource]="videoSource()"></app-video-player>
|
<app-video-player [videoSource]="videoSource()"></app-video-player>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (provider() === 'twitch') {
|
|
||||||
<div class="mt-2">
|
|
||||||
<a [href]="twitchOpenUrl()" target="_blank" rel="noopener noreferrer"
|
|
||||||
class="inline-flex items-center gap-2 text-sm text-slate-300 hover:text-white underline">
|
|
||||||
Ouvrir sur Twitch
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
@if (availableQualities().length > 0) {
|
@if (availableQualities().length > 0) {
|
||||||
@ -86,6 +78,18 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.787l.09.044a2 2 0 002.253-.334l.09-.092l3.535-3.536a2 2 0 00.586-1.414V5a2 2 0 00-2-2H9a2 2 0 00-2 2v5.333z" /></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.787l.09.044a2 2 0 002.253-.334l.09-.092l3.535-3.536a2 2 0 00.586-1.414V5a2 2 0 00-2-2H9a2 2 0 00-2 2v5.333z" /></svg>
|
||||||
<span>{{ formatNumber(v.likes) }}</span>
|
<span>{{ formatNumber(v.likes) }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
@if (isLoggedIn()) {
|
||||||
|
<button (click)="toggleLike()"
|
||||||
|
[disabled]="likeBusy()"
|
||||||
|
[class.bg-red-600]="liked()"
|
||||||
|
[class.hover\:bg-red-500]="liked()"
|
||||||
|
class="flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 disabled:opacity-60 disabled:cursor-not-allowed px-4 py-2 rounded-full transition font-semibold">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" [attr.fill]="liked() ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21s-6.716-4.248-9.193-6.725A6 6 0 0112 5.414 6 6 0 0121.193 14.275C18.716 16.752 12 21 12 21z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ liked() ? 'Liked' : 'Like' }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
@if (isLoggedIn()) {
|
@if (isLoggedIn()) {
|
||||||
<button (click)="openDownloadPanel()"
|
<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">
|
class="flex items-center space-x-2 bg-red-600 hover:bg-red-500 px-4 py-2 rounded-full transition font-semibold">
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject, signal, computed, OnDestroy } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject, signal, computed, OnDestroy } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { YoutubeApiService } from '../../services/youtube-api.service';
|
import { YoutubeApiService } from '../../services/youtube-api.service';
|
||||||
import { Video, VideoDetail } from '../../models/video.model';
|
import { Video, VideoDetail } from '../../models/video.model';
|
||||||
import { VideoPlayerComponent } from '../video-player/video-player.component';
|
import { VideoPlayerComponent } from '../video-player/video-player.component';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { GeminiService } from '../../services/gemini.service';
|
import { GeminiService } from '../../services/gemini.service';
|
||||||
|
import { LikesService } from '../../services/likes.service';
|
||||||
import { HistoryService } from '../../services/history.service';
|
import { HistoryService } from '../../services/history.service';
|
||||||
import { InstanceService } from '../../services/instance.service';
|
import { InstanceService, Provider } from '../../services/instance.service';
|
||||||
import { DownloadService, DownloadFormat, DownloadJob } from '../../services/download.service';
|
import { DownloadService, DownloadFormat, DownloadJob } from '../../services/download.service';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
@ -23,12 +24,14 @@ import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
|
|||||||
})
|
})
|
||||||
export class WatchComponent implements OnDestroy {
|
export class WatchComponent implements OnDestroy {
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
private apiService = inject(YoutubeApiService);
|
private apiService = inject(YoutubeApiService);
|
||||||
private geminiService = inject(GeminiService);
|
private geminiService = inject(GeminiService);
|
||||||
private instances = inject(InstanceService);
|
private instances = inject(InstanceService);
|
||||||
private sanitizer = inject(DomSanitizer);
|
private sanitizer = inject(DomSanitizer);
|
||||||
private history = inject(HistoryService);
|
private history = inject(HistoryService);
|
||||||
private downloads = inject(DownloadService);
|
private downloads = inject(DownloadService);
|
||||||
|
private likes = inject(LikesService);
|
||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private routeSubscription: Subscription;
|
private routeSubscription: Subscription;
|
||||||
@ -143,6 +146,10 @@ export class WatchComponent implements OnDestroy {
|
|||||||
jobFileSize = signal<number | null>(null);
|
jobFileSize = signal<number | null>(null);
|
||||||
private jobPoller: any = null;
|
private jobPoller: any = null;
|
||||||
|
|
||||||
|
// --- Like state ---
|
||||||
|
liked = signal<boolean>(false);
|
||||||
|
likeBusy = signal<boolean>(false);
|
||||||
|
|
||||||
// Route params for building embeds
|
// Route params for building embeds
|
||||||
private videoId = signal<string>('');
|
private videoId = signal<string>('');
|
||||||
private odyseeSlug = signal<string | null>(null);
|
private odyseeSlug = signal<string | null>(null);
|
||||||
@ -207,13 +214,31 @@ export class WatchComponent implements OnDestroy {
|
|||||||
const id = params.get('id');
|
const id = params.get('id');
|
||||||
const providerFromPath = params.get('provider');
|
const providerFromPath = params.get('provider');
|
||||||
const slug = this.route.snapshot.queryParamMap.get('slug');
|
const slug = this.route.snapshot.queryParamMap.get('slug');
|
||||||
const p = this.route.snapshot.queryParamMap.get('p');
|
const p = (this.route.snapshot.queryParamMap.get('p') || providerFromPath) as Provider | null;
|
||||||
const ch = this.route.snapshot.queryParamMap.get('channel');
|
const ch = this.route.snapshot.queryParamMap.get('channel');
|
||||||
if (id) {
|
if (id) {
|
||||||
this.videoId.set(id);
|
this.videoId.set(id);
|
||||||
this.odyseeSlug.set(slug);
|
this.odyseeSlug.set(slug);
|
||||||
this.providerSel.set(p || providerFromPath || '');
|
this.providerSel.set(p || '');
|
||||||
this.twitchChannel.set(ch);
|
this.twitchChannel.set(ch);
|
||||||
|
|
||||||
|
// Choisir un provider final (paramètre présent, segment d'URL, ou fallback depuis état courant)
|
||||||
|
let finalProvider = p as Provider | null;
|
||||||
|
if (!finalProvider) {
|
||||||
|
try { finalProvider = this.provider() as Provider; } catch { finalProvider = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour le fournisseur sélectionné dans le service et synchroniser le header
|
||||||
|
if (finalProvider && this.instances.providers().some(prov => prov.id === finalProvider)) {
|
||||||
|
this.instances.setSelectedProvider(finalProvider);
|
||||||
|
// Mettre à jour l'URL pour que le Header détecte le provider via son parsing
|
||||||
|
try {
|
||||||
|
this.router.navigate([], { queryParams: { p: finalProvider }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||||
|
} catch {}
|
||||||
|
// En complément, émettre un événement pour les cas où l'URL n'est pas suffisante
|
||||||
|
document.dispatchEvent(new CustomEvent('updateProviderContext', { detail: finalProvider }));
|
||||||
|
}
|
||||||
|
|
||||||
this.loadVideo(id);
|
this.loadVideo(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -223,6 +248,39 @@ export class WatchComponent implements OnDestroy {
|
|||||||
this.routeSubscription.unsubscribe();
|
this.routeSubscription.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle like status for the current video
|
||||||
|
toggleLike(): void {
|
||||||
|
if (!this.isLoggedIn()) return;
|
||||||
|
|
||||||
|
const provider = this.provider();
|
||||||
|
const videoId = this.videoId();
|
||||||
|
if (!provider || !videoId || this.likeBusy()) return;
|
||||||
|
|
||||||
|
this.likeBusy.set(true);
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
this.likeBusy.set(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.liked()) {
|
||||||
|
this.likes.unlike(provider, videoId).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.liked.set(false);
|
||||||
|
this.likeBusy.set(false);
|
||||||
|
},
|
||||||
|
error: handleError
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.likes.like(provider, videoId).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.liked.set(true);
|
||||||
|
this.likeBusy.set(false);
|
||||||
|
},
|
||||||
|
error: handleError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadVideo(id: string) {
|
loadVideo(id: string) {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
// Prepare placeholder from router state if available for richer UI
|
// Prepare placeholder from router state if available for richer UI
|
||||||
@ -256,6 +314,9 @@ export class WatchComponent implements OnDestroy {
|
|||||||
this.summaryError.set(null);
|
this.summaryError.set(null);
|
||||||
this.selectedQuality.set(null);
|
this.selectedQuality.set(null);
|
||||||
this.resetDownloadUi();
|
this.resetDownloadUi();
|
||||||
|
// Reset like state while loading
|
||||||
|
this.liked.set(false);
|
||||||
|
this.likeBusy.set(false);
|
||||||
const provider = this.provider();
|
const provider = this.provider();
|
||||||
if (provider === 'rumble') {
|
if (provider === 'rumble') {
|
||||||
// Validate and enrich Rumble video via backend scraping (may also normalize id)
|
// Validate and enrich Rumble video via backend scraping (may also normalize id)
|
||||||
@ -281,6 +342,15 @@ export class WatchComponent implements OnDestroy {
|
|||||||
} : v);
|
} : v);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.loadRelatedSuggestions();
|
this.loadRelatedSuggestions();
|
||||||
|
// Update watch history with enriched title/thumbnail
|
||||||
|
try {
|
||||||
|
const provider = this.provider();
|
||||||
|
const vid = this.videoId();
|
||||||
|
const v = this.video();
|
||||||
|
const title = v?.title || '';
|
||||||
|
const thumbnail = v?.thumbnail || '';
|
||||||
|
if (provider && (vid || id)) this.history.recordWatchStart(provider, vid || id, title, undefined, thumbnail).subscribe({ next: () => {}, error: () => {} });
|
||||||
|
} catch {}
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
// Even if details fail, try to display embed with the original id
|
// Even if details fail, try to display embed with the original id
|
||||||
@ -306,6 +376,14 @@ export class WatchComponent implements OnDestroy {
|
|||||||
} : v);
|
} : v);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.loadRelatedSuggestions();
|
this.loadRelatedSuggestions();
|
||||||
|
// Update watch history with enriched title/thumbnail
|
||||||
|
try {
|
||||||
|
const provider = this.provider();
|
||||||
|
const v = this.video();
|
||||||
|
const title = v?.title || '';
|
||||||
|
const thumbnail = v?.thumbnail || '';
|
||||||
|
this.history.recordWatchStart(provider, id, title, undefined, thumbnail).subscribe({ next: () => {}, error: () => {} });
|
||||||
|
} catch {}
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
// Fallback to existing placeholder
|
// Fallback to existing placeholder
|
||||||
@ -319,7 +397,19 @@ export class WatchComponent implements OnDestroy {
|
|||||||
try {
|
try {
|
||||||
const provider = this.provider();
|
const provider = this.provider();
|
||||||
const title = this.video()?.title || '';
|
const title = this.video()?.title || '';
|
||||||
this.history.recordWatchStart(provider, id, title).subscribe({ next: () => {}, error: () => {} });
|
const thumbnail = this.video()?.thumbnail || '';
|
||||||
|
this.history.recordWatchStart(provider, id, title, undefined, thumbnail).subscribe({ next: () => {}, error: () => {} });
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Load like status for this video (only if logged in)
|
||||||
|
try {
|
||||||
|
if (this.isLoggedIn()) {
|
||||||
|
const p = this.provider();
|
||||||
|
const vid = this.videoId();
|
||||||
|
if (p && vid) {
|
||||||
|
this.likes.isLiked(p, vid).subscribe({ next: (res) => this.liked.set(!!res?.liked), error: () => this.liked.set(false) });
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,6 +19,7 @@ export interface WatchHistoryItem {
|
|||||||
duration_seconds: number;
|
duration_seconds: number;
|
||||||
last_position_seconds: number;
|
last_position_seconds: number;
|
||||||
last_watched_at?: string;
|
last_watched_at?: string;
|
||||||
|
thumbnail?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@ -42,10 +43,10 @@ export class HistoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Watch ---
|
// --- Watch ---
|
||||||
recordWatchStart(provider: string, videoId: string, title?: string | null, watchedAt?: string): Observable<WatchHistoryItem> {
|
recordWatchStart(provider: string, videoId: string, title?: string | null, watchedAt?: string, thumbnail?: string | null): Observable<WatchHistoryItem> {
|
||||||
return this.http.post<WatchHistoryItem>(
|
return this.http.post<WatchHistoryItem>(
|
||||||
'/proxy/api/user/history/watch',
|
'/proxy/api/user/history/watch',
|
||||||
{ provider, videoId, title: title ?? null, watchedAt },
|
{ provider, videoId, title: title ?? null, watchedAt, thumbnail: thumbnail ?? null },
|
||||||
{ withCredentials: true }
|
{ withCredentials: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -64,4 +65,35 @@ export class HistoryService {
|
|||||||
if (before) params.set('before', before);
|
if (before) params.set('before', before);
|
||||||
return this.http.get<WatchHistoryItem[]>(`/proxy/api/user/history/watch?${params.toString()}`, { withCredentials: true });
|
return this.http.get<WatchHistoryItem[]>(`/proxy/api/user/history/watch?${params.toString()}`, { withCredentials: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Delete single items ---
|
||||||
|
deleteSearchItem(id: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(`/proxy/api/user/history/search/${encodeURIComponent(id)}`, { withCredentials: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteWatchItem(id: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(`/proxy/api/user/history/watch/${encodeURIComponent(id)}`, { withCredentials: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recherche dans l'historique des recherches
|
||||||
|
searchInSearchHistory(query: string, limit = 100): Observable<SearchHistoryItem[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('q', query);
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
return this.http.get<SearchHistoryItem[]>(
|
||||||
|
`/proxy/api/user/history/search?${params.toString()}`,
|
||||||
|
{ withCredentials: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recherche dans l'historique de visionnage
|
||||||
|
searchInWatchHistory(query: string, limit = 100): Observable<WatchHistoryItem[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('q', query);
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
return this.http.get<WatchHistoryItem[]>(
|
||||||
|
`/proxy/api/user/history/watch?${params.toString()}`,
|
||||||
|
{ withCredentials: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
49
src/services/likes.service.ts
Normal file
49
src/services/likes.service.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
export interface LikedVideoItem {
|
||||||
|
provider: string;
|
||||||
|
video_id: string;
|
||||||
|
title?: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
last_watched_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class LikesService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
private apiBase(): string {
|
||||||
|
try {
|
||||||
|
const port = window?.location?.port || '';
|
||||||
|
return port && port !== '4000' ? '/proxy/api' : '/api';
|
||||||
|
} catch {
|
||||||
|
return '/api';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list(limit = 100, q?: string): Observable<LikedVideoItem[]> {
|
||||||
|
let params = new HttpParams().set('limit', String(limit));
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
like(provider: string, videoId: string): Observable<{ provider: string; video_id: string }> {
|
||||||
|
return this.http.post<{ provider: string; video_id: string }>(
|
||||||
|
`${this.apiBase()}/user/likes`,
|
||||||
|
{ provider, videoId },
|
||||||
|
{ withCredentials: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
unlike(provider: string, videoId: string): Observable<{ removed: boolean }> {
|
||||||
|
const params = new HttpParams().set('provider', provider).set('videoId', videoId);
|
||||||
|
return this.http.delete<{ removed: boolean }>(`${this.apiBase()}/user/likes`, { params, withCredentials: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
isLiked(provider: string, videoId: string): Observable<{ liked: boolean }> {
|
||||||
|
const params = new HttpParams().set('provider', provider).set('videoId', videoId);
|
||||||
|
return this.http.get<{ liked: boolean }>(`${this.apiBase()}/user/likes/status`, { params, withCredentials: true });
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user