From 49c3b5dfdfd4174a0cacd054462d22bb561d08b7 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 26 Sep 2026 10:45:34 -0400 Subject: [PATCH] =?UTF-8?q?feat(subscriptions):=20gestion=20des=20abonneme?= =?UTF-8?q?nts=20par=20groupes=20fa=C3=A7on=20PocketTube?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../20260926_add_subscription_groups.sql | 28 ++ db/schema.sql | 24 ++ server/db.mjs | 129 ++++++ server/index.mjs | 95 +++++ .../subscriptions.component.html | 372 +++++++++++++++++- .../subscriptions/subscriptions.component.ts | 203 +++++++++- src/components/sidebar/sidebar.component.html | 14 + src/components/sidebar/sidebar.component.ts | 15 +- src/services/i18n.service.ts | 2 + src/services/subscriptions.service.spec.ts | 72 +++- src/services/subscriptions.service.ts | 132 +++++++ 11 files changed, 1058 insertions(+), 28 deletions(-) create mode 100644 db/migrations/20260926_add_subscription_groups.sql diff --git a/db/migrations/20260926_add_subscription_groups.sql b/db/migrations/20260926_add_subscription_groups.sql new file mode 100644 index 0000000..2dbcc7b --- /dev/null +++ b/db/migrations/20260926_add_subscription_groups.sql @@ -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; diff --git a/db/schema.sql b/db/schema.sql index d37248d..8496b45 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -109,6 +109,30 @@ CREATE TABLE IF NOT EXISTS subscriptions ( 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 ----------------------------------------------------------------- CREATE TABLE IF NOT EXISTS playlists ( id TEXT PRIMARY KEY, diff --git a/server/db.mjs b/server/db.mjs index 9772c9e..83f09f5 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -978,6 +978,135 @@ export function isSubscribed({ userId, provider, externalId }) { `).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) { if (!row) return null; const channel = channelRowToMeta({ diff --git a/server/index.mjs b/server/index.mjs index 2d57911..aae2b39 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -66,6 +66,13 @@ import { subscribeChannel, unsubscribeChannel, isSubscribed, + listSubscriptionGroups, + createSubscriptionGroup, + updateSubscriptionGroup, + deleteSubscriptionGroup, + setSubscriptionGroupMembers, + setSubscriptionGroups, + listSubscriptionGroupMembersByUser, insertDownloadJob, getDownloadJob, 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. r.get('/playlists/:id/view', (req, res) => { try { diff --git a/src/components/library/subscriptions/subscriptions.component.html b/src/components/library/subscriptions/subscriptions.component.html index 6b43ed1..f91cd1f 100644 --- a/src/components/library/subscriptions/subscriptions.component.html +++ b/src/components/library/subscriptions/subscriptions.component.html @@ -2,9 +2,16 @@

Abonnements

-

Gérez vos chaînes suivies multi-fournisseurs

+

Organisez vos chaînes suivies en groupes thématiques

+
-
+ +
+ + +
+ + +
+
+
+
+
+ {{ group.icon }} +
+

{{ group.name }}

+

{{ group.channelCount }} chaîne(s)

+
+
+
+ + +
+
+
+ + Voir les chaînes → +
+
+
+ +
+
📁
+

Aucun groupe pour le moment

+

Créez des groupes thématiques (Tech, Musique, Sport…) pour organiser vos abonnements.

+ +
+
+
+ + +
+
- Filtres + Groupe +
+ + + +
+
+ + +
+
-
+
Trier par :
+ +
+ + +
+ {{ selectedCount() }} sélectionnée(s) + + +
@@ -62,7 +248,14 @@ class="flex flex-col gap-4 py-4 md:flex-row md:items-center md:justify-between" > -
+ +
+ +
+
+ Aucun groupe. + +
+ +
+
-

Aucune chaîne suivie

-

Explorez la page Trending ou effectuez une recherche pour commencer à vous abonner.

+

Aucune chaîne dans ce filtre

+

Changez de groupe, de filtre ou abonnez-vous à de nouvelles chaînes.

+ + +
+
+

+ {{ editingGroup() ? 'Modifier le groupe' : 'Nouveau groupe' }} +

+ + + +
+ +
+ +
+ +
+
+ {{ groupIcon() }} + {{ groupName() || 'Aperçu du groupe' }} +
+
+ + +
+
+
diff --git a/src/components/library/subscriptions/subscriptions.component.ts b/src/components/library/subscriptions/subscriptions.component.ts index 2e645b4..0190cf1 100644 --- a/src/components/library/subscriptions/subscriptions.component.ts +++ b/src/components/library/subscriptions/subscriptions.component.ts @@ -1,8 +1,15 @@ import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { DatePipe, DecimalPipe } from '@angular/common'; -import { Router, RouterLink } from '@angular/router'; -import { SubscriptionsService, type SubscriptionItem } from '../../../services/subscriptions.service'; +import { FormsModule } from '@angular/forms'; +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 { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component'; import { SubscribeButtonComponent } from '../../../app/shared/components/subscribe-button/subscribe-button.component'; @@ -13,17 +20,19 @@ interface FilterChip { } type SortKey = 'alpha' | 'recent' | 'subs'; +type TabId = 'groups' | 'channels'; @Component({ selector: 'app-library-subscriptions', standalone: true, - imports: [CommonModule, DatePipe, DecimalPipe, RouterLink, ProviderBadgeComponent, SubscribeButtonComponent], + imports: [CommonModule, DatePipe, DecimalPipe, FormsModule, RouterLink, ProviderBadgeComponent, SubscribeButtonComponent], templateUrl: './subscriptions.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class SubscriptionsComponent { private subs = inject(SubscriptionsService); private router = inject(Router); + private route = inject(ActivatedRoute); filters: FilterChip[] = [ { id: 'all', label: 'Tous' }, @@ -41,19 +50,64 @@ export class SubscriptionsComponent { { key: 'subs', label: 'Abonnés' }, ]; + readonly colors = GROUP_COLORS; + readonly icons = GROUP_ICONS; + + activeTab = signal('groups'); activeFilter = signal('all'); activeSort = signal('alpha'); + activeGroupId = signal('all'); + searchQuery = signal(''); + selectedIds = signal([]); + + // --- Modale groupe --- + showGroupModal = signal(false); + editingGroup = signal(null); + groupName = signal(''); + groupColor = signal(GROUP_COLORS[0]); + groupIcon = signal(GROUP_ICONS[0]); + groupSaving = signal(false); + + // --- Assigneur de groupes (par ligne) --- + pickerFor = signal(null); + + // --- Assignation en lot --- + bulkGroupId = signal(''); 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 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() { 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 { this.subs.list().subscribe(); + this.subs.listGroups().subscribe(); + } + + setTab(tab: TabId): void { + this.activeTab.set(tab); } setFilter(id: FilterChip['id']): void { @@ -64,6 +118,16 @@ export class SubscriptionsComponent { 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 { if (!item?.channel?.provider || !item?.channel?.externalId) { return; @@ -71,13 +135,144 @@ export class SubscriptionsComponent { 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[] { const filter = this.activeFilter(); const sort = this.activeSort(); + const groupId = this.activeGroupId(); + const q = this.searchQuery().trim().toLowerCase(); let filtered = list; if (filter !== 'all') { 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) { case 'recent': filtered = [...filtered].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); diff --git a/src/components/sidebar/sidebar.component.html b/src/components/sidebar/sidebar.component.html index b89d103..5f98d90 100644 --- a/src/components/sidebar/sidebar.component.html +++ b/src/components/sidebar/sidebar.component.html @@ -61,6 +61,20 @@
+ + +
+
{{ 'nav.groups' | t : 'Groupes' }}
+ +
} diff --git a/src/components/sidebar/sidebar.component.ts b/src/components/sidebar/sidebar.component.ts index 8e460ca..9073122 100644 --- a/src/components/sidebar/sidebar.component.ts +++ b/src/components/sidebar/sidebar.component.ts @@ -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 { RouterLink, RouterLinkActive } from '@angular/router'; import { InstanceService } from '../../services/instance.service'; import { ThemesService } from '../../services/themes.service'; import { AuthService } from '../../services/auth.service'; +import { SubscriptionsService } from '../../services/subscriptions.service'; import { TranslatePipe } from '../../pipes/translate.pipe'; @Component({ @@ -16,10 +17,20 @@ import { TranslatePipe } from '../../pipes/translate.pipe'; export class SidebarComponent { private instances = inject(InstanceService); private auth = inject(AuthService); + private subs = inject(SubscriptionsService); themes = inject(ThemesService); - + // Check if user is logged in isLoggedIn = computed(() => !!this.auth.currentUser()); + subscriptionGroups = computed(() => this.subs.subscriptionGroups()); + + constructor() { + effect(() => { + if (this.isLoggedIn()) { + this.subs.listGroups().subscribe(); + } + }); + } provider = computed(() => this.instances.selectedProvider()); providerLabel = computed(() => this.instances.selectedProviderLabel()); diff --git a/src/services/i18n.service.ts b/src/services/i18n.service.ts index 9a998d8..8e685c6 100644 --- a/src/services/i18n.service.ts +++ b/src/services/i18n.service.ts @@ -47,6 +47,7 @@ const EN: Record = { 'nav.home': 'Home', 'nav.shorts': 'Shorts', 'nav.subscriptions': 'Subscriptions', + 'nav.groups': 'Groups', 'nav.you': 'You', 'nav.history': 'History', 'nav.playlists': 'Playlists', @@ -185,6 +186,7 @@ const FR: Record = { 'nav.home': 'Accueil', 'nav.shorts': 'Shorts', 'nav.subscriptions': 'Abonnements', + 'nav.groups': 'Groupes', 'nav.you': 'Vous', 'nav.history': 'Historique', 'nav.playlists': 'Listes de lecture', diff --git a/src/services/subscriptions.service.spec.ts b/src/services/subscriptions.service.spec.ts index cf0101c..0bff245 100644 --- a/src/services/subscriptions.service.spec.ts +++ b/src/services/subscriptions.service.spec.ts @@ -3,7 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { DOCUMENT } from '@angular/common'; 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'; const mockDocument = { @@ -153,8 +153,78 @@ const tests: Array<[string, () => void]> = [ ['list() caches fetched subscriptions', testListCachesResults], ['subscribe() appends to cache', testSubscribeAppendsToCache], ['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; for (const [name, fn] of tests) { try { diff --git a/src/services/subscriptions.service.ts b/src/services/subscriptions.service.ts index 6a340c2..6636612 100644 --- a/src/services/subscriptions.service.ts +++ b/src/services/subscriptions.service.ts @@ -10,11 +10,37 @@ export interface SubscriptionItem { channel: ChannelMeta; } +export interface SubscriptionGroup { + id: string; + name: string; + color: string; + icon: string; + channelCount: number; + createdAt: number; + updatedAt: number; +} + interface ListResponse { items: SubscriptionItem[]; ttl?: number; } +interface GroupsResponse { + groups: SubscriptionGroup[]; + members: Record; +} + +export const GROUP_COLORS = [ + '#ef4444', '#f97316', '#f59e0b', '#84cc16', '#22c55e', + '#14b8a6', '#06b6d4', '#3b82f6', '#8b5cf6', '#d946ef', + '#ec4899', '#64748b', +]; + +export const GROUP_ICONS = [ + '📁', '🎵', '🎮', '📰', '⚽', '🎓', '💻', '🤖', + '🏥', '🍳', '✈️', '🎬', '📚', '💡', '🔬', '⭐', +]; + @Injectable({ providedIn: 'root' }) export class SubscriptionsService { private http = inject(HttpClient); @@ -22,16 +48,26 @@ export class SubscriptionsService { private ttlMs = signal(null); private loading = signal(false); private lastErrorMsg = signal(null); + private groups = signal([]); + private members = signal>({}); readonly items = computed(() => this.subscriptions()); readonly isLoading = computed(() => this.loading()); /** Dernière erreur d'abonnement/désabonnement (affichée par les pages). */ 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 { this.lastErrorMsg.set(null); } + groupsOf(subscriptionId: number): string[] { + return this.members()[String(subscriptionId)] || []; + } + list(): Observable { this.loading.set(true); return this.http.get('/api/subscriptions').pipe( @@ -79,6 +115,10 @@ export class SubscriptionsService { tap(() => { const current = this.subscriptions(); 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), catchError((err) => { @@ -97,4 +137,96 @@ export class SubscriptionsService { const current = this.subscriptions(); return current.some((item) => item.channel?.provider === provider && item.channel?.externalId === externalId); } + + // -------------------- Groupes -------------------- + + listGroups(): Observable { + return this.http.get('/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 { + return this.http.post('/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 { + return this.http.patch(`/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 { + 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 { + 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 = {}; + 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 }))); + } }