chore: update Angular TypeScript build info cache

This commit is contained in:
Bruno Charest 2025-09-17 22:29:57 -04:00
parent f3a78b7d7e
commit fff9b88663
15 changed files with 1002 additions and 11 deletions

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -115,6 +115,9 @@ CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT,
thumbnail TEXT,
is_private INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
UNIQUE(user_id, name) UNIQUE(user_id, name)
@ -126,12 +129,25 @@ CREATE TABLE IF NOT EXISTS playlist_items (
provider TEXT NOT NULL, provider TEXT NOT NULL,
video_id TEXT NOT NULL, video_id TEXT NOT NULL,
title TEXT, title TEXT,
thumbnail TEXT,
added_at TEXT NOT NULL, added_at TEXT NOT NULL,
position INTEGER NOT NULL, position INTEGER NOT NULL,
UNIQUE(playlist_id, provider, video_id) UNIQUE(playlist_id, provider, video_id)
); );
CREATE INDEX IF NOT EXISTS idx_playlist_items_order ON playlist_items(playlist_id, position); CREATE INDEX IF NOT EXISTS idx_playlist_items_order ON playlist_items(playlist_id, position);
-- Playlist metrics -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS playlist_metrics (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
action TEXT NOT NULL, -- create|update|delete|add_video|remove_video|reorder
meta_json TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_playlist_metrics_user_time ON playlist_metrics(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_playlist_metrics_playlist_time ON playlist_metrics(playlist_id, created_at DESC);
-- Tags (optional) ----------------------------------------------------------- -- Tags (optional) -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS tags ( CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,

View File

@ -24,6 +24,36 @@ if (fs.existsSync(schemaFile)) {
} }
} }
// Lightweight schema upgrades for existing databases (SQLite is permissive)
(function ensurePlaylistSchemaUpgrades() {
try {
const colsPlaylists = db.prepare(`PRAGMA table_info(playlists)`).all();
const have = new Set(colsPlaylists.map(c => c.name));
if (!have.has('description')) db.exec(`ALTER TABLE playlists ADD COLUMN description TEXT`);
if (!have.has('thumbnail')) db.exec(`ALTER TABLE playlists ADD COLUMN thumbnail TEXT`);
if (!have.has('is_private')) db.exec(`ALTER TABLE playlists ADD COLUMN is_private INTEGER NOT NULL DEFAULT 1`);
} catch {}
try {
const colsItems = db.prepare(`PRAGMA table_info(playlist_items)`).all();
const have2 = new Set(colsItems.map(c => c.name));
if (!have2.has('thumbnail')) db.exec(`ALTER TABLE playlist_items ADD COLUMN thumbnail TEXT`);
} catch {}
try {
db.exec(`CREATE TABLE IF NOT EXISTS playlist_metrics (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
action TEXT NOT NULL,
meta_json TEXT,
created_at TEXT NOT NULL
);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_playlist_metrics_user_time ON playlist_metrics(user_id, created_at DESC);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_playlist_metrics_playlist_time ON playlist_metrics(playlist_id, created_at DESC);`);
} catch {}
})();
// (duplicate schema exec removed)
// Helpers // Helpers
export function nowIso() { export function nowIso() {
return new Date().toISOString(); return new Date().toISOString();
@ -390,3 +420,133 @@ export function listLikedVideos({ userId, limit = 100, q }) {
throw error; // Renvoyer l'erreur pour qu'elle soit gérée par le routeur throw error; // Renvoyer l'erreur pour qu'elle soit gérée par le routeur
} }
} }
// -------------------- Playlists --------------------
export function recordPlaylistMetric({ userId, playlistId, action, meta }) {
const id = cryptoRandomId();
const created_at = nowIso();
const meta_json = meta ? JSON.stringify(meta) : null;
db.prepare(`INSERT INTO playlist_metrics (id, user_id, playlist_id, action, meta_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)`)
.run(id, userId, playlistId, action, meta_json, created_at);
return { id, created_at };
}
export function createPlaylist({ userId, title, description, thumbnail, isPrivate = true }) {
const id = cryptoRandomUUID();
const now = nowIso();
const name = String(title || '').trim();
if (!name) throw new Error('title_required');
db.prepare(`INSERT INTO playlists (id, user_id, name, description, thumbnail, is_private, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
.run(id, userId, name, description || null, thumbnail || null, isPrivate ? 1 : 0, now, now);
recordPlaylistMetric({ userId, playlistId: id, action: 'create', meta: { title: name } });
return db.prepare(`SELECT id, user_id AS userId, name AS title, description, thumbnail, is_private AS isPrivate, created_at AS createdAt, updated_at AS updatedAt,
(SELECT COUNT(1) FROM playlist_items WHERE playlist_id = playlists.id) AS itemsCount
FROM playlists WHERE id = ?`).get(id);
}
export function listPlaylists({ userId, limit = 50, offset = 0, q }) {
const like = `%${(q || '').trim()}%`;
const hasQ = typeof q === 'string' && q.trim().length > 0;
const base = `SELECT id, user_id AS userId, name AS title, description, thumbnail, is_private AS isPrivate, created_at AS createdAt, updated_at AS updatedAt,
(SELECT COUNT(1) FROM playlist_items WHERE playlist_id = playlists.id) AS itemsCount
FROM playlists WHERE user_id = ?`;
const sql = hasQ ? `${base} AND (name LIKE ? OR COALESCE(description,'') LIKE ?)
ORDER BY updated_at DESC LIMIT ? OFFSET ?` :
`${base} ORDER BY updated_at DESC LIMIT ? OFFSET ?`;
return hasQ
? db.prepare(sql).all(userId, like, like, limit, offset)
: db.prepare(sql).all(userId, limit, offset);
}
export function getPlaylistRaw(id) {
return db.prepare(`SELECT id, user_id AS userId, name AS title, description, thumbnail, is_private AS isPrivate, created_at AS createdAt, updated_at AS updatedAt
FROM playlists WHERE id = ?`).get(id);
}
export function updatePlaylist({ userId, id, patch }) {
const cur = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(id);
if (!cur) return null;
if (cur.user_id !== userId) return 'forbidden';
const next = {
name: patch.title != null ? String(patch.title).trim() : cur.name,
description: patch.description != null ? String(patch.description).trim() : cur.description,
thumbnail: patch.thumbnail != null ? String(patch.thumbnail).trim() : cur.thumbnail,
is_private: typeof patch.isPrivate === 'boolean' ? (patch.isPrivate ? 1 : 0) : cur.is_private,
updated_at: nowIso(),
};
db.prepare(`UPDATE playlists SET name=@name, description=@description, thumbnail=@thumbnail, is_private=@is_private, updated_at=@updated_at WHERE id = ?`)
.run(id, next);
recordPlaylistMetric({ userId, playlistId: id, action: 'update', meta: { title: next.name } });
return db.prepare(`SELECT id, user_id AS userId, name AS title, description, thumbnail, is_private AS isPrivate, created_at AS createdAt, updated_at AS updatedAt
FROM playlists WHERE id = ?`).get(id);
}
export function deletePlaylist({ userId, id }) {
const cur = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(id);
if (!cur) return { removed: false };
if (cur.user_id !== userId) return 'forbidden';
const info = db.prepare(`DELETE FROM playlists WHERE id = ?`).run(id);
recordPlaylistMetric({ userId, playlistId: id, action: 'delete' });
return { removed: (info.changes || 0) > 0 };
}
export function listPlaylistItems({ playlistId, limit = 200, offset = 0 }) {
return db.prepare(`SELECT id, playlist_id AS playlistId, provider, video_id AS videoId, title, thumbnail, added_at AS addedAt, position
FROM playlist_items WHERE playlist_id = ?
ORDER BY position ASC LIMIT ? OFFSET ?`).all(playlistId, limit, offset);
}
export function addPlaylistVideo({ userId, playlistId, provider, videoId, title, thumbnail }) {
const pl = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(playlistId);
if (!pl) return 'not_found';
if (pl.user_id !== userId) return 'forbidden';
const now = nowIso();
const posRow = db.prepare(`SELECT COALESCE(MAX(position), 0) + 1 AS nextPos FROM playlist_items WHERE playlist_id = ?`).get(playlistId);
const position = Number(posRow?.nextPos || 1);
const id = cryptoRandomId();
db.prepare(`INSERT OR IGNORE INTO playlist_items (id, playlist_id, provider, video_id, title, thumbnail, added_at, position)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(id, playlistId, provider, videoId, title || null, thumbnail || null, now, position);
recordPlaylistMetric({ userId, playlistId, action: 'add_video', meta: { provider, videoId } });
// Return the row (if it existed we need to fetch by key)
const row = db.prepare(`SELECT id, playlist_id AS playlistId, provider, video_id AS videoId, title, thumbnail, added_at AS addedAt, position
FROM playlist_items WHERE playlist_id = ? AND provider = ? AND video_id = ?`).get(playlistId, provider, videoId);
return row;
}
export function removePlaylistVideo({ userId, playlistId, provider, videoId }) {
const pl = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(playlistId);
if (!pl) return 'not_found';
if (pl.user_id !== userId) return 'forbidden';
const info = db.prepare(`DELETE FROM playlist_items WHERE playlist_id = ? AND provider = ? AND video_id = ?`).run(playlistId, provider, videoId);
recordPlaylistMetric({ userId, playlistId, action: 'remove_video', meta: { provider, videoId } });
return { removed: (info.changes || 0) > 0 };
}
export function reorderPlaylistVideos({ userId, playlistId, order }) {
const pl = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(playlistId);
if (!pl) return 'not_found';
if (pl.user_id !== userId) return 'forbidden';
if (!Array.isArray(order) || order.length === 0) return { changed: 0 };
const tx = db.transaction((rows) => {
let i = 1, changed = 0;
for (const it of rows) {
// Support either item ids or provider+videoId pairs
if (it && typeof it === 'string') {
const info = db.prepare(`UPDATE playlist_items SET position = ? WHERE id = ? AND playlist_id = ?`).run(i, it, playlistId);
changed += info.changes || 0;
} else if (it && (it.id || (it.provider && it.videoId))) {
const info = it.id
? db.prepare(`UPDATE playlist_items SET position = ? WHERE id = ? AND playlist_id = ?`).run(i, it.id, playlistId)
: db.prepare(`UPDATE playlist_items SET position = ? WHERE provider = ? AND video_id = ? AND playlist_id = ?`).run(i, it.provider, it.videoId, playlistId);
changed += info.changes || 0;
}
i++;
}
return changed;
});
const changed = tx(order);
recordPlaylistMetric({ userId, playlistId, action: 'reorder', meta: { count: order.length } });
return { changed };
}

View File

@ -41,6 +41,16 @@ import {
unlikeVideo, unlikeVideo,
listLikedVideos, listLikedVideos,
isVideoLiked, isVideoLiked,
// Playlists
createPlaylist,
listPlaylists,
getPlaylistRaw,
updatePlaylist,
deletePlaylist,
listPlaylistItems,
addPlaylistVideo,
removePlaylistVideo,
reorderPlaylistVideos,
} from './db.mjs'; } from './db.mjs';
const app = express(); const app = express();
@ -1286,3 +1296,178 @@ try {
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`[newtube-api] listening on http://localhost:${PORT}`); console.log(`[newtube-api] listening on http://localhost:${PORT}`);
}); });
// --- Playlists ---
// Create a new playlist
r.post('/playlists', authMiddleware, (req, res) => {
try {
const { title, description, thumbnail, isPrivate } = req.body || {};
if (!title || String(title).trim().length === 0) {
return res.status(400).json({ error: 'title_required' });
}
const pl = createPlaylist({ userId: req.user.id, title: String(title).trim(), description, thumbnail, isPrivate: !!isPrivate });
return res.status(201).json(pl);
} catch (e) {
const msg = String(e?.message || e);
if (msg === 'title_required') return res.status(400).json({ error: msg });
return res.status(500).json({ error: 'create_failed', details: msg });
}
});
// List current user's playlists (pagination + search)
r.get('/playlists', authMiddleware, (req, res) => {
try {
const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50)));
const offset = Math.max(0, Number(req.query.offset || 0));
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
const rows = listPlaylists({ userId: req.user.id, limit, offset, q });
return res.json(rows);
} catch (e) {
return res.status(500).json({ error: 'list_failed', details: String(e?.message || e) });
}
});
// Get playlist details (owner only for now)
r.get('/playlists/:id', authMiddleware, (req, res) => {
try {
const id = String(req.params.id || '');
const pl = getPlaylistRaw(id);
if (!pl) return res.status(404).json({ error: 'not_found' });
if (pl.userId !== req.user.id) return res.status(404).json({ error: 'not_found' });
const limit = Math.min(2000, Math.max(1, Number(req.query.limit || 500)));
const offset = Math.max(0, Number(req.query.offset || 0));
const items = listPlaylistItems({ playlistId: id, limit, offset });
return res.json({ ...pl, items });
} catch (e) {
return res.status(500).json({ error: 'get_failed', details: String(e?.message || e) });
}
});
// Update a playlist (title/description/thumbnail/isPrivate)
r.put('/playlists/:id', authMiddleware, (req, res) => {
try {
const id = String(req.params.id || '');
const patch = req.body || {};
const result = updatePlaylist({ userId: req.user.id, id, patch });
if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' });
if (!result) return res.status(404).json({ error: 'not_found' });
return res.json(result);
} catch (e) {
return res.status(500).json({ error: 'update_failed', details: String(e?.message || e) });
}
});
// Delete a playlist
r.delete('/playlists/:id', authMiddleware, (req, res) => {
try {
const id = String(req.params.id || '');
const result = deletePlaylist({ userId: req.user.id, id });
if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' });
if (!result || !result.removed) return res.status(404).json({ error: 'not_found' });
return res.status(204).end();
} catch (e) {
return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) });
}
});
// Add a video to a playlist (enrich title/thumbnail if missing)
r.post('/playlists/:id/videos', authMiddleware, async (req, res) => {
try {
const playlistId = String(req.params.id || '');
let { provider, videoId, title, thumbnail, sourceUrl, slug, instance } = req.body || {};
provider = String(provider || '').trim();
videoId = String(videoId || '').trim();
if (!provider || !videoId) return res.status(400).json({ error: 'provider_and_videoId_required' });
// Optional enrichment like likes route
try {
const needTitle = !(typeof title === 'string' && title.trim().length > 0);
const needThumb = !(typeof thumbnail === 'string' && thumbnail.trim().length > 0);
if (needTitle || needThumb) {
const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl });
try {
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
if (needTitle) title = meta?.title || title || '';
if (needThumb) thumbnail = meta?.thumbnail || (Array.isArray(meta?.thumbnails) && meta.thumbnails.length ? meta.thumbnails[0].url : thumbnail || '');
} catch {}
}
} catch {}
const row = addPlaylistVideo({ userId: req.user.id, playlistId, provider, videoId, title, thumbnail });
if (row === 'not_found') return res.status(404).json({ error: 'playlist_not_found' });
if (row === 'forbidden') return res.status(403).json({ error: 'forbidden' });
return res.status(201).json(row);
} catch (e) {
return res.status(500).json({ error: 'add_video_failed', details: String(e?.message || e) });
}
});
// Remove a video from a playlist (provider required via query)
r.delete('/playlists/:id/videos/:videoId', authMiddleware, (req, res) => {
try {
const playlistId = String(req.params.id || '');
const videoId = String(req.params.videoId || '');
const provider = req.query.provider ? String(req.query.provider) : '';
if (!provider || !videoId) return res.status(400).json({ error: 'provider_and_videoId_required' });
const result = removePlaylistVideo({ userId: req.user.id, playlistId, provider, videoId });
if (result === 'not_found') return res.status(404).json({ error: 'playlist_not_found' });
if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' });
return res.json(result);
} catch (e) {
return res.status(500).json({ error: 'remove_video_failed', details: String(e?.message || e) });
}
});
// Reorder playlist items
r.put('/playlists/:id/reorder', authMiddleware, (req, res) => {
try {
const playlistId = String(req.params.id || '');
const order = Array.isArray(req.body?.order) ? req.body.order : [];
if (!order.length) return res.status(400).json({ error: 'order_required' });
const result = reorderPlaylistVideos({ userId: req.user.id, playlistId, order });
if (result === 'not_found') return res.status(404).json({ error: 'playlist_not_found' });
if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' });
return res.json(result);
} catch (e) {
return res.status(500).json({ error: 'reorder_failed', details: String(e?.message || e) });
}
});
// --- OpenAPI (minimal for playlists) ---
r.get('/openapi.json', (_req, res) => {
const doc = {
openapi: '3.0.0',
info: { title: 'NewTube API', version: '1.0.0' },
paths: {
'/playlists': {
get: { summary: 'List user playlists', security: [{ bearerAuth: [] }], parameters: [
{ name: 'limit', in: 'query', schema: { type: 'integer' } },
{ name: 'offset', in: 'query', schema: { type: 'integer' } },
{ name: 'q', in: 'query', schema: { type: 'string' } },
] },
post: { summary: 'Create playlist', security: [{ bearerAuth: [] }], requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', properties: { title: { type: 'string' }, description: { type: 'string' }, thumbnail: { type: 'string' }, isPrivate: { type: 'boolean' } }, required: ['title'] } } } } }
},
'/playlists/{id}': {
get: { summary: 'Get playlist details', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] },
put: { summary: 'Update playlist', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] },
delete: { summary: 'Delete playlist', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] }
},
'/playlists/{id}/videos': {
post: { summary: 'Add video to playlist', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] }
},
'/playlists/{id}/videos/{videoId}': {
delete: { summary: 'Remove video from playlist', security: [{ bearerAuth: [] }], parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'videoId', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'provider', in: 'query', required: true, schema: { type: 'string' } }
] }
},
'/playlists/{id}/reorder': {
put: { summary: 'Reorder playlist items', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] }
}
},
components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } } }
};
res.json(doc);
});

