feat(groups): 216 icones N&B + fermeture auto menu + suggestions auto

This commit is contained in:
2026-09-27 13:03:58 -04:00
parent 856b38bbea
commit dde0747ba3
3 changed files with 262 additions and 2 deletions
@@ -12,6 +12,14 @@
>
+ Nouveau groupe
</button>
<button
class="rounded-md border border-violet-500/50 bg-violet-600/20 px-3 py-1.5 text-sm font-semibold text-violet-200 transition hover:bg-violet-600/30"
(click)="showSuggestions.set(!showSuggestions())"
type="button"
[attr.aria-expanded]="showSuggestions()"
>
✨ Suggestions<span *ngIf="suggestions().length"> ({{ suggestions().length }})</span>
</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()"
@@ -28,6 +36,52 @@
</div>
</div>
<!-- ==================== SUGGESTIONS AUTO ==================== -->
<div *ngIf="showSuggestions()" class="mb-6 rounded-xl border border-violet-500/30 bg-violet-950/20 p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 class="font-semibold text-slate-100">✨ Groupes suggérés</h3>
<p class="text-xs text-slate-400">Analyse des noms de chaînes (mots-clés FR/EN). Vérifiez avant d’appliquer.</p>
</div>
<button
*ngIf="suggestions().length > 1"
type="button"
(click)="applyAllSuggestions()"
[disabled]="suggestBusy()"
class="rounded-md bg-violet-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-violet-500 disabled:opacity-40"
>
{{ suggestBusy() ? 'Application…' : 'Tout appliquer' }}
</button>
</div>
<div *ngIf="suggestions().length === 0" class="mt-3 rounded-lg border border-dashed border-slate-700 p-4 text-center text-sm text-slate-400">
Aucune suggestion : chaque thème a moins de 2 chaînes, ou tout est déjà rangé.
</div>
<ul *ngIf="suggestions().length > 0" class="mt-3 space-y-2">
<li *ngFor="let s of suggestions()" class="flex flex-col gap-2 rounded-lg border border-slate-800 bg-slate-900/60 p-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 items-center gap-3">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-xl" [style.background-color]="s.color + '26'" [style.border]="'1px solid ' + s.color + '66'">{{ s.icon }}</span>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="font-semibold text-slate-100">{{ s.name }}</span>
<span *ngIf="s.kind === 'create'" class="rounded-full bg-green-900/40 border border-green-700 px-2 py-0.5 text-[11px] text-green-200">Nouveau</span>
<span *ngIf="s.kind === 'complete'" class="rounded-full bg-sky-900/40 border border-sky-700 px-2 py-0.5 text-[11px] text-sky-200">Compléter « {{ s.existingGroupName }} »</span>
<span class="text-xs text-slate-400">{{ s.channelIds.length }} chaîne(s)</span>
</div>
<p class="truncate text-xs text-slate-500">{{ s.sample.join(' • ') }}</p>
</div>
</div>
<button
type="button"
(click)="applySuggestion(s)"
[disabled]="suggestBusy()"
class="shrink-0 rounded-md bg-violet-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-violet-500 disabled:opacity-40"
>
{{ s.kind === 'create' ? 'Créer + assigner' : 'Assigner' }}
</button>
</li>
</ul>
</div>
<!-- Onglets façon PocketTube -->
<div class="mb-4 flex gap-2 border-b border-slate-800">
<button
@@ -307,7 +361,7 @@
<div class="flex flex-col items-stretch gap-2 text-sm md:text-right">
<!-- Assigneur de groupes -->
<div class="relative">
<div class="picker-root 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"
@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, HostListener, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DatePipe, DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
@@ -7,7 +7,9 @@ import {
GROUP_COLORS,
GROUP_ICONS,
GROUP_ICON_KEYWORDS,
suggestGroupsForChannels,
SubscriptionsService,
type GroupSuggestion,
type SubscriptionGroup,
type SubscriptionItem,
} from '../../../services/subscriptions.service';
@@ -87,6 +89,52 @@ export class SubscriptionsComponent {
// --- Assignation en lot ---
bulkGroupId = signal<string>('');
// --- Suggestions auto de groupes (analyse des chaînes) ---
showSuggestions = signal<boolean>(false);
suggestBusy = signal<boolean>(false);
readonly suggestions = computed<GroupSuggestion[]>(() =>
suggestGroupsForChannels(
this.items().map((item) => ({
subscriptionId: item.subscriptionId,
title: item.channel?.title || '',
handle: item.channel?.handle || null,
groupIds: this.itemGroupIds(item),
})),
this.groups().map((g) => ({ id: g.id, name: g.name })),
)
);
/** Crée le groupe suggéré (ou complète l'existant) et y assigne les chaînes. */
applySuggestion(s: GroupSuggestion): Promise<void> {
if (this.suggestBusy()) return Promise.resolve();
this.suggestBusy.set(true);
const assignAll = (groupId: string): Promise<void> => {
const calls = s.channelIds
.filter((sid) => !(this.members()[String(sid)] || []).includes(groupId))
.map((sid) => new Promise<void>((resolve) => {
const current = this.members()[String(sid)] || [];
this.subs.setSubscriptionGroups(sid, [...current, groupId]).subscribe({ next: () => resolve(), error: () => resolve() });
}));
return Promise.all(calls).then(() => undefined);
};
const done = () => this.suggestBusy.set(false);
if (s.kind === 'complete' && s.existingGroupId) {
return assignAll(s.existingGroupId).finally(done);
}
return new Promise<void>((resolve) => {
this.subs.createGroup(s.name, s.color, s.icon).subscribe((g) => resolve(g?.id ? assignAll(g.id) : undefined));
}).finally(done);
}
/** Applique toutes les suggestions d'un coup, dans l'ordre. */
async applyAllSuggestions(): Promise<void> {
if (this.suggestBusy()) return;
for (const s of this.suggestions()) {
await this.applySuggestion(s);
}
}
readonly items = computed(() => this.subs.items());
readonly groups = computed(() => this.subs.subscriptionGroups());
readonly members = computed(() => this.subs.groupMembers());
@@ -217,6 +265,20 @@ export class SubscriptionsComponent {
this.pickerFor.set(this.pickerFor() === subscriptionId ? null : subscriptionId);
}
/** Ferme le menu dès que le focus/clic sort du menu (clic extérieur, Escape). */
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
if (this.pickerFor() === null) return;
const el = event.target as HTMLElement | null;
if (el?.closest?.('.picker-root')) return;
this.pickerFor.set(null);
}
@HostListener('document:keydown', ['$event'])
onDocumentKey(event: KeyboardEvent): void {
if (event.key === 'Escape' && this.pickerFor() !== null) this.pickerFor.set(null);
}
toggleMembership(item: SubscriptionItem, groupId: string): void {
const current = this.itemGroupIds(item);
const next = current.includes(groupId)