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 + Nouveau groupe
</button> </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 <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" 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()" (click)="refresh()"
@@ -28,6 +36,52 @@
</div> </div>
</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 --> <!-- Onglets façon PocketTube -->
<div class="mb-4 flex gap-2 border-b border-slate-800"> <div class="mb-4 flex gap-2 border-b border-slate-800">
<button <button
@@ -307,7 +361,7 @@
<div class="flex flex-col items-stretch gap-2 text-sm md:text-right"> <div class="flex flex-col items-stretch gap-2 text-sm md:text-right">
<!-- Assigneur de groupes --> <!-- Assigneur de groupes -->
<div class="relative"> <div class="picker-root relative">
<button <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" 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" 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 { CommonModule } from '@angular/common';
import { DatePipe, DecimalPipe } from '@angular/common'; import { DatePipe, DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -7,7 +7,9 @@ import {
GROUP_COLORS, GROUP_COLORS,
GROUP_ICONS, GROUP_ICONS,
GROUP_ICON_KEYWORDS, GROUP_ICON_KEYWORDS,
suggestGroupsForChannels,
SubscriptionsService, SubscriptionsService,
type GroupSuggestion,
type SubscriptionGroup, type SubscriptionGroup,
type SubscriptionItem, type SubscriptionItem,
} from '../../../services/subscriptions.service'; } from '../../../services/subscriptions.service';
@@ -87,6 +89,52 @@ export class SubscriptionsComponent {
// --- Assignation en lot --- // --- Assignation en lot ---
bulkGroupId = signal<string>(''); 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 items = computed(() => this.subs.items());
readonly groups = computed(() => this.subs.subscriptionGroups()); readonly groups = computed(() => this.subs.subscriptionGroups());
readonly members = computed(() => this.subs.groupMembers()); readonly members = computed(() => this.subs.groupMembers());
@@ -217,6 +265,20 @@ export class SubscriptionsComponent {
this.pickerFor.set(this.pickerFor() === subscriptionId ? null : subscriptionId); 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 { toggleMembership(item: SubscriptionItem, groupId: string): void {
const current = this.itemGroupIds(item); const current = this.itemGroupIds(item);
const next = current.includes(groupId) const next = current.includes(groupId)
+144
View File
@@ -76,6 +76,10 @@ export const GROUP_ICONS = [
'🌙', '☀️', '🌧️', '❄️', '🎄', '🎃', '🎉', '🎈', '🌙', '☀️', '🌧️', '❄️', '🎄', '🎃', '🎉', '🎈',
// Avis & shopping // Avis & shopping
'📦', '🛒', '💳', '🔑', '📦', '🛒', '💳', '🔑',
// Noir & blanc / symboles (rendu monochrome partout)
'⬛', '⬜', '◼️', '◻️', '▪️', '▫️', '◾', '◽', '●', '○', '■', '□', '▲', '△', '◆', '◇',
'★', '☆', '♥', '♡', '✔', '✖', '➕', '➖', '☯', '☮', '♠', '♣', '♪', '♫',
'⚑', '✂', '✉', '☎', '⌛', '⏳', '♻', '⚠️', '⛔', '❓', '❗',
]; ];
/** Mots-clés de recherche par icône (FR + EN). Défaut : nom générique. */ /** Mots-clés de recherche par icône (FR + EN). Défaut : nom générique. */
@@ -144,8 +148,148 @@ export const GROUP_ICON_KEYWORDS: Record<string, string> = {
'🎉': 'fete party anniversaire birthday', '🎈': 'ballon balloon fete', '🎉': 'fete party anniversaire birthday', '🎈': 'ballon balloon fete',
'📦': 'colis paquet unboxing avis review test', '🛒': 'courses shopping panier cart', '📦': 'colis paquet unboxing avis review test', '🛒': 'courses shopping panier cart',
'💳': 'carte bancaire paiement pay', '🔑': 'cle key', '💳': 'carte bancaire paiement pay', '🔑': 'cle key',
'⬛': 'noir black carre square symbole', '⬜': 'blanc white carre square symbole',
'◼️': 'noir black carre square symbole', '◻️': 'blanc white carre square symbole',
'▪️': 'noir black petit carre symbole', '▫️': 'blanc white petit carre symbole',
'◾': 'noir black carre symbole', '◽': 'blanc white carre symbole',
'●': 'noir black rond circle point symbole', '○': 'blanc white rond circle symbole',
'■': 'noir black carre square symbole', '□': 'blanc white carre square symbole',
'▲': 'noir black triangle haut up symbole', '△': 'blanc white triangle haut symbole',
'◆': 'noir black losange diamond symbole', '◇': 'blanc white losange diamond symbole',
'★': 'noir black etoile star favori symbole', '☆': 'blanc white etoile star symbole',
'♥': 'noir black coeur love symbole', '♡': 'blanc white coeur love symbole',
'✔': 'noir black coche check valider ok symbole', '✖': 'noir black croix cross fermer symbole',
'➕': 'noir black plus ajouter symbole', '➖': 'noir black moins retirer symbole',
'☯': 'noir blanc ying yang equilibre symbole', '☮': 'paix peace symbole',
'♠': 'noir black pique carte poker symbole', '♣': 'noir black trefle carte poker symbole',
'♪': 'noir black note musique music symbole', '♫': 'noir black notes musique music symbole',
'⚑': 'noir black drapeau flag symbole', '✂': 'noir black ciseaux cut symbole',
'✉': 'enveloppe mail email symbole', '☎': 'noir black telephone phone symbole',
'⌛': 'sablier temps time attente symbole', '⏳': 'sablier temps time attente symbole',
'♻': 'recyclage recycle ecologie symbole', '⚠️': 'attention warning alerte danger symbole',
'⛔': 'interdit stop bloque symbole', '❓': 'question aide help symbole', '❗': 'exclamation important alerte symbole',
}; };
/** Suggestion de rangement : créer un groupe ou compléter un groupe existant. */
export interface GroupSuggestion {
kind: 'create' | 'complete';
name: string;
icon: string;
color: string;
/** subscriptionIds à assigner. */
channelIds: number[];
existingGroupId?: string;
existingGroupName?: string;
/** Titres d'exemple (max 3) pour l'aperçu. */
sample: string[];
}
interface SuggestionRule {
name: string;
icon: string;
color: number;
keywords: string[];
}
/** Règles mot-clé (FR + EN, sans accents : le texte est normalisé avant match). */
const GROUP_SUGGESTION_RULES: SuggestionRule[] = [
{ name: 'Musique', icon: '🎵', color: 12, keywords: ['music', 'musique', 'rap', 'metal', 'rock', 'piano', 'guitar', 'guitare', 'dj', 'beat', 'song', 'chanson', 'concert', 'records', 'label', 'vevo', 'lofi', 'jazz', 'electro', 'techno', 'cover', 'remix', 'chanteur', 'orchestre'] },
{ name: 'Gaming', icon: '🎮', color: 11, keywords: ['game', 'gaming', 'gamer', 'jeux video', 'jeux', 'esport', 'playthrough', 'speedrun', 'xbox', 'playstation', 'nintendo', 'steam', 'minecraft', 'fortnite', 'zelda', 'mario', 'retro', 'arcade', 'walkthrough', 'videogame', 'lets play'] },
{ name: 'Tech & Code', icon: '💻', color: 10, keywords: ['tech', 'dev', 'code', 'coding', 'programmation', 'programming', 'python', 'javascript', 'linux', 'devops', 'docker', 'kubernetes', 'intelligence artificielle', 'machine learning', 'data science', 'hardware', 'software', 'open source', 'github', 'cyber', 'gadget', 'smartphone', 'apple', 'android', 'windows', 'raspberry', 'arduino', 'robotique', 'informatique', 'developer', 'developpeur', 'web dev'] },
{ name: 'Science & Espace', icon: '🔭', color: 9, keywords: ['science', 'espace', 'space', 'nasa', 'astronomie', 'astrophysique', 'univers', 'cosmos', 'quantique', 'biologie', 'chimie', 'planete', 'physique'] },
{ name: 'Sport', icon: '⚽', color: 5, keywords: ['sport', 'foot', 'football', 'soccer', 'basket', 'tennis', 'rugby', 'hockey', 'golf', 'cyclisme', 'formule 1', 'league', 'club', 'olympique', 'nhl', 'nba'] },
{ name: 'Cuisine', icon: '🍳', color: 3, keywords: ['cuisine', 'cooking', 'recette', 'recipe', 'food', 'chef', 'resto', 'patisserie', 'boulangerie', 'bbq', 'bouffe', 'miam'] },
{ name: 'Voyage', icon: '✈️', color: 8, keywords: ['travel', 'voyage', 'tourisme', 'vacances', 'backpack', 'avion', 'roadtrip', 'vanlife', 'expat', 'aventure', 'adventure', 'hotel'] },
{ name: 'Cinéma & Séries', icon: '🎬', color: 0, keywords: ['cinema', 'film', 'movie', 'serie', 'series', 'episode', 'trailer', 'bande annonce', 'hollywood', 'anime', 'manga', 'cartoon', 'court metrage'] },
{ name: 'News & Politique', icon: '🗞️', color: 17, keywords: ['news', 'actu', 'actualite', 'info', 'journal', 'politique', 'debat', 'monde', 'international', 'president', 'election', 'media', 'presse'] },
{ name: 'Finance & Crypto', icon: '💰', color: 4, keywords: ['finance', 'crypto', 'bitcoin', 'bourse', 'trading', 'investissement', 'argent', 'economie', 'immobilier', 'ethereum', 'money'] },
{ name: 'Business & Startups', icon: '💼', color: 7, keywords: ['business', 'startup', 'entrepreneur', 'marketing', 'ecommerce', 'entreprise', 'management'] },
{ name: 'Santé & Fitness', icon: '🏋️', color: 6, keywords: ['sante', 'health', 'fitness', 'musculation', 'medecine', 'docteur', 'nutrition', 'bienetre', 'yoga', 'meditation', 'gym', 'workout'] },
{ name: 'Humour', icon: '😂', color: 4, keywords: ['humour', 'humor', 'comedy', 'drole', 'funny', 'lol', 'sketch', 'blague', 'parodie', 'standup', 'stand up'] },
{ name: 'Éducation', icon: '🎓', color: 11, keywords: ['education', 'cours', 'tuto', 'tutoriel', 'tutorial', 'apprendre', 'learn', 'formation', 'ecole', 'histoire', 'history', 'langue', 'maths', 'documentaire', 'documentary'] },
{ name: 'Auto & Moto', icon: '🚗', color: 1, keywords: ['auto', 'voiture', 'car', 'moto', 'automobile', 'garage', 'mecanique', 'tesla', 'tuning'] },
{ name: 'Nature & Animaux', icon: '🌿', color: 5, keywords: ['nature', 'animaux', 'animal', 'wildlife', 'peche', 'chasse', 'jardin', 'jardinage', 'ferme', 'chat', 'chien', 'oiseau'] },
{ name: 'Art & Design', icon: '🎨', color: 13, keywords: ['art', 'design', 'photo', 'photographie', 'dessin', 'peinture', 'architecture', 'deco', 'graphisme', 'illustration'] },
{ name: 'Vlogs & Lifestyle', icon: '📹', color: 15, keywords: ['vlog', 'lifestyle', 'quotidien', 'famille', 'daily', 'routine', 'blog', 'vlogger'] },
];
/** Normalisation insensible aux accents/casse pour le match des règles. */
export function normalizeChannelText(value: string | null | undefined): string {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
/**
* Analyse les chaînes et propose des groupes (création ou complétion).
* Pur et testable : ne touche pas au réseau.
*/
export function suggestGroupsForChannels(
items: Array<{ subscriptionId: number; title: string; handle?: string | null; groupIds: string[] }>,
existingGroups: Array<{ id: string; name: string }>,
): GroupSuggestion[] {
const byRule = new Map<number, typeof items>();
// Mots courts (≤4) : mot entier uniquement (évite 'car' dans 'oscar').
// Mots longs : préfixe de mot (pluriels, 'metallica', 'documentaires'...).
const wordRe = (kw: string) => {
const esc = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return kw.length <= 4 ? new RegExp(`\\b${esc}(s|x)?\\b`) : new RegExp(`\\b${esc}\\w*`);
};
for (const item of items) {
const hay = normalizeChannelText(`${item.title || ''} ${item.handle || ''}`);
if (!hay.trim()) continue;
let best = -1;
let bestScore = 0;
GROUP_SUGGESTION_RULES.forEach((rule, idx) => {
let score = 0;
for (const kw of rule.keywords) {
if (kw && wordRe(kw).test(hay)) score++;
}
if (score > bestScore) {
bestScore = score;
best = idx;
}
});
if (best < 0) continue;
const list = byRule.get(best) || [];
list.push(item);
byRule.set(best, list);
}
const suggestions: GroupSuggestion[] = [];
const normName = (s: string) => normalizeChannelText(s).trim();
for (const [ruleIdx, matched] of byRule) {
if (matched.length < 2) continue; // anti-bruit : 1 chaîne isolée ne fait pas un groupe
const rule = GROUP_SUGGESTION_RULES[ruleIdx];
const existing = existingGroups.find((g) => normName(g.name) === normName(rule.name));
const sample = matched.slice(0, 3).map((m) => m.title).filter(Boolean);
if (existing) {
const missing = matched.filter((m) => !m.groupIds.includes(existing.id));
if (!missing.length) continue;
suggestions.push({
kind: 'complete',
name: existing.name,
icon: rule.icon,
color: GROUP_COLORS[rule.color % GROUP_COLORS.length],
channelIds: missing.map((m) => m.subscriptionId),
existingGroupId: existing.id,
existingGroupName: existing.name,
sample,
});
} else {
suggestions.push({
kind: 'create',
name: rule.name,
icon: rule.icon,
color: GROUP_COLORS[rule.color % GROUP_COLORS.length],
channelIds: matched.map((m) => m.subscriptionId),
sample,
});
}
}
return suggestions.sort((a, b) => b.channelIds.length - a.channelIds.length);
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SubscriptionsService { export class SubscriptionsService {
private http = inject(HttpClient); private http = inject(HttpClient);