feat(history): refonte page historique en tabs + transcripts conserves et rejoues sans regeneration
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
-- Historique des transcripts générés (conservés par utilisateur/vidéo/langue).
|
||||
CREATE TABLE IF NOT EXISTS transcript_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
video_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
thumbnail TEXT,
|
||||
lang TEXT NOT NULL,
|
||||
languages_json TEXT,
|
||||
lines_json TEXT NOT NULL DEFAULT '[]',
|
||||
line_count INTEGER NOT NULL DEFAULT 0,
|
||||
char_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_transcript_history_user_video_lang
|
||||
ON transcript_history(user_id, provider, video_id, lang);
|
||||
CREATE INDEX IF NOT EXISTS idx_transcript_history_user_time
|
||||
ON transcript_history(user_id, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_transcript_history_user_provider
|
||||
ON transcript_history(user_id, provider);
|
||||
@@ -85,6 +85,29 @@ CREATE TABLE IF NOT EXISTS watch_history (
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_watch_history_user_video ON watch_history(user_id, provider, video_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_watch_history_user_time ON watch_history(user_id, watched_at DESC);
|
||||
|
||||
-- Transcript history (transcripts générés et conservés) --------------------
|
||||
CREATE TABLE IF NOT EXISTS transcript_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
video_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
thumbnail TEXT,
|
||||
lang TEXT NOT NULL,
|
||||
languages_json TEXT,
|
||||
lines_json TEXT NOT NULL DEFAULT '[]',
|
||||
line_count INTEGER NOT NULL DEFAULT 0,
|
||||
char_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_transcript_history_user_video_lang
|
||||
ON transcript_history(user_id, provider, video_id, lang);
|
||||
CREATE INDEX IF NOT EXISTS idx_transcript_history_user_time
|
||||
ON transcript_history(user_id, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_transcript_history_user_provider
|
||||
ON transcript_history(user_id, provider);
|
||||
|
||||
-- Channels cache & subscriptions --------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
+172
-65
@@ -392,40 +392,23 @@ export function insertSearchHistory({ userId, query, filters }) {
|
||||
return { id, created_at };
|
||||
}
|
||||
|
||||
export function listSearchHistory({ userId, limit = 50, before, q }) {
|
||||
const hasBefore = Boolean(before);
|
||||
const hasQ = typeof q === 'string' && q.trim().length > 0;
|
||||
const like = `%${(q || '').trim()}%`;
|
||||
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);
|
||||
export function listSearchHistory({ userId, limit = 50, before, q, provider }) {
|
||||
const lim = Math.max(1, Math.min(200, Number(limit) || 50));
|
||||
const conds = ['user_id = ?'];
|
||||
const params = [userId];
|
||||
if (before) { conds.push('created_at < ?'); params.push(String(before)); }
|
||||
if (typeof q === 'string' && q.trim().length > 0) {
|
||||
const like = `%${q.trim()}%`;
|
||||
conds.push(`(query LIKE ? OR COALESCE(filters_json,'') LIKE ?)`);
|
||||
params.push(like, like);
|
||||
}
|
||||
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 (provider && String(provider).trim()) {
|
||||
const likeP = `%${String(provider).trim()}%`;
|
||||
conds.push(`COALESCE(filters_json,'') LIKE ?`);
|
||||
params.push(likeP);
|
||||
}
|
||||
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);
|
||||
const where = `WHERE ${conds.join(' AND ')}`;
|
||||
return db.prepare(`SELECT * FROM search_history ${where} ORDER BY created_at DESC LIMIT ?`).all(...params, lim);
|
||||
}
|
||||
|
||||
export function deleteSearchHistoryById(userId, id) {
|
||||
@@ -468,41 +451,22 @@ export function updateWatchHistoryById(id, { progressSeconds, lastPositionSecond
|
||||
return db.prepare(`SELECT * FROM watch_history WHERE id = ?`).get(id);
|
||||
}
|
||||
|
||||
export function listWatchHistory({ userId, limit = 50, before, q }) {
|
||||
const hasBefore = Boolean(before);
|
||||
const hasQ = typeof q === 'string' && q.trim().length > 0;
|
||||
const like = `%${(q || '').trim()}%`;
|
||||
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);
|
||||
export function listWatchHistory({ userId, limit = 50, before, q, provider }) {
|
||||
const lim = Math.max(1, Math.min(200, Number(limit) || 50));
|
||||
const conds = ['user_id = ?'];
|
||||
const params = [userId];
|
||||
if (before) { conds.push('watched_at < ?'); params.push(String(before)); }
|
||||
if (provider && String(provider).trim()) {
|
||||
conds.push('provider = ?');
|
||||
params.push(String(provider).trim().toLowerCase());
|
||||
}
|
||||
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 (typeof q === 'string' && q.trim().length > 0) {
|
||||
const like = `%${q.trim()}%`;
|
||||
conds.push(`(COALESCE(title,'') LIKE ? OR provider LIKE ? OR video_id LIKE ?)`);
|
||||
params.push(like, like, like);
|
||||
}
|
||||
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);
|
||||
const where = `WHERE ${conds.join(' AND ')}`;
|
||||
return db.prepare(`SELECT * FROM watch_history ${where} ORDER BY watched_at DESC LIMIT ?`).all(...params, lim);
|
||||
}
|
||||
|
||||
export function deleteWatchHistoryById(userId, id) {
|
||||
@@ -513,6 +477,149 @@ export function deleteAllWatchHistory(userId) {
|
||||
db.prepare(`DELETE FROM watch_history WHERE user_id = ?`).run(userId);
|
||||
}
|
||||
|
||||
// -------------------- Transcript History --------------------
|
||||
// Conserve les transcripts générés pour les rejouer sans régénération.
|
||||
function ensureTranscriptHistoryTable() {
|
||||
try {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS transcript_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
video_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
thumbnail TEXT,
|
||||
lang TEXT NOT NULL,
|
||||
languages_json TEXT,
|
||||
lines_json TEXT NOT NULL DEFAULT '[]',
|
||||
line_count INTEGER NOT NULL DEFAULT 0,
|
||||
char_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);`);
|
||||
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_transcript_history_user_video_lang
|
||||
ON transcript_history(user_id, provider, video_id, lang);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_transcript_history_user_time
|
||||
ON transcript_history(user_id, updated_at DESC);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_transcript_history_user_provider
|
||||
ON transcript_history(user_id, provider);`);
|
||||
} catch {}
|
||||
}
|
||||
ensureTranscriptHistoryTable();
|
||||
|
||||
function transcriptRowToApi(row) {
|
||||
if (!row) return null;
|
||||
let lines = [];
|
||||
let languages = [];
|
||||
try { lines = JSON.parse(row.lines_json || '[]'); if (!Array.isArray(lines)) lines = []; } catch { lines = []; }
|
||||
try { languages = JSON.parse(row.languages_json || '[]'); if (!Array.isArray(languages)) languages = []; } catch { languages = []; }
|
||||
const preview = lines.slice(0, 3).map((l) => String(l?.text || '')).join(' ').slice(0, 280);
|
||||
return {
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
video_id: row.video_id,
|
||||
title: row.title,
|
||||
thumbnail: row.thumbnail,
|
||||
lang: row.lang,
|
||||
languages,
|
||||
line_count: row.line_count,
|
||||
char_count: row.char_count,
|
||||
preview,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
export function upsertTranscriptHistory({ userId, provider, videoId, title, thumbnail, lang, languages, lines }) {
|
||||
ensureTranscriptHistoryTable();
|
||||
if (!userId || !provider || !videoId || !lang) return null;
|
||||
const cleanLines = Array.isArray(lines) ? lines.slice(0, 2000).map((l) => ({
|
||||
t: Number(l?.t) || 0,
|
||||
dur: Number(l?.dur) || 0,
|
||||
text: String(l?.text || '').slice(0, 2000),
|
||||
})) : [];
|
||||
if (!cleanLines.length) return null;
|
||||
const now = nowIso();
|
||||
const normLang = String(lang).trim().slice(0, 12).toLowerCase();
|
||||
const normProvider = String(provider).trim().slice(0, 32).toLowerCase();
|
||||
const normVideoId = String(videoId).trim().slice(0, 256);
|
||||
const languages_json = JSON.stringify(Array.isArray(languages) ? languages.slice(0, 32) : []);
|
||||
const lines_json = JSON.stringify(cleanLines);
|
||||
const line_count = cleanLines.length;
|
||||
const char_count = cleanLines.reduce((n, l) => n + String(l.text || '').length, 0);
|
||||
const existing = db.prepare(
|
||||
`SELECT * FROM transcript_history WHERE user_id = ? AND provider = ? AND video_id = ? AND lang = ?`
|
||||
).get(userId, normProvider, normVideoId, normLang);
|
||||
if (existing) {
|
||||
db.prepare(`UPDATE transcript_history SET title = COALESCE(?, title), thumbnail = COALESCE(?, thumbnail),
|
||||
languages_json = ?, lines_json = ?, line_count = ?, char_count = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(title || null, thumbnail || null, languages_json, lines_json, line_count, char_count, now, existing.id);
|
||||
return transcriptRowToApi(db.prepare(`SELECT * FROM transcript_history WHERE id = ?`).get(existing.id));
|
||||
}
|
||||
const id = cryptoRandomId();
|
||||
db.prepare(`INSERT INTO transcript_history (id, user_id, provider, video_id, title, thumbnail, lang, languages_json, lines_json, line_count, char_count, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(id, userId, normProvider, normVideoId, title || null, thumbnail || null, normLang, languages_json, lines_json, line_count, char_count, now, now);
|
||||
return transcriptRowToApi(db.prepare(`SELECT * FROM transcript_history WHERE id = ?`).get(id));
|
||||
}
|
||||
|
||||
export function getTranscriptHistoryItem({ userId, provider, videoId, lang, id }) {
|
||||
ensureTranscriptHistoryTable();
|
||||
if (id) {
|
||||
return transcriptRowToApi(db.prepare(`SELECT * FROM transcript_history WHERE id = ? AND user_id = ?`).get(id, userId));
|
||||
}
|
||||
if (!provider || !videoId) return null;
|
||||
const normProvider = String(provider).trim().toLowerCase();
|
||||
const normVideoId = String(videoId).trim();
|
||||
if (lang) {
|
||||
const normLang = String(lang).trim().slice(0, 12).toLowerCase();
|
||||
return transcriptRowToApi(db.prepare(
|
||||
`SELECT * FROM transcript_history WHERE user_id = ? AND provider = ? AND video_id = ? AND lang = ?`
|
||||
).get(userId, normProvider, normVideoId, normLang));
|
||||
}
|
||||
return transcriptRowToApi(db.prepare(
|
||||
`SELECT * FROM transcript_history WHERE user_id = ? AND provider = ? AND video_id = ? ORDER BY updated_at DESC LIMIT 1`
|
||||
).get(userId, normProvider, normVideoId));
|
||||
}
|
||||
|
||||
export function listTranscriptHistory({ userId, limit = 50, before, q, provider, lang }) {
|
||||
ensureTranscriptHistoryTable();
|
||||
const lim = Math.max(1, Math.min(200, Number(limit) || 50));
|
||||
const conds = ['user_id = ?'];
|
||||
const params = [userId];
|
||||
if (before) { conds.push('updated_at < ?'); params.push(String(before)); }
|
||||
if (provider && String(provider).trim()) {
|
||||
conds.push('provider = ?');
|
||||
params.push(String(provider).trim().toLowerCase());
|
||||
}
|
||||
if (lang && String(lang).trim()) {
|
||||
conds.push('lang = ?');
|
||||
params.push(String(lang).trim().slice(0, 12).toLowerCase());
|
||||
}
|
||||
if (typeof q === 'string' && q.trim().length > 0) {
|
||||
const like = `%${q.trim()}%`;
|
||||
conds.push(`(COALESCE(title,'') LIKE ? OR video_id LIKE ? OR lang LIKE ? OR COALESCE(lines_json,'') LIKE ?)`);
|
||||
params.push(like, like, like, like);
|
||||
}
|
||||
const where = `WHERE ${conds.join(' AND ')}`;
|
||||
const rows = db.prepare(`SELECT * FROM transcript_history ${where} ORDER BY updated_at DESC LIMIT ?`).all(...params, lim);
|
||||
return rows.map(transcriptRowToApi);
|
||||
}
|
||||
|
||||
export function deleteTranscriptHistoryById(userId, id) {
|
||||
ensureTranscriptHistoryTable();
|
||||
db.prepare(`DELETE FROM transcript_history WHERE id = ? AND user_id = ?`).run(id, userId);
|
||||
}
|
||||
|
||||
export function deleteAllTranscriptHistory(userId, provider) {
|
||||
ensureTranscriptHistoryTable();
|
||||
if (provider && String(provider).trim()) {
|
||||
db.prepare(`DELETE FROM transcript_history WHERE user_id = ? AND provider = ?`).run(userId, String(provider).trim().toLowerCase());
|
||||
return;
|
||||
}
|
||||
db.prepare(`DELETE FROM transcript_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);
|
||||
|
||||
+75
-2
@@ -83,6 +83,11 @@ import {
|
||||
sumCompletedDownloadBytes,
|
||||
SUPPORTED_DOWNLOAD_LANGUAGES as SUPPORTED_DL_LANGS,
|
||||
DEFAULT_DOWNLOAD_LANGUAGES as DEFAULT_DL_LANGS,
|
||||
upsertTranscriptHistory,
|
||||
getTranscriptHistoryItem,
|
||||
listTranscriptHistory,
|
||||
deleteTranscriptHistoryById,
|
||||
deleteAllTranscriptHistory,
|
||||
} from './db.mjs';
|
||||
import { getChannelAdapter, setTwitchTokenProvider } from './providers/channel-registry.mjs';
|
||||
import { fetchChannelContent } from './providers/channel-content.mjs';
|
||||
@@ -1976,7 +1981,8 @@ r.get('/user/history/search', authMiddleware, (req, res) => {
|
||||
const limit = Math.min(200, Number(req.query.limit || 50));
|
||||
const before = req.query.before ? String(req.query.before) : undefined;
|
||||
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||||
const rows = listSearchHistory({ userId: req.user.id, limit, before, q });
|
||||
const provider = typeof req.query.provider === 'string' ? req.query.provider : undefined;
|
||||
const rows = listSearchHistory({ userId: req.user.id, limit, before, q, provider });
|
||||
return res.json(rows);
|
||||
});
|
||||
|
||||
@@ -2003,7 +2009,8 @@ r.get('/user/history/watch', authMiddleware, (req, res) => {
|
||||
const limit = Math.min(200, Number(req.query.limit || 50));
|
||||
const before = req.query.before ? String(req.query.before) : undefined;
|
||||
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||||
const rows = listWatchHistory({ userId: req.user.id, limit, before, q });
|
||||
const provider = typeof req.query.provider === 'string' ? req.query.provider : undefined;
|
||||
const rows = listWatchHistory({ userId: req.user.id, limit, before, q, provider });
|
||||
return res.json(rows);
|
||||
});
|
||||
|
||||
@@ -2032,6 +2039,72 @@ r.delete('/user/history/watch', authMiddleware, (req, res) => {
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
// --- History: Transcripts (conservés, rejoués sans régénération) ---
|
||||
r.post('/user/history/transcripts', authMiddleware, (req, res) => {
|
||||
const { provider, videoId, title, thumbnail, lang, languages, lines } = req.body || {};
|
||||
if (!provider || !videoId || !lang) {
|
||||
return res.status(400).json({ error: 'provider, videoId and lang are required' });
|
||||
}
|
||||
if (!Array.isArray(lines) || !lines.length) {
|
||||
return res.status(400).json({ error: 'lines required' });
|
||||
}
|
||||
try {
|
||||
const row = upsertTranscriptHistory({
|
||||
userId: req.user.id, provider, videoId, title, thumbnail, lang, languages, lines,
|
||||
});
|
||||
if (!row) return res.status(400).json({ error: 'invalid_transcript' });
|
||||
return res.status(201).json(row);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: 'save_failed', details: String(e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
r.get('/user/history/transcripts', authMiddleware, (req, res) => {
|
||||
const limit = Math.min(200, Number(req.query.limit || 50));
|
||||
const before = req.query.before ? String(req.query.before) : undefined;
|
||||
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||||
const provider = typeof req.query.provider === 'string' ? req.query.provider : undefined;
|
||||
const lang = typeof req.query.lang === 'string' ? req.query.lang : undefined;
|
||||
try {
|
||||
const rows = listTranscriptHistory({ userId: req.user.id, limit, before, q, provider, lang });
|
||||
return res.json(rows);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: 'list_failed', details: String(e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
r.get('/user/history/transcripts/:provider/:videoId', authMiddleware, (req, res) => {
|
||||
const { provider, videoId } = req.params;
|
||||
const lang = typeof req.query.lang === 'string' ? req.query.lang : undefined;
|
||||
try {
|
||||
const row = getTranscriptHistoryItem({ userId: req.user.id, provider, videoId, lang });
|
||||
if (!row) return res.status(404).json({ available: false, error: 'not_found' });
|
||||
return res.json({ available: true, fromHistory: true, ...row });
|
||||
} catch (e) {
|
||||
return res.status(500).json({ available: false, error: 'lookup_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
r.delete('/user/history/transcripts/:id', authMiddleware, (req, res) => {
|
||||
try {
|
||||
deleteTranscriptHistoryById(req.user.id, req.params.id);
|
||||
return res.status(204).end();
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
r.delete('/user/history/transcripts', authMiddleware, (req, res) => {
|
||||
if (String(req.query.all || '') !== '1') return res.status(400).json({ error: 'set all=1' });
|
||||
const provider = typeof req.query.provider === 'string' && req.query.provider ? String(req.query.provider) : undefined;
|
||||
try {
|
||||
deleteAllTranscriptHistory(req.user.id, provider);
|
||||
return res.status(204).end();
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Likes ---
|
||||
// NOTE : cookie-aware (et non Bearer seul) : le front s'authentifie via
|
||||
// cookies httpOnly + Bearer en mémoire (perdu au F5) ; exiger le Bearer
|
||||
|
||||
@@ -1,316 +1,288 @@
|
||||
<div class="container mx-auto p-4 sm:p-6 max-w-6xl">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6 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">
|
||||
<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>
|
||||
Historique
|
||||
</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">
|
||||
<!-- Search History Section -->
|
||||
<section class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-slate-200 flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-blue-400" 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>
|
||||
Historique des recherches
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs bg-slate-700 text-slate-300 px-2 py-1 rounded-full">
|
||||
{{ searchHistory().length }} recherche(s)
|
||||
<span *ngIf="searchQuery()">
|
||||
• {{ filteredSearchHistory().length }} résultat(s)
|
||||
</span>
|
||||
</span>
|
||||
<button (click)="resetFilters()" [disabled]="!hasActiveFilters()"
|
||||
class="px-3 py-2 text-xs rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700 disabled:opacity-40 transition-colors">
|
||||
Réinitialiser les filtres
|
||||
</button>
|
||||
<button (click)="clearActiveTab()" [disabled]="!!clearingTab()"
|
||||
class="px-3 py-2 text-xs rounded-lg bg-red-600/90 hover:bg-red-500 text-white disabled:opacity-60 transition-colors">
|
||||
<span *ngIf="!clearingTab()">Tout effacer (onglet)</span>
|
||||
<span *ngIf="clearingTab()">Suppression…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="searchQuery(); else normalSearchHistory">
|
||||
<!-- Mode recherche -->
|
||||
<div *ngIf="isLoading()" class="text-center py-6">
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2 mb-6 border-b border-slate-700" role="tablist">
|
||||
<button role="tab" (click)="setTab('search')"
|
||||
[class]="activeTab() === 'search'
|
||||
? 'px-4 py-2 text-sm font-semibold text-white border-b-2 border-blue-500 -mb-px'
|
||||
: 'px-4 py-2 text-sm text-slate-400 hover:text-slate-200 border-b-2 border-transparent -mb-px'">
|
||||
Recherches
|
||||
<span class="ml-1 text-xs bg-slate-700 px-2 py-0.5 rounded-full">{{ filteredSearch().length }}</span>
|
||||
</button>
|
||||
<button role="tab" (click)="setTab('watch')"
|
||||
[class]="activeTab() === 'watch'
|
||||
? 'px-4 py-2 text-sm font-semibold text-white border-b-2 border-red-500 -mb-px'
|
||||
: 'px-4 py-2 text-sm text-slate-400 hover:text-slate-200 border-b-2 border-transparent -mb-px'">
|
||||
Visionnage
|
||||
<span class="ml-1 text-xs bg-slate-700 px-2 py-0.5 rounded-full">{{ filteredWatch().length }}</span>
|
||||
</button>
|
||||
<button role="tab" (click)="setTab('transcripts')"
|
||||
[class]="activeTab() === 'transcripts'
|
||||
? 'px-4 py-2 text-sm font-semibold text-white border-b-2 border-emerald-500 -mb-px'
|
||||
: 'px-4 py-2 text-sm text-slate-400 hover:text-slate-200 border-b-2 border-transparent -mb-px'">
|
||||
Transcripts
|
||||
<span class="ml-1 text-xs bg-slate-700 px-2 py-0.5 rounded-full">{{ filteredTranscripts().length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ===== Barre de recherche + filtres : RECHERCHES ===== -->
|
||||
<div *ngIf="activeTab() === 'search'" class="bg-slate-800/50 rounded-lg p-4 mb-4 shadow">
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-3">
|
||||
<div class="md:col-span-5 relative">
|
||||
<input type="text" [ngModel]="searchQuery()" (ngModelChange)="searchQuery.set($event); onFilterChange()"
|
||||
placeholder="Rechercher dans les recherches…"
|
||||
class="block w-full pl-3 pr-8 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" />
|
||||
<button *ngIf="searchQuery()" (click)="searchQuery.set(''); onFilterChange()"
|
||||
class="absolute inset-y-0 right-0 pr-3 text-slate-400 hover:text-slate-200">✕</button>
|
||||
</div>
|
||||
<select [ngModel]="searchProvider()" (ngModelChange)="searchProvider.set($event); onFilterChange()"
|
||||
class="md:col-span-3 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
|
||||
<option *ngFor="let p of providerOptions" [value]="p.id">{{ p.label }}</option>
|
||||
</select>
|
||||
<select [ngModel]="searchDate()" (ngModelChange)="searchDate.set($event)"
|
||||
class="md:col-span-2 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
|
||||
<option value="all">Toutes dates</option>
|
||||
<option value="today">Aujourd'hui</option>
|
||||
<option value="week">7 derniers jours</option>
|
||||
<option value="month">30 derniers jours</option>
|
||||
</select>
|
||||
<select [ngModel]="searchSort()" (ngModelChange)="searchSort.set($event)"
|
||||
class="md:col-span-2 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
|
||||
<option value="recent">Plus récents</option>
|
||||
<option value="oldest">Plus anciens</option>
|
||||
<option value="alpha">Alphabétique</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Barre de recherche + filtres : VISIONNAGE ===== -->
|
||||
<div *ngIf="activeTab() === 'watch'" class="bg-slate-800/50 rounded-lg p-4 mb-4 shadow">
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-3">
|
||||
<div class="md:col-span-5 relative">
|
||||
<input type="text" [ngModel]="watchQuery()" (ngModelChange)="watchQuery.set($event); onFilterChange()"
|
||||
placeholder="Rechercher une vidéo (titre, provider, id)…"
|
||||
class="block w-full pl-3 pr-8 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-red-500" />
|
||||
<button *ngIf="watchQuery()" (click)="watchQuery.set(''); onFilterChange()"
|
||||
class="absolute inset-y-0 right-0 pr-3 text-slate-400 hover:text-slate-200">✕</button>
|
||||
</div>
|
||||
<select [ngModel]="watchProvider()" (ngModelChange)="watchProvider.set($event); onFilterChange()"
|
||||
class="md:col-span-3 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
|
||||
<option *ngFor="let p of providerOptions" [value]="p.id">{{ p.label }}</option>
|
||||
</select>
|
||||
<select [ngModel]="watchDate()" (ngModelChange)="watchDate.set($event)"
|
||||
class="md:col-span-2 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
|
||||
<option value="all">Toutes dates</option>
|
||||
<option value="today">Aujourd'hui</option>
|
||||
<option value="week">7 derniers jours</option>
|
||||
<option value="month">30 derniers jours</option>
|
||||
</select>
|
||||
<select [ngModel]="watchSort()" (ngModelChange)="watchSort.set($event)"
|
||||
class="md:col-span-2 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
|
||||
<option value="recent">Plus récents</option>
|
||||
<option value="oldest">Plus anciens</option>
|
||||
<option value="alpha">Alphabétique</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Barre de recherche + filtres : TRANSCRIPTS ===== -->
|
||||
<div *ngIf="activeTab() === 'transcripts'" class="bg-slate-800/50 rounded-lg p-4 mb-4 shadow">
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-3">
|
||||
<div class="md:col-span-4 relative">
|
||||
<input type="text" [ngModel]="transcriptQuery()" (ngModelChange)="transcriptQuery.set($event); onFilterChange()"
|
||||
placeholder="Rechercher (titre, vidéo, langue, contenu)…"
|
||||
class="block w-full pl-3 pr-8 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-emerald-500" />
|
||||
<button *ngIf="transcriptQuery()" (click)="transcriptQuery.set(''); onFilterChange()"
|
||||
class="absolute inset-y-0 right-0 pr-3 text-slate-400 hover:text-slate-200">✕</button>
|
||||
</div>
|
||||
<input type="text" [ngModel]="transcriptInnerSearch()" (ngModelChange)="transcriptInnerSearch.set($event)"
|
||||
placeholder="Filtrer dans le texte conservé…"
|
||||
class="md:col-span-3 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100 placeholder-slate-400" />
|
||||
<select [ngModel]="transcriptProvider()" (ngModelChange)="transcriptProvider.set($event); onFilterChange()"
|
||||
class="md:col-span-2 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
|
||||
<option *ngFor="let p of providerOptions" [value]="p.id">{{ p.label }}</option>
|
||||
</select>
|
||||
<input type="text" [ngModel]="transcriptLang()" (ngModelChange)="transcriptLang.set($event); onFilterChange()"
|
||||
placeholder="Langue (fr, en…)"
|
||||
class="md:col-span-1 px-3 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100 placeholder-slate-400" />
|
||||
<select [ngModel]="transcriptDate()" (ngModelChange)="transcriptDate.set($event)"
|
||||
class="md:col-span-1 px-2 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
|
||||
<option value="all">Toutes</option>
|
||||
<option value="today">Jour</option>
|
||||
<option value="week">7j</option>
|
||||
<option value="month">30j</option>
|
||||
</select>
|
||||
<select [ngModel]="transcriptSort()" (ngModelChange)="transcriptSort.set($event)"
|
||||
class="md:col-span-1 px-2 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100">
|
||||
<option value="recent">Récents</option>
|
||||
<option value="oldest">Anciens</option>
|
||||
<option value="alpha">A→Z</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-slate-400">
|
||||
Les transcripts sont conservés à la génération et rejoués depuis l'historique — aucune régénération inutile.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ===== Contenu : RECHERCHES ===== -->
|
||||
<section *ngIf="activeTab() === 'search'" class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
|
||||
<div *ngIf="searchLoading() && filteredSearch().length === 0" 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>
|
||||
<p class="mt-2 text-slate-400">Chargement…</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 *ngIf="!searchLoading() && filteredSearch().length === 0" class="text-slate-400 text-center py-4">
|
||||
Aucune recherche {{ searchQuery() ? 'pour « ' + searchQuery() + ' »' : 'récente' }}
|
||||
</div>
|
||||
|
||||
<ul *ngIf="!isLoading() && filteredSearchHistory().length > 0" class="space-y-3">
|
||||
<li *ngFor="let s of filteredSearchHistory()"
|
||||
<ul *ngIf="filteredSearch().length > 0" class="space-y-3">
|
||||
<li *ngFor="let s of filteredSearch()"
|
||||
class="group relative bg-slate-700/50 hover:bg-slate-700/80 rounded-lg overflow-hidden transition-all duration-200">
|
||||
<a [routerLink]="['/search']" [queryParams]="getSearchQueryParams(s)"
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</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)">
|
||||
<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>
|
||||
<a [routerLink]="['/search']" [queryParams]="getSearchQueryParams(s)" class="block p-4 pr-24 hover:no-underline">
|
||||
<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 gap-2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded border" [ngStyle]="getProviderColors(getSearchProvider(s).id)">
|
||||
{{ getSearchProvider(s).name }}
|
||||
</span>
|
||||
<span class="text-slate-500">
|
||||
{{ formatDate(s.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span>{{ formatDate(s.created_at) }}</span>
|
||||
</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 (click)="onDeleteSearch(s)" [disabled]="deletingSearchId() === s.id"
|
||||
class="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-red-600/90 hover:bg-red-500 text-white text-xs disabled:opacity-60">
|
||||
Supprimer
|
||||
</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]="getSearchQueryParams(s)"
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</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)">
|
||||
<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>
|
||||
</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>
|
||||
<div class="mt-4 text-center" *ngIf="searchHasMore() && filteredSearch().length > 0">
|
||||
<button (click)="loadMore()" [disabled]="searchLoading()"
|
||||
class="px-4 py-2 text-sm rounded-lg border border-slate-600 text-slate-200 hover:bg-slate-700 disabled:opacity-50">
|
||||
{{ searchLoading() ? 'Chargement…' : 'Charger plus' }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Watch History Section -->
|
||||
<section class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-slate-200 flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-red-400" 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>
|
||||
Historique de visionnage
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs bg-slate-700 text-slate-300 px-2 py-1 rounded-full">
|
||||
{{ watchHistory().length }} vidéo(s)
|
||||
<span *ngIf="searchQuery()">
|
||||
• {{ filteredWatchHistory().length }} résultat(s)
|
||||
</span>
|
||||
</span>
|
||||
<!-- ===== Contenu : VISIONNAGE ===== -->
|
||||
<section *ngIf="activeTab() === 'watch'" class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
|
||||
<div *ngIf="watchLoading() && filteredWatch().length === 0" class="text-center py-6">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-red-500 border-t-transparent"></div>
|
||||
<p class="mt-2 text-slate-400">Chargement…</p>
|
||||
</div>
|
||||
<div *ngIf="!watchLoading() && filteredWatch().length === 0" class="text-slate-400 text-center py-4">
|
||||
Aucune vidéo {{ watchQuery() ? 'pour « ' + watchQuery() + ' »' : 'récemment regardée' }}
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="searchQuery(); else normalWatchHistory">
|
||||
<!-- Mode recherche -->
|
||||
<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()"
|
||||
<ul *ngIf="filteredWatch().length > 0" class="space-y-3">
|
||||
<li *ngFor="let w of filteredWatch()"
|
||||
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">
|
||||
<a [routerLink]="['/watch', w.video_id]" [queryParams]="{ p: w.provider }" class="block p-4 pr-24 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>
|
||||
}
|
||||
@if (w.thumbnail) { <img [src]="w.thumbnail" alt="" class="w-full h-full object-cover" /> }
|
||||
<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>
|
||||
<h4 class="text-sm font-medium text-slate-100 group-hover:text-blue-400 truncate">{{ w.title || 'Sans titre' }}</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>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded mr-2 border" [ngStyle]="getProviderColors(w.provider)">
|
||||
{{ getProviderDisplayName(w.provider) }}
|
||||
</span>
|
||||
<span>Visionné le {{ formatDate(w.watched_at) }}</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>
|
||||
Progression : {{ formatDuration(w.progress_seconds || 0) }} / {{ formatDuration(w.duration_seconds || 0) }}
|
||||
</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 (click)="onDeleteWatch(w)" [disabled]="deletingWatchId() === w.id"
|
||||
class="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-red-600/90 hover:bg-red-500 text-white text-xs disabled:opacity-60">
|
||||
Supprimer
|
||||
</button>
|
||||
</li>
|
||||
</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>
|
||||
<div class="mt-4 text-center" *ngIf="watchHasMore() && filteredWatch().length > 0">
|
||||
<button (click)="loadMore()" [disabled]="watchLoading()"
|
||||
class="px-4 py-2 text-sm rounded-lg border border-slate-600 text-slate-200 hover:bg-slate-700 disabled:opacity-50">
|
||||
{{ watchLoading() ? 'Chargement…' : 'Charger plus' }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Contenu : TRANSCRIPTS ===== -->
|
||||
<section *ngIf="activeTab() === 'transcripts'" class="bg-slate-800/50 rounded-lg p-6 shadow-lg">
|
||||
<div *ngIf="transcriptLoading() && filteredTranscripts().length === 0" class="text-center py-6">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-emerald-500 border-t-transparent"></div>
|
||||
<p class="mt-2 text-slate-400">Chargement…</p>
|
||||
</div>
|
||||
<div *ngIf="!transcriptLoading() && filteredTranscripts().length === 0" class="text-slate-400 text-center py-4">
|
||||
Aucun transcript conservé {{ transcriptQuery() ? 'pour « ' + transcriptQuery() + ' »' : 'pour le moment' }}.
|
||||
Ouvrez un transcript depuis une page Watch : il sera conservé ici.
|
||||
</div>
|
||||
<ul *ngIf="filteredTranscripts().length > 0" class="space-y-3">
|
||||
<li *ngFor="let t of filteredTranscripts()" class="bg-slate-700/50 rounded-lg overflow-hidden">
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0 w-20 h-12 bg-slate-700 rounded overflow-hidden flex items-center justify-center">
|
||||
@if (t.thumbnail) { <img [src]="t.thumbnail" alt="" class="w-full h-full object-cover" /> }
|
||||
@else { <span class="text-slate-500 text-xs">TXT</span> }
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-slate-100 font-medium truncate">{{ t.title || t.video_id }}</div>
|
||||
<div class="flex flex-wrap items-center gap-2 mt-1 text-xs text-slate-400">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded border" [ngStyle]="getProviderColors(t.provider)">
|
||||
{{ getProviderDisplayName(t.provider) }}
|
||||
</span>
|
||||
<span class="px-2 py-0.5 rounded bg-emerald-600/20 text-emerald-300 border border-emerald-500/30">{{ t.lang }}</span>
|
||||
<span>{{ t.line_count }} passages</span>
|
||||
<span>{{ formatDate(t.updated_at) }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-400 line-clamp-2">{{ t.preview || '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<a [routerLink]="['/watch', t.video_id]" [queryParams]="{ p: t.provider }"
|
||||
class="px-3 py-1.5 text-xs rounded-lg bg-blue-600/90 hover:bg-blue-500 text-white">Revoir</a>
|
||||
<button (click)="toggleTranscriptDetail(t)"
|
||||
class="px-3 py-1.5 text-xs rounded-lg border border-slate-500 text-slate-200 hover:bg-slate-600">
|
||||
{{ expandedTranscriptId() === t.id ? 'Masquer le texte' : 'Voir le texte conservé' }}
|
||||
</button>
|
||||
<button (click)="onDeleteTranscript(t)" [disabled]="deletingTranscriptId() === t.id"
|
||||
class="px-3 py-1.5 text-xs rounded-lg bg-red-600/90 hover:bg-red-500 text-white disabled:opacity-60">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="expandedTranscriptId() === t.id" class="mt-3 max-h-64 overflow-y-auto rounded bg-slate-900/60 p-3 space-y-1">
|
||||
<div *ngFor="let l of expandedTranscriptLines()" class="text-xs text-slate-300 flex gap-2">
|
||||
<span class="text-slate-500 font-mono shrink-0">[{{ formatDuration(l.t) }}]</span>
|
||||
<span>{{ l.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-4 text-center" *ngIf="transcriptHasMore() && filteredTranscripts().length > 0">
|
||||
<button (click)="loadMore()" [disabled]="transcriptLoading()"
|
||||
class="px-4 py-2 text-sm rounded-lg border border-slate-600 text-slate-200 hover:bg-slate-700 disabled:opacity-50">
|
||||
{{ transcriptLoading() ? 'Chargement…' : 'Charger plus' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal, computed, effect } from '@angular/core';
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { ChangeDetectionStrategy, Component, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule, ActivatedRoute } 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 { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
import { Subject } from 'rxjs';
|
||||
import {
|
||||
HistoryService, SearchHistoryItem, WatchHistoryItem, TranscriptHistoryItem,
|
||||
} from '../../../services/history.service';
|
||||
|
||||
export type HistoryTab = 'search' | 'watch' | 'transcripts';
|
||||
export type DateFilter = 'all' | 'today' | 'week' | 'month';
|
||||
export type SortOrder = 'recent' | 'oldest' | 'alpha';
|
||||
|
||||
const PROVIDER_OPTIONS = [
|
||||
{ id: '', label: 'Tous providers' },
|
||||
{ id: 'youtube', label: 'YouTube' },
|
||||
{ id: 'dailymotion', label: 'Dailymotion' },
|
||||
{ id: 'twitch', label: 'Twitch' },
|
||||
{ id: 'peertube', label: 'PeerTube' },
|
||||
{ id: 'odysee', label: 'Odysee' },
|
||||
{ id: 'rumble', label: 'Rumble' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-history',
|
||||
@@ -15,330 +31,379 @@ import { HistoryService, SearchHistoryItem, WatchHistoryItem } from '../../../se
|
||||
})
|
||||
export class HistoryComponent {
|
||||
private history = inject(HistoryService);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
activeTab = signal<HistoryTab>('search');
|
||||
providerOptions = PROVIDER_OPTIONS;
|
||||
|
||||
// --- Filtres par onglet ---
|
||||
searchQuery = signal('');
|
||||
searchProvider = signal('');
|
||||
searchDate = signal<DateFilter>('all');
|
||||
searchSort = signal<SortOrder>('recent');
|
||||
|
||||
watchQuery = signal('');
|
||||
watchProvider = signal('');
|
||||
watchDate = signal<DateFilter>('all');
|
||||
watchSort = signal<SortOrder>('recent');
|
||||
|
||||
transcriptQuery = signal('');
|
||||
transcriptProvider = signal('');
|
||||
transcriptLang = signal('');
|
||||
transcriptDate = signal<DateFilter>('all');
|
||||
transcriptSort = signal<SortOrder>('recent');
|
||||
transcriptInnerSearch = signal('');
|
||||
expandedTranscriptId = signal<string | null>(null);
|
||||
|
||||
// --- Données brutes (serveur déjà filtré par q/provider/lang) ---
|
||||
searchItems = signal<SearchHistoryItem[]>([]);
|
||||
watchItems = signal<WatchHistoryItem[]>([]);
|
||||
transcriptItems = signal<TranscriptHistoryItem[]>([]);
|
||||
|
||||
searchLoading = signal(false);
|
||||
watchLoading = signal(false);
|
||||
transcriptLoading = signal(false);
|
||||
|
||||
searchHasMore = signal(true);
|
||||
watchHasMore = signal(true);
|
||||
transcriptHasMore = signal(true);
|
||||
|
||||
loading = signal<boolean>(false);
|
||||
searchHistory = signal<SearchHistoryItem[]>([]);
|
||||
watchHistory = signal<WatchHistoryItem[]>([]);
|
||||
searchQuery = signal<string>('');
|
||||
isLoading = signal<boolean>(false);
|
||||
deletingSearchId = signal<string | null>(null);
|
||||
deletingWatchId = signal<string | null>(null);
|
||||
deletingTranscriptId = signal<string | null>(null);
|
||||
clearingTab = signal<HistoryTab | null>(null);
|
||||
|
||||
// Historiques complets
|
||||
filteredSearchHistory = signal<SearchHistoryItem[]>([]);
|
||||
filteredWatchHistory = signal<WatchHistoryItem[]>([]);
|
||||
|
||||
// Sujet pour la recherche avec debounce
|
||||
private searchTerms = new Subject<string>();
|
||||
|
||||
|
||||
// Obtenir le nom d'affichage d'un provider
|
||||
getProviderName(providerId: string): string {
|
||||
const names: Record<string, string> = {
|
||||
'youtube': 'YouTube',
|
||||
'dailymotion': 'Dailymotion',
|
||||
'twitch': 'Twitch',
|
||||
'peertube': 'PeerTube',
|
||||
'odysee': 'Odysee',
|
||||
'rumble': 'Rumble'
|
||||
};
|
||||
return names[providerId.toLowerCase()] || providerId;
|
||||
}
|
||||
private searchTerms = new Subject<void>();
|
||||
|
||||
constructor() {
|
||||
// Chargement initial des données
|
||||
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: [] });
|
||||
try {
|
||||
const tab = this.route.snapshot.queryParamMap.get('tab');
|
||||
if (tab === 'watch' || tab === 'transcripts' || tab === 'search') this.activeTab.set(tab);
|
||||
} catch {}
|
||||
// Recherche debouncée -> recharge serveur avec q/provider/lang
|
||||
this.searchTerms.pipe(debounceTime(300), distinctUntilChanged()).subscribe(() => this.reloadActive(true));
|
||||
this.reloadAll();
|
||||
}
|
||||
this.isLoading.set(true);
|
||||
return this.history.searchInSearchHistory(term).pipe(
|
||||
switchMap(searchResults =>
|
||||
this.history.searchInWatchHistory(term).pipe(
|
||||
switchMap(watchResults =>
|
||||
of({ search: searchResults, watch: watchResults })
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// ============ Tabs ============
|
||||
setTab(tab: HistoryTab): void {
|
||||
this.activeTab.set(tab);
|
||||
this.reloadActive(true);
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
this.searchTerms.next();
|
||||
}
|
||||
|
||||
// ============ Chargement ============
|
||||
reloadAll(): void {
|
||||
this.loadSearch(true);
|
||||
this.loadWatch(true);
|
||||
this.loadTranscripts(true);
|
||||
}
|
||||
|
||||
reloadActive(reset = false): void {
|
||||
const tab = this.activeTab();
|
||||
if (tab === 'search') this.loadSearch(reset);
|
||||
else if (tab === 'watch') this.loadWatch(reset);
|
||||
else this.loadTranscripts(reset);
|
||||
}
|
||||
|
||||
loadMore(): void {
|
||||
const tab = this.activeTab();
|
||||
if (tab === 'search') this.loadSearch(false);
|
||||
else if (tab === 'watch') this.loadWatch(false);
|
||||
else this.loadTranscripts(false);
|
||||
}
|
||||
|
||||
private loadSearch(reset: boolean): void {
|
||||
if (this.searchLoading()) return;
|
||||
this.searchLoading.set(true);
|
||||
const before = reset ? undefined : this.oldestSearchDate();
|
||||
this.history.getSearchHistory(50, before, this.searchQuery().trim() || undefined, this.searchProvider() || undefined).subscribe({
|
||||
next: (items) => {
|
||||
const list = items || [];
|
||||
this.searchItems.update(prev => reset ? list : [...(prev || []), ...list]);
|
||||
this.searchHasMore.set(list.length >= 50);
|
||||
this.searchLoading.set(false);
|
||||
},
|
||||
error: () => this.searchLoading.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
private loadWatch(reset: boolean): void {
|
||||
if (this.watchLoading()) return;
|
||||
this.watchLoading.set(true);
|
||||
const before = reset ? undefined : this.oldestWatchDate();
|
||||
this.history.getWatchHistory(50, before, this.watchQuery().trim() || undefined, this.watchProvider() || undefined).subscribe({
|
||||
next: (items) => {
|
||||
const list = items || [];
|
||||
this.watchItems.update(prev => reset ? list : [...(prev || []), ...list]);
|
||||
this.watchHasMore.set(list.length >= 50);
|
||||
this.watchLoading.set(false);
|
||||
},
|
||||
error: () => this.watchLoading.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
private loadTranscripts(reset: boolean): void {
|
||||
if (this.transcriptLoading()) return;
|
||||
this.transcriptLoading.set(true);
|
||||
const before = reset ? undefined : this.oldestTranscriptDate();
|
||||
this.history.listTranscripts({
|
||||
limit: 50, before,
|
||||
q: this.transcriptQuery().trim() || undefined,
|
||||
provider: this.transcriptProvider() || undefined,
|
||||
lang: this.transcriptLang().trim() || undefined,
|
||||
}).subscribe({
|
||||
next: (items) => {
|
||||
const list = items || [];
|
||||
this.transcriptItems.update(prev => reset ? list : [...(prev || []), ...list]);
|
||||
this.transcriptHasMore.set(list.length >= 50);
|
||||
this.transcriptLoading.set(false);
|
||||
},
|
||||
error: () => this.transcriptLoading.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
private oldestSearchDate(): string | undefined {
|
||||
const l = this.searchItems();
|
||||
return l.length ? l[l.length - 1].created_at : undefined;
|
||||
}
|
||||
private oldestWatchDate(): string | undefined {
|
||||
const l = this.watchItems();
|
||||
return l.length ? l[l.length - 1].watched_at : undefined;
|
||||
}
|
||||
private oldestTranscriptDate(): string | undefined {
|
||||
const l = this.transcriptItems();
|
||||
return l.length ? l[l.length - 1].updated_at : undefined;
|
||||
}
|
||||
|
||||
// ============ Filtrage client (date + tri) ============
|
||||
private applyDateFilter<T>(items: T[], getDate: (x: T) => string, f: DateFilter): T[] {
|
||||
if (f === 'all') return items;
|
||||
const now = Date.now();
|
||||
const day = 24 * 3600 * 1000;
|
||||
const maxAge = f === 'today' ? day : f === 'week' ? 7 * day : 30 * day;
|
||||
return items.filter((x) => {
|
||||
const t = new Date(getDate(x)).getTime();
|
||||
return Number.isFinite(t) && (now - t) <= maxAge;
|
||||
});
|
||||
}
|
||||
|
||||
private applySort<T>(items: T[], getDate: (x: T) => string, getLabel: (x: T) => string, sort: SortOrder): T[] {
|
||||
const arr = [...items];
|
||||
if (sort === 'alpha') arr.sort((a, b) => getLabel(a).localeCompare(getLabel(b)));
|
||||
else if (sort === 'oldest') arr.sort((a, b) => new Date(getDate(a)).getTime() - new Date(getDate(b)).getTime());
|
||||
else arr.sort((a, b) => new Date(getDate(b)).getTime() - new Date(getDate(a)).getTime());
|
||||
return arr;
|
||||
}
|
||||
|
||||
filteredSearch = computed(() => {
|
||||
const dated = this.applyDateFilter(this.searchItems(), (x) => x.created_at, this.searchDate());
|
||||
return this.applySort(dated, (x) => x.created_at, (x) => x.query, this.searchSort());
|
||||
});
|
||||
|
||||
filteredWatch = computed(() => {
|
||||
const dated = this.applyDateFilter(this.watchItems(), (x) => x.watched_at, this.watchDate());
|
||||
return this.applySort(dated, (x) => x.watched_at, (x) => x.title || '', this.watchSort());
|
||||
});
|
||||
|
||||
filteredTranscripts = computed(() => {
|
||||
const dated = this.applyDateFilter(this.transcriptItems(), (x) => x.updated_at, this.transcriptDate());
|
||||
const sorted = this.applySort(dated, (x) => x.updated_at, (x) => x.title || '', this.transcriptSort());
|
||||
const q = this.transcriptInnerSearch().trim().toLowerCase();
|
||||
if (!q) return sorted;
|
||||
return sorted.filter((t) =>
|
||||
(t.title || '').toLowerCase().includes(q) ||
|
||||
(t.preview || '').toLowerCase().includes(q) ||
|
||||
(t.lines || []).some((l) => l.text.toLowerCase().includes(q))
|
||||
);
|
||||
})
|
||||
).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);
|
||||
}
|
||||
expandedTranscript = computed(() => {
|
||||
const id = this.expandedTranscriptId();
|
||||
if (!id) return null;
|
||||
return this.transcriptItems().find((t) => t.id === id) || null;
|
||||
});
|
||||
|
||||
expandedTranscriptLines = computed(() => {
|
||||
const t = this.expandedTranscript();
|
||||
if (!t) return [];
|
||||
const q = this.transcriptInnerSearch().trim().toLowerCase();
|
||||
const lines = t.lines || [];
|
||||
if (!q) return lines.slice(0, 200);
|
||||
return lines.filter((l) => l.text.toLowerCase().includes(q)).slice(0, 200);
|
||||
});
|
||||
|
||||
toggleTranscriptDetail(item: TranscriptHistoryItem): void {
|
||||
this.expandedTranscriptId.set(this.expandedTranscriptId() === item.id ? null : item.id);
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.isLoading.set(true);
|
||||
this.history.getSearchHistory(50).subscribe({
|
||||
next: (items) => {
|
||||
this.searchHistory.set(items || []);
|
||||
if (!this.searchQuery()) {
|
||||
this.filteredSearchHistory.set(items || []);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
});
|
||||
this.history.getWatchHistory(50).subscribe({
|
||||
next: (items) => {
|
||||
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
|
||||
// ============ Suppressions ============
|
||||
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));
|
||||
},
|
||||
next: () => this.searchItems.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));
|
||||
},
|
||||
next: () => this.watchItems.update(list => (list || []).filter(x => x.id !== item.id)),
|
||||
error: () => {},
|
||||
complete: () => this.deletingWatchId.set(null)
|
||||
});
|
||||
}
|
||||
|
||||
// Format a date to a readable string
|
||||
onDeleteTranscript(item: TranscriptHistoryItem): void {
|
||||
if (!item?.id || this.deletingTranscriptId()) return;
|
||||
this.deletingTranscriptId.set(item.id);
|
||||
this.history.deleteTranscriptItem(item.id).subscribe({
|
||||
next: () => {
|
||||
this.transcriptItems.update(list => (list || []).filter(x => x.id !== item.id));
|
||||
if (this.expandedTranscriptId() === item.id) this.expandedTranscriptId.set(null);
|
||||
},
|
||||
error: () => {},
|
||||
complete: () => this.deletingTranscriptId.set(null)
|
||||
});
|
||||
}
|
||||
|
||||
clearActiveTab(): void {
|
||||
const tab = this.activeTab();
|
||||
if (this.clearingTab()) return;
|
||||
if (!confirm('Tout effacer pour cet onglet ?')) return;
|
||||
this.clearingTab.set(tab);
|
||||
const done = () => this.clearingTab.set(null);
|
||||
if (tab === 'search') {
|
||||
this.history.clearSearchHistory().subscribe({ next: () => this.searchItems.set([]), error: () => {}, complete: done });
|
||||
} else if (tab === 'watch') {
|
||||
this.history.clearWatchHistory().subscribe({ next: () => this.watchItems.set([]), error: () => {}, complete: done });
|
||||
} else {
|
||||
const provider = this.transcriptProvider() || undefined;
|
||||
this.history.clearTranscriptHistory(provider).subscribe({ next: () => this.reloadActive(true), error: () => {}, complete: done });
|
||||
}
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
const tab = this.activeTab();
|
||||
if (tab === 'search') {
|
||||
this.searchQuery.set(''); this.searchProvider.set(''); this.searchDate.set('all'); this.searchSort.set('recent');
|
||||
} else if (tab === 'watch') {
|
||||
this.watchQuery.set(''); this.watchProvider.set(''); this.watchDate.set('all'); this.watchSort.set('recent');
|
||||
} else {
|
||||
this.transcriptQuery.set(''); this.transcriptProvider.set(''); this.transcriptLang.set('');
|
||||
this.transcriptDate.set('all'); this.transcriptSort.set('recent'); this.transcriptInnerSearch.set('');
|
||||
}
|
||||
this.reloadActive(true);
|
||||
}
|
||||
|
||||
hasActiveFilters = computed(() => {
|
||||
const tab = this.activeTab();
|
||||
if (tab === 'search') return !!(this.searchQuery() || this.searchProvider() || this.searchDate() !== 'all' || this.searchSort() !== 'recent');
|
||||
if (tab === 'watch') return !!(this.watchQuery() || this.watchProvider() || this.watchDate() !== 'all' || this.watchSort() !== 'recent');
|
||||
return !!(this.transcriptQuery() || this.transcriptProvider() || this.transcriptLang() || this.transcriptDate() !== 'all' || this.transcriptSort() !== 'recent' || this.transcriptInnerSearch());
|
||||
});
|
||||
|
||||
// ============ Helpers affichage (conservés) ============
|
||||
formatDate(dateString: string | Date): string {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
getSearchProvider(item: SearchHistoryItem): { name: string; id: string; providers?: string[] } {
|
||||
// Try to get providers from filters first
|
||||
if (item.filters_json) {
|
||||
try {
|
||||
const filters = JSON.parse(item.filters_json);
|
||||
if (filters.providers) {
|
||||
// Multiple providers available
|
||||
const providers = Array.isArray(filters.providers) ? filters.providers : [filters.providers];
|
||||
if (providers.length === 1) {
|
||||
const providerName = this.formatProviderName(providers[0]);
|
||||
return { name: providerName, id: providers[0].toLowerCase() };
|
||||
} else {
|
||||
const firstProviderName = this.formatProviderName(providers[0]);
|
||||
const othersCount = providers.length - 1;
|
||||
const name = `${firstProviderName} + ${othersCount} autre${othersCount > 1 ? 's' : ''}`;
|
||||
return { name: name, id: providers[0], providers: providers };
|
||||
}
|
||||
} else if (filters.provider) {
|
||||
// Single provider
|
||||
const providerName = this.formatProviderName(filters.provider);
|
||||
return { name: providerName, id: String(filters.provider).toLowerCase() };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing filters_json:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// If no provider in filters, try to extract from URL query params
|
||||
try {
|
||||
const url = new URL(item.query);
|
||||
const provider = url.searchParams.get('provider');
|
||||
if (provider) {
|
||||
const providerName = this.formatProviderName(provider);
|
||||
return { name: providerName, id: provider.toLowerCase() };
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a valid URL or no provider in query params
|
||||
}
|
||||
|
||||
// Fallback to 'all' if no provider can be determined
|
||||
return { name: 'Tous', id: 'all' };
|
||||
}
|
||||
|
||||
// Get the default provider from the current URL or settings
|
||||
private getDefaultProvider(): string | null {
|
||||
try {
|
||||
// Try to get from current URL
|
||||
const url = new URL(window.location.href);
|
||||
const provider = url.searchParams.get('provider');
|
||||
if (provider) return provider.toLowerCase();
|
||||
|
||||
// Or get from localStorage if available
|
||||
const savedProvider = localStorage.getItem('selectedProvider');
|
||||
if (savedProvider) return savedProvider.toLowerCase();
|
||||
|
||||
// Default to youtube if nothing else
|
||||
return 'youtube';
|
||||
} catch (e) {
|
||||
return 'youtube'; // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
getProvidersWithInfo(item: SearchHistoryItem) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get query params for search navigation
|
||||
getSearchQueryParams(item: SearchHistoryItem): any {
|
||||
const providerInfo = this.getSearchProvider(item);
|
||||
if (providerInfo.providers && providerInfo.providers.length > 1) {
|
||||
// Convert array to comma-separated string for URL
|
||||
return { q: item.query, providers: providerInfo.providers.join(',') };
|
||||
} else {
|
||||
return { q: item.query, provider: providerInfo.id };
|
||||
}
|
||||
}
|
||||
|
||||
// Format provider ID to a display name
|
||||
private formatProviderName(providerId: string): string {
|
||||
const providerMap: { [key: string]: string } = {
|
||||
'youtube': 'YouTube',
|
||||
'vimeo': 'Vimeo',
|
||||
'dailymotion': 'Dailymotion',
|
||||
'peertube': 'PeerTube',
|
||||
'rumble': 'Rumble'
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
// Obtenir les styles pour un provider
|
||||
getProviderColors(providerId: string | null | undefined): { [key: string]: string } {
|
||||
if (!providerId) {
|
||||
return {
|
||||
'background-color': 'rgb(51 65 85 / 0.15)',
|
||||
'color': 'rgb(148 163 184)',
|
||||
'border-color': 'rgb(71 85 105 / 0.3)'
|
||||
};
|
||||
}
|
||||
|
||||
const id = providerId.toLowerCase();
|
||||
let bgColor = 'rgb(51 65 85 / 0.15)';
|
||||
let textColor = 'rgb(148 163 184)';
|
||||
let borderColor = 'rgb(71 85 105 / 0.3)';
|
||||
|
||||
switch (id) {
|
||||
case 'youtube':
|
||||
case 'yt':
|
||||
bgColor = 'rgb(220 38 38 / 0.15)';
|
||||
textColor = 'rgb(248 113 113)';
|
||||
borderColor = 'rgb(239 68 68 / 0.3)';
|
||||
break;
|
||||
case 'dailymotion':
|
||||
case 'dm':
|
||||
bgColor = 'rgb(37 99 235 / 0.15)';
|
||||
textColor = 'rgb(96 165 250)';
|
||||
borderColor = 'rgb(59 130 246 / 0.3)';
|
||||
break;
|
||||
case 'twitch':
|
||||
case 'tw':
|
||||
bgColor = 'rgb(147 51 234 / 0.15)';
|
||||
textColor = 'rgb(192 132 252)';
|
||||
borderColor = 'rgb(168 85 247 / 0.3)';
|
||||
break;
|
||||
case 'peertube':
|
||||
case 'pt':
|
||||
bgColor = 'rgb(217 119 6 / 0.15)';
|
||||
textColor = 'rgb(251 191 36)';
|
||||
borderColor = 'rgb(245 158 11 / 0.3)';
|
||||
break;
|
||||
case 'odysee':
|
||||
case 'od':
|
||||
bgColor = 'rgb(22 163 74 / 0.15)';
|
||||
textColor = 'rgb(74 222 128)';
|
||||
borderColor = 'rgb(34 197 94 / 0.3)';
|
||||
break;
|
||||
case 'rumble':
|
||||
case 'ru':
|
||||
bgColor = 'rgb(234 88 12 / 0.15)';
|
||||
textColor = 'rgb(251 146 60)';
|
||||
borderColor = 'rgb(249 115 22 / 0.3)';
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
'background-color': bgColor,
|
||||
'color': textColor,
|
||||
'border-color': borderColor
|
||||
};
|
||||
}
|
||||
|
||||
// Format seconds to HH:MM:SS or MM:SS
|
||||
formatDuration(seconds: number): string {
|
||||
if (!seconds && seconds !== 0) return '00:00';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
} else {
|
||||
if (hours > 0) return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate watch progress percentage
|
||||
getProgressPercentage(item: WatchHistoryItem): number {
|
||||
if (!item.duration_seconds || item.duration_seconds === 0) return 0;
|
||||
const progress = (item.progress_seconds || 0) / item.duration_seconds;
|
||||
return Math.min(100, Math.max(0, progress * 100));
|
||||
}
|
||||
|
||||
getSearchProvider(item: SearchHistoryItem): { name: string; id: string; providers?: string[] } {
|
||||
if (item.filters_json) {
|
||||
try {
|
||||
const filters = JSON.parse(item.filters_json);
|
||||
if (filters.providers) {
|
||||
const providers = Array.isArray(filters.providers) ? filters.providers : [filters.providers];
|
||||
if (providers.length === 1) {
|
||||
return { name: this.formatProviderName(providers[0]), id: providers[0].toLowerCase() };
|
||||
}
|
||||
const firstProviderName = this.formatProviderName(providers[0]);
|
||||
const othersCount = providers.length - 1;
|
||||
return { name: `${firstProviderName} + ${othersCount} autre${othersCount > 1 ? 's' : ''}`, id: providers[0], providers };
|
||||
} else if (filters.provider) {
|
||||
return { name: this.formatProviderName(filters.provider), id: String(filters.provider).toLowerCase() };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
const url = new URL(item.query);
|
||||
const provider = url.searchParams.get('provider');
|
||||
if (provider) return { name: this.formatProviderName(provider), id: provider.toLowerCase() };
|
||||
} catch {}
|
||||
return { name: 'Tous', id: 'all' };
|
||||
}
|
||||
|
||||
getSearchQueryParams(item: SearchHistoryItem): any {
|
||||
const providerInfo = this.getSearchProvider(item);
|
||||
if (providerInfo.providers && providerInfo.providers.length > 1) {
|
||||
return { q: item.query, providers: providerInfo.providers.join(',') };
|
||||
}
|
||||
return { q: item.query, provider: providerInfo.id };
|
||||
}
|
||||
|
||||
private formatProviderName(providerId: string): string {
|
||||
const providerMap: { [key: string]: string } = {
|
||||
youtube: 'YouTube', yt: 'YouTube', vimeo: 'Vimeo',
|
||||
dailymotion: 'Dailymotion', dm: 'Dailymotion', peertube: 'PeerTube', pt: 'PeerTube',
|
||||
rumble: 'Rumble', ru: 'Rumble', twitch: 'Twitch', tw: 'Twitch',
|
||||
odysee: 'Odysee', od: 'Odysee',
|
||||
};
|
||||
return providerMap[providerId.toLowerCase()] || providerId;
|
||||
}
|
||||
|
||||
getProviderDisplayName(providerId: string | null | undefined): string {
|
||||
if (!providerId) return '';
|
||||
return this.formatProviderName(providerId);
|
||||
}
|
||||
|
||||
getProviderColors(providerId: string | null | undefined): { [key: string]: string } {
|
||||
if (!providerId) {
|
||||
return { 'background-color': 'rgb(51 65 85 / 0.15)', 'color': 'rgb(148 163 184)', 'border-color': 'rgb(71 85 105 / 0.3)' };
|
||||
}
|
||||
const id = providerId.toLowerCase();
|
||||
let bgColor = 'rgb(51 65 85 / 0.15)';
|
||||
let textColor = 'rgb(148 163 184)';
|
||||
let borderColor = 'rgb(71 85 105 / 0.3)';
|
||||
switch (id) {
|
||||
case 'youtube': case 'yt':
|
||||
bgColor = 'rgb(220 38 38 / 0.15)'; textColor = 'rgb(248 113 113)'; borderColor = 'rgb(239 68 68 / 0.3)'; break;
|
||||
case 'dailymotion': case 'dm':
|
||||
bgColor = 'rgb(37 99 235 / 0.15)'; textColor = 'rgb(96 165 250)'; borderColor = 'rgb(59 130 246 / 0.3)'; break;
|
||||
case 'twitch': case 'tw':
|
||||
bgColor = 'rgb(147 51 234 / 0.15)'; textColor = 'rgb(192 132 252)'; borderColor = 'rgb(168 85 247 / 0.3)'; break;
|
||||
case 'peertube': case 'pt':
|
||||
bgColor = 'rgb(217 119 6 / 0.15)'; textColor = 'rgb(251 191 36)'; borderColor = 'rgb(245 158 11 / 0.3)'; break;
|
||||
case 'odysee': case 'od':
|
||||
bgColor = 'rgb(22 163 74 / 0.15)'; textColor = 'rgb(74 222 128)'; borderColor = 'rgb(34 197 94 / 0.3)'; break;
|
||||
case 'rumble': case 'ru':
|
||||
bgColor = 'rgb(234 88 12 / 0.15)'; textColor = 'rgb(251 146 60)'; borderColor = 'rgb(249 115 22 / 0.3)'; break;
|
||||
}
|
||||
return { 'background-color': bgColor, 'color': textColor, 'border-color': borderColor };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1401,6 +1401,51 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Conserve le transcript généré dans l'historique (rejoué sans régénération). */
|
||||
private persistTranscriptToHistory(lang: string, languages: string[], lines: Array<{ t: number; dur: number; text: string }>): void {
|
||||
try {
|
||||
if (!this.auth.currentUser()) return;
|
||||
if (!Array.isArray(lines) || !lines.length) return;
|
||||
const v = this.video();
|
||||
this.history.saveTranscript({
|
||||
provider: this.provider(),
|
||||
videoId: this.videoId(),
|
||||
title: v?.title || undefined,
|
||||
thumbnail: v?.thumbnail || undefined,
|
||||
lang, languages, lines,
|
||||
}).subscribe();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private applyTranscriptSuccess(data: any): void {
|
||||
this.transcriptLoading.set(false);
|
||||
this.transcriptAvailable.set(!!data?.available);
|
||||
this.transcriptLanguages.set(this.filterTranscriptLanguages(data?.languages));
|
||||
this.transcriptLines.set(Array.isArray(data?.lines) ? data.lines : []);
|
||||
if (data?.available && typeof data?.lang === 'string' && data.lang) {
|
||||
this.selectedTranscriptLang.set(data.lang);
|
||||
}
|
||||
if (!data?.available) {
|
||||
if (this.autoFallbackTranscriptLang(data?.languages)) return;
|
||||
const st = this.transcriptStatusFor(data);
|
||||
this.transcriptReason.set(st.reason);
|
||||
this.transcriptRetryable.set(st.retryable);
|
||||
this.transcriptError.set(st.message);
|
||||
} else {
|
||||
this.transcriptReason.set(null);
|
||||
this.transcriptRetryable.set(false);
|
||||
this.transcriptError.set(null);
|
||||
this.transcriptActiveIndex.set(-1);
|
||||
this.startTranscriptClock();
|
||||
// Conserve pour l'onglet Historique > Transcripts.
|
||||
this.persistTranscriptToHistory(
|
||||
String((data?.lang || this.selectedTranscriptLang()) || 'fr'),
|
||||
Array.isArray(data?.languages) ? data.languages : [],
|
||||
Array.isArray(data?.lines) ? data.lines : [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
loadTranscript() {
|
||||
const p = this.provider();
|
||||
const id = this.videoId();
|
||||
@@ -1431,36 +1476,15 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
this.transcriptReason.set(null);
|
||||
this.transcriptRetryable.set(false);
|
||||
this.transcriptTriedLangs.add(String(requestedLang || '').toLowerCase());
|
||||
// 1) Historique d'abord : rejoue le transcript conservé sans régénération.
|
||||
const fetchFromNetwork = () => {
|
||||
const opts = this.buildProviderOpts() as Record<string, string>;
|
||||
const params = new URLSearchParams({ lang: requestedLang, langs: allowed.join(',') });
|
||||
if (opts['instance']) params.set('instance', opts['instance']);
|
||||
if (opts['slug']) params.set('slug', opts['slug']);
|
||||
const url = `${this.apiBase()}/transcript/${encodeURIComponent(p)}/${encodeURIComponent(id)}?${params.toString()}`;
|
||||
this.http.get<any>(url).subscribe({
|
||||
next: (data) => {
|
||||
this.transcriptLoading.set(false);
|
||||
this.transcriptAvailable.set(!!data?.available);
|
||||
this.transcriptLanguages.set(this.filterTranscriptLanguages(data?.languages));
|
||||
this.transcriptLines.set(Array.isArray(data?.lines) ? data.lines : []);
|
||||
if (data?.available && typeof data?.lang === 'string' && data.lang) {
|
||||
this.selectedTranscriptLang.set(data.lang);
|
||||
}
|
||||
if (!data?.available) {
|
||||
// La langue demandée n'a rien donné mais d'autres pistes existent :
|
||||
// on bascule automatiquement dessus au lieu d'afficher une erreur.
|
||||
if (this.autoFallbackTranscriptLang(data?.languages)) return;
|
||||
const st = this.transcriptStatusFor(data);
|
||||
this.transcriptReason.set(st.reason);
|
||||
this.transcriptRetryable.set(st.retryable);
|
||||
this.transcriptError.set(st.message);
|
||||
} else {
|
||||
this.transcriptReason.set(null);
|
||||
this.transcriptRetryable.set(false);
|
||||
this.transcriptError.set(null);
|
||||
this.transcriptActiveIndex.set(-1);
|
||||
this.startTranscriptClock();
|
||||
}
|
||||
},
|
||||
next: (data) => this.applyTranscriptSuccess(data),
|
||||
error: (err) => {
|
||||
this.transcriptLoading.set(false);
|
||||
this.transcriptAvailable.set(false);
|
||||
@@ -1492,6 +1516,21 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
this.transcriptError.set(st.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
try {
|
||||
if (!this.auth.currentUser()) { fetchFromNetwork(); return; }
|
||||
this.history.getCachedTranscript(p, id, requestedLang).subscribe({
|
||||
next: (cached) => {
|
||||
const lines = (cached as any)?.lines;
|
||||
if (cached && Array.isArray(lines) && lines.length) {
|
||||
this.applyTranscriptSuccess({ available: true, lang: (cached as any).lang, languages: (cached as any).languages, lines });
|
||||
return;
|
||||
}
|
||||
fetchFromNetwork();
|
||||
},
|
||||
error: () => fetchFromNetwork(),
|
||||
});
|
||||
} catch { fetchFromNetwork(); }
|
||||
} catch {
|
||||
this.transcriptLoading.set(false);
|
||||
this.transcriptAvailable.set(false);
|
||||
|
||||
+103
-26
@@ -1,6 +1,6 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of, tap } from 'rxjs';
|
||||
import { Observable, of, tap, catchError } from 'rxjs';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
export interface SearchHistoryItem {
|
||||
@@ -24,6 +24,37 @@ export interface WatchHistoryItem {
|
||||
thumbnail?: string | null;
|
||||
}
|
||||
|
||||
export interface TranscriptLine {
|
||||
t: number;
|
||||
dur: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface TranscriptHistoryItem {
|
||||
id: string;
|
||||
provider: string;
|
||||
video_id: string;
|
||||
title?: string | null;
|
||||
thumbnail?: string | null;
|
||||
lang: string;
|
||||
languages: string[];
|
||||
lines: TranscriptLine[];
|
||||
line_count: number;
|
||||
char_count: number;
|
||||
preview?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
fromHistory?: boolean;
|
||||
}
|
||||
|
||||
export interface HistoryFilters {
|
||||
q?: string;
|
||||
provider?: string;
|
||||
lang?: string;
|
||||
limit?: number;
|
||||
before?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class HistoryService {
|
||||
private http = inject(HttpClient);
|
||||
@@ -38,6 +69,16 @@ export class HistoryService {
|
||||
}
|
||||
}
|
||||
|
||||
private buildParams(f: HistoryFilters): string {
|
||||
const params = new URLSearchParams();
|
||||
if (f.limit) params.set('limit', String(f.limit));
|
||||
if (f.before) params.set('before', f.before);
|
||||
if (f.q) params.set('q', f.q);
|
||||
if (f.provider) params.set('provider', f.provider);
|
||||
if (f.lang) params.set('lang', f.lang);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
// --- Search ---
|
||||
recordSearch(query: string, filters?: Record<string, any>): Observable<{ id: string; created_at: string }> {
|
||||
const url = `${this.apiBase()}/user/history/search`;
|
||||
@@ -53,11 +94,9 @@ export class HistoryService {
|
||||
);
|
||||
}
|
||||
|
||||
getSearchHistory(limit = 50, before?: string): Observable<SearchHistoryItem[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(limit));
|
||||
if (before) params.set('before', before);
|
||||
return this.http.get<SearchHistoryItem[]>(`${this.apiBase()}/user/history/search?${params.toString()}`, { withCredentials: true });
|
||||
getSearchHistory(limit = 50, before?: string, q?: string, provider?: string): Observable<SearchHistoryItem[]> {
|
||||
const qs = this.buildParams({ limit, before, q, provider });
|
||||
return this.http.get<SearchHistoryItem[]>(`${this.apiBase()}/user/history/search?${qs}`, { withCredentials: true });
|
||||
}
|
||||
|
||||
// --- Watch ---
|
||||
@@ -92,11 +131,9 @@ export class HistoryService {
|
||||
);
|
||||
}
|
||||
|
||||
getWatchHistory(limit = 50, before?: string): Observable<WatchHistoryItem[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(limit));
|
||||
if (before) params.set('before', before);
|
||||
return this.http.get<WatchHistoryItem[]>(`${this.apiBase()}/user/history/watch?${params.toString()}`, { withCredentials: true });
|
||||
getWatchHistory(limit = 50, before?: string, q?: string, provider?: string): Observable<WatchHistoryItem[]> {
|
||||
const qs = this.buildParams({ limit, before, q, provider });
|
||||
return this.http.get<WatchHistoryItem[]>(`${this.apiBase()}/user/history/watch?${qs}`, { withCredentials: true });
|
||||
}
|
||||
|
||||
// --- Delete single items ---
|
||||
@@ -108,25 +145,65 @@ export class HistoryService {
|
||||
return this.http.delete<void>(`${this.apiBase()}/user/history/watch/${encodeURIComponent(id)}`, { withCredentials: true });
|
||||
}
|
||||
|
||||
clearSearchHistory(): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiBase()}/user/history/search?all=1`, { withCredentials: true });
|
||||
}
|
||||
|
||||
clearWatchHistory(): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiBase()}/user/history/watch?all=1`, { 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[]>(
|
||||
`${this.apiBase()}/user/history/search?${params.toString()}`,
|
||||
{ withCredentials: true }
|
||||
);
|
||||
searchInSearchHistory(query: string, limit = 100, provider?: string): Observable<SearchHistoryItem[]> {
|
||||
return this.getSearchHistory(limit, undefined, query, provider);
|
||||
}
|
||||
|
||||
// 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[]>(
|
||||
`${this.apiBase()}/user/history/watch?${params.toString()}`,
|
||||
{ withCredentials: true }
|
||||
searchInWatchHistory(query: string, limit = 100, provider?: string): Observable<WatchHistoryItem[]> {
|
||||
return this.getWatchHistory(limit, undefined, query, provider);
|
||||
}
|
||||
|
||||
// --- Transcripts (conservés, rejoués sans régénération) ---
|
||||
/** Lecture prioritaire depuis l'historique : évite de régénérer à chaque demande. */
|
||||
getCachedTranscript(provider: string, videoId: string, lang?: string): Observable<TranscriptHistoryItem | null> {
|
||||
if (!this.auth.currentUser()) return of(null);
|
||||
let url = `${this.apiBase()}/user/history/transcripts/${encodeURIComponent(provider)}/${encodeURIComponent(videoId)}`;
|
||||
if (lang) url += `?lang=${encodeURIComponent(lang)}`;
|
||||
return this.http.get<TranscriptHistoryItem>(url, { withCredentials: true }).pipe(
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}
|
||||
|
||||
listTranscripts(filters: HistoryFilters = {}): Observable<TranscriptHistoryItem[]> {
|
||||
const qs = this.buildParams({ limit: filters.limit ?? 50, ...filters });
|
||||
return this.http.get<TranscriptHistoryItem[]>(`${this.apiBase()}/user/history/transcripts?${qs}`, { withCredentials: true });
|
||||
}
|
||||
|
||||
searchInTranscriptHistory(query: string, limit = 100, provider?: string, lang?: string): Observable<TranscriptHistoryItem[]> {
|
||||
return this.listTranscripts({ q: query, limit, provider, lang });
|
||||
}
|
||||
|
||||
saveTranscript(entry: {
|
||||
provider: string; videoId: string; title?: string | null; thumbnail?: string | null;
|
||||
lang: string; languages?: string[]; lines: TranscriptLine[];
|
||||
}): Observable<TranscriptHistoryItem | null> {
|
||||
if (!this.auth.currentUser()) return of(null);
|
||||
if (!entry?.provider || !entry?.videoId || !entry?.lang || !Array.isArray(entry.lines) || !entry.lines.length) {
|
||||
return of(null);
|
||||
}
|
||||
return this.http.post<TranscriptHistoryItem>(
|
||||
`${this.apiBase()}/user/history/transcripts`,
|
||||
{ ...entry },
|
||||
{ withCredentials: true }
|
||||
).pipe(catchError(() => of(null)));
|
||||
}
|
||||
|
||||
deleteTranscriptItem(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiBase()}/user/history/transcripts/${encodeURIComponent(id)}`, { withCredentials: true });
|
||||
}
|
||||
|
||||
clearTranscriptHistory(provider?: string): Observable<void> {
|
||||
const qs = provider ? `?all=1&provider=${encodeURIComponent(provider)}` : '?all=1';
|
||||
return this.http.delete<void>(`${this.apiBase()}/user/history/transcripts${qs}`, { withCredentials: true });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user