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:
@@ -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<TabId>('groups');
|
||||
activeFilter = signal<FilterChip['id']>('all');
|
||||
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 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));
|
||||
|
||||
Reference in New Issue
Block a user