View File

@ -1,4 +1,3 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
export const APP_ROUTES: Routes = [ export const APP_ROUTES: Routes = [
@ -45,6 +44,11 @@ export const APP_ROUTES: Routes = [
loadComponent: () => import('./components/library/playlists/playlists.component').then(m => m.PlaylistsComponent), loadComponent: () => import('./components/library/playlists/playlists.component').then(m => m.PlaylistsComponent),
title: 'NewTube - Playlists' title: 'NewTube - Playlists'
}, },
{
path: 'library/playlists/:id',
loadComponent: () => import('./components/library/playlists/playlist-detail.component').then(m => m.PlaylistDetailComponent),
title: 'NewTube - Playlist'
},
{ {
path: 'library/liked', path: 'library/liked',
loadComponent: () => import('./components/library/liked/liked.component').then(m => m.LikedComponent), loadComponent: () => import('./components/library/liked/liked.component').then(m => m.LikedComponent),

View File

@ -0,0 +1,158 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { PlaylistsService, Playlist, PlaylistItem } from '../../../services/playlists.service';
@Component({
selector: 'app-playlist-detail',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="container mx-auto p-4 sm:p-6">
<div class="flex items-center justify-between gap-3 mb-4">
<a routerLink="/library/playlists" class="text-slate-300 hover:text-slate-100"> Retour</a>
<div class="flex items-center gap-2" *ngIf="dirty()">
<button (click)="saveOrder()" [disabled]="saving()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white font-semibold">
{{ saving() ? 'Enregistrement...' : 'Enregistrer l\'ordre' }}
</button>
</div>
</div>
<ng-container *ngIf="loading(); else bodyTpl">
<div class="animate-pulse">
<div class="h-8 bg-slate-700 w-1/2 rounded mb-4"></div>
<div class="h-4 bg-slate-700 w-1/3 rounded mb-6"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="h-24 bg-slate-800 rounded"></div>
<div class="h-24 bg-slate-800 rounded"></div>
<div class="h-24 bg-slate-800 rounded"></div>
<div class="h-24 bg-slate-800 rounded"></div>
</div>
</div>
</ng-container>
<ng-template #bodyTpl>
<ng-container *ngIf="error(); else contentTpl">
<div class="bg-red-900/40 border border-red-700 text-red-300 p-4 rounded">{{ error() }}</div>
</ng-container>
</ng-template>
<ng-template #contentTpl>
<div class="flex flex-col sm:flex-row gap-4 mb-6">
<div class="w-full sm:w-64">
<div class="rounded overflow-hidden border border-slate-700 bg-slate-800">
<img *ngIf="playlist()?.thumbnail; else noThumb" [src]="playlist()!.thumbnail!" alt="" class="w-full h-40 object-cover"/>
<ng-template #noThumb>
<div class="w-full h-40 bg-gradient-to-br from-slate-700 to-slate-800"></div>
</ng-template>
</div>
</div>
<div class="flex-1">
<h1 class="text-2xl font-bold text-slate-100">{{ playlist()?.title }}</h1>
<div class="text-slate-400 mt-1 text-sm">
{{ playlist()?.itemsCount || 0 }} vidéo(s) {{ (playlist()?.updatedAt || '').slice(0,10) }}
<span class="ml-2 text-xs px-2 py-1 rounded border" [ngClass]="playlist()?.isPrivate ? 'border-slate-600 text-slate-300' : 'border-emerald-700 text-emerald-300'">{{ playlist()?.isPrivate ? 'Privée' : 'Publique' }}</span>
</div>
<p class="mt-2 text-slate-300 whitespace-pre-wrap">{{ playlist()?.description }}</p>
</div>
</div>
<div class="space-y-3">
<div *ngFor="let it of items(); let i = index" class="flex items-center gap-3 p-2 rounded border border-slate-700 bg-slate-800/60">
<div class="w-28 flex-shrink-0">
<img *ngIf="it.thumbnail; else noThumb2" [src]="it.thumbnail!" alt="" class="w-28 h-16 object-cover rounded">
<ng-template #noThumb2>
<div class="w-28 h-16 bg-slate-700 rounded"></div>
</ng-template>
</div>
<div class="flex-1">
<a [routerLink]="['/watch', it.videoId]" [queryParams]="{ p: it.provider }" class="font-semibold text-slate-100 hover:text-red-400 line-clamp-2">{{ it.title || it.videoId }}</a>
<div class="text-xs text-slate-400 mt-1">{{ it.provider }} Pos. {{ i + 1 }}</div>
</div>
<div class="flex items-center gap-2">
<button (click)="moveUp(i)" [disabled]="i===0" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm"></button>
<button (click)="moveDown(i)" [disabled]="i===items().length-1" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm"></button>
<button (click)="removeItem(it)" class="px-2 py-1 rounded bg-red-700 hover:bg-red-600 text-white text-sm">Retirer</button>
</div>
</div>
</div>
</ng-template>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlaylistDetailComponent {
private route = inject(ActivatedRoute);
private api = inject(PlaylistsService);
loading = signal<boolean>(true);
error = signal<string | null>(null);
playlist = signal<(Playlist & { items?: PlaylistItem[] }) | null>(null);
items = signal<PlaylistItem[]>([]);
dirty = signal<boolean>(false);
saving = signal<boolean>(false);
private id = signal<string>('');
constructor() {
const pid = this.route.snapshot.paramMap.get('id') || '';
this.id.set(pid);
this.load(pid);
}
load(id: string) {
this.loading.set(true);
this.error.set(null);
this.api.get(id, 1000, 0).subscribe({
next: (pl) => {
this.playlist.set(pl);
this.items.set((pl.items || []).slice().sort((a, b) => a.position - b.position));
this.loading.set(false);
this.dirty.set(false);
},
error: (err) => { this.error.set(err?.error?.error || err?.message || 'Erreur de chargement'); this.loading.set(false); }
});
}
moveUp(i: number) {
if (i <= 0) return;
const arr = this.items().slice();
const tmp = arr[i-1];
arr[i-1] = arr[i];
arr[i] = tmp;
this.items.set(arr);
this.dirty.set(true);
}
moveDown(i: number) {
const arr = this.items().slice();
if (i >= arr.length - 1) return;
const tmp = arr[i+1];
arr[i+1] = arr[i];
arr[i] = tmp;
this.items.set(arr);
this.dirty.set(true);
}
saveOrder() {
if (!this.dirty() || !this.id()) return;
this.saving.set(true);
const order = this.items().map(it => it.id);
this.api.reorder(this.id(), order).subscribe({
next: () => { this.saving.set(false); this.dirty.set(false); },
error: (err) => { this.saving.set(false); this.error.set(err?.error?.error || err?.message || 'Échec de l\'enregistrement'); }
});
}
removeItem(it: PlaylistItem) {
const ok = confirm('Retirer cette vidéo de la playlist ?');
if (!ok) return;
this.api.removeVideo(this.id(), it.provider, it.videoId).subscribe({
next: () => {
this.items.set(this.items().filter(x => !(x.provider === it.provider && x.videoId === it.videoId)));
this.dirty.set(true);
},
error: (err) => this.error.set(err?.error?.error || err?.message || 'Échec du retrait')
});
}
}

View File

@ -1,6 +1,107 @@
<div class="container mx-auto p-4 sm:p-6"> <div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">Listes de lecture</h2> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
<div class="bg-slate-800/50 rounded-lg p-6 border border-slate-700"> <h2 class="text-3xl font-bold text-slate-100 border-l-4 border-red-500 pl-4">Listes de lecture</h2>
<p class="text-slate-300">Bientôt disponible: vos listes de lecture centralisées, tous providers confondus.</p> <div class="flex items-center gap-3 w-full sm:w-auto">
<input type="text" class="flex-1 sm:w-64 bg-slate-900 text-slate-100 border border-slate-700 rounded px-3 py-2"
placeholder="Rechercher..."
[value]="searchQuery()"
(input)="onSearchChange($event)"
/>
<button (click)="openCreate()" class="bg-red-600 hover:bg-red-500 text-white font-semibold px-4 py-2 rounded">
Nouvelle playlist
</button>
</div>
</div> </div>
@if (loading()) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
@for (i of [1,2,3,4,5,6,7,8]; track i) {
<div class="animate-pulse bg-slate-800 rounded-lg overflow-hidden border border-slate-700">
<div class="w-full h-40 bg-slate-700"></div>
<div class="p-4 space-y-3">
<div class="h-5 bg-slate-700 rounded w-3/4"></div>
<div class="h-4 bg-slate-700 rounded w-1/2"></div>
</div>
</div>
}
</div>
} @else if (error()) {
<div class="bg-red-900/40 border border-red-700 text-red-300 p-4 rounded">{{ error() }}</div>
} @else if (playlists().length === 0) {
<div class="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<p class="text-slate-300">Aucune playlist pour le moment. Créez-en une pour organiser vos vidéos.</p>
</div>
} @else {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
@for (pl of playlists(); track pl.id) {
<div class="bg-slate-800/70 rounded-lg overflow-hidden border border-slate-700 flex flex-col">
<a [routerLink]="['/library/playlists', pl.id]" class="block group">
<div class="relative">
@if (pl.thumbnail) {
<img [src]="pl.thumbnail!" alt="" class="w-full h-40 object-cover group-hover:opacity-90 transition" />
} @else {
<div class="w-full h-40 bg-gradient-to-br from-slate-700 to-slate-800 flex items-center justify-center text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" viewBox="0 0 24 24" fill="currentColor"><path d="M4 5a2 2 0 00-2 2v7.5A2.5 2.5 0 004.5 17H20a2 2 0 002-2V7a2 2 0 00-2-2H4zm0 2h16v8H4.5a.5.5 0 01-.5-.5V7z"/><path d="M12 8.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z"/></svg>
</div>
}
</div>
</a>
<div class="p-4 flex-1 flex flex-col">
<div class="flex items-start justify-between gap-3">
<a [routerLink]="['/library/playlists', pl.id]" class="font-semibold text-slate-100 hover:text-red-400 transition line-clamp-2">{{ pl.title }}</a>
<span class="text-xs px-2 py-1 rounded border" [ngClass]="pl.isPrivate ? 'border-slate-600 text-slate-300' : 'border-emerald-700 text-emerald-300'">
{{ pl.isPrivate ? 'Privée' : 'Publique' }}
</span>
</div>
<div class="mt-2 text-sm text-slate-400 flex items-center justify-between">
<span>{{ pl.itemsCount || 0 }} vidéo(s)</span>
<span>MAJ: {{ (pl.updatedAt || '').slice(0,10) }}</span>
</div>
<div class="mt-3 flex items-center gap-2">
<button (click)="openEdit(pl)" class="px-3 py-1.5 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm">Modifier</button>
<button (click)="deletePlaylist(pl)" class="px-3 py-1.5 rounded bg-red-700 hover:bg-red-600 text-white text-sm">Supprimer</button>
</div>
</div>
</div>
}
</div>
}
@if (modalOpen()) {
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" role="dialog" aria-modal="true">
<div class="w-full max-w-lg bg-slate-900 border border-slate-700 rounded-lg shadow-xl">
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-100">{{ editId() ? 'Modifier la playlist' : 'Nouvelle playlist' }}</h3>
<button (click)="closeModal()" class="text-slate-400 hover:text-slate-200"></button>
</div>
<div class="p-4 space-y-4">
<div>
<label class="block text-sm text-slate-400 mb-1">Titre</label>
<input type="text" class="w-full bg-slate-950 text-slate-100 border border-slate-700 rounded px-3 py-2"
[value]="formTitle()" (input)="onTitleChange($event)" />
</div>
<div>
<label class="block text-sm text-slate-400 mb-1">Description</label>
<textarea rows="3" class="w-full bg-slate-950 text-slate-100 border border-slate-700 rounded px-3 py-2"
[value]="formDescription()" (input)="onDescriptionChange($event)"></textarea>
</div>
<div>
<label class="block text-sm text-slate-400 mb-1">URL de l'image</label>
<input type="text" class="w-full bg-slate-950 text-slate-100 border border-slate-700 rounded px-3 py-2"
[value]="formThumbnail()" (input)="onThumbnailChange($event)" />
</div>
<div class="flex items-center gap-2">
<input id="priv" type="checkbox" class="accent-red-500" [checked]="formPrivate()" (change)="onPrivateChange($event)" />
<label for="priv" class="text-sm text-slate-300">Privée</label>
</div>
</div>
<div class="p-4 border-t border-slate-700 flex items-center justify-end gap-3">
<button (click)="closeModal()" class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" [disabled]="busySave()">Annuler</button>
<button (click)="savePlaylist()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white font-semibold disabled:opacity-60" [disabled]="busySave() || !formTitle().trim()">
{{ busySave() ? 'Enregistrement...' : 'Enregistrer' }}
</button>
</div>
</div>
</div>
}
</div> </div>

View File

@ -1,11 +1,132 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { RouterLink } from '@angular/router';
@Component({ @Component({
selector: 'app-library-playlists', selector: 'app-library-playlists',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule, FormsModule, RouterLink],
templateUrl: './playlists.component.html', templateUrl: './playlists.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class PlaylistsComponent { } export class PlaylistsComponent {
private http = inject(HttpClient);
// UI state
loading = signal<boolean>(true);
error = signal<string | null>(null);
searchQuery = signal<string>('');
playlists = signal<Array<{ id: string; title: string; description?: string | null; thumbnail?: string | null; isPrivate: number | boolean; createdAt: string; updatedAt: string; itemsCount: number }>>([]);
// Modal state
modalOpen = signal<boolean>(false);
editId = signal<string | null>(null);
formTitle = signal<string>('');
formDescription = signal<string>('');
formThumbnail = signal<string>('');
formPrivate = signal<boolean>(true);
busySave = signal<boolean>(false);
constructor() {
this.reload();
}
private apiBase(): string {
try {
const port = window?.location?.port || '';
return port && port !== '4000' ? '/proxy/api' : '/api';
} catch { return '/api'; }
}
reload(q?: string): void {
this.loading.set(true);
this.error.set(null);
const params: any = { limit: 200 };
if (q && q.trim()) params.q = q.trim();
this.http.get<any[]>(`${this.apiBase()}/playlists`, { params, withCredentials: true }).subscribe({
next: (rows) => { this.playlists.set(rows || []); this.loading.set(false); },
error: (err) => { this.error.set(err?.error?.error || err?.message || 'Erreur de chargement'); this.loading.set(false); }
});
}
onSearchChange(event: Event) {
const input = event.target as HTMLInputElement;
this.searchQuery.set(input.value);
const q = input.value.trim();
this.reload(q || undefined);
}
onTitleChange(event: Event) {
const input = event.target as HTMLInputElement;
this.formTitle.set(input.value);
}
onDescriptionChange(event: Event) {
const textarea = event.target as HTMLTextAreaElement;
this.formDescription.set(textarea.value);
}
onThumbnailChange(event: Event) {
const input = event.target as HTMLInputElement;
this.formThumbnail.set(input.value);
}
onPrivateChange(event: Event) {
const input = event.target as HTMLInputElement;
this.formPrivate.set(input.checked);
}
openCreate() {
this.editId.set(null);
this.formTitle.set('');
this.formDescription.set('');
this.formThumbnail.set('');
this.formPrivate.set(true);
this.modalOpen.set(true);
}
openEdit(pl: any) {
this.editId.set(pl.id);
this.formTitle.set(pl.title || '');
this.formDescription.set(pl.description || '');
this.formThumbnail.set(pl.thumbnail || '');
this.formPrivate.set(!!pl.isPrivate);
this.modalOpen.set(true);
}
closeModal() {
if (this.busySave()) return;
this.modalOpen.set(false);
}
savePlaylist() {
if (!this.formTitle().trim()) return;
this.busySave.set(true);
const body = {
title: this.formTitle().trim(),
description: this.formDescription().trim() || undefined,
thumbnail: this.formThumbnail().trim() || undefined,
isPrivate: !!this.formPrivate()
};
const id = this.editId();
const req = id
? this.http.put(`${this.apiBase()}/playlists/${encodeURIComponent(id)}`, body, { withCredentials: true })
: this.http.post(`${this.apiBase()}/playlists`, body, { withCredentials: true });
req.subscribe({
next: () => { this.busySave.set(false); this.modalOpen.set(false); this.reload(this.searchQuery().trim() || undefined); },
error: (err) => { this.error.set(err?.error?.error || err?.message || 'Échec de lenregistrement'); this.busySave.set(false); }
});
}
deletePlaylist(pl: any) {
if (!pl?.id) return;
const ok = confirm(`Supprimer la playlist "${pl.title}" ?`);
if (!ok) return;
this.http.delete(`${this.apiBase()}/playlists/${encodeURIComponent(pl.id)}`, { withCredentials: true }).subscribe({
next: () => this.reload(this.searchQuery().trim() || undefined),
error: (err) => this.error.set(err?.error?.error || err?.message || 'Échec de la suppression')
});
}
}

View File

@ -56,6 +56,7 @@
</div> </div>
<div class="absolute bottom-2 left-2"> <div class="absolute bottom-2 left-2">
<app-like-button [videoId]="video.videoId" [provider]="'twitch'" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button> <app-like-button [videoId]="video.videoId" [provider]="'twitch'" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
<app-add-to-playlist class="ml-2" [provider]="'twitch'" [videoId]="video.videoId" [title]="video.title" [thumbnail]="video.thumbnail"></app-add-to-playlist>
</div> </div>
</div> </div>
<div class="p-4 flex-grow flex flex-col"> <div class="p-4 flex-grow flex flex-col">
@ -92,6 +93,7 @@
</div> </div>
<div class="absolute bottom-2 left-2"> <div class="absolute bottom-2 left-2">
<app-like-button [videoId]="video.videoId" [provider]="'twitch'" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button> <app-like-button [videoId]="video.videoId" [provider]="'twitch'" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
<app-add-to-playlist class="ml-2" [provider]="'twitch'" [videoId]="video.videoId" [title]="video.title" [thumbnail]="video.thumbnail"></app-add-to-playlist>
</div> </div>
</div> </div>
<div class="p-4 flex-grow flex flex-col"> <div class="p-4 flex-grow flex flex-col">
@ -128,6 +130,7 @@
</div> </div>
<div class="absolute bottom-2 left-2"> <div class="absolute bottom-2 left-2">
<app-like-button [videoId]="video.videoId" [provider]="selectedProviderForView()" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button> <app-like-button [videoId]="video.videoId" [provider]="selectedProviderForView()" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
<app-add-to-playlist class="ml-2" [provider]="selectedProviderForView()" [videoId]="video.videoId" [title]="video.title" [thumbnail]="video.thumbnail"></app-add-to-playlist>
</div> </div>
</div> </div>
<div class="p-4 flex-grow flex flex-col"> <div class="p-4 flex-grow flex flex-col">

View File

@ -10,13 +10,14 @@ import { HistoryService } from '../../services/history.service';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { TranslatePipe } from '../../pipes/translate.pipe'; import { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component'; import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
import { AddToPlaylistComponent } from '../shared/components/add-to-playlist/add-to-playlist.component';
@Component({ @Component({
selector: 'app-search', selector: 'app-search',
standalone: true, standalone: true,
templateUrl: './search.component.html', templateUrl: './search.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent] imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent]
}) })
export class SearchComponent { export class SearchComponent {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);

View File

@ -0,0 +1,158 @@
import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, SimpleChanges, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { PlaylistsService, Playlist } from '../../../../services/playlists.service';
import { AuthService } from '../../../../services/auth.service';
@Component({
selector: 'app-add-to-playlist',
standalone: true,
imports: [CommonModule, HttpClientModule],
template: `
<div class="relative inline-block text-left" (click)="$event.stopPropagation();">
<button (click)="toggleOpen($event)"
[disabled]="!isAuthenticated()"
class="transition-colors duration-200 p-1 rounded-full hover:bg-slate-700/50 flex items-center justify-center text-slate-300 disabled:opacity-60"
[attr.aria-expanded]="open()"
[attr.aria-haspopup]="true"
[attr.title]="!isAuthenticated() ? 'Connectez-vous pour gérer vos playlists' : 'Ajouter à une playlist'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 6a2 2 0 012-2h12a1 1 0 010 2H6v10a1 1 0 11-2 0V6z" />
<path d="M14 11a1 1 0 011-1h5a1 1 0 110 2h-5a1 1 0 01-1-1zM14 15a1 1 0 011-1h5a1 1 0 110 2h-5a1 1 0 01-1-1zM10 11a1 1 0 112 0v1h1a1 1 0 110 2h-1v1a1 1 0 11-2 0v-1H9a1 1 0 110-2h1v-1z" />
</svg>
<span class="sr-only">Ajouter à une playlist</span>
</button>
@if (open()) {
<div class="absolute right-0 mt-2 w-72 origin-top-right rounded-md bg-slate-900 border border-slate-700 shadow-lg focus:outline-none z-50">
<div class="p-3 space-y-3">
<div class="flex items-center justify-between">
<div class="text-sm font-semibold text-slate-200">Vos playlists</div>
<button class="text-xs text-slate-400 hover:text-slate-200" (click)="reloadPlaylists()">Rafraîchir</button>
</div>
@if (loading()) {
<div class="text-sm text-slate-400">Chargement...</div>
} @else if (error()) {
<div class="text-sm text-red-300">{{ error() }}</div>
} @else if (playlists().length === 0) {
<div class="text-sm text-slate-400">Aucune playlist. Créez-en une ci-dessous.</div>
} @else {
<div class="max-h-48 overflow-auto space-y-2">
@for (pl of playlists(); track pl.id) {
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<div class="truncate text-sm text-slate-200">{{ pl.title }}</div>
<div class="text-xs text-slate-400">{{ pl.itemsCount || 0 }} vidéo(s)</div>
</div>
<button class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-xs"
[disabled]="busyAdd()"
(click)="onAddTo(pl)">Ajouter</button>
</div>
}
</div>
}
<div class="border-t border-slate-700 pt-2">
<label class="block text-xs text-slate-400 mb-1">Nouvelle playlist</label>
<input type="text" class="w-full bg-slate-950 text-slate-100 border border-slate-700 rounded px-2 py-1 text-sm"
[value]="newTitle()" (input)="onTitleChange($event)" placeholder="Titre de la playlist" />
<div class="mt-2 flex items-center justify-end gap-2">
<label class="flex items-center gap-2 text-xs text-slate-300">
<input type="checkbox" class="accent-red-500" [checked]="newPrivate()" (change)="onPrivateChange($event)" />
Privée
</label>
<button class="px-3 py-1.5 rounded bg-red-600 hover:bg-red-500 text-white text-xs font-semibold disabled:opacity-60"
[disabled]="!newTitle().trim() || busyAdd()"
(click)="onQuickCreate()">Créer + Ajouter</button>
</div>
</div>
</div>
</div>
}
</div>
`,
styles: [
`
.sr-only { position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0; }
`
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AddToPlaylistComponent implements OnInit, OnDestroy {
private api = inject(PlaylistsService);
private auth = inject(AuthService);
@Input() provider: string = 'youtube';
@Input() videoId: string = '';
@Input() title?: string;
@Input() thumbnail?: string;
open = signal<boolean>(false);
loading = signal<boolean>(false);
error = signal<string | null>(null);
playlists = signal<Playlist[]>([]);
busyAdd = signal<boolean>(false);
newTitle = signal<string>('');
newPrivate = signal<boolean>(true);
private destroyed = false;
ngOnInit(): void {}
ngOnDestroy(): void { this.destroyed = true; }
isAuthenticated(): boolean { return !!this.auth.currentUser(); }
toggleOpen(ev: Event): void {
try { ev.preventDefault(); ev.stopPropagation(); } catch {}
if (!this.isAuthenticated()) return;
const next = !this.open();
this.open.set(next);
if (next && this.playlists().length === 0 && !this.loading()) {
this.reloadPlaylists();
}
}
reloadPlaylists(): void {
this.loading.set(true);
this.error.set(null);
this.api.list(200, 0).subscribe({
next: (rows) => { if (!this.destroyed) { this.playlists.set(rows || []); this.loading.set(false); } },
error: (err) => { if (!this.destroyed) { this.error.set(err?.error?.error || err?.message || 'Erreur'); this.loading.set(false); } }
});
}
onAddTo(pl: Playlist): void {
if (this.busyAdd() || !pl?.id) return;
this.busyAdd.set(true);
this.api.addVideo(pl.id, this.provider, this.videoId, this.title, this.thumbnail).subscribe({
next: () => { this.busyAdd.set(false); this.open.set(false); },
error: (err) => { this.busyAdd.set(false); this.error.set(err?.error?.error || err?.message || 'Échec de l\'ajout'); }
});
}
onQuickCreate(): void {
const t = this.newTitle().trim();
if (!t) return;
if (this.busyAdd()) return;
this.busyAdd.set(true);
this.api.create(t, undefined, undefined, this.newPrivate()).subscribe({
next: (pl) => {
this.api.addVideo(pl.id, this.provider, this.videoId, this.title, this.thumbnail).subscribe({
next: () => { this.busyAdd.set(false); this.open.set(false); this.newTitle.set(''); },
error: (err) => { this.busyAdd.set(false); this.error.set(err?.error?.error || err?.message || 'Échec de l\'ajout'); }
});
},
error: (err) => { this.busyAdd.set(false); this.error.set(err?.error?.error || err?.message || 'Échec de la création'); }
});
}
onTitleChange(event: Event) {
const input = event.target as HTMLInputElement;
this.newTitle.set(input.value);
}
onPrivateChange(event: Event) {
const input = event.target as HTMLInputElement;
this.newPrivate.set(input.checked);
}
}

View File

@ -94,6 +94,9 @@
<span>{{ liked() ? 'Liked' : 'Like' }}</span> <span>{{ liked() ? 'Liked' : 'Like' }}</span>
</button> </button>
} }
@if (isLoggedIn()) {
<app-add-to-playlist [provider]="provider()" [videoId]="v.videoId" [title]="v.title" [thumbnail]="v.thumbnail"></app-add-to-playlist>
}
@if (isLoggedIn()) { @if (isLoggedIn()) {
<button (click)="openDownloadPanel()" <button (click)="openDownloadPanel()"
class="flex items-center space-x-2 bg-red-600 hover:bg-red-500 px-4 py-2 rounded-full transition font-semibold"> class="flex items-center space-x-2 bg-red-600 hover:bg-red-500 px-4 py-2 rounded-full transition font-semibold">

View File

@ -5,6 +5,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { YoutubeApiService } from '../../services/youtube-api.service'; import { YoutubeApiService } from '../../services/youtube-api.service';
import { Video, VideoDetail } from '../../models/video.model'; import { Video, VideoDetail } from '../../models/video.model';
import { VideoPlayerComponent } from '../video-player/video-player.component'; import { VideoPlayerComponent } from '../video-player/video-player.component';
import { AddToPlaylistComponent } from '../shared/components/add-to-playlist/add-to-playlist.component';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { GeminiService } from '../../services/gemini.service'; import { GeminiService } from '../../services/gemini.service';
import { LikesService } from '../../services/likes.service'; import { LikesService } from '../../services/likes.service';
@ -21,7 +22,7 @@ import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
templateUrl: './watch.component.html', templateUrl: './watch.component.html',
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, VideoPlayerComponent, RouterLink] imports: [CommonModule, VideoPlayerComponent, RouterLink, AddToPlaylistComponent]
}) })
export class WatchComponent implements OnDestroy, AfterViewInit { export class WatchComponent implements OnDestroy, AfterViewInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
@ -165,7 +166,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
if (directUrl) return directUrl; if (directUrl) return directUrl;
if (id) return `https://rumble.com/${encodeURIComponent(id)}`; if (id) return `https://rumble.com/${encodeURIComponent(id)}`;
} }
return directUrl; return directUrl || null;
} catch { } catch {
return directUrl || null; return directUrl || null;
} }

View File

@ -0,0 +1,80 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface Playlist {
id: string;
userId?: string;
title: string;
description?: string | null;
thumbnail?: string | null;
isPrivate: boolean | number;
createdAt: string;
updatedAt: string;
itemsCount?: number;
}
export interface PlaylistItem {
id: string;
playlistId: string;
provider: string;
videoId: string;
title?: string | null;
thumbnail?: string | null;
addedAt: string;
position: number;
}
@Injectable({ providedIn: 'root' })
export class PlaylistsService {
private http = inject(HttpClient);
private apiBase(): string {
try {
const port = window?.location?.port || '';
return port && port !== '4000' ? '/proxy/api' : '/api';
} catch {
return '/api';
}
}
list(limit = 100, offset = 0, q?: string): Observable<Playlist[]> {
let params = new HttpParams().set('limit', String(limit)).set('offset', String(offset));
if (q && q.trim().length) params = params.set('q', q.trim());
return this.http.get<Playlist[]>(`${this.apiBase()}/playlists`, { params, withCredentials: true });
}
get(id: string, limit = 200, offset = 0): Observable<Playlist & { items: PlaylistItem[] }> {
const params = new HttpParams().set('limit', String(limit)).set('offset', String(offset));
return this.http.get<Playlist & { items: PlaylistItem[] }>(`${this.apiBase()}/playlists/${encodeURIComponent(id)}`, { params, withCredentials: true });
}
create(title: string, description?: string, thumbnail?: string, isPrivate: boolean = true): Observable<Playlist> {
return this.http.post<Playlist>(`${this.apiBase()}/playlists`, { title, description, thumbnail, isPrivate }, { withCredentials: true });
}
update(id: string, patch: Partial<{ title: string; description: string; thumbnail: string; isPrivate: boolean }>): Observable<Playlist> {
return this.http.put<Playlist>(`${this.apiBase()}/playlists/${encodeURIComponent(id)}`, patch, { withCredentials: true });
}
remove(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiBase()}/playlists/${encodeURIComponent(id)}`, { withCredentials: true });
}
addVideo(playlistId: string, provider: string, videoId: string, title?: string, thumbnail?: string, opts?: { slug?: string; instance?: string; sourceUrl?: string }): Observable<PlaylistItem> {
const body: any = { provider, videoId, title, thumbnail };
if (opts?.slug) body.slug = opts.slug;
if (opts?.instance) body.instance = opts.instance;
if (opts?.sourceUrl) body.sourceUrl = opts.sourceUrl;
return this.http.post<PlaylistItem>(`${this.apiBase()}/playlists/${encodeURIComponent(playlistId)}/videos`, body, { withCredentials: true });
}
removeVideo(playlistId: string, provider: string, videoId: string): Observable<{ removed: boolean }> {
const params = new HttpParams().set('provider', provider);
return this.http.delete<{ removed: boolean }>(`${this.apiBase()}/playlists/${encodeURIComponent(playlistId)}/videos/${encodeURIComponent(videoId)}`, { params, withCredentials: true });
}
reorder(playlistId: string, order: Array<string | { id?: string; provider?: string; videoId?: string }>): Observable<{ changed: number }> {
return this.http.put<{ changed: number }>(`${this.apiBase()}/playlists/${encodeURIComponent(playlistId)}/reorder`, { order }, { withCredentials: true });
}
}