feat(shorts): pastilles a logos + filtre de langues preferees
CI / build-and-test (push) Successful in 14m49s

Boutons fournisseurs avec disque aux couleurs de la marque + glyphe SVG (play YT, bulle Twitch, initiale sinon) ; icone etincelles pour Pour toi. Selecteur de langues Profil/FR/EN/FR+EN/Toutes (persiste newtube.shorts.langFilter, invite compris) : chaque flux passe par effectiveShortsLangs (resolveShortsLangs pur + spec). Docs 9.9.
This commit is contained in:
2026-09-30 12:57:31 -04:00
parent 3e1980283a
commit 49859f4e81
5 changed files with 173 additions and 11 deletions
+13
View File
@@ -3,6 +3,8 @@ import {
formatShortMeta,
formatCapturedAgo,
formatCompactFr,
resolveShortsLangs,
normalizeShortsLangMode,
SHORTS_CATALOG_PAGE_SIZE,
SHORTS_CATALOG_MIN_ITEMS,
} from './shorts-catalog';
@@ -104,6 +106,17 @@ console.log('--- allowEmbed (Dailymotion Forbidden) ---');
assertEqual(v?.raw, undefined, 'embeddable absent → raw absent (doute profite à la vidéo)');
}
console.log('--- resolveShortsLangs (filtre de préférence) ---');
assertEqual(normalizeShortsLangMode('fr'), 'fr', 'mode fr reconnu');
assertEqual(normalizeShortsLangMode('ES'), 'profile', 'mode inconnu → profile');
assertEqual(normalizeShortsLangMode(null), 'profile', 'mode absent → profile');
assertEqual(resolveShortsLangs('all', ['fr', 'en']), [], 'all → aucune restriction');
assertEqual(resolveShortsLangs('fr', ['fr', 'en']), ['fr'], 'fr → restriction sûre');
assertEqual(resolveShortsLangs('en', ['fr', 'en']), ['en'], 'en → restriction sûre');
assertEqual(resolveShortsLangs('fren', ['fr']), ['fr', 'en'], 'fren → les deux');
assertEqual(resolveShortsLangs('profile', ['en', 'fr', 'fr', 'es-419', 'e']), ['en', 'fr', 'es'], 'profile → normalisé, dédupliqué, base ISO (forme invalide rejetée)');
assertEqual(resolveShortsLangs('profile', null), [], 'profil vide → aucune restriction (pas de vide)');
console.log('--- constantes ---');
assertEqual(SHORTS_CATALOG_PAGE_SIZE, 25, 'page catalogue 25 (serveur plafonne à 50)');
assertEqual(SHORTS_CATALOG_MIN_ITEMS, 3, 'seuil de repli legacy');
+43 -2
View File
@@ -138,8 +138,7 @@ export function formatShortMeta(views?: number, publishedAt?: string, now: numbe
* `undefined` → `''` (pas de badge) : un `capturedAt` absent ne doit jamais
* passer pour du frais — doctrine phase 7.3.
*/
export function formatCapturedAgo(capturedAt?: number, now: number = Date.now()): string {
if (!Number.isFinite(Number(capturedAt)) || Number(capturedAt) <= 0) return '';
export function formatCapturedAgo(capturedAt?: number, now: number = Date.now()): string { if (!Number.isFinite(Number(capturedAt)) || Number(capturedAt) <= 0) return '';
const at = Number(capturedAt);
if (at > now) return 'capturé à l’instant';
const min = Math.floor((now - at) / 60000);
@@ -149,3 +148,45 @@ export function formatCapturedAgo(capturedAt?: number, now: number = Date.now())
if (h < 24) return `capturé il y a ${h} h`;
return `capturé il y a ${Math.floor(h / 24)} j`;
}
// ---------------------------------------------------------------------------
// Filtre de langues préférées (boutons + flux Shorts)
// ---------------------------------------------------------------------------
/**
* Mode du filtre de langues de la page Shorts :
* - `profile` : langues du profil utilisateur (UI + préférences + TOUJOURS
* fr/en, voir `getShortsLanguages`) — défaut ;
* - `fr` / `en` / `fren` : restriction explicite (sur-ensemble sûr : le
* serveur a cherché dans les langues du profil, le client ne fait que
* resserrer) ;
* - `all` : aucun filtre (on montre tout ce qui a été collecté).
*/
export type ShortsLangMode = 'profile' | 'fr' | 'en' | 'fren' | 'all';
export const SHORTS_LANG_MODES: readonly ShortsLangMode[] = ['profile', 'fr', 'en', 'fren', 'all'] as const;
/** Valeur stockée (localStorage) → mode valide, `profile` en repli. */
export function normalizeShortsLangMode(raw: unknown): ShortsLangMode {
const s = String(raw || '').trim().toLowerCase();
return (SHORTS_LANG_MODES as readonly string[]).includes(s) ? (s as ShortsLangMode) : 'profile';
}
/**
* Langues effectives d'un mode : `[]` = aucune restriction (tout passe).
* Pur et testé hors-ligne : c'est ce que `shortsLangOk` et tous les flux
* (catalogue, historique, « Pour toi ») consultent — chaque bouton passe
* donc par le même filtre de préférence.
*/
export function resolveShortsLangs(mode: ShortsLangMode, profileLangs: readonly string[] | null | undefined): string[] {
if (mode === 'all') return [];
if (mode === 'fr') return ['fr'];
if (mode === 'en') return ['en'];
if (mode === 'fren') return ['fr', 'en'];
const out: string[] = [];
for (const l of profileLangs || []) {
const base = String(l || '').trim().toLowerCase().split('-')[0];
if (/^[a-z]{2,3}$/.test(base) && !out.includes(base)) out.push(base);
}
return out;
}