feat(subscriptions): gestion des abonnements par groupes façon PocketTube
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:
2026-09-26 10:45:34 -04:00
parent 95e4358b01
commit 49c3b5dfdf
11 changed files with 1058 additions and 28 deletions
@@ -2,9 +2,16 @@
<div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<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 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
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()"
@@ -15,10 +22,152 @@
</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">
<!-- Filtre par groupe -->
<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">
<button
*ngFor="let filter of filters"
@@ -37,7 +186,7 @@
</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">
<span>Trier par :</span>
<button
@@ -53,6 +202,43 @@
{{ sort.label }}
</button>
</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>
<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"
>
<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">
<img
[src]="channel.avatarUrl || 'https://placehold.co/80x80/png'"
@@ -79,6 +272,21 @@
<app-provider-badge [provider]="channel.provider"></app-provider-badge>
<span *ngIf="channel.verified" class="text-xs text-emerald-300">✓ Vérifiée</span>
</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">
<span *ngIf="channel.handle">{{ channel.handle }}</span>
<span *ngIf="channel.subsCount != null">
@@ -88,19 +296,42 @@
Abonné depuis {{ item.createdAt | date:'mediumDate' }}
</span>
</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 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
[provider]="channel.provider"
[externalId]="channel.externalId"
@@ -129,21 +360,120 @@
<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">
<h3 class="text-lg font-semibold text-slate-100">Aucune chaîne suivie</h3>
<p class="text-sm text-slate-400">Explorez la page Trending ou effectuez une recherche pour commencer à vous abonner.</p>
<h3 class="text-lg font-semibold text-slate-100">Aucune chaîne dans ce filtre</h3>
<p class="text-sm text-slate-400">Changez de groupe, de filtre ou abonnez-vous à de nouvelles chaînes.</p>
</div>
</ng-template>
</div>
<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>
<p class="mt-2 text-sm text-slate-400">
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
rapidement le bouton d'abonnement. Les abonnements sont synchronisés sur tous vos appareils.
</p>
<h3 class="text-lg font-semibold text-slate-100">Groupes</h3>
<p class="mt-1 text-xs text-slate-500">Cliquez pour filtrer les chaînes.</p>
<ul class="mt-3 space-y-1">
<li>
<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">
Cache TTL: {{ hasCache() ? '6h' : '—' }}.
</p>
</aside>
</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>
@@ -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));
@@ -61,6 +61,20 @@
</li>
</ul>
</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 -->
+13 -2
View File
@@ -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());