feat(groups): 216 icones N&B + fermeture auto menu + suggestions auto
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user