diff --git a/src/components/library/subscriptions/subscriptions.component.html b/src/components/library/subscriptions/subscriptions.component.html index ebc7efb..d5574d7 100644 --- a/src/components/library/subscriptions/subscriptions.component.html +++ b/src/components/library/subscriptions/subscriptions.component.html @@ -12,6 +12,14 @@ > + Nouveau groupe + + ✨ Suggestions ({{ suggestions().length }}) + + + + + + ✨ Groupes suggérés + Analyse des noms de chaînes (mots-clés FR/EN). Vérifiez avant d’appliquer. + + 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' }} + + + + Aucune suggestion : chaque thème a moins de 2 chaînes, ou tout est déjà rangé. + + 0" class="mt-3 space-y-2"> + + + {{ s.icon }} + + + {{ s.name }} + Nouveau + Compléter « {{ s.existingGroupName }} » + {{ s.channelIds.length }} chaîne(s) + + {{ s.sample.join(' • ') }} + + + + {{ s.kind === 'create' ? 'Créer + assigner' : 'Assigner' }} + + + + + - + (''); + // --- Suggestions auto de groupes (analyse des chaînes) --- + showSuggestions = signal(false); + suggestBusy = signal(false); + + readonly suggestions = computed(() => + 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 { + if (this.suggestBusy()) return Promise.resolve(); + this.suggestBusy.set(true); + const assignAll = (groupId: string): Promise => { + const calls = s.channelIds + .filter((sid) => !(this.members()[String(sid)] || []).includes(groupId)) + .map((sid) => new Promise((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((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 { + 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) diff --git a/src/services/subscriptions.service.ts b/src/services/subscriptions.service.ts index a58ffd8..2bf6a18 100644 --- a/src/services/subscriptions.service.ts +++ b/src/services/subscriptions.service.ts @@ -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 = { '🎉': '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(); + // 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);
Analyse des noms de chaînes (mots-clés FR/EN). Vérifiez avant d’appliquer.
{{ s.sample.join(' • ') }}