feat(subscriptions): gestion des abonnements par groupes façon PocketTube
CI / build-and-test (push) Failing after 8m18s
CI / build-and-test (push) Failing after 8m18s
- Tables subscription_groups + subscription_group_members (migration + schema) - API CRUD groupes, membres par groupe, groupes par abonnement - Page Abonnements: onglets Groupes/Chaines, modale creation/edition (icone/couleur), assignation unitaire + lot, filtre par groupe + recherche - Sidebar: section Groupes avec navigation directe (queryParams) - i18n nav.groups FR/EN + 4 tests unitaires service
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
-- Groupes d'abonnements (façon PocketTube) : dossiers thématiques par utilisateur.
|
||||||
|
CREATE TABLE IF NOT EXISTS subscription_groups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '#ef4444',
|
||||||
|
icon TEXT NOT NULL DEFAULT '📁',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(user_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sub_groups_user ON subscription_groups(user_id);
|
||||||
|
|
||||||
|
-- Relation plusieurs-à-plusieurs : une chaîne (abonnement) dans plusieurs groupes.
|
||||||
|
CREATE TABLE IF NOT EXISTS subscription_group_members (
|
||||||
|
group_id TEXT NOT NULL REFERENCES subscription_groups(id) ON DELETE CASCADE,
|
||||||
|
subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||||
|
added_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (group_id, subscription_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sub_group_members_sub ON subscription_group_members(subscription_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sub_group_members_group ON subscription_group_members(group_id);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -109,6 +109,30 @@ CREATE TABLE IF NOT EXISTS subscriptions (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_subs_user ON subscriptions(user_id);
|
CREATE INDEX IF NOT EXISTS idx_subs_user ON subscriptions(user_id);
|
||||||
|
|
||||||
|
-- Groupes d'abonnements (façon PocketTube) -----------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS subscription_groups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '#ef4444',
|
||||||
|
icon TEXT NOT NULL DEFAULT '📁',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(user_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sub_groups_user ON subscription_groups(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS subscription_group_members (
|
||||||
|
group_id TEXT NOT NULL REFERENCES subscription_groups(id) ON DELETE CASCADE,
|
||||||
|
subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
||||||
|
added_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (group_id, subscription_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sub_group_members_sub ON subscription_group_members(subscription_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sub_group_members_group ON subscription_group_members(group_id);
|
||||||
|
|
||||||
-- Playlists -----------------------------------------------------------------
|
-- Playlists -----------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS playlists (
|
CREATE TABLE IF NOT EXISTS playlists (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
+129
@@ -978,6 +978,135 @@ export function isSubscribed({ userId, provider, externalId }) {
|
|||||||
`).get(userId, provider, externalId) != null;
|
`).get(userId, provider, externalId) != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------- Subscription groups (façon PocketTube) --------------------
|
||||||
|
|
||||||
|
function subscriptionGroupRowToDto(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
color: row.color || '#ef4444',
|
||||||
|
icon: row.icon || '📁',
|
||||||
|
channelCount: typeof row.channelCount === 'number' ? row.channelCount : Number(row.channelCount || 0),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSubscriptionGroups(userId) {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT g.id, g.user_id, g.name, g.color, g.icon, g.created_at, g.updated_at,
|
||||||
|
COUNT(m.subscription_id) AS channelCount
|
||||||
|
FROM subscription_groups g
|
||||||
|
LEFT JOIN subscription_group_members m ON m.group_id = g.id
|
||||||
|
WHERE g.user_id = ?
|
||||||
|
GROUP BY g.id
|
||||||
|
ORDER BY g.created_at ASC
|
||||||
|
`).all(userId);
|
||||||
|
return rows.map(subscriptionGroupRowToDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSubscriptionGroup({ userId, name, color, icon }) {
|
||||||
|
const cleanName = String(name || '').trim().slice(0, 80);
|
||||||
|
if (!cleanName) throw new Error('group_name_required');
|
||||||
|
const id = cryptoRandomId();
|
||||||
|
const now = Date.now();
|
||||||
|
try {
|
||||||
|
db.prepare(`INSERT INTO subscription_groups (id, user_id, name, color, icon, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||||
|
.run(id, userId, cleanName, String(color || '#ef4444').slice(0, 16), String(icon || '📁').slice(0, 16), now, now);
|
||||||
|
} catch (e) {
|
||||||
|
if (String(e?.message || '').includes('UNIQUE')) throw new Error('group_name_taken');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return subscriptionGroupRowToDto(
|
||||||
|
db.prepare(`SELECT *, 0 AS channelCount FROM subscription_groups WHERE id = ?`).get(id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSubscriptionGroup({ userId, groupId, name, color, icon }) {
|
||||||
|
const existing = db.prepare(`SELECT * FROM subscription_groups WHERE id = ? AND user_id = ?`).get(groupId, userId);
|
||||||
|
if (!existing) return null;
|
||||||
|
const next = {
|
||||||
|
name: name === undefined ? existing.name : String(name).trim().slice(0, 80),
|
||||||
|
color: color === undefined ? existing.color : String(color).slice(0, 16),
|
||||||
|
icon: icon === undefined ? existing.icon : String(icon).slice(0, 16),
|
||||||
|
};
|
||||||
|
if (!next.name) throw new Error('group_name_required');
|
||||||
|
try {
|
||||||
|
db.prepare(`UPDATE subscription_groups SET name = ?, color = ?, icon = ?, updated_at = ? WHERE id = ? AND user_id = ?`)
|
||||||
|
.run(next.name, next.color, next.icon, Date.now(), groupId, userId);
|
||||||
|
} catch (e) {
|
||||||
|
if (String(e?.message || '').includes('UNIQUE')) throw new Error('group_name_taken');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT g.*, COUNT(m.subscription_id) AS channelCount
|
||||||
|
FROM subscription_groups g
|
||||||
|
LEFT JOIN subscription_group_members m ON m.group_id = g.id
|
||||||
|
WHERE g.id = ? AND g.user_id = ?
|
||||||
|
GROUP BY g.id
|
||||||
|
`).get(groupId, userId);
|
||||||
|
return subscriptionGroupRowToDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSubscriptionGroup({ userId, groupId }) {
|
||||||
|
return db.prepare(`DELETE FROM subscription_groups WHERE id = ? AND user_id = ?`).run(groupId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remplace le contenu d'un groupe (ids d'abonnements appartenant à l'utilisateur). */
|
||||||
|
export function setSubscriptionGroupMembers({ userId, groupId, subscriptionIds }) {
|
||||||
|
const group = db.prepare(`SELECT * FROM subscription_groups WHERE id = ? AND user_id = ?`).get(groupId, userId);
|
||||||
|
if (!group) return null;
|
||||||
|
const ids = [...new Set((subscriptionIds || []).map(Number).filter(Number.isFinite))];
|
||||||
|
const owned = ids.length
|
||||||
|
? db.prepare(`SELECT id FROM subscriptions WHERE user_id = ? AND id IN (${ids.map(() => '?').join(',')})`).all(userId, ...ids).map(r => r.id)
|
||||||
|
: [];
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
db.prepare(`DELETE FROM subscription_group_members WHERE group_id = ?`).run(groupId);
|
||||||
|
const now = Date.now();
|
||||||
|
const stmt = db.prepare(`INSERT OR IGNORE INTO subscription_group_members (group_id, subscription_id, added_at) VALUES (?, ?, ?)`);
|
||||||
|
for (const sid of owned) stmt.run(groupId, sid, now);
|
||||||
|
db.prepare(`UPDATE subscription_groups SET updated_at = ? WHERE id = ?`).run(now, groupId);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
return updateSubscriptionGroup({ userId, groupId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remplace les groupes d'un abonnement (relation plusieurs-à-plusieurs). */
|
||||||
|
export function setSubscriptionGroups({ userId, subscriptionId, groupIds }) {
|
||||||
|
const sub = db.prepare(`SELECT id FROM subscriptions WHERE id = ? AND user_id = ?`).get(subscriptionId, userId);
|
||||||
|
if (!sub) return null;
|
||||||
|
const ids = [...new Set((groupIds || []).map(String))];
|
||||||
|
const owned = ids.length
|
||||||
|
? db.prepare(`SELECT id FROM subscription_groups WHERE user_id = ? AND id IN (${ids.map(() => '?').join(',')})`).all(userId, ...ids).map(r => r.id)
|
||||||
|
: [];
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
db.prepare(`DELETE FROM subscription_group_members WHERE subscription_id = ?`).run(subscriptionId);
|
||||||
|
const now = Date.now();
|
||||||
|
const stmt = db.prepare(`INSERT OR IGNORE INTO subscription_group_members (group_id, subscription_id, added_at) VALUES (?, ?, ?)`);
|
||||||
|
for (const gid of owned) stmt.run(gid, subscriptionId, now);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
return owned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carte subscriptionId -> groupId[] pour tout l'utilisateur (une seule requête). */
|
||||||
|
export function listSubscriptionGroupMembersByUser(userId) {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT m.subscription_id AS subscriptionId, m.group_id AS groupId
|
||||||
|
FROM subscription_group_members m
|
||||||
|
JOIN subscriptions s ON s.id = m.subscription_id AND s.user_id = ?
|
||||||
|
JOIN subscription_groups g ON g.id = m.group_id AND g.user_id = ?
|
||||||
|
`).all(userId, userId);
|
||||||
|
const map = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!map[r.subscriptionId]) map[r.subscriptionId] = [];
|
||||||
|
map[r.subscriptionId].push(r.groupId);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
function subscriptionRowToDto(row) {
|
function subscriptionRowToDto(row) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
const channel = channelRowToMeta({
|
const channel = channelRowToMeta({
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ import {
|
|||||||
subscribeChannel,
|
subscribeChannel,
|
||||||
unsubscribeChannel,
|
unsubscribeChannel,
|
||||||
isSubscribed,
|
isSubscribed,
|
||||||
|
listSubscriptionGroups,
|
||||||
|
createSubscriptionGroup,
|
||||||
|
updateSubscriptionGroup,
|
||||||
|
deleteSubscriptionGroup,
|
||||||
|
setSubscriptionGroupMembers,
|
||||||
|
setSubscriptionGroups,
|
||||||
|
listSubscriptionGroupMembersByUser,
|
||||||
insertDownloadJob,
|
insertDownloadJob,
|
||||||
getDownloadJob,
|
getDownloadJob,
|
||||||
listDownloadJobs,
|
listDownloadJobs,
|
||||||
@@ -334,6 +341,94 @@ r.delete('/subscriptions/:subscriptionId', authMiddlewareCookieAware, subscripti
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------- Subscription groups APIs (façon PocketTube) --------------------
|
||||||
|
|
||||||
|
r.get('/subscription-groups', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
|
||||||
|
try {
|
||||||
|
const groups = listSubscriptionGroups(req.user.id);
|
||||||
|
const members = listSubscriptionGroupMembersByUser(req.user.id);
|
||||||
|
return res.json({ groups, members });
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ error: 'groups_list_failed', details: String(error?.message || error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
r.post('/subscription-groups', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
|
||||||
|
try {
|
||||||
|
const group = createSubscriptionGroup({
|
||||||
|
userId: req.user.id,
|
||||||
|
name: req.body?.name,
|
||||||
|
color: req.body?.color,
|
||||||
|
icon: req.body?.icon,
|
||||||
|
});
|
||||||
|
return res.status(201).json(group);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error?.message || 'group_create_failed';
|
||||||
|
if (msg === 'group_name_required') return res.status(400).json({ error: msg });
|
||||||
|
if (msg === 'group_name_taken') return res.status(409).json({ error: msg });
|
||||||
|
return res.status(500).json({ error: 'group_create_failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
r.patch('/subscription-groups/:groupId', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
|
||||||
|
try {
|
||||||
|
const group = updateSubscriptionGroup({
|
||||||
|
userId: req.user.id,
|
||||||
|
groupId: req.params.groupId,
|
||||||
|
name: req.body?.name,
|
||||||
|
color: req.body?.color,
|
||||||
|
icon: req.body?.icon,
|
||||||
|
});
|
||||||
|
if (!group) return res.status(404).json({ error: 'not_found' });
|
||||||
|
return res.json(group);
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error?.message || 'group_update_failed';
|
||||||
|
if (msg === 'group_name_required') return res.status(400).json({ error: msg });
|
||||||
|
if (msg === 'group_name_taken') return res.status(409).json({ error: msg });
|
||||||
|
return res.status(500).json({ error: 'group_update_failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
r.delete('/subscription-groups/:groupId', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
|
||||||
|
try {
|
||||||
|
const info = deleteSubscriptionGroup({ userId: req.user.id, groupId: req.params.groupId });
|
||||||
|
if ((info?.changes || 0) === 0) return res.status(404).json({ error: 'not_found' });
|
||||||
|
return res.status(204).end();
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ error: 'group_delete_failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remplace les chaînes d'un groupe : { subscriptionIds: number[] }
|
||||||
|
r.put('/subscription-groups/:groupId/members', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
|
||||||
|
try {
|
||||||
|
const group = setSubscriptionGroupMembers({
|
||||||
|
userId: req.user.id,
|
||||||
|
groupId: req.params.groupId,
|
||||||
|
subscriptionIds: req.body?.subscriptionIds,
|
||||||
|
});
|
||||||
|
if (!group) return res.status(404).json({ error: 'not_found' });
|
||||||
|
return res.json(group);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ error: 'group_members_failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remplace les groupes d'un abonnement : { groupIds: string[] }
|
||||||
|
r.put('/subscriptions/:subscriptionId/groups', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
|
||||||
|
try {
|
||||||
|
const groupIds = setSubscriptionGroups({
|
||||||
|
userId: req.user.id,
|
||||||
|
subscriptionId: Number(req.params.subscriptionId),
|
||||||
|
groupIds: req.body?.groupIds,
|
||||||
|
});
|
||||||
|
if (!groupIds) return res.status(404).json({ error: 'not_found' });
|
||||||
|
return res.json({ subscriptionId: Number(req.params.subscriptionId), groupIds });
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ error: 'subscription_groups_failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Public: view a playlist if allowed (owner or public). Authorization header is optional.
|
// Public: view a playlist if allowed (owner or public). Authorization header is optional.
|
||||||
r.get('/playlists/:id/view', (req, res) => {
|
r.get('/playlists/:id/view', (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,9 +2,16 @@
|
|||||||
<div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-3xl font-bold text-slate-100">Abonnements</h2>
|
<h2 class="text-3xl font-bold text-slate-100">Abonnements</h2>
|
||||||
<p class="text-sm text-slate-400">Gérez vos chaînes suivies multi-fournisseurs</p>
|
<p class="text-sm text-slate-400">Organisez vos chaînes suivies en groupes thématiques</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-red-500"
|
||||||
|
(click)="openNewGroup()"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
+ Nouveau groupe
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="rounded-md border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm text-slate-200 transition hover:border-slate-500 hover:text-white"
|
class="rounded-md border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm text-slate-200 transition hover:border-slate-500 hover:text-white"
|
||||||
(click)="refresh()"
|
(click)="refresh()"
|
||||||
@@ -15,10 +22,152 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
<!-- Onglets façon PocketTube -->
|
||||||
|
<div class="mb-4 flex gap-2 border-b border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="setTab('groups')"
|
||||||
|
class="px-4 py-2 text-sm font-semibold transition"
|
||||||
|
[class.text-red-300]="activeTab() === 'groups'"
|
||||||
|
[class.border-b-2]="activeTab() === 'groups'"
|
||||||
|
[class.border-red-500]="activeTab() === 'groups'"
|
||||||
|
[class.text-slate-400]="activeTab() !== 'groups'"
|
||||||
|
>
|
||||||
|
Groupes ({{ groups().length }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="setTab('channels')"
|
||||||
|
class="px-4 py-2 text-sm font-semibold transition"
|
||||||
|
[class.text-red-300]="activeTab() === 'channels'"
|
||||||
|
[class.border-b-2]="activeTab() === 'channels'"
|
||||||
|
[class.border-red-500]="activeTab() === 'channels'"
|
||||||
|
[class.text-slate-400]="activeTab() !== 'channels'"
|
||||||
|
>
|
||||||
|
Chaînes ({{ items().length }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== ONGLET GROUPES ==================== -->
|
||||||
|
<div *ngIf="activeTab() === 'groups'">
|
||||||
|
<div *ngIf="groups().length > 0; else noGroups" class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<div
|
||||||
|
*ngFor="let group of groups()"
|
||||||
|
(click)="openGroupChannel(group)"
|
||||||
|
class="cursor-pointer rounded-xl border border-slate-800 bg-slate-900/60 p-4 shadow-lg shadow-black/30 transition hover:border-slate-600"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="flex h-11 w-11 items-center justify-center rounded-lg text-xl"
|
||||||
|
[style.background-color]="group.color + '26'"
|
||||||
|
[style.border]="'1px solid ' + group.color + '66'"
|
||||||
|
>{{ group.icon }}</span>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-slate-100">{{ group.name }}</h3>
|
||||||
|
<p class="text-xs text-slate-400">{{ group.channelCount }} chaîne(s)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1" (click)="$event.stopPropagation()">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="openEditGroup(group, $event)"
|
||||||
|
class="rounded px-2 py-1 text-xs text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||||
|
title="Modifier le groupe"
|
||||||
|
>✏️</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="deleteGroup(group, $event)"
|
||||||
|
class="rounded px-2 py-1 text-xs text-slate-400 hover:bg-slate-800 hover:text-red-300"
|
||||||
|
title="Supprimer le groupe"
|
||||||
|
>🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
class="inline-block h-1.5 w-24 rounded-full"
|
||||||
|
[style.background-color]="group.color"
|
||||||
|
></span>
|
||||||
|
<span class="text-xs font-medium text-slate-400 hover:text-red-300">Voir les chaînes →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ng-template #noGroups>
|
||||||
|
<div class="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-slate-700 bg-slate-900/60 p-10 text-center">
|
||||||
|
<div class="text-4xl">📁</div>
|
||||||
|
<h3 class="text-lg font-semibold text-slate-100">Aucun groupe pour le moment</h3>
|
||||||
|
<p class="text-sm text-slate-400">Créez des groupes thématiques (Tech, Musique, Sport…) pour organiser vos abonnements.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="openNewGroup()"
|
||||||
|
class="mt-2 rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-500"
|
||||||
|
>
|
||||||
|
+ Créer mon premier groupe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== ONGLET CHAÎNES ==================== -->
|
||||||
|
<div *ngIf="activeTab() === 'channels'" class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
||||||
<div class="bg-slate-900/60 rounded-xl border border-slate-800 p-4 shadow-lg shadow-black/30">
|
<div class="bg-slate-900/60 rounded-xl border border-slate-800 p-4 shadow-lg shadow-black/30">
|
||||||
|
<!-- Filtre par groupe -->
|
||||||
<div class="flex flex-wrap items-center gap-2 border-b border-slate-800 pb-4">
|
<div class="flex flex-wrap items-center gap-2 border-b border-slate-800 pb-4">
|
||||||
<span class="text-xs uppercase tracking-wide text-slate-400">Filtres</span>
|
<span class="text-xs uppercase tracking-wide text-slate-400">Groupe</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="setGroupFilter('all')"
|
||||||
|
class="rounded-full border px-3 py-1 text-xs font-semibold transition"
|
||||||
|
[class.bg-red-500/20]="activeGroupId() === 'all'"
|
||||||
|
[class.text-red-200]="activeGroupId() === 'all'"
|
||||||
|
[class.border-red-500/40]="activeGroupId() === 'all'"
|
||||||
|
[class.border-slate-700]="activeGroupId() !== 'all'"
|
||||||
|
[class.text-slate-300]="activeGroupId() !== 'all'"
|
||||||
|
[class.bg-slate-800/70]="activeGroupId() !== 'all'"
|
||||||
|
>
|
||||||
|
Tous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
*ngFor="let group of groups()"
|
||||||
|
type="button"
|
||||||
|
(click)="setGroupFilter(group.id)"
|
||||||
|
class="rounded-full border px-3 py-1 text-xs font-semibold transition"
|
||||||
|
[class.bg-red-500/20]="activeGroupId() === group.id"
|
||||||
|
[class.text-red-200]="activeGroupId() === group.id"
|
||||||
|
[class.border-red-500/40]="activeGroupId() === group.id"
|
||||||
|
[class.border-slate-700]="activeGroupId() !== group.id"
|
||||||
|
[class.text-slate-300]="activeGroupId() !== group.id"
|
||||||
|
[class.bg-slate-800/70]="activeGroupId() !== group.id"
|
||||||
|
>
|
||||||
|
{{ group.icon }} {{ group.name }} ({{ group.channelCount }})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
*ngIf="ungroupedCount() > 0"
|
||||||
|
type="button"
|
||||||
|
(click)="setGroupFilter('ungrouped')"
|
||||||
|
class="rounded-full border px-3 py-1 text-xs font-semibold transition"
|
||||||
|
[class.bg-amber-500/20]="activeGroupId() === 'ungrouped'"
|
||||||
|
[class.text-amber-200]="activeGroupId() === 'ungrouped'"
|
||||||
|
[class.border-amber-500/40]="activeGroupId() === 'ungrouped'"
|
||||||
|
[class.border-slate-700]="activeGroupId() !== 'ungrouped'"
|
||||||
|
[class.text-slate-300]="activeGroupId() !== 'ungrouped'"
|
||||||
|
[class.bg-slate-800/70]="activeGroupId() !== 'ungrouped'"
|
||||||
|
>
|
||||||
|
Sans groupe ({{ ungroupedCount() }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recherche + filtres provider -->
|
||||||
|
<div class="flex flex-wrap items-center gap-2 border-b border-slate-800 py-4">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher une chaîne…"
|
||||||
|
[ngModel]="searchQuery()"
|
||||||
|
(ngModelChange)="searchQuery.set($event)"
|
||||||
|
class="w-full sm:w-56 rounded-md border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm text-slate-100 placeholder:text-slate-500 focus:border-red-500 focus:outline-none"
|
||||||
|
/>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
*ngFor="let filter of filters"
|
*ngFor="let filter of filters"
|
||||||
@@ -37,7 +186,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between py-4">
|
<div class="flex flex-wrap items-center justify-between gap-2 py-4">
|
||||||
<div class="flex items-center gap-2 text-sm text-slate-400">
|
<div class="flex items-center gap-2 text-sm text-slate-400">
|
||||||
<span>Trier par :</span>
|
<span>Trier par :</span>
|
||||||
<button
|
<button
|
||||||
@@ -53,6 +202,43 @@
|
|||||||
{{ sort.label }}
|
{{ sort.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
*ngIf="filteredItems().length > 0"
|
||||||
|
type="button"
|
||||||
|
(click)="toggleSelectAll()"
|
||||||
|
class="text-xs text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
{{ selectedCount() > 0 ? 'Tout désélectionner' : 'Tout sélectionner' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Barre d'actions en lot -->
|
||||||
|
<div *ngIf="selectedCount() > 0" class="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-sky-500/30 bg-sky-500/10 p-3">
|
||||||
|
<span class="text-xs font-semibold text-sky-200">{{ selectedCount() }} sélectionnée(s)</span>
|
||||||
|
<select
|
||||||
|
[ngModel]="bulkGroupId()"
|
||||||
|
(ngModelChange)="bulkGroupId.set($event)"
|
||||||
|
class="rounded-md border border-slate-700 bg-slate-800 px-2 py-1 text-xs text-slate-100"
|
||||||
|
>
|
||||||
|
<option value="">— Choisir un groupe —</option>
|
||||||
|
<option *ngFor="let group of groups()" [value]="group.id">{{ group.icon }} {{ group.name }}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="bulkAddToGroup()"
|
||||||
|
[disabled]="!bulkGroupId()"
|
||||||
|
class="rounded-md bg-sky-600 px-3 py-1 text-xs font-semibold text-white disabled:opacity-40 hover:bg-sky-500"
|
||||||
|
>
|
||||||
|
Ajouter au groupe
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
*ngIf="activeGroupId() !== 'all' && activeGroupId() !== 'ungrouped'"
|
||||||
|
type="button"
|
||||||
|
(click)="bulkRemoveFromActiveGroup()"
|
||||||
|
class="rounded-md border border-slate-600 px-3 py-1 text-xs text-slate-200 hover:border-red-400 hover:text-red-200"
|
||||||
|
>
|
||||||
|
Retirer du groupe actif
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-container *ngIf="filteredItems().length > 0; else emptyState">
|
<ng-container *ngIf="filteredItems().length > 0; else emptyState">
|
||||||
@@ -62,7 +248,14 @@
|
|||||||
class="flex flex-col gap-4 py-4 md:flex-row md:items-center md:justify-between"
|
class="flex flex-col gap-4 py-4 md:flex-row md:items-center md:justify-between"
|
||||||
>
|
>
|
||||||
<ng-container *ngIf="item.channel as channel; else subscriptionFallback">
|
<ng-container *ngIf="item.channel as channel; else subscriptionFallback">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="isSelected(item.subscriptionId)"
|
||||||
|
(change)="toggleSelect(item.subscriptionId)"
|
||||||
|
class="h-4 w-4 shrink-0 accent-red-500"
|
||||||
|
title="Sélectionner pour action en lot"
|
||||||
|
/>
|
||||||
<a [routerLink]="['/channel', channel.provider, channel.externalId]" title="Ouvrir la page de la chaîne">
|
<a [routerLink]="['/channel', channel.provider, channel.externalId]" title="Ouvrir la page de la chaîne">
|
||||||
<img
|
<img
|
||||||
[src]="channel.avatarUrl || 'https://placehold.co/80x80/png'"
|
[src]="channel.avatarUrl || 'https://placehold.co/80x80/png'"
|
||||||
@@ -79,6 +272,21 @@
|
|||||||
<app-provider-badge [provider]="channel.provider"></app-provider-badge>
|
<app-provider-badge [provider]="channel.provider"></app-provider-badge>
|
||||||
<span *ngIf="channel.verified" class="text-xs text-emerald-300">✓ Vérifiée</span>
|
<span *ngIf="channel.verified" class="text-xs text-emerald-300">✓ Vérifiée</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Badges groupes -->
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
*ngFor="let gid of itemGroupIds(item)"
|
||||||
|
class="rounded-full px-2 py-0.5 text-[11px] font-semibold"
|
||||||
|
[style.background-color]="'#1e293b'"
|
||||||
|
[style.color]="'#e2e8f0'"
|
||||||
|
[style.border]="'1px solid #475569'"
|
||||||
|
>
|
||||||
|
{{ groupNameById(gid) }}
|
||||||
|
</span>
|
||||||
|
<span *ngIf="itemGroupIds(item).length === 0" class="text-[11px] italic text-slate-500">
|
||||||
|
Sans groupe
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="flex flex-wrap gap-3 text-sm text-slate-400">
|
<div class="flex flex-wrap gap-3 text-sm text-slate-400">
|
||||||
<span *ngIf="channel.handle">{{ channel.handle }}</span>
|
<span *ngIf="channel.handle">{{ channel.handle }}</span>
|
||||||
<span *ngIf="channel.subsCount != null">
|
<span *ngIf="channel.subsCount != null">
|
||||||
@@ -88,19 +296,42 @@
|
|||||||
Abonné depuis {{ item.createdAt | date:'mediumDate' }}
|
Abonné depuis {{ item.createdAt | date:'mediumDate' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<a
|
|
||||||
*ngIf="channel.url"
|
|
||||||
class="text-xs text-blue-300 hover:text-blue-200"
|
|
||||||
[href]="channel.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
Voir la chaîne externe
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col items-stretch gap-2 text-sm md:text-right">
|
<div class="flex flex-col items-stretch gap-2 text-sm md:text-right">
|
||||||
|
<!-- Assigneur de groupes -->
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
class="w-full rounded-md border border-slate-700 bg-slate-800/60 px-3 py-1 text-sm text-slate-300 transition hover:border-slate-500 hover:text-white"
|
||||||
|
type="button"
|
||||||
|
(click)="togglePicker(item.subscriptionId, $event)"
|
||||||
|
>
|
||||||
|
📁 Groupes ({{ itemGroupIds(item).length }})
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
*ngIf="pickerFor() === item.subscriptionId"
|
||||||
|
class="absolute right-0 z-20 mt-1 max-h-64 w-64 overflow-y-auto rounded-lg border border-slate-700 bg-slate-900 p-2 text-left shadow-xl"
|
||||||
|
>
|
||||||
|
<div *ngIf="groups().length === 0" class="p-2 text-xs text-slate-400">
|
||||||
|
Aucun groupe.
|
||||||
|
<button type="button" (click)="openNewGroup()" class="text-red-300 hover:underline">Créer un groupe</button>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
*ngFor="let group of groups()"
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm text-slate-200 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="itemGroupIds(item).includes(group.id)"
|
||||||
|
(change)="toggleMembership(item, group.id)"
|
||||||
|
class="h-4 w-4 accent-red-500"
|
||||||
|
/>
|
||||||
|
<span>{{ group.icon }}</span>
|
||||||
|
<span class="flex-1 truncate">{{ group.name }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<app-subscribe-button
|
<app-subscribe-button
|
||||||
[provider]="channel.provider"
|
[provider]="channel.provider"
|
||||||
[externalId]="channel.externalId"
|
[externalId]="channel.externalId"
|
||||||
@@ -129,21 +360,120 @@
|
|||||||
|
|
||||||
<ng-template #emptyState>
|
<ng-template #emptyState>
|
||||||
<div class="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-slate-700 bg-slate-900/60 p-10 text-center">
|
<div class="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-slate-700 bg-slate-900/60 p-10 text-center">
|
||||||
<h3 class="text-lg font-semibold text-slate-100">Aucune chaîne suivie</h3>
|
<h3 class="text-lg font-semibold text-slate-100">Aucune chaîne dans ce filtre</h3>
|
||||||
<p class="text-sm text-slate-400">Explorez la page Trending ou effectuez une recherche pour commencer à vous abonner.</p>
|
<p class="text-sm text-slate-400">Changez de groupe, de filtre ou abonnez-vous à de nouvelles chaînes.</p>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="rounded-xl border border-slate-800 bg-slate-900/50 p-4 shadow-lg shadow-black/30">
|
<aside class="rounded-xl border border-slate-800 bg-slate-900/50 p-4 shadow-lg shadow-black/30">
|
||||||
<h3 class="text-lg font-semibold text-slate-100">Astuce</h3>
|
<h3 class="text-lg font-semibold text-slate-100">Groupes</h3>
|
||||||
<p class="mt-2 text-sm text-slate-400">
|
<p class="mt-1 text-xs text-slate-500">Cliquez pour filtrer les chaînes.</p>
|
||||||
Utilisez le raccourci <code class="rounded bg-slate-800 px-2 py-0.5 text-xs">S</code> sur une carte vidéo pour activer
|
<ul class="mt-3 space-y-1">
|
||||||
rapidement le bouton d'abonnement. Les abonnements sont synchronisés sur tous vos appareils.
|
<li>
|
||||||
</p>
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="setGroupFilter('all'); setTab('channels')"
|
||||||
|
class="flex w-full items-center justify-between rounded px-2 py-1.5 text-sm hover:bg-slate-800"
|
||||||
|
[class.text-red-200]="activeGroupId() === 'all'"
|
||||||
|
[class.text-slate-300]="activeGroupId() !== 'all'"
|
||||||
|
>
|
||||||
|
<span>📚 Toutes</span>
|
||||||
|
<span class="text-xs text-slate-500">{{ items().length }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li *ngFor="let group of groups()">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="setGroupFilter(group.id); setTab('channels')"
|
||||||
|
class="flex w-full items-center justify-between rounded px-2 py-1.5 text-sm hover:bg-slate-800"
|
||||||
|
[class.text-red-200]="activeGroupId() === group.id"
|
||||||
|
[class.text-slate-300]="activeGroupId() !== group.id"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ group.icon }} {{ group.name }}</span>
|
||||||
|
<span class="text-xs text-slate-500">{{ group.channelCount }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="openNewGroup()"
|
||||||
|
class="mt-3 w-full rounded-md border border-dashed border-slate-600 px-3 py-1.5 text-xs text-slate-300 hover:border-red-400 hover:text-white"
|
||||||
|
>
|
||||||
|
+ Nouveau groupe
|
||||||
|
</button>
|
||||||
<p class="mt-4 text-sm text-slate-500">
|
<p class="mt-4 text-sm text-slate-500">
|
||||||
Cache TTL: {{ hasCache() ? '6h' : '—' }}.
|
Cache TTL: {{ hasCache() ? '6h' : '—' }}.
|
||||||
</p>
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== MODALE GROUPE ==================== -->
|
||||||
|
<div *ngIf="showGroupModal()" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" (click)="closeGroupModal()">
|
||||||
|
<div class="w-full max-w-md rounded-xl border border-slate-700 bg-slate-900 p-5 shadow-2xl" (click)="$event.stopPropagation()">
|
||||||
|
<h3 class="text-lg font-semibold text-slate-100">
|
||||||
|
{{ editingGroup() ? 'Modifier le groupe' : 'Nouveau groupe' }}
|
||||||
|
</h3>
|
||||||
|
<label class="mt-4 block text-xs font-semibold uppercase tracking-wide text-slate-400">Nom</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[ngModel]="groupName()"
|
||||||
|
(ngModelChange)="groupName.set($event)"
|
||||||
|
maxlength="80"
|
||||||
|
placeholder="Ex : Tech, Musique, Sport…"
|
||||||
|
class="mt-1 w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-red-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<label class="mt-4 block text-xs font-semibold uppercase tracking-wide text-slate-400">Icône</label>
|
||||||
|
<div class="mt-1 flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
*ngFor="let icon of icons"
|
||||||
|
type="button"
|
||||||
|
(click)="groupIcon.set(icon)"
|
||||||
|
class="rounded-md px-2 py-1 text-lg"
|
||||||
|
[class.bg-red-500/20]="groupIcon() === icon"
|
||||||
|
[class.ring-1]="groupIcon() === icon"
|
||||||
|
[class.ring-red-500]="groupIcon() === icon"
|
||||||
|
[class.hover:bg-slate-800]="groupIcon() !== icon"
|
||||||
|
>{{ icon }}</button>
|
||||||
|
</div>
|
||||||
|
<label class="mt-4 block text-xs font-semibold uppercase tracking-wide text-slate-400">Couleur</label>
|
||||||
|
<div class="mt-1 flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
*ngFor="let color of colors"
|
||||||
|
type="button"
|
||||||
|
(click)="groupColor.set(color)"
|
||||||
|
class="h-7 w-7 rounded-full"
|
||||||
|
[style.background-color]="color"
|
||||||
|
[class.ring-2]="groupColor() === color"
|
||||||
|
[class.ring-white]="groupColor() === color"
|
||||||
|
[title]="color"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800/60 p-3">
|
||||||
|
<span
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-lg text-xl"
|
||||||
|
[style.background-color]="groupColor() + '26'"
|
||||||
|
[style.border]="'1px solid ' + groupColor() + '66'"
|
||||||
|
>{{ groupIcon() }}</span>
|
||||||
|
<span class="font-semibold text-slate-100">{{ groupName() || 'Aperçu du groupe' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="closeGroupModal()"
|
||||||
|
class="rounded-md border border-slate-700 px-4 py-2 text-sm text-slate-300 hover:border-slate-500 hover:text-white"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="saveGroup()"
|
||||||
|
[disabled]="!groupName().trim() || groupSaving()"
|
||||||
|
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-40 hover:bg-red-500"
|
||||||
|
>
|
||||||
|
{{ groupSaving() ? '…' : (editingGroup() ? 'Enregistrer' : 'Créer') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { DatePipe, DecimalPipe } from '@angular/common';
|
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { SubscriptionsService, type SubscriptionItem } from '../../../services/subscriptions.service';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import {
|
||||||
|
GROUP_COLORS,
|
||||||
|
GROUP_ICONS,
|
||||||
|
SubscriptionsService,
|
||||||
|
type SubscriptionGroup,
|
||||||
|
type SubscriptionItem,
|
||||||
|
} from '../../../services/subscriptions.service';
|
||||||
import type { ProviderId } from '../../../app/shared/models/channel.model';
|
import type { ProviderId } from '../../../app/shared/models/channel.model';
|
||||||
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
|
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
|
||||||
import { SubscribeButtonComponent } from '../../../app/shared/components/subscribe-button/subscribe-button.component';
|
import { SubscribeButtonComponent } from '../../../app/shared/components/subscribe-button/subscribe-button.component';
|
||||||
@@ -13,17 +20,19 @@ interface FilterChip {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SortKey = 'alpha' | 'recent' | 'subs';
|
type SortKey = 'alpha' | 'recent' | 'subs';
|
||||||
|
type TabId = 'groups' | 'channels';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-library-subscriptions',
|
selector: 'app-library-subscriptions',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, DatePipe, DecimalPipe, RouterLink, ProviderBadgeComponent, SubscribeButtonComponent],
|
imports: [CommonModule, DatePipe, DecimalPipe, FormsModule, RouterLink, ProviderBadgeComponent, SubscribeButtonComponent],
|
||||||
templateUrl: './subscriptions.component.html',
|
templateUrl: './subscriptions.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class SubscriptionsComponent {
|
export class SubscriptionsComponent {
|
||||||
private subs = inject(SubscriptionsService);
|
private subs = inject(SubscriptionsService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
|
||||||
filters: FilterChip[] = [
|
filters: FilterChip[] = [
|
||||||
{ id: 'all', label: 'Tous' },
|
{ id: 'all', label: 'Tous' },
|
||||||
@@ -41,19 +50,64 @@ export class SubscriptionsComponent {
|
|||||||
{ key: 'subs', label: 'Abonnés' },
|
{ key: 'subs', label: 'Abonnés' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
readonly colors = GROUP_COLORS;
|
||||||
|
readonly icons = GROUP_ICONS;
|
||||||
|
|
||||||
|
activeTab = signal<TabId>('groups');
|
||||||
activeFilter = signal<FilterChip['id']>('all');
|
activeFilter = signal<FilterChip['id']>('all');
|
||||||
activeSort = signal<SortKey>('alpha');
|
activeSort = signal<SortKey>('alpha');
|
||||||
|
activeGroupId = signal<string>('all');
|
||||||
|
searchQuery = signal<string>('');
|
||||||
|
selectedIds = signal<number[]>([]);
|
||||||
|
|
||||||
|
// --- Modale groupe ---
|
||||||
|
showGroupModal = signal<boolean>(false);
|
||||||
|
editingGroup = signal<SubscriptionGroup | null>(null);
|
||||||
|
groupName = signal<string>('');
|
||||||
|
groupColor = signal<string>(GROUP_COLORS[0]);
|
||||||
|
groupIcon = signal<string>(GROUP_ICONS[0]);
|
||||||
|
groupSaving = signal<boolean>(false);
|
||||||
|
|
||||||
|
// --- Assigneur de groupes (par ligne) ---
|
||||||
|
pickerFor = signal<number | null>(null);
|
||||||
|
|
||||||
|
// --- Assignation en lot ---
|
||||||
|
bulkGroupId = signal<string>('');
|
||||||
|
|
||||||
readonly items = computed(() => this.subs.items());
|
readonly items = computed(() => this.subs.items());
|
||||||
readonly filteredItems = computed(() => this.applyFilters(this.items()));
|
readonly groups = computed(() => this.subs.subscriptionGroups());
|
||||||
|
readonly members = computed(() => this.subs.groupMembers());
|
||||||
readonly hasCache = computed(() => this.items().length > 0);
|
readonly hasCache = computed(() => this.items().length > 0);
|
||||||
|
readonly selectedCount = computed(() => this.selectedIds().length);
|
||||||
|
|
||||||
|
readonly ungroupedCount = computed(() => {
|
||||||
|
const members = this.members();
|
||||||
|
return this.items().filter((item) => (members[String(item.subscriptionId)] || []).length === 0).length;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly filteredItems = computed(() => this.applyFilters(this.items()));
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.refresh();
|
this.refresh();
|
||||||
|
this.subs.listGroups().subscribe();
|
||||||
|
this.route.queryParamMap.subscribe((params) => {
|
||||||
|
const group = params.get('group');
|
||||||
|
const tab = params.get('tab');
|
||||||
|
if (group) {
|
||||||
|
this.activeGroupId.set(group);
|
||||||
|
this.activeTab.set('channels');
|
||||||
|
}
|
||||||
|
if (tab === 'groups' || tab === 'channels') this.activeTab.set(tab);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh(): void {
|
refresh(): void {
|
||||||
this.subs.list().subscribe();
|
this.subs.list().subscribe();
|
||||||
|
this.subs.listGroups().subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTab(tab: TabId): void {
|
||||||
|
this.activeTab.set(tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
setFilter(id: FilterChip['id']): void {
|
setFilter(id: FilterChip['id']): void {
|
||||||
@@ -64,6 +118,16 @@ export class SubscriptionsComponent {
|
|||||||
this.activeSort.set(key);
|
this.activeSort.set(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setGroupFilter(id: string): void {
|
||||||
|
this.activeGroupId.set(id);
|
||||||
|
this.selectedIds.set([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
openGroupChannel(group: SubscriptionGroup): void {
|
||||||
|
this.activeGroupId.set(group.id);
|
||||||
|
this.activeTab.set('channels');
|
||||||
|
}
|
||||||
|
|
||||||
openChannel(item: SubscriptionItem): void {
|
openChannel(item: SubscriptionItem): void {
|
||||||
if (!item?.channel?.provider || !item?.channel?.externalId) {
|
if (!item?.channel?.provider || !item?.channel?.externalId) {
|
||||||
return;
|
return;
|
||||||
@@ -71,13 +135,144 @@ export class SubscriptionsComponent {
|
|||||||
this.router.navigate(['/channel', item.channel.provider, item.channel.externalId]);
|
this.router.navigate(['/channel', item.channel.provider, item.channel.externalId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------- Modale groupe --------------------
|
||||||
|
|
||||||
|
openNewGroup(): void {
|
||||||
|
this.editingGroup.set(null);
|
||||||
|
this.groupName.set('');
|
||||||
|
this.groupColor.set(GROUP_COLORS[this.groups().length % GROUP_COLORS.length]);
|
||||||
|
this.groupIcon.set(GROUP_ICONS[this.groups().length % GROUP_ICONS.length]);
|
||||||
|
this.showGroupModal.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
openEditGroup(group: SubscriptionGroup, event?: Event): void {
|
||||||
|
event?.stopPropagation();
|
||||||
|
this.editingGroup.set(group);
|
||||||
|
this.groupName.set(group.name);
|
||||||
|
this.groupColor.set(group.color);
|
||||||
|
this.groupIcon.set(group.icon);
|
||||||
|
this.showGroupModal.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeGroupModal(): void {
|
||||||
|
this.showGroupModal.set(false);
|
||||||
|
this.editingGroup.set(null);
|
||||||
|
this.groupSaving.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveGroup(): void {
|
||||||
|
const name = this.groupName().trim();
|
||||||
|
if (!name || this.groupSaving()) return;
|
||||||
|
this.groupSaving.set(true);
|
||||||
|
const editing = this.editingGroup();
|
||||||
|
const done = () => this.closeGroupModal();
|
||||||
|
if (editing) {
|
||||||
|
this.subs.updateGroup(editing.id, { name, color: this.groupColor(), icon: this.groupIcon() }).subscribe((g) => {
|
||||||
|
this.groupSaving.set(false);
|
||||||
|
if (g) done();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.subs.createGroup(name, this.groupColor(), this.groupIcon()).subscribe((g) => {
|
||||||
|
this.groupSaving.set(false);
|
||||||
|
if (g) done();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteGroup(group: SubscriptionGroup, event?: Event): void {
|
||||||
|
event?.stopPropagation();
|
||||||
|
if (!confirm(`Supprimer le groupe « ${group.name} » ? Les chaînes seront conservées.`)) return;
|
||||||
|
this.subs.deleteGroup(group.id).subscribe((ok) => {
|
||||||
|
if (ok && this.activeGroupId() === group.id) this.activeGroupId.set('all');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Assignation --------------------
|
||||||
|
|
||||||
|
groupNameById(id: string): string {
|
||||||
|
return this.groups().find((g) => g.id === id)?.name || '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
itemGroupIds(item: SubscriptionItem): string[] {
|
||||||
|
return this.members()[String(item.subscriptionId)] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePicker(subscriptionId: number, event?: Event): void {
|
||||||
|
event?.stopPropagation();
|
||||||
|
this.pickerFor.set(this.pickerFor() === subscriptionId ? null : subscriptionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMembership(item: SubscriptionItem, groupId: string): void {
|
||||||
|
const current = this.itemGroupIds(item);
|
||||||
|
const next = current.includes(groupId)
|
||||||
|
? current.filter((id) => id !== groupId)
|
||||||
|
: [...current, groupId];
|
||||||
|
this.subs.setSubscriptionGroups(item.subscriptionId, next).subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Sélection en lot --------------------
|
||||||
|
|
||||||
|
isSelected(id: number): boolean {
|
||||||
|
return this.selectedIds().includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelect(id: number): void {
|
||||||
|
const current = this.selectedIds();
|
||||||
|
this.selectedIds.set(current.includes(id) ? current.filter((s) => s !== id) : [...current, id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelectAll(): void {
|
||||||
|
const visible = this.filteredItems().map((i) => i.subscriptionId);
|
||||||
|
const all = visible.every((id) => this.selectedIds().includes(id));
|
||||||
|
this.selectedIds.set(all ? [] : visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkAddToGroup(): void {
|
||||||
|
const gid = this.bulkGroupId();
|
||||||
|
if (!gid || this.selectedIds().length === 0) return;
|
||||||
|
const selected = this.filteredItems().filter((i) => this.selectedIds().includes(i.subscriptionId));
|
||||||
|
for (const item of selected) {
|
||||||
|
const current = this.itemGroupIds(item);
|
||||||
|
if (!current.includes(gid)) {
|
||||||
|
this.subs.setSubscriptionGroups(item.subscriptionId, [...current, gid]).subscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.selectedIds.set([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkRemoveFromActiveGroup(): void {
|
||||||
|
const gid = this.activeGroupId();
|
||||||
|
if (gid === 'all' || gid === 'ungrouped' || this.selectedIds().length === 0) return;
|
||||||
|
const selected = this.filteredItems().filter((i) => this.selectedIds().includes(i.subscriptionId));
|
||||||
|
for (const item of selected) {
|
||||||
|
this.subs.setSubscriptionGroups(item.subscriptionId, this.itemGroupIds(item).filter((id) => id !== gid)).subscribe();
|
||||||
|
}
|
||||||
|
this.selectedIds.set([]);
|
||||||
|
}
|
||||||
|
|
||||||
private applyFilters(list: SubscriptionItem[]): SubscriptionItem[] {
|
private applyFilters(list: SubscriptionItem[]): SubscriptionItem[] {
|
||||||
const filter = this.activeFilter();
|
const filter = this.activeFilter();
|
||||||
const sort = this.activeSort();
|
const sort = this.activeSort();
|
||||||
|
const groupId = this.activeGroupId();
|
||||||
|
const q = this.searchQuery().trim().toLowerCase();
|
||||||
let filtered = list;
|
let filtered = list;
|
||||||
if (filter !== 'all') {
|
if (filter !== 'all') {
|
||||||
filtered = filtered.filter((item) => item.channel?.provider === filter);
|
filtered = filtered.filter((item) => item.channel?.provider === filter);
|
||||||
}
|
}
|
||||||
|
if (groupId !== 'all') {
|
||||||
|
const members = this.members();
|
||||||
|
if (groupId === 'ungrouped') {
|
||||||
|
filtered = filtered.filter((item) => (members[String(item.subscriptionId)] || []).length === 0);
|
||||||
|
} else {
|
||||||
|
filtered = filtered.filter((item) => (members[String(item.subscriptionId)] || []).includes(groupId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
filtered = filtered.filter((item) => {
|
||||||
|
const hay = `${item.channel?.title || ''} ${item.channel?.handle || ''}`.toLowerCase();
|
||||||
|
return hay.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
switch (sort) {
|
switch (sort) {
|
||||||
case 'recent':
|
case 'recent':
|
||||||
filtered = [...filtered].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
filtered = [...filtered].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||||
|
|||||||
@@ -61,6 +61,20 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Groupes d'abonnements (façon PocketTube) -->
|
||||||
|
<div *ngIf="!collapsed && subscriptionGroups().length > 0">
|
||||||
|
<div class="px-3 py-2 text-xs uppercase tracking-wide text-slate-400">{{ 'nav.groups' | t : 'Groupes' }}</div>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li *ngFor="let group of subscriptionGroups()">
|
||||||
|
<a [routerLink]="['/library/subscriptions']" [queryParams]="{ group: group.id, tab: 'channels' }" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition gap-3">
|
||||||
|
<span class="flex h-6 w-6 items-center justify-center rounded text-base" [style.background-color]="group.color + '26'">{{ group.icon }}</span>
|
||||||
|
<span class="flex-1 truncate text-sm">{{ group.name }}</span>
|
||||||
|
<span class="text-xs text-slate-500">{{ group.channelCount }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Fournisseurs -->
|
<!-- Fournisseurs -->
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject, Input } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, effect, inject, Input } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
import { InstanceService } from '../../services/instance.service';
|
import { InstanceService } from '../../services/instance.service';
|
||||||
import { ThemesService } from '../../services/themes.service';
|
import { ThemesService } from '../../services/themes.service';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { SubscriptionsService } from '../../services/subscriptions.service';
|
||||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -16,10 +17,20 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
|||||||
export class SidebarComponent {
|
export class SidebarComponent {
|
||||||
private instances = inject(InstanceService);
|
private instances = inject(InstanceService);
|
||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
|
private subs = inject(SubscriptionsService);
|
||||||
themes = inject(ThemesService);
|
themes = inject(ThemesService);
|
||||||
|
|
||||||
// Check if user is logged in
|
// Check if user is logged in
|
||||||
isLoggedIn = computed(() => !!this.auth.currentUser());
|
isLoggedIn = computed(() => !!this.auth.currentUser());
|
||||||
|
subscriptionGroups = computed(() => this.subs.subscriptionGroups());
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if (this.isLoggedIn()) {
|
||||||
|
this.subs.listGroups().subscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
provider = computed(() => this.instances.selectedProvider());
|
provider = computed(() => this.instances.selectedProvider());
|
||||||
providerLabel = computed(() => this.instances.selectedProviderLabel());
|
providerLabel = computed(() => this.instances.selectedProviderLabel());
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const EN: Record<string, string> = {
|
|||||||
'nav.home': 'Home',
|
'nav.home': 'Home',
|
||||||
'nav.shorts': 'Shorts',
|
'nav.shorts': 'Shorts',
|
||||||
'nav.subscriptions': 'Subscriptions',
|
'nav.subscriptions': 'Subscriptions',
|
||||||
|
'nav.groups': 'Groups',
|
||||||
'nav.you': 'You',
|
'nav.you': 'You',
|
||||||
'nav.history': 'History',
|
'nav.history': 'History',
|
||||||
'nav.playlists': 'Playlists',
|
'nav.playlists': 'Playlists',
|
||||||
@@ -185,6 +186,7 @@ const FR: Record<string, string> = {
|
|||||||
'nav.home': 'Accueil',
|
'nav.home': 'Accueil',
|
||||||
'nav.shorts': 'Shorts',
|
'nav.shorts': 'Shorts',
|
||||||
'nav.subscriptions': 'Abonnements',
|
'nav.subscriptions': 'Abonnements',
|
||||||
|
'nav.groups': 'Groupes',
|
||||||
'nav.you': 'Vous',
|
'nav.you': 'Vous',
|
||||||
'nav.history': 'Historique',
|
'nav.history': 'Historique',
|
||||||
'nav.playlists': 'Listes de lecture',
|
'nav.playlists': 'Listes de lecture',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { TestBed } from '@angular/core/testing';
|
|||||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||||
import { DOCUMENT } from '@angular/common';
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
|
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
|
||||||
import { SubscriptionsService, type SubscriptionItem } from './subscriptions.service';
|
import { SubscriptionsService, type SubscriptionGroup, type SubscriptionItem } from './subscriptions.service';
|
||||||
import type { ChannelMeta, ProviderId } from '../app/shared/models/channel.model';
|
import type { ChannelMeta, ProviderId } from '../app/shared/models/channel.model';
|
||||||
|
|
||||||
const mockDocument = {
|
const mockDocument = {
|
||||||
@@ -153,8 +153,78 @@ const tests: Array<[string, () => void]> = [
|
|||||||
['list() caches fetched subscriptions', testListCachesResults],
|
['list() caches fetched subscriptions', testListCachesResults],
|
||||||
['subscribe() appends to cache', testSubscribeAppendsToCache],
|
['subscribe() appends to cache', testSubscribeAppendsToCache],
|
||||||
['unsubscribe() removes from cache', testUnsubscribeRemovesFromCache],
|
['unsubscribe() removes from cache', testUnsubscribeRemovesFromCache],
|
||||||
|
['listGroups() caches groups and members', testListGroupsCaches],
|
||||||
|
['createGroup() appends to cache', testCreateGroupAppends],
|
||||||
|
['setSubscriptionGroups() updates members map', testSetSubscriptionGroups],
|
||||||
|
['deleteGroup() removes group and cleans members', testDeleteGroupCleans],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function makeGroup(id: string, name: string): SubscriptionGroup {
|
||||||
|
return { id, name, color: '#3b82f6', icon: '💻', channelCount: 0, createdAt: Date.now(), updatedAt: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function testListGroupsCaches(): void {
|
||||||
|
const { service, httpMock } = setup();
|
||||||
|
const g1 = makeGroup('g1', 'Tech');
|
||||||
|
let received: SubscriptionGroup[] | undefined;
|
||||||
|
service.listGroups().subscribe(groups => (received = groups));
|
||||||
|
const req = httpMock.expectOne('/api/subscription-groups');
|
||||||
|
assert(req.request.method === 'GET', 'listGroups() should perform GET request');
|
||||||
|
req.flush({ groups: [g1], members: { '5': ['g1'] } });
|
||||||
|
assertEqual(received, [g1], 'listGroups() should emit fetched groups');
|
||||||
|
assertEqual(service.subscriptionGroups(), [g1], 'groups signal should reflect fetched groups');
|
||||||
|
assertEqual(service.groupsOf(5), ['g1'], 'members map should be queryable via groupsOf()');
|
||||||
|
httpMock.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function testCreateGroupAppends(): void {
|
||||||
|
const { service, httpMock } = setup();
|
||||||
|
service.listGroups().subscribe();
|
||||||
|
httpMock.expectOne('/api/subscription-groups').flush({ groups: [], members: {} });
|
||||||
|
const created = makeGroup('g2', 'Musique');
|
||||||
|
let result: SubscriptionGroup | null = null;
|
||||||
|
service.createGroup('Musique', '#ef4444', '🎵').subscribe(g => (result = g));
|
||||||
|
const req = httpMock.expectOne('/api/subscription-groups');
|
||||||
|
assert(req.request.method === 'POST', 'createGroup() should perform POST request');
|
||||||
|
req.flush(created);
|
||||||
|
assertEqual(result, created, 'createGroup() should emit created group');
|
||||||
|
assertEqual(service.subscriptionGroups().map(g => g.id), ['g2'], 'createGroup() should append group into cache');
|
||||||
|
httpMock.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function testSetSubscriptionGroups(): void {
|
||||||
|
const { service, httpMock } = setup();
|
||||||
|
service.listGroups().subscribe();
|
||||||
|
httpMock.expectOne('/api/subscription-groups').flush({ groups: [makeGroup('g1', 'Tech')], members: {} });
|
||||||
|
let result: string[] | null = null;
|
||||||
|
service.setSubscriptionGroups(9, ['g1']).subscribe(ids => (result = ids));
|
||||||
|
const req = httpMock.expectOne('/api/subscriptions/9/groups');
|
||||||
|
assert(req.request.method === 'PUT', 'setSubscriptionGroups() should perform PUT request');
|
||||||
|
assertEqual(req.request.body, { groupIds: ['g1'] }, 'setSubscriptionGroups() should send groupIds payload');
|
||||||
|
req.flush({ subscriptionId: 9, groupIds: ['g1'] });
|
||||||
|
assertEqual(result, ['g1'], 'setSubscriptionGroups() should emit group ids');
|
||||||
|
assertEqual(service.groupsOf(9), ['g1'], 'members map should reflect new assignment');
|
||||||
|
httpMock.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function testDeleteGroupCleans(): void {
|
||||||
|
const { service, httpMock } = setup();
|
||||||
|
service.listGroups().subscribe();
|
||||||
|
httpMock.expectOne('/api/subscription-groups').flush({
|
||||||
|
groups: [makeGroup('g1', 'Tech'), makeGroup('g2', 'Musique')],
|
||||||
|
members: { '7': ['g1', 'g2'] },
|
||||||
|
});
|
||||||
|
let result: boolean | null = null;
|
||||||
|
service.deleteGroup('g1').subscribe(ok => (result = ok));
|
||||||
|
const req = httpMock.expectOne('/api/subscription-groups/g1');
|
||||||
|
assert(req.request.method === 'DELETE', 'deleteGroup() should perform DELETE request');
|
||||||
|
req.flush({}, { status: 204, statusText: 'No Content' });
|
||||||
|
assert(result === true, 'deleteGroup() should emit true when deletion succeeds');
|
||||||
|
assertEqual(service.subscriptionGroups().map(g => g.id), ['g2'], 'deleteGroup() should remove group from cache');
|
||||||
|
assertEqual(service.groupsOf(7), ['g2'], 'deleteGroup() should clean members map');
|
||||||
|
httpMock.verify();
|
||||||
|
}
|
||||||
|
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
for (const [name, fn] of tests) {
|
for (const [name, fn] of tests) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,11 +10,37 @@ export interface SubscriptionItem {
|
|||||||
channel: ChannelMeta;
|
channel: ChannelMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
icon: string;
|
||||||
|
channelCount: number;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ListResponse {
|
interface ListResponse {
|
||||||
items: SubscriptionItem[];
|
items: SubscriptionItem[];
|
||||||
ttl?: number;
|
ttl?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GroupsResponse {
|
||||||
|
groups: SubscriptionGroup[];
|
||||||
|
members: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GROUP_COLORS = [
|
||||||
|
'#ef4444', '#f97316', '#f59e0b', '#84cc16', '#22c55e',
|
||||||
|
'#14b8a6', '#06b6d4', '#3b82f6', '#8b5cf6', '#d946ef',
|
||||||
|
'#ec4899', '#64748b',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const GROUP_ICONS = [
|
||||||
|
'📁', '🎵', '🎮', '📰', '⚽', '🎓', '💻', '🤖',
|
||||||
|
'🏥', '🍳', '✈️', '🎬', '📚', '💡', '🔬', '⭐',
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SubscriptionsService {
|
export class SubscriptionsService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
@@ -22,16 +48,26 @@ export class SubscriptionsService {
|
|||||||
private ttlMs = signal<number | null>(null);
|
private ttlMs = signal<number | null>(null);
|
||||||
private loading = signal<boolean>(false);
|
private loading = signal<boolean>(false);
|
||||||
private lastErrorMsg = signal<string | null>(null);
|
private lastErrorMsg = signal<string | null>(null);
|
||||||
|
private groups = signal<SubscriptionGroup[]>([]);
|
||||||
|
private members = signal<Record<string, string[]>>({});
|
||||||
|
|
||||||
readonly items = computed(() => this.subscriptions());
|
readonly items = computed(() => this.subscriptions());
|
||||||
readonly isLoading = computed(() => this.loading());
|
readonly isLoading = computed(() => this.loading());
|
||||||
/** Dernière erreur d'abonnement/désabonnement (affichée par les pages). */
|
/** Dernière erreur d'abonnement/désabonnement (affichée par les pages). */
|
||||||
readonly lastError = computed(() => this.lastErrorMsg());
|
readonly lastError = computed(() => this.lastErrorMsg());
|
||||||
|
/** Groupes d'abonnements (façon PocketTube), triés par création. */
|
||||||
|
readonly subscriptionGroups = computed(() => this.groups());
|
||||||
|
/** Map subscriptionId -> groupId[] (relation plusieurs-à-plusieurs). */
|
||||||
|
readonly groupMembers = computed(() => this.members());
|
||||||
|
|
||||||
clearError(): void {
|
clearError(): void {
|
||||||
this.lastErrorMsg.set(null);
|
this.lastErrorMsg.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
groupsOf(subscriptionId: number): string[] {
|
||||||
|
return this.members()[String(subscriptionId)] || [];
|
||||||
|
}
|
||||||
|
|
||||||
list(): Observable<SubscriptionItem[]> {
|
list(): Observable<SubscriptionItem[]> {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
return this.http.get<ListResponse>('/api/subscriptions').pipe(
|
return this.http.get<ListResponse>('/api/subscriptions').pipe(
|
||||||
@@ -79,6 +115,10 @@ export class SubscriptionsService {
|
|||||||
tap(() => {
|
tap(() => {
|
||||||
const current = this.subscriptions();
|
const current = this.subscriptions();
|
||||||
this.subscriptions.set(current.filter((item) => item.subscriptionId !== subscriptionId));
|
this.subscriptions.set(current.filter((item) => item.subscriptionId !== subscriptionId));
|
||||||
|
const members = { ...this.members() };
|
||||||
|
delete members[String(subscriptionId)];
|
||||||
|
this.members.set(members);
|
||||||
|
this.refreshGroupCounts();
|
||||||
}),
|
}),
|
||||||
map(() => true),
|
map(() => true),
|
||||||
catchError((err) => {
|
catchError((err) => {
|
||||||
@@ -97,4 +137,96 @@ export class SubscriptionsService {
|
|||||||
const current = this.subscriptions();
|
const current = this.subscriptions();
|
||||||
return current.some((item) => item.channel?.provider === provider && item.channel?.externalId === externalId);
|
return current.some((item) => item.channel?.provider === provider && item.channel?.externalId === externalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------- Groupes --------------------
|
||||||
|
|
||||||
|
listGroups(): Observable<SubscriptionGroup[]> {
|
||||||
|
return this.http.get<GroupsResponse>('/api/subscription-groups').pipe(
|
||||||
|
tap((resp) => {
|
||||||
|
this.groups.set(resp.groups || []);
|
||||||
|
this.members.set(resp.members || {});
|
||||||
|
}),
|
||||||
|
map((resp) => resp.groups || []),
|
||||||
|
catchError((err) => {
|
||||||
|
console.error('Failed to load subscription groups', err);
|
||||||
|
return of([]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createGroup(name: string, color?: string, icon?: string): Observable<SubscriptionGroup | null> {
|
||||||
|
return this.http.post<SubscriptionGroup>('/api/subscription-groups', { name, color, icon }).pipe(
|
||||||
|
tap((group) => {
|
||||||
|
if (group?.id) this.groups.set([...this.groups(), group]);
|
||||||
|
}),
|
||||||
|
catchError((err) => {
|
||||||
|
console.error('Failed to create group', err);
|
||||||
|
this.lastErrorMsg.set(
|
||||||
|
err?.status === 409 ? 'Un groupe porte déjà ce nom.' : 'Création du groupe impossible.'
|
||||||
|
);
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGroup(id: string, patch: { name?: string; color?: string; icon?: string }): Observable<SubscriptionGroup | null> {
|
||||||
|
return this.http.patch<SubscriptionGroup>(`/api/subscription-groups/${id}`, patch).pipe(
|
||||||
|
tap((group) => {
|
||||||
|
if (group?.id) this.groups.set(this.groups().map((g) => (g.id === group.id ? group : g)));
|
||||||
|
}),
|
||||||
|
catchError((err) => {
|
||||||
|
console.error('Failed to update group', err);
|
||||||
|
this.lastErrorMsg.set(
|
||||||
|
err?.status === 409 ? 'Un groupe porte déjà ce nom.' : 'Modification du groupe impossible.'
|
||||||
|
);
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteGroup(id: string): Observable<boolean> {
|
||||||
|
return this.http.delete(`/api/subscription-groups/${id}`).pipe(
|
||||||
|
tap(() => {
|
||||||
|
this.groups.set(this.groups().filter((g) => g.id !== id));
|
||||||
|
const members = { ...this.members() };
|
||||||
|
for (const key of Object.keys(members)) {
|
||||||
|
members[key] = members[key].filter((gid) => gid !== id);
|
||||||
|
}
|
||||||
|
this.members.set(members);
|
||||||
|
this.refreshGroupCounts();
|
||||||
|
}),
|
||||||
|
map(() => true),
|
||||||
|
catchError((err) => {
|
||||||
|
console.error('Failed to delete group', err);
|
||||||
|
return of(false);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remplace les groupes d'un abonnement. */
|
||||||
|
setSubscriptionGroups(subscriptionId: number, groupIds: string[]): Observable<string[] | null> {
|
||||||
|
return this.http.put<{ subscriptionId: number; groupIds: string[] }>(`/api/subscriptions/${subscriptionId}/groups`, { groupIds }).pipe(
|
||||||
|
tap((resp) => {
|
||||||
|
const members = { ...this.members() };
|
||||||
|
members[String(subscriptionId)] = resp.groupIds || [];
|
||||||
|
this.members.set(members);
|
||||||
|
this.refreshGroupCounts();
|
||||||
|
}),
|
||||||
|
map((resp) => resp.groupIds || []),
|
||||||
|
catchError((err) => {
|
||||||
|
console.error('Failed to set subscription groups', err);
|
||||||
|
return of(null);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recalcule les compteurs locaux sans appel réseau. */
|
||||||
|
private refreshGroupCounts(): void {
|
||||||
|
const members = this.members();
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const key of Object.keys(members)) {
|
||||||
|
for (const gid of members[key] || []) counts[gid] = (counts[gid] || 0) + 1;
|
||||||
|
}
|
||||||
|
this.groups.set(this.groups().map((g) => ({ ...g, channelCount: counts[g.id] || 0 })));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user