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)
|
||||
|
||||
@@ -76,6 +76,10 @@ export const GROUP_ICONS = [
|
||||
'🌙', '☀️', '🌧️', '❄️', '🎄', '🎃', '🎉', '🎈',
|
||||
// Avis & shopping
|
||||
'📦', '🛒', '💳', '🔑',
|
||||
// Noir & blanc / symboles (rendu monochrome partout)
|
||||
'⬛', '⬜', '◼️', '◻️', '▪️', '▫️', '◾', '◽', '●', '○', '■', '□', '▲', '△', '◆', '◇',
|
||||
'★', '☆', '♥', '♡', '✔', '✖', '➕', '➖', '☯', '☮', '♠', '♣', '♪', '♫',
|
||||
'⚑', '✂', '✉', '☎', '⌛', '⏳', '♻', '⚠️', '⛔', '❓', '❗',
|
||||
];
|
||||
|
||||
/** 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',
|
||||
'📦': 'colis paquet unboxing avis review test', '🛒': 'courses shopping panier cart',
|
||||
'💳': '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' })
|
||||
export class SubscriptionsService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
Reference in New Issue
Block a user