feat(search): panneau de filtres unifié, clavier/focus du typeahead
CI / build-and-test (push) Successful in 14m10s
CI / build-and-test (push) Successful in 14m10s
Barre de recherche - Panneau de filtres unifié (remplace le ProviderPicker) : sources, type (vidéos/shorts/direct/chaînes), période (heure/jour/semaine/mois/année), durée (<4 / 4-20 / >20 min) et tri. Pastilles + roving tabindex (←/→ dans un groupe, ↑/↓ entre groupes), focus trap, Échap, Ctrl/⌘+Maj+F, recherches récentes, « mémoriser par défaut », « Réinitialiser ». - Pastilles des filtres actifs sous la barre (retrait en un clic) + compteur. - Typeahead : un seul keydown (fin du double Enter), ↑/↓ bouclants, Home/End, PageUp/PageDown, Entrée valide la ligne surlignée (sinon recherche brute), Tab complète sans chercher, Échap ferme puis vide, ligne « Rechercher <q> », défilement de l'option active, aria à jour. - Focus : le panneau se referme dès que le focus quitte la barre (focusout + relatedTarget + activeElement), pointerdown neutralisé sur les lignes pour garder le focus dans l'input (plus de scintillement). - Opérateurs en clair : `linux live:`, `tuto today: long:` deviennent des filtres et sont retirés de la requête (autocomplétion après le `:`). Filtres côté serveur - Modèle partagé : src/app/search/filters.ts + server/search-filters.mjs. - /api/search?type=&duration=&period=&sort= : InnerTube (upload_date, type, duration, features), Data API v3 (type, videoDuration, publishedAfter), yt-dlp --dateafter ; post-filtrage pour les providers sans filtre natif. Un champ manquant ne fait jamais disparaître un résultat. - Filtres dans l'URL (partageables) + adapters + SearchService (clé de cache). - Correction : la recherche Shorts renvoyait 0 (ShortsLockupView/GridShelfView non mappés, id/titre dans l'endpoint) -> mappage + shelf + vignette/vues. Tests / docs - 23 tests unitaires (clavier, focus, opérateurs, panneau de filtres), server/tests/search-filters.test.mjs (npm run test:filters) + CI, scénarios e2e des filtres, README/api/README/todo/MCP à jour.
This commit is contained in:
@@ -40,6 +40,9 @@ jobs:
|
|||||||
- name: Unit tests (search UX)
|
- name: Unit tests (search UX)
|
||||||
run: npm run test:search
|
run: npm run test:search
|
||||||
|
|
||||||
|
- name: Search filters (offline)
|
||||||
|
run: npm run test:filters
|
||||||
|
|
||||||
- name: E2E scenarios (search UX)
|
- name: E2E scenarios (search UX)
|
||||||
run: npm run test:search-e2e
|
run: npm run test:search-e2e
|
||||||
|
|
||||||
|
|||||||
@@ -49,33 +49,35 @@ Agrégez, explorez et regardez des vidéos depuis
|
|||||||
|
|
||||||
Un seul champ, tous les fournisseurs — avec filtres, raccourcis et deep-links :
|
Un seul champ, tous les fournisseurs — avec filtres, raccourcis et deep-links :
|
||||||
|
|
||||||
* **Chips providers** dans la barre : `All / YT / DM / TW / PT / OD / RU` (raccourcis **Alt+1..6**)
|
* **Panneau de filtres** : bouton **Filtres** (ou **Ctrl/⌘+Maj+F**) — un seul écran pour *toutes* les dimensions : **sources** (multi), **type de contenu** (vidéos / shorts-clips / en direct / chaînes), **période** (dernière heure, aujourd’hui, semaine, mois, année), **durée** (courte < 4 min, moyenne 4-20 min, longue > 20 min) et **tri**. Pastilles, roving tabindex (←/→ dans un groupe, ↑/↓ entre groupes, Entrée pour choisir, Échap pour fermer), focus trap, « Réinitialiser », « Mémoriser comme sources par défaut » et les **recherches récentes** en raccourci
|
||||||
* **Autocomplete `@`** : tapez `@yt` dans le champ pour filtrer sur YouTube (flèches + Enter, Esc pour fermer)
|
* **Filtres appliqués côté serveur** : `?type=…&duration=…&period=…&sort=…` voyagent dans l’URL et dans `/api/search` — YouTube filtre nativement (InnerTube `upload_date`/`type`/`duration`/`features`, Data API `type`/`videoDuration`/`publishedAfter`, yt-dlp `--dateafter`), les autres providers sont affinés en post-traitement (`server/search-filters.mjs`)
|
||||||
* **Provider picker** : bouton `@` ou **Ctrl/⌘+K** — modal avec filtre texte, sélection multiple et option **« Remember as default »** (persistée dans vos préférences utilisateur)
|
* **Opérateurs en clair** : `linux live:`, `tuto today: long:`, `concert shorts:`… tapés dans la barre sont convertis en filtres et retirés de la requête (autocomplétion après le `:`)
|
||||||
* **Quick menu Ctrl/⌘+K** : historique des recherches + toggles providers en un écran
|
* **Autocomplete `@`** : tapez `@yt` dans le champ pour cocher/décocher une source (↑/↓/Entrée, Échap pour fermer) — raccourcis **Alt+1..6**
|
||||||
* **Deep-links** : `/#/search?q=…&providers=yt,ru` relance la recherche filtrée — partageable
|
* **Deep-links** : `/#/search?q=…&providers=yt,ru&period=week&type=live` relance la recherche filtrée — partageable
|
||||||
* **Fallback préférence** : URL sans `providers` → préférence `defaultProviders` de l’utilisateur → provider actif
|
* **Fallback préférence** : URL sans `providers` → préférence `defaultProviders` de l’utilisateur → provider actif
|
||||||
* **Accessibilité** : focus trap dans les modals, Esc pour fermer, aria-combobox sur le champ, focus restauré à la fermeture
|
* **Accessibilité** : focus trap dans le panneau, Esc pour fermer, aria-combobox + `aria-activedescendant` sur le champ, focus restauré à la fermeture
|
||||||
* **Typeahead requête** : sous l’input, suggestions de requêtes (recherches récentes 🕘 + groupes par provider `YT/DM/…`, sous-chaîne surlignée) — `GET /api/search/suggest?q=…&providers=…&limit=…` (min 2 caractères, debounce 250 ms, cache 5 min, dégradation `[]` par provider) ; ↑/↓/Enter/Tab/Esc, priorité au popover `@`
|
* **Panneau de suggestions** : sous l’input, ligne « Rechercher `<q>` » puis suggestions (recherches récentes 🕘 + groupes par provider `YT/DM/…`, sous-chaîne surlignée) — `GET /api/search/suggest?q=…&providers=…&limit=…` (min 2 caractères, debounce 250 ms, cache 5 min, dégradation `[]` par provider) ; ↑/↓ (bouclants), `Home`/`End`, `PageUp`/`PageDown`, `Entrée` (valide la ligne surlignée, sinon lance la recherche), `Tab` (complète sans chercher), `Échap` (ferme puis vide), priorité au popover `@`
|
||||||
|
* **Focus** : le panneau se referme dès que le focus quitte la barre (`focusout` + `relatedTarget`, clic neutralisé sur les lignes pour garder le focus dans l’input, pas de scintillement au re-clic)
|
||||||
|
|
||||||
<!-- TODO: add docs/search-ux.gif (capture des chips, du picker Ctrl+K et du deep-link) -->
|
<!-- TODO: add docs/search-ux.gif (capture du panneau de filtres, du panneau de suggestions et du deep-link) -->
|
||||||
|
|
||||||
### Endpoints API concernés
|
### Endpoints API concernés
|
||||||
|
|
||||||
* `GET /api/search?q=…&providers=yt,dm` — fan-out parallèle, réponse groupée par provider (`page`/`pageSize`/`sort` ; YT sans quota via InnerTube + continuations)
|
* `GET /api/search?q=…&providers=yt,dm&type=live&duration=short&period=week&sort=date` — fan-out parallèle, réponse groupée par provider (`page`/`pageSize`/`sort` + `filters` ; YT sans quota via InnerTube + continuations)
|
||||||
* `GET /api/search/suggest?q=…&providers=yt,dm&limit=10` — typeahead `{ q, groups: { yt: string[], … } }` (cache 5 min, rate-limit)
|
* `GET /api/search/suggest?q=…&providers=yt,dm&limit=10` — typeahead `{ q, groups: { yt: string[], … } }` (cache 5 min, rate-limit)
|
||||||
* `GET /api/details/youtube/:videoId` — métadonnées + `related[]` (watch-next InnerTube, `?related=0` pour désactiver)
|
* `GET /api/details/youtube/:videoId` — métadonnées + `related[]` (watch-next InnerTube, `?related=0` pour désactiver)
|
||||||
* `GET /api/trending?provider=yt&limit=…` — tendances YT sans clé
|
* `GET /api/trending?provider=yt&limit=…` — tendances YT sans clé
|
||||||
* `GET /healthz` (alias `/api/healthz`) — mode YT, binaire yt-dlp `binOk`, cache, métriques quota/jour, clés
|
* `GET /healthz` (alias `/api/healthz`) — mode YT, binaire yt-dlp `binOk`, cache, métriques quota/jour, clés
|
||||||
* `GET /api/transcript/:provider/:videoId?lang=&instance=&slug=&sourceUrl=` — transcript `{ lang, available, languages, lines: [{ t, dur, text }] }` (cache 24 h, rate-limit 10/min ; absent → 200 `{ available: false }`, échec → 502 ; YouTube : découverte des pistes via InnerTube, `YT_TRANSCRIPT_SOURCE`)
|
* `GET /api/transcript/:provider/:videoId?lang=&instance=&slug=&sourceUrl=` — transcript `{ lang, available, languages, lines: [{ t, dur, text }] }` (cache 24 h, rate-limit 10/min ; absent → 200 `{ available: false }`, échec → 502 ; YouTube : découverte des pistes via InnerTube, `YT_TRANSCRIPT_SOURCE`)
|
||||||
* `GET/PATCH /api/user/preferences` — `defaultProviders` (tableau JSON, sanitizé serveur)
|
* `GET/PATCH /api/user/preferences` — `defaultProviders` (tableau JSON, sanitizé serveur)
|
||||||
* `POST /api/telemetry/events` — événements UX anonymes (whitelist : `search_submit`, `provider_picker_open`, `provider_apply`, `at_autocomplete_use`, `quick_menu_open`, `suggest_shown`, `suggest_used`)
|
* `POST /api/telemetry/events` — événements UX anonymes (whitelist : `search_submit`, `provider_picker_open`, `provider_apply`, `at_autocomplete_use`, `quick_menu_open`, `filter_panel_open`, `filter_apply`, `suggest_shown`, `suggest_used`)
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test:search # unitaires SearchService + parsing @ + picker
|
npm run test:search # unitaires SearchService + clavier/focus du panneau de suggestions + panneau de filtres
|
||||||
npm run test:search-e2e # scénarios e2e (serveur réel isolé)
|
npm run test:search-e2e # scénarios e2e (serveur réel isolé)
|
||||||
|
npm run test:filters # filtres de recherche : normalisation, bornes, mapping providers (offline)
|
||||||
npm run test:suggest # typeahead : parsing/dédup + contrat /api/search/suggest
|
npm run test:suggest # typeahead : parsing/dédup + contrat /api/search/suggest
|
||||||
npm run test:transcript # transcripts : parseurs json3/vtt + contrat /api/transcript
|
npm run test:transcript # transcripts : parseurs json3/vtt + contrat /api/transcript
|
||||||
npm run test:preferences # persistance defaultProviders
|
npm run test:preferences # persistance defaultProviders
|
||||||
@@ -241,8 +243,8 @@ Ajoutez au besoin `log-driver`, `log-opts`, `default-address-pools`, etc.
|
|||||||
* ✅ Thème (système / dark / light / blue / black)
|
* ✅ Thème (système / dark / light / blue / black)
|
||||||
* ✅ Préférences (langue, thème, région, qualité par défaut)
|
* ✅ Préférences (langue, thème, région, qualité par défaut)
|
||||||
* ✅ Auth légère (JWT), rate-limit API
|
* ✅ Auth légère (JWT), rate-limit API
|
||||||
* ✅ **Recherche unifiée multi-providers** — chips + autocomplete `@`, picker Ctrl/⌘+K, deep-links `?providers=…`, préférence `defaultProviders` persistée, télémétrie minimale, a11y (focus trap, Esc)
|
* ✅ **Recherche unifiée multi-providers** — panneau de filtres (sources / type / période / durée / tri, Ctrl/⌘+Maj+F), autocomplete `@` + opérateurs `live:`/`today:`, deep-links `?providers=…&type=…&period=…`, préférence `defaultProviders` persistée, filtres serveur, télémétrie minimale, a11y (focus trap, Esc, clavier complet sur le panneau de suggestions)
|
||||||
* ✅ **Recherche unifiée multi-providers** — chips + autocomplete `@`, picker Ctrl/⌘+K, deep-links `?providers=…`, préférence `defaultProviders` persistée, télémétrie minimale, a11y (focus trap, Esc)
|
* ✅ **Recherche unifiée multi-providers** — panneau de filtres (sources / type / période / durée / tri, Ctrl/⌘+Maj+F), autocomplete `@` + opérateurs `live:`/`today:`, deep-links `?providers=…&type=…&period=…`, préférence `defaultProviders` persistée, filtres serveur, télémétrie minimale, a11y (focus trap, Esc, clavier complet sur le panneau de suggestions)
|
||||||
* ✅ Navigation par thèmes (Trending, Live, Gaming, News, Finance, Tech, Science, Health, Music, Podcasts, Movies/TV, Education, Travel, Food, DIY, Auto…)
|
* ✅ Navigation par thèmes (Trending, Live, Gaming, News, Finance, Tech, Science, Health, Music, Podcasts, Movies/TV, Education, Travel, Food, DIY, Auto…)
|
||||||
* ⏳ **Abonnements** (routes + DB)
|
* ⏳ **Abonnements** (routes + DB)
|
||||||
* ⏳ **Tags** & recherche par tags
|
* ⏳ **Tags** & recherche par tags
|
||||||
@@ -304,6 +306,7 @@ MIT (voir `LICENSE`)
|
|||||||
- Créez un fichier `server/providers/<provider>.mjs` qui exporte `default` avec:
|
- Créez un fichier `server/providers/<provider>.mjs` qui exporte `default` avec:
|
||||||
- `id`, `label`
|
- `id`, `label`
|
||||||
- `async search(q, { limit, page })` → `Promise<Suggestion[]>`
|
- `async search(q, { limit, page })` → `Promise<Suggestion[]>`
|
||||||
|
- `async search(q, { limit, page, sort, filters })` → `Promise<Suggestion[]>` (`filters` = `{ type, duration, period, sort }` normalisé par `server/search-filters.mjs` ; ignoré par défaut, affiné en post‑traitement si besoin).
|
||||||
- Enregistrez-le dans `server/providers/registry.mjs` pour être éligible au fan‑out `/api/search`.
|
- Enregistrez-le dans `server/providers/registry.mjs` pour être éligible au fan‑out `/api/search`.
|
||||||
|
|
||||||
3) API — Recherche multi‑providers
|
3) API — Recherche multi‑providers
|
||||||
@@ -322,10 +325,11 @@ MIT (voir `LICENSE`)
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
4) UI — Sélecteur & Chips
|
4) UI — Panneau de filtres
|
||||||
|
|
||||||
- Le `SearchBoxComponent` (standalone) rend les chips `All / YT / DM / TW / PT / OD / RU` + menu rapide `@` (ProviderPicker).
|
- Le `SearchBoxComponent` (standalone) rend le champ + le bouton **Filtres** (`SearchFilterPanelComponent` : sources, type, période, durée, tri, recherches récentes) et les pastilles des filtres actifs.
|
||||||
- Le composant émet `(submitted)` avec `{ q, providers }` et met à jour `SearchService` (RxJS state) pour l’appel API.
|
- Le composant émet `(submitted)` avec `{ q, providers, filters }` et `(filtersChange)` avec les filtres seuls ; `SearchService` (RxJS state) porte `filters$` et l’envoie à chaque adapter.
|
||||||
|
- Le modèle de filtres est partagé : `src/app/search/filters.ts` (front) et `server/search-filters.mjs` (API) implémentent les mêmes règles.
|
||||||
|
|
||||||
5) Deep‑link
|
5) Deep‑link
|
||||||
|
|
||||||
@@ -333,7 +337,7 @@ MIT (voir `LICENSE`)
|
|||||||
|
|
||||||
6) Tests
|
6) Tests
|
||||||
|
|
||||||
- Unit (`npm run test:search`) : parsing `@yt` dans l’input, toggles ProviderPicker, composition et fan-out de `SearchService`.
|
- Unit (`npm run test:search`) : parsing `@yt`, clavier/focus du panneau de suggestions, opérateurs `live:`, brouillon + apply du panneau de filtres, composition et fan-out de `SearchService. `npm run test:filters` : normalisation/bornes/post-filtrage/mapping providers (offline).
|
||||||
- e2e (`npm run test:search-e2e`) : `providers=yt,dm` → réponse ciblée sur [yt,dm]; deep-link `providers=pt`; fallback registry complet; préférence persistée; route SPA.
|
- e2e (`npm run test:search-e2e`) : `providers=yt,dm` → réponse ciblée sur [yt,dm]; deep-link `providers=pt`; fallback registry complet; préférence persistée; route SPA.
|
||||||
|
|
||||||
Astuce: l’ajout d’un provider ne nécessite pas de modifier les composants — il suffit d’ajouter une entrée dans le registry front + un handler API.
|
Astuce: l’ajout d’un provider ne nécessite pas de modifier les composants — il suffit d’ajouter une entrée dans le registry front + un handler API.
|
||||||
|
|||||||
+18
-3
@@ -14,7 +14,10 @@ Query parameters
|
|||||||
- providers (string, optional): Comma-separated list of provider ids. Allowed values: yt, dm, tw, pt, od, ru. Defaults to all when omitted or invalid.
|
- providers (string, optional): Comma-separated list of provider ids. Allowed values: yt, dm, tw, pt, od, ru. Defaults to all when omitted or invalid.
|
||||||
- page (integer, optional): Page index starting at 1. Default: 1.
|
- page (integer, optional): Page index starting at 1. Default: 1.
|
||||||
- pageSize (integer, optional): Page size, max 50. Default: 24.
|
- pageSize (integer, optional): Page size, max 50. Default: 24.
|
||||||
- sort (string, optional): Reserved for future use.
|
- sort (string, optional): relevance | date | views. Default: relevance.
|
||||||
|
- type (string, optional): video | shorts | live | channel. Default: all.
|
||||||
|
- duration (string, optional): short (< 4 min) | medium (4-20 min) | long (> 20 min). Default: all.
|
||||||
|
- period (string, optional): hour | today | week | month | year (upload date). Default: all.
|
||||||
|
|
||||||
Response
|
Response
|
||||||
```
|
```
|
||||||
@@ -32,9 +35,13 @@ Response
|
|||||||
"ru": []
|
"ru": []
|
||||||
},
|
},
|
||||||
"page": 1,
|
"page": 1,
|
||||||
"pageSize": 24
|
"pageSize": 24,
|
||||||
|
"sort": "relevance",
|
||||||
|
"filters": { "type": "live", "period": "week" }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
`filters` echoes the active filters (default values are omitted). Unknown filter
|
||||||
|
values are ignored (never a 4xx), so a typo silently degrades to "no filter".
|
||||||
|
|
||||||
Error responses
|
Error responses
|
||||||
- 400 { error: "invalid_query", details: "min_length_2" }
|
- 400 { error: "invalid_query", details: "min_length_2" }
|
||||||
@@ -51,4 +58,12 @@ Providers
|
|||||||
Notes
|
Notes
|
||||||
- Each provider is queried in parallel with a per-provider limit equal to `pageSize`.
|
- Each provider is queried in parallel with a per-provider limit equal to `pageSize`.
|
||||||
- The endpoint currently does not expose a `total` field; the frontend should offer a simple Next page affordance or infinite scroll when appropriate.
|
- The endpoint currently does not expose a `total` field; the frontend should offer a simple Next page affordance or infinite scroll when appropriate.
|
||||||
- Future: support for `sort`.
|
- Filters are applied natively when the provider supports them (YouTube: InnerTube
|
||||||
|
`upload_date` / `type` / `duration` / `features`, Data API v3 `type` /
|
||||||
|
`videoDuration` / `publishedAfter`, yt-dlp `--dateafter`), and refined in
|
||||||
|
post-processing otherwise (see `server/search-filters.mjs`).
|
||||||
|
- A result whose metadata is missing is never dropped by a filter: only values
|
||||||
|
that are present and out of range are filtered out.
|
||||||
|
- The `channel` type is only enforced for YouTube (the only provider that
|
||||||
|
returns channels as search results).
|
||||||
|
- `period=hour` is not a native InnerTube filter: it is applied in post-processing.
|
||||||
|
|||||||
@@ -243,6 +243,9 @@ body {
|
|||||||
@supports not (height: 100dvh) {
|
@supports not (height: 100dvh) {
|
||||||
.quick-menu-dialog { max-height: 85vh; }
|
.quick-menu-dialog { max-height: 85vh; }
|
||||||
}
|
}
|
||||||
|
/* Panneau de filtres : même coque que la palette Ctrl+K, plus étroit (les
|
||||||
|
options sont des pastilles, pas des colonnes). */
|
||||||
|
.filter-panel-dialog { max-width: 44rem; }
|
||||||
@media (max-width: 639px) {
|
@media (max-width: 639px) {
|
||||||
/* On phones the dialog owns the viewport: no floating "modal" look. */
|
/* On phones the dialog owns the viewport: no floating "modal" look. */
|
||||||
.quick-menu-backdrop { padding: 0; }
|
.quick-menu-backdrop { padding: 0; }
|
||||||
|
|||||||
+10
-2
@@ -97,7 +97,7 @@ function transcriptToSrt(data) {
|
|||||||
const TOOLS = [
|
const TOOLS = [
|
||||||
{
|
{
|
||||||
name: 'search_videos',
|
name: 'search_videos',
|
||||||
description: 'Recherche unifiée multi-providers (YouTube, Dailymotion, Twitch, PeerTube, Odysee, Rumble). q min 2 caractères.',
|
description: 'Recherche unifiée multi-providers (YouTube, Dailymotion, Twitch, PeerTube, Odysee, Rumble) avec filtres (type de contenu, durée, période de mise en ligne). q min 2 caractères.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -106,6 +106,9 @@ const TOOLS = [
|
|||||||
page: { type: 'integer', minimum: 1, default: 1 },
|
page: { type: 'integer', minimum: 1, default: 1 },
|
||||||
pageSize: { type: 'integer', minimum: 1, maximum: 50, default: 10 },
|
pageSize: { type: 'integer', minimum: 1, maximum: 50, default: 10 },
|
||||||
sort: { type: 'string', enum: ['relevance', 'date', 'views'], default: 'relevance' },
|
sort: { type: 'string', enum: ['relevance', 'date', 'views'], default: 'relevance' },
|
||||||
|
type: { type: 'string', enum: ['video', 'shorts', 'live', 'channel'], description: 'Type de contenu attendu' },
|
||||||
|
duration: { type: 'string', enum: ['short', 'medium', 'long'], description: 'Durée : court < 4 min, moyen 4-20 min, long > 20 min' },
|
||||||
|
period: { type: 'string', enum: ['hour', 'today', 'week', 'month', 'year'], description: 'Période de mise en ligne' },
|
||||||
},
|
},
|
||||||
required: ['q'],
|
required: ['q'],
|
||||||
},
|
},
|
||||||
@@ -240,10 +243,15 @@ async function callTool(name, args = {}) {
|
|||||||
page: String(args.page ?? 1),
|
page: String(args.page ?? 1),
|
||||||
pageSize: String(args.pageSize ?? 10),
|
pageSize: String(args.pageSize ?? 10),
|
||||||
sort: String(args.sort ?? 'relevance'),
|
sort: String(args.sort ?? 'relevance'),
|
||||||
|
// Filtres : ignorés côté API s'ils sont inconnus, donc sûrs à passer.
|
||||||
|
...(args.type ? { type: String(args.type) } : {}),
|
||||||
|
...(args.duration ? { duration: String(args.duration) } : {}),
|
||||||
|
...(args.period ? { period: String(args.period) } : {}),
|
||||||
});
|
});
|
||||||
const data = await apiFetch(`/search?${qs}`);
|
const data = await apiFetch(`/search?${qs}`);
|
||||||
const counts = Object.fromEntries(Object.entries(data.groups || {}).map(([k, v]) => [k, v.length]));
|
const counts = Object.fromEntries(Object.entries(data.groups || {}).map(([k, v]) => [k, v.length]));
|
||||||
return { summary: `${data.groups ? Object.values(data.groups).flat().length : 0} résultats pour "${q}" ${JSON.stringify(counts)}`, data };
|
const filterHint = data.filters && Object.keys(data.filters).length ? ` [${JSON.stringify(data.filters)}]` : '';
|
||||||
|
return { summary: `${data.groups ? Object.values(data.groups).flat().length : 0} résultats pour "${q}" ${JSON.stringify(counts)}${filterHint}`, data };
|
||||||
}
|
}
|
||||||
case 'suggest_queries': {
|
case 'suggest_queries': {
|
||||||
const q = String(args.q || '').trim();
|
const q = String(args.q || '').trim();
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"test:subscriptions": "node --loader ts-node/esm --experimental-specifier-resolution=node src/services/subscriptions.service.spec.ts",
|
"test:subscriptions": "node --loader ts-node/esm --experimental-specifier-resolution=node src/services/subscriptions.service.spec.ts",
|
||||||
"test:search": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/search.service.spec.ts && node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/search-components.spec.ts",
|
"test:search": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/search.service.spec.ts && node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/search-components.spec.ts",
|
||||||
"test:suggest": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/suggest.spec.ts && node ./server/tests/suggest.test.mjs",
|
"test:suggest": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/search/suggest.spec.ts && node ./server/tests/suggest.test.mjs",
|
||||||
|
"test:filters": "node ./server/tests/search-filters.test.mjs",
|
||||||
"test:transcript": "node --test server/tests/transcript.test.mjs",
|
"test:transcript": "node --test server/tests/transcript.test.mjs",
|
||||||
"test:history": "node server/tests/history_filters.test.mjs",
|
"test:history": "node server/tests/history_filters.test.mjs",
|
||||||
"test:highlight": "node --loader ts-node/esm --experimental-specifier-resolution=node src/components/account/history/highlight.util.spec.ts",
|
"test:highlight": "node --loader ts-node/esm --experimental-specifier-resolution=node src/components/account/history/highlight.util.spec.ts",
|
||||||
|
|||||||
+17
-6
@@ -17,6 +17,7 @@ import axios from 'axios';
|
|||||||
import rumbleRouter from './rumble.mjs';
|
import rumbleRouter from './rumble.mjs';
|
||||||
import { providerRegistry, validateProviders } from './providers/registry.mjs';
|
import { providerRegistry, validateProviders } from './providers/registry.mjs';
|
||||||
import { dedupeSuggestGroups } from './suggest.mjs';
|
import { dedupeSuggestGroups } from './suggest.mjs';
|
||||||
|
import { parseSearchFilters, activeFilters as activeSearchFilters, applySearchFilters, filtersCacheKey } from './search-filters.mjs';
|
||||||
import { fetchWebSuggest, fetchOdyseeLighthouseSuggest } from './suggest-web.mjs';
|
import { fetchWebSuggest, fetchOdyseeLighthouseSuggest } from './suggest-web.mjs';
|
||||||
import { pickTrack, parseTrackText, parseVtt, dedupeTranscriptLines, orderedTracks, translatedFallbacks, firstPerLanguage, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError, ensureFmtParam, isEmptyTimedTextBody, isBlobTranscript, mergeTranscriptCandidates } from './transcript.mjs';
|
import { pickTrack, parseTrackText, parseVtt, dedupeTranscriptLines, orderedTracks, translatedFallbacks, firstPerLanguage, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError, ensureFmtParam, isEmptyTimedTextBody, isBlobTranscript, mergeTranscriptCandidates } from './transcript.mjs';
|
||||||
import {
|
import {
|
||||||
@@ -2522,7 +2523,7 @@ r.post('/telemetry/events', authMiddleware, telemetryLimiter, (req, res) => {
|
|||||||
const { event, meta } = req.body || {};
|
const { event, meta } = req.body || {};
|
||||||
if (!event || typeof event !== 'string') return res.status(400).json({ error: 'event_required' });
|
if (!event || typeof event !== 'string') return res.status(400).json({ error: 'event_required' });
|
||||||
// Whitelist known event names to keep the table clean
|
// Whitelist known event names to keep the table clean
|
||||||
const allowed = new Set(['search_submit', 'provider_picker_open', 'provider_apply', 'at_autocomplete_use', 'quick_menu_open', 'suggest_shown', 'suggest_used']);
|
const allowed = new Set(['search_submit', 'provider_picker_open', 'provider_apply', 'at_autocomplete_use', 'quick_menu_open', 'filter_panel_open', 'filter_apply', 'suggest_shown', 'suggest_used']);
|
||||||
if (!allowed.has(event)) return res.status(400).json({ error: 'unknown_event' });
|
if (!allowed.has(event)) return res.status(400).json({ error: 'unknown_event' });
|
||||||
const row = insertTelemetryEvent({ userId: req.user.id, event, meta: (meta && typeof meta === 'object') ? meta : null });
|
const row = insertTelemetryEvent({ userId: req.user.id, event, meta: (meta && typeof meta === 'object') ? meta : null });
|
||||||
return res.status(201).json(row || { ok: true });
|
return res.status(201).json(row || { ok: true });
|
||||||
@@ -2542,7 +2543,7 @@ r.get('/telemetry/events', authMiddleware, (req, res) => {
|
|||||||
r.get('/telemetry/summary', authMiddleware, (req, res) => {
|
r.get('/telemetry/summary', authMiddleware, (req, res) => {
|
||||||
const since = typeof req.query.since === 'string' ? req.query.since : undefined;
|
const since = typeof req.query.since === 'string' ? req.query.since : undefined;
|
||||||
const events = {};
|
const events = {};
|
||||||
for (const name of ['search_submit', 'provider_picker_open', 'provider_apply', 'at_autocomplete_use', 'quick_menu_open', 'suggest_shown', 'suggest_used']) {
|
for (const name of ['search_submit', 'provider_picker_open', 'provider_apply', 'at_autocomplete_use', 'quick_menu_open', 'filter_panel_open', 'filter_apply', 'suggest_shown', 'suggest_used']) {
|
||||||
events[name] = countTelemetryEvents({ event: name, since });
|
events[name] = countTelemetryEvents({ event: name, since });
|
||||||
}
|
}
|
||||||
return res.json({ events });
|
return res.json({ events });
|
||||||
@@ -3105,10 +3106,15 @@ app.get('/api/search', async (req, res) => {
|
|||||||
}
|
}
|
||||||
const pageNum = Math.max(1, Number(req.query.page || 1));
|
const pageNum = Math.max(1, Number(req.query.page || 1));
|
||||||
const pageSize = Math.min(50, Math.max(1, Number(req.query.pageSize || 24)));
|
const pageSize = Math.min(50, Math.max(1, Number(req.query.pageSize || 24)));
|
||||||
|
// Filtres de recherche (type / durée / période / tri). Un filtre inconnu est
|
||||||
|
// ignoré (jamais 400) : `normalizeFilters` retombe sur 'all'.
|
||||||
|
const filters = parseSearchFilters(req.query);
|
||||||
// Optional sort parameter (normalized to known set)
|
// Optional sort parameter (normalized to known set)
|
||||||
const allowedSort = new Set(['relevance', 'date', 'views']);
|
const allowedSort = new Set(['relevance', 'date', 'views']);
|
||||||
let sort = (typeof req.query.sort === 'string' ? req.query.sort.trim().toLowerCase() : 'relevance');
|
let sort = (typeof req.query.sort === 'string' ? req.query.sort.trim().toLowerCase() : 'relevance');
|
||||||
if (!allowedSort.has(sort)) sort = 'relevance';
|
if (!allowedSort.has(sort)) sort = 'relevance';
|
||||||
|
// `filters.sort` fait autorité (le front envoie les deux, ils concordent)
|
||||||
|
sort = filters.sort;
|
||||||
// Validate and normalize providers list (default to all supported when none/invalid)
|
// Validate and normalize providers list (default to all supported when none/invalid)
|
||||||
const requested = typeof providers === 'string' ? String(providers) : '';
|
const requested = typeof providers === 'string' ? String(providers) : '';
|
||||||
const validProviders = validateProviders(requested);
|
const validProviders = validateProviders(requested);
|
||||||
@@ -3118,8 +3124,8 @@ app.get('/api/search', async (req, res) => {
|
|||||||
validProviders.map((providerId) => {
|
validProviders.map((providerId) => {
|
||||||
const mod = providerRegistry[/** @type {any} */(providerId)];
|
const mod = providerRegistry[/** @type {any} */(providerId)];
|
||||||
if (!mod || typeof mod.search !== 'function') return Promise.resolve([]);
|
if (!mod || typeof mod.search !== 'function') return Promise.resolve([]);
|
||||||
// Basic options include pagination and sort hints
|
// Basic options include pagination, sort hints and the search filters
|
||||||
return Promise.resolve().then(() => mod.search(q, { limit: pageSize, page: pageNum, sort }));
|
return Promise.resolve().then(() => mod.search(q, { limit: pageSize, page: pageNum, sort, filters }));
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -3129,7 +3135,8 @@ app.get('/api/search', async (req, res) => {
|
|||||||
results.forEach((result, index) => {
|
results.forEach((result, index) => {
|
||||||
const providerId = validProviders[index];
|
const providerId = validProviders[index];
|
||||||
if (result.status === 'fulfilled') {
|
if (result.status === 'fulfilled') {
|
||||||
groups[providerId] = Array.isArray(result.value) ? result.value : [];
|
// Filtres non supportés nativement par le provider -> affinage ici.
|
||||||
|
groups[providerId] = applySearchFilters(providerId, Array.isArray(result.value) ? result.value : [], filters);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`Search failed for provider ${providerId}:`, result.reason?.message || result.reason);
|
console.warn(`Search failed for provider ${providerId}:`, result.reason?.message || result.reason);
|
||||||
groups[providerId] = [];
|
groups[providerId] = [];
|
||||||
@@ -3144,7 +3151,11 @@ app.get('/api/search', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({ q, providers: validProviders, groups, errors, page: pageNum, pageSize, sort });
|
return res.json({
|
||||||
|
q, providers: validProviders, groups, errors,
|
||||||
|
page: pageNum, pageSize, sort,
|
||||||
|
filters: activeSearchFilters(filters),
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return res.status(500).json({ error: 'search_failed', details: String(e?.message || e) });
|
return res.status(500).json({ error: 'search_failed', details: String(e?.message || e) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// WEB client : search + continuations (pagination illimitée), watch-next (related),
|
// WEB client : search + continuations (pagination illimitée), watch-next (related),
|
||||||
// sans clé API ni quota. Le dispatcher bascule sur scrape/API si indisponible.
|
// sans clé API ni quota. Le dispatcher bascule sur scrape/API si indisponible.
|
||||||
import { hashSearchKey, ytMetrics } from './youtube-common.mjs';
|
import { hashSearchKey, ytMetrics } from './youtube-common.mjs';
|
||||||
|
import { innertubeSearchFilters, filtersCacheKey } from '../search-filters.mjs';
|
||||||
|
|
||||||
let sessionPromise = null;
|
let sessionPromise = null;
|
||||||
let LogSilenced = false;
|
let LogSilenced = false;
|
||||||
@@ -95,22 +96,53 @@ export function mapVideoNode(n) {
|
|||||||
if (type === 'LockupView') return mapLockupView(n);
|
if (type === 'LockupView') return mapLockupView(n);
|
||||||
if (/playlist|channel|gridchannel|shelf|radio|show|album/i.test(type)
|
if (/playlist|channel|gridchannel|shelf|radio|show|album/i.test(type)
|
||||||
&& !/playlistpanelvideo|watchcard/i.test(type)) return null;
|
&& !/playlistpanelvideo|watchcard/i.test(type)) return null;
|
||||||
const id = n.id ? String(n.id) : null;
|
// Les Shorts (ShortsLockupView / ReelItem) n'ont pas de `id` : il vit dans
|
||||||
|
// l'endpoint de navigation. Sans cela, une recherche `type=shorts` ne
|
||||||
|
// remonterait aucun résultat.
|
||||||
|
const entityId = n.entity_id ? String(n.entity_id) : '';
|
||||||
|
const id = n.id ? String(n.id) : (
|
||||||
|
n.on_tap_endpoint?.payload?.videoId
|
||||||
|
|| n.inline_player_data?.payload?.videoId
|
||||||
|
|| n.on_tap?.innertubeCommand?.reelWatchEndpoint?.videoId
|
||||||
|
|| n.on_tap?.innertubeCommand?.reelPlayerEndpoint?.videoId
|
||||||
|
|| n.navigation_endpoint?.watchEndpoint?.videoId
|
||||||
|
|| n.innertubeCommand?.reelWatchEndpoint?.videoId
|
||||||
|
// `shorts-shelf-item-<videoId>` : dernier recours.
|
||||||
|
|| (entityId.startsWith('shorts-shelf-item-') ? entityId.slice('shorts-shelf-item-'.length) : '')
|
||||||
|
|| null
|
||||||
|
);
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
const title = n.title?.text ?? (typeof n.title === 'string' ? n.title : '');
|
// `??` ne suffit pas : `typeof n.title === 'string' ? … : ''` renvoie ''
|
||||||
|
// (non nullish) et court-circuiterait les replis. On chaîne donc à part.
|
||||||
|
const title = n.title?.text
|
||||||
|
|| (typeof n.title === 'string' ? n.title : '')
|
||||||
|
// ShortsLockupView / ReelItem : le titre est dans les métadonnées superposées.
|
||||||
|
|| n.overlay_metadata?.primary_text?.text
|
||||||
|
|| n.overlay_metadata?.headline?.text
|
||||||
|
|| '';
|
||||||
if (!title) return null;
|
if (!title) return null;
|
||||||
const duration = n.duration?.seconds != null ? Number(n.duration.seconds) : undefined;
|
const duration = n.duration?.seconds != null ? Number(n.duration.seconds) : undefined;
|
||||||
const author = n.author || n.uploader || null;
|
const author = n.author || n.uploader || null;
|
||||||
const authorName = author?.name ?? (typeof author === 'string' ? author : undefined);
|
const authorName = author?.name ?? (typeof author === 'string' ? author : undefined);
|
||||||
const authorId = author?.id ? String(author.id) : undefined;
|
const authorId = author?.id ? String(author.id) : undefined;
|
||||||
const views = parseViewsText(n.view_count?.text ?? n.view_count ?? n.views?.text);
|
const views = parseViewsText(
|
||||||
|
n.view_count?.text ?? n.view_count ?? n.views?.text
|
||||||
|
// ShortsLockupView : vues dans la 2e ligne des métadonnées superposées.
|
||||||
|
?? n.overlay_metadata?.secondary_text?.text,
|
||||||
|
);
|
||||||
|
// Vignette : `thumbnails[]` (classique), `thumbnail` (ShortsLockupView :
|
||||||
|
// tableau de Thumbnail) ou la vignette portée par l'endpoint du Short.
|
||||||
|
const thumb = bestThumb(n.thumbnails)
|
||||||
|
|| bestThumb(n.thumbnail)
|
||||||
|
|| bestThumb(n.on_tap_endpoint?.payload?.thumbnail?.thumbnails)
|
||||||
|
|| undefined;
|
||||||
const isShort = /short|reel/i.test(type) || (Number.isFinite(duration) && duration > 0 && duration <= 70 && /short/i.test(title) === false && /reel|short/i.test(type));
|
const isShort = /short|reel/i.test(type) || (Number.isFinite(duration) && duration > 0 && duration <= 70 && /short/i.test(title) === false && /reel|short/i.test(type));
|
||||||
const badges = Array.isArray(n.badges) ? n.badges.map((b) => b?.label).filter(Boolean) : [];
|
const badges = Array.isArray(n.badges) ? n.badges.map((b) => b?.label).filter(Boolean) : [];
|
||||||
return {
|
return {
|
||||||
title: String(title),
|
title: String(title),
|
||||||
id,
|
id,
|
||||||
url: `https://www.youtube.com/watch?v=${id}`,
|
url: `https://www.youtube.com/watch?v=${id}`,
|
||||||
thumbnail: bestThumb(n.thumbnails),
|
thumbnail: thumb,
|
||||||
uploaderName: authorName,
|
uploaderName: authorName,
|
||||||
type: 'video',
|
type: 'video',
|
||||||
...(Number.isFinite(duration) && duration > 0 ? { duration } : {}),
|
...(Number.isFinite(duration) && duration > 0 ? { duration } : {}),
|
||||||
@@ -190,6 +222,55 @@ export function mapNodes(nodes) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aplatit un nœud InnerTube (YTNode) en ses feuilles.
|
||||||
|
* `Shelf.content` peut être une `Grid`/`Shelf` imbriquée : les Shorts d'une
|
||||||
|
* recherche « Shorts » vivent là, hors de `feed.videos`.
|
||||||
|
*/
|
||||||
|
function flattenNodes(node, depth = 0, out = []) {
|
||||||
|
if (!node || depth > 5) return out;
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const n of node) flattenNodes(n, depth, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (typeof node !== 'object') return out;
|
||||||
|
let descended = false;
|
||||||
|
for (const key of ['contents', 'items', 'content']) {
|
||||||
|
const child = node[key];
|
||||||
|
if (child && typeof child === 'object') {
|
||||||
|
descended = true;
|
||||||
|
flattenNodes(child, depth + 1, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!descended) out.push(node);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vidéos contenues dans les `Shelf` d'un feed de recherche (Shorts/clips).
|
||||||
|
* `feed.videos` ne regarde que les rendus directs, donc une recherche
|
||||||
|
* `type=shorts` y renvoie 0 sans ce complément.
|
||||||
|
* Pur et testable offline.
|
||||||
|
*/
|
||||||
|
export function mapShelfNodes(feed) {
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
try {
|
||||||
|
const results = Array.isArray(feed?.results) ? Array.from(feed.results) : [];
|
||||||
|
for (const node of results) {
|
||||||
|
const type = String(node?.type || '');
|
||||||
|
if (!/shelf/i.test(type)) continue;
|
||||||
|
for (const leaf of flattenNodes(node.content ?? node)) {
|
||||||
|
const mapped = mapVideoNode(leaf);
|
||||||
|
if (!mapped || seen.has(mapped.id)) continue;
|
||||||
|
seen.add(mapped.id);
|
||||||
|
out.push(mapped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { return out; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Chaîne de continuations en mémoire : q_hash -> { feed, pages }.
|
// Chaîne de continuations en mémoire : q_hash -> { feed, pages }.
|
||||||
// (SQLite persiste les pages déjà servies ; la chaîne évite de rejouer les pages 1..N-1.)
|
// (SQLite persiste les pages déjà servies ; la chaîne évite de rejouer les pages 1..N-1.)
|
||||||
const chainCache = new Map();
|
const chainCache = new Map();
|
||||||
@@ -212,6 +293,8 @@ function sortParam(sort) {
|
|||||||
/**
|
/**
|
||||||
* Recherche InnerTube avec vraie pagination (continuations).
|
* Recherche InnerTube avec vraie pagination (continuations).
|
||||||
* Page 1 ~35-40 vidéos ; pages suivantes via getContinuation() fusionnées (façon SmartTube).
|
* Page 1 ~35-40 vidéos ; pages suivantes via getContinuation() fusionnées (façon SmartTube).
|
||||||
|
* `opts.filters` : { type, duration, period, sort } — traduits en filtres
|
||||||
|
* natifs InnerTube (upload_date / type / duration / features).
|
||||||
*/
|
*/
|
||||||
export async function searchViaInnerTube(q, opts = {}) {
|
export async function searchViaInnerTube(q, opts = {}) {
|
||||||
const query = String(q || '').trim();
|
const query = String(q || '').trim();
|
||||||
@@ -219,9 +302,16 @@ export async function searchViaInnerTube(q, opts = {}) {
|
|||||||
const limit = Math.min(50, Math.max(1, Number(opts?.limit || 24)));
|
const limit = Math.min(50, Math.max(1, Number(opts?.limit || 24)));
|
||||||
const page = Math.min(10, Math.max(1, Number(opts?.page || 1)));
|
const page = Math.min(10, Math.max(1, Number(opts?.page || 1)));
|
||||||
const sort = String(opts?.sort || 'relevance');
|
const sort = String(opts?.sort || 'relevance');
|
||||||
|
const itFilters = innertubeSearchFilters(opts?.filters);
|
||||||
|
// Les Shorts d'une recherche « Shorts » sont servis dans un `Shelf`, hors de
|
||||||
|
// `feed.videos` : on complète le lot dans ce seul cas (une recherche normale
|
||||||
|
// garde exactement son comportement et son ordre actuels).
|
||||||
|
const withShelves = itFilters.type === 'shorts';
|
||||||
ytMetrics.innertubeCalls = (ytMetrics.innertubeCalls || 0) + 1;
|
ytMetrics.innertubeCalls = (ytMetrics.innertubeCalls || 0) + 1;
|
||||||
const yt = await getSession();
|
const yt = await getSession();
|
||||||
const key = `it|${hashSearchKey(`${query.toLowerCase()}|${sort}`)}`;
|
// La chaîne de continuations est propre à une combinaison
|
||||||
|
// (requête + tri + filtres) : sinon on servirait les pages d'un filtre à l'autre.
|
||||||
|
const key = `it|${hashSearchKey(`${query.toLowerCase()}|${sort}|${filtersCacheKey(opts?.filters)}`)}`;
|
||||||
let entry = chainGet(key);
|
let entry = chainGet(key);
|
||||||
let feed = entry?.feed || null;
|
let feed = entry?.feed || null;
|
||||||
let collected = entry?.items ? [...entry.items] : [];
|
let collected = entry?.items ? [...entry.items] : [];
|
||||||
@@ -231,17 +321,23 @@ export async function searchViaInnerTube(q, opts = {}) {
|
|||||||
// [start, start+limit[ (façon SmartTube qui remplit son écran au fil des
|
// [start, start+limit[ (façon SmartTube qui remplit son écran au fil des
|
||||||
// continuations), au lieu d'aligner 1 page API = 1 page UI.
|
// continuations), au lieu d'aligner 1 page API = 1 page UI.
|
||||||
const need = page * limit;
|
const need = page * limit;
|
||||||
|
const harvest = (f) => {
|
||||||
|
const direct = mapNodes(f.videos);
|
||||||
|
if (!withShelves) return direct;
|
||||||
|
const extra = mapShelfNodes(f).filter((m) => !direct.some((d) => d.id === m.id));
|
||||||
|
return direct.concat(extra);
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
if (!feed) {
|
if (!feed) {
|
||||||
feed = await yt.search(query, { sort_by: sortParam(sort) });
|
feed = await yt.search(query, { ...itFilters, sort_by: sortParam(sort) });
|
||||||
collected = mapNodes(feed.videos);
|
collected = harvest(feed);
|
||||||
pagesDone = 1;
|
pagesDone = 1;
|
||||||
}
|
}
|
||||||
let guard = 0;
|
let guard = 0;
|
||||||
while (collected.length < need && feed?.has_continuation && guard < 12) {
|
while (collected.length < need && feed?.has_continuation && guard < 12) {
|
||||||
guard++;
|
guard++;
|
||||||
feed = await feed.getContinuation();
|
feed = await feed.getContinuation();
|
||||||
const fresh = mapNodes(feed.videos).filter((m) => !collected.some((c) => c.id === m.id));
|
const fresh = harvest(feed).filter((m) => !collected.some((c) => c.id === m.id));
|
||||||
collected = collected.concat(fresh);
|
collected = collected.concat(fresh);
|
||||||
pagesDone++;
|
pagesDone++;
|
||||||
if (!fresh.length) break;
|
if (!fresh.length) break;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { execFile as execFileCb } from 'node:child_process';
|
import { execFile as execFileCb } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { getYtDlpTimeoutMs, buildYtDlpExtraArgs, resolveYtDlpBin } from './youtube-common.mjs';
|
import { getYtDlpTimeoutMs, buildYtDlpExtraArgs, resolveYtDlpBin } from './youtube-common.mjs';
|
||||||
|
import { periodMs } from '../search-filters.mjs';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFileCb);
|
const execFileAsync = promisify(execFileCb);
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ export function classifyScrapeError(e) {
|
|||||||
return Object.assign(new Error(`YouTube scrape failed: ${msg.slice(0, 200)}`), { ytStatus: 502, code: 'yt_scrape_failed' });
|
return Object.assign(new Error(`YouTube scrape failed: ${msg.slice(0, 200)}`), { ytStatus: 502, code: 'yt_scrape_failed' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runYtDlpFlat(queryOrUrl, { limit = 10, playlistStart = 1 } = {}) {
|
async function runYtDlpFlat(queryOrUrl, { limit = 10, playlistStart = 1, extraArgs = [] } = {}) {
|
||||||
const bin = await resolveYtDlpBin();
|
const bin = await resolveYtDlpBin();
|
||||||
const timeout = getYtDlpTimeoutMs();
|
const timeout = getYtDlpTimeoutMs();
|
||||||
const end = playlistStart + Math.max(1, Math.min(50, Number(limit || 10))) - 1;
|
const end = playlistStart + Math.max(1, Math.min(50, Number(limit || 10))) - 1;
|
||||||
@@ -98,6 +99,7 @@ async function runYtDlpFlat(queryOrUrl, { limit = 10, playlistStart = 1 } = {})
|
|||||||
'--no-playlist',
|
'--no-playlist',
|
||||||
'--playlist-start', String(playlistStart),
|
'--playlist-start', String(playlistStart),
|
||||||
'--playlist-end', String(end),
|
'--playlist-end', String(end),
|
||||||
|
...extraArgs,
|
||||||
...buildYtDlpExtraArgs(),
|
...buildYtDlpExtraArgs(),
|
||||||
queryOrUrl,
|
queryOrUrl,
|
||||||
];
|
];
|
||||||
@@ -112,7 +114,11 @@ async function runYtDlpFlat(queryOrUrl, { limit = 10, playlistStart = 1 } = {})
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recherche sans clé. sort: relevance|date|views */
|
/**
|
||||||
|
* Recherche sans clé. sort: relevance|date|views
|
||||||
|
* `opts.filters` : seule la période est traduite nativement (`--dateafter`) ;
|
||||||
|
* durée / type sont affinés en post-traitement (côté /api/search).
|
||||||
|
*/
|
||||||
export async function searchViaScrape(q, opts = {}) {
|
export async function searchViaScrape(q, opts = {}) {
|
||||||
const query = String(q || '').trim();
|
const query = String(q || '').trim();
|
||||||
if (query.length < 2) return [];
|
if (query.length < 2) return [];
|
||||||
@@ -125,7 +131,12 @@ export async function searchViaScrape(q, opts = {}) {
|
|||||||
if (sort === 'date') prefix = 'ytsearchdate';
|
if (sort === 'date') prefix = 'ytsearchdate';
|
||||||
// views : pas de préfixe natif stable -> ytsearch + tri local
|
// views : pas de préfixe natif stable -> ytsearch + tri local
|
||||||
const target = `${prefix}${perPage}:${query}`;
|
const target = `${prefix}${perPage}:${query}`;
|
||||||
let items = await runYtDlpFlat(target, { limit: perPage, playlistStart: start });
|
// --dateafter : borne basse de mise en ligne (format YYYYMMDD attendu par yt-dlp).
|
||||||
|
const maxAge = periodMs(opts?.filters?.period);
|
||||||
|
const extraArgs = maxAge > 0
|
||||||
|
? ['--dateafter', new Date(Date.now() - maxAge).toISOString().slice(0, 10).replace(/-/g, '')]
|
||||||
|
: [];
|
||||||
|
let items = await runYtDlpFlat(target, { limit: perPage, playlistStart: start, extraArgs });
|
||||||
if (sort === 'views') items = [...items].sort((a, b) => (b.views || 0) - (a.views || 0));
|
if (sort === 'views') items = [...items].sort((a, b) => (b.views || 0) - (a.views || 0));
|
||||||
return items.slice(0, perPage);
|
return items.slice(0, perPage);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from './youtube-common.mjs';
|
} from './youtube-common.mjs';
|
||||||
import { searchViaScrape } from './youtube-scrape.mjs';
|
import { searchViaScrape } from './youtube-scrape.mjs';
|
||||||
import { searchViaInnerTube } from './youtube-innertube.mjs';
|
import { searchViaInnerTube } from './youtube-innertube.mjs';
|
||||||
|
import { apiSearchParams, filtersCacheKey } from '../search-filters.mjs';
|
||||||
|
|
||||||
function parseISODurationToSeconds(iso) {
|
function parseISODurationToSeconds(iso) {
|
||||||
if (typeof iso !== 'string' || !iso) return 0;
|
if (typeof iso !== 'string' || !iso) return 0;
|
||||||
@@ -108,7 +109,7 @@ export function ytScrapeCacheStats() {
|
|||||||
return { memEntries: memCache.size, memMax: MEM_MAX };
|
return { memEntries: memCache.size, memMax: MEM_MAX };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance' } = {}) {
|
async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filters = null } = {}) {
|
||||||
const keys = getYouTubeKeys();
|
const keys = getYouTubeKeys();
|
||||||
if (!keys.length) {
|
if (!keys.length) {
|
||||||
throw Object.assign(new Error('YOUTUBE_API_KEY not configured'), { ytStatus: 503, code: 'youtube_api_key_unavailable' });
|
throw Object.assign(new Error('YOUTUBE_API_KEY not configured'), { ytStatus: 503, code: 'youtube_api_key_unavailable' });
|
||||||
@@ -116,6 +117,8 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance' } = {}
|
|||||||
let order = 'relevance';
|
let order = 'relevance';
|
||||||
if (sort === 'date') order = 'date';
|
if (sort === 'date') order = 'date';
|
||||||
else if (sort === 'views') order = 'viewCount';
|
else if (sort === 'views') order = 'viewCount';
|
||||||
|
// Filtres natifs de l'API v3 : type, videoDuration, publishedAfter.
|
||||||
|
const extra = apiSearchParams(filters);
|
||||||
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
|
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
|
||||||
const targetPage = Math.max(1, Number(page || 1));
|
const targetPage = Math.max(1, Number(page || 1));
|
||||||
let pageToken = '';
|
let pageToken = '';
|
||||||
@@ -123,8 +126,9 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance' } = {}
|
|||||||
let lastItems = [];
|
let lastItems = [];
|
||||||
while (currentPage <= targetPage) {
|
while (currentPage <= targetPage) {
|
||||||
const params = {
|
const params = {
|
||||||
part: 'snippet', q, type: 'video', maxResults: String(perPage), order,
|
part: 'snippet', q, maxResults: String(perPage), order,
|
||||||
videoEmbeddable: 'true', safeSearch: 'moderate',
|
videoEmbeddable: 'true', safeSearch: 'moderate',
|
||||||
|
...extra,
|
||||||
};
|
};
|
||||||
if (pageToken) params.pageToken = pageToken;
|
if (pageToken) params.pageToken = pageToken;
|
||||||
const data = await ytFetchJson('https://www.googleapis.com/youtube/v3/search', params);
|
const data = await ytFetchJson('https://www.googleapis.com/youtube/v3/search', params);
|
||||||
@@ -148,35 +152,51 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance' } = {}
|
|||||||
}
|
}
|
||||||
return (lastItems || []).map((item) => {
|
return (lastItems || []).map((item) => {
|
||||||
const videoId = item?.id?.videoId;
|
const videoId = item?.id?.videoId;
|
||||||
|
// Une recherche de type `channel` renvoie des channelId, pas des videoId.
|
||||||
|
const rawId = videoId || item?.id?.channelId || null;
|
||||||
|
const isChannel = !videoId && !!item?.id?.channelId;
|
||||||
|
if (!rawId) return null;
|
||||||
const snippet = item?.snippet || {};
|
const snippet = item?.snippet || {};
|
||||||
const thumb = snippet.thumbnails?.high?.url || snippet.thumbnails?.medium?.url || snippet.thumbnails?.default?.url || undefined;
|
const thumb = snippet.thumbnails?.high?.url || snippet.thumbnails?.medium?.url || snippet.thumbnails?.default?.url || undefined;
|
||||||
const details = videoId ? detailsMap.get(videoId) : null;
|
const details = videoId ? detailsMap.get(videoId) : null;
|
||||||
const duration = parseISODurationToSeconds(details?.contentDetails?.duration || '');
|
const duration = parseISODurationToSeconds(details?.contentDetails?.duration || '');
|
||||||
const views = details?.statistics?.viewCount != null ? Number(details.statistics.viewCount) : undefined;
|
const views = details?.statistics?.viewCount != null ? Number(details.statistics.viewCount) : undefined;
|
||||||
const channelId = snippet.channelId || undefined;
|
const channelId = snippet.channelId || item?.id?.channelId || undefined;
|
||||||
const embeddable = details?.status ? details.status.embeddable !== false : undefined;
|
const embeddable = details?.status ? details.status.embeddable !== false : undefined;
|
||||||
return {
|
return {
|
||||||
title: snippet.title || '', id: videoId,
|
title: snippet.title || '', id: rawId,
|
||||||
url: videoId ? `https://www.youtube.com/watch?v=${videoId}` : undefined,
|
url: isChannel
|
||||||
thumbnail: thumb, uploaderName: snippet.channelTitle || undefined, type: 'video',
|
? `https://www.youtube.com/channel/${rawId}`
|
||||||
|
: `https://www.youtube.com/watch?v=${rawId}`,
|
||||||
|
thumbnail: thumb, uploaderName: snippet.channelTitle || undefined,
|
||||||
|
type: isChannel ? 'channel' : 'video',
|
||||||
duration: duration > 0 ? duration : undefined, views,
|
duration: duration > 0 ? duration : undefined, views,
|
||||||
publishedAt: snippet.publishedAt || undefined, channelId,
|
publishedAt: snippet.publishedAt || undefined, channelId,
|
||||||
channelHandle: snippet.channelTitle || undefined, channelExternalId: channelId,
|
channelHandle: snippet.channelTitle || undefined, channelExternalId: channelId,
|
||||||
channelUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : undefined, embeddable,
|
channelUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : undefined, embeddable,
|
||||||
};
|
};
|
||||||
});
|
}).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {{ id: 'yt', label: string, search: (q: string, opts: { limit: number, page?: number, sort?: 'relevance'|'date'|'views' }) => Promise<Suggestion[]> }} */
|
/**
|
||||||
|
* @typedef {Object} SearchFilters
|
||||||
|
* @property {string} [type] all|video|shorts|live|channel
|
||||||
|
* @property {string} [duration] all|short|medium|long
|
||||||
|
* @property {string} [period] all|hour|today|week|month|year
|
||||||
|
* @property {string} [sort] relevance|date|views
|
||||||
|
*/
|
||||||
|
/** @type {{ id: 'yt', label: string, search: (q: string, opts: { limit: number, page?: number, sort?: 'relevance'|'date'|'views', filters?: SearchFilters }) => Promise<Suggestion[]> }} */
|
||||||
const handler = {
|
const handler = {
|
||||||
id: 'yt',
|
id: 'yt',
|
||||||
label: 'YouTube',
|
label: 'YouTube',
|
||||||
async search(q, opts) {
|
async search(q, opts) {
|
||||||
const { limit = 10, page = 1, sort = 'relevance' } = opts || {};
|
const { limit = 10, page = 1, sort = 'relevance', filters = null } = opts || {};
|
||||||
const mode = getSearchMode();
|
const mode = getSearchMode();
|
||||||
const ttl = getScrapeTtlMs();
|
const ttl = getScrapeTtlMs();
|
||||||
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
|
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
|
||||||
const key = `yt|${hashSearchKey(`${String(q).toLowerCase().trim()}|${perPage}|${page}|${sort}|${mode}`)}`;
|
// La signature des filtres entre dans la clé de cache : deux recherches
|
||||||
|
// identiques avec des filtres différents ne doivent pas se partager de cache.
|
||||||
|
const key = `yt|${hashSearchKey(`${String(q).toLowerCase().trim()}|${perPage}|${page}|${sort}|${mode}|${filtersCacheKey(filters)}`)}`;
|
||||||
// 1) mémoire
|
// 1) mémoire
|
||||||
const memHit = memGet(key, ttl);
|
const memHit = memGet(key, ttl);
|
||||||
if (memHit) { ytMetrics.scrapeHits++; return memHit; }
|
if (memHit) { ytMetrics.scrapeHits++; return memHit; }
|
||||||
@@ -207,10 +227,10 @@ const handler = {
|
|||||||
const tryScrape = async () => {
|
const tryScrape = async () => {
|
||||||
ytMetrics.scrapeCalls++;
|
ytMetrics.scrapeCalls++;
|
||||||
try { const { incYoutubeMetrics } = await import('../db.mjs'); incYoutubeMetrics({ scrapeCalls: 1 }); } catch {}
|
try { const { incYoutubeMetrics } = await import('../db.mjs'); incYoutubeMetrics({ scrapeCalls: 1 }); } catch {}
|
||||||
return searchViaScrape(q, { limit: perPage, page, sort });
|
return searchViaScrape(q, { limit: perPage, page, sort, filters });
|
||||||
};
|
};
|
||||||
const tryApi = () => searchViaApi(q, { limit: perPage, page, sort });
|
const tryApi = () => searchViaApi(q, { limit: perPage, page, sort, filters });
|
||||||
const tryInnerTube = () => searchViaInnerTube(q, { limit: perPage, page, sort });
|
const tryInnerTube = () => searchViaInnerTube(q, { limit: perPage, page, sort, filters });
|
||||||
const log = (source, extra = '') => console.log(`[YT search] source=${source} mode=${mode} latency=${Date.now() - t0}ms results=${extra}`);
|
const log = (source, extra = '') => console.log(`[YT search] source=${source} mode=${mode} latency=${Date.now() - t0}ms results=${extra}`);
|
||||||
try {
|
try {
|
||||||
if (mode === 'api-only') {
|
if (mode === 'api-only') {
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* Filtres de recherche — modèle partagé avec le front (`src/app/search/filters.ts`).
|
||||||
|
*
|
||||||
|
* Rôle : valider/normaliser les filtres reçus par `GET /api/search`, les
|
||||||
|
* traduire dans les paramètres natifs de chaque provider (InnerTube, Data API
|
||||||
|
* v3…) et, pour les providers qui ne savent pas filtrer, affiner la liste
|
||||||
|
* renvoyée.
|
||||||
|
*
|
||||||
|
* Principe de sûreté : un filtre inconnu est IGNORÉ (jamais d'erreur 4xx), et
|
||||||
|
* un champ métadonnée absent ne fait jamais disparaître un résultat.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TYPES = ['all', 'video', 'shorts', 'live', 'channel'];
|
||||||
|
const DURATIONS = ['all', 'short', 'medium', 'long'];
|
||||||
|
const PERIODS = ['all', 'hour', 'today', 'week', 'month', 'year'];
|
||||||
|
const SORTS = ['relevance', 'date', 'views'];
|
||||||
|
|
||||||
|
/** @returns {any} filtres normalisés (toujours les 4 clés) */
|
||||||
|
export function normalizeFilters(raw) {
|
||||||
|
const src = raw && typeof raw === 'object' ? raw : {};
|
||||||
|
const pick = (v, allowed, fallback) => {
|
||||||
|
const s = String(v ?? '').trim().toLowerCase();
|
||||||
|
return allowed.includes(s) ? s : fallback;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
type: pick(src.type, TYPES, 'all'),
|
||||||
|
duration: pick(src.duration, DURATIONS, 'all'),
|
||||||
|
period: pick(src.period, PERIODS, 'all'),
|
||||||
|
sort: pick(src.sort, SORTS, 'relevance'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extrait + normalise les filtres depuis une query string Express. */
|
||||||
|
export function parseSearchFilters(query) {
|
||||||
|
const q = query && typeof query === 'object' ? query : {};
|
||||||
|
return normalizeFilters({
|
||||||
|
type: q.type,
|
||||||
|
duration: q.duration,
|
||||||
|
period: q.period,
|
||||||
|
sort: q.sort,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filtres actifs seulement (pour le cache + la réponse JSON). */
|
||||||
|
export function activeFilters(filters) {
|
||||||
|
const n = normalizeFilters(filters);
|
||||||
|
const out = {};
|
||||||
|
if (n.type !== 'all') out.type = n.type;
|
||||||
|
if (n.duration !== 'all') out.duration = n.duration;
|
||||||
|
if (n.period !== 'all') out.period = n.period;
|
||||||
|
if (n.sort !== 'relevance') out.sort = n.sort;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signature courte et stable (cache mémoire / SQLite). */
|
||||||
|
export function filtersCacheKey(filters) {
|
||||||
|
const n = normalizeFilters(filters);
|
||||||
|
return `${n.type}.${n.duration}.${n.period}.${n.sort}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOUR = 3600 * 1000;
|
||||||
|
const DAY = 24 * HOUR;
|
||||||
|
|
||||||
|
/** Bornes de la période de mise en ligne, en ms. 0 = pas de borne. */
|
||||||
|
export function periodMs(period) {
|
||||||
|
switch (normalizeFilters({ period }).period) {
|
||||||
|
case 'hour': return HOUR;
|
||||||
|
case 'today': return DAY;
|
||||||
|
case 'week': return 7 * DAY;
|
||||||
|
case 'month': return 30 * DAY;
|
||||||
|
case 'year': return 365 * DAY;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bornes de durée en secondes. */
|
||||||
|
export function durationBounds(duration) {
|
||||||
|
switch (normalizeFilters({ duration }).duration) {
|
||||||
|
case 'short': return { min: 1, max: 240 };
|
||||||
|
case 'medium': return { min: 240, max: 1200 };
|
||||||
|
case 'long': return { min: 1200, max: Infinity };
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Post-filtrage (providers sans filtre natif)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dimensions que l'on peut affiner en post-traitement, par provider.
|
||||||
|
* - Twitch : les chaînes n'ont pas de durée exploitable, et `live` est le seul
|
||||||
|
* type fiable (les VOD portent `type: 'video'`).
|
||||||
|
* - Odysee : pas de durée fiable sur la recherche.
|
||||||
|
* - Le type `channel` n'a de sens que pour YouTube (seul provider qui expose
|
||||||
|
* des chaines comme résultats de recherche) : ailleurs on ne filtre pas, sinon
|
||||||
|
* la recherche vide le résultat au lieu de le restreindre.
|
||||||
|
*/
|
||||||
|
const POST_FILTER_DIMS = {
|
||||||
|
yt: ['period', 'duration', 'type'],
|
||||||
|
dm: ['period', 'duration', 'type'],
|
||||||
|
tw: ['period', 'type'],
|
||||||
|
pt: ['period', 'duration', 'type'],
|
||||||
|
od: ['period', 'type'],
|
||||||
|
ru: ['period', 'duration', 'type'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function num(item, keys) {
|
||||||
|
for (const k of keys) {
|
||||||
|
const v = item?.[k];
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||||||
|
if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v))) return Number(v);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemDurationSec(item) {
|
||||||
|
const d = num(item, ['duration', 'durationSec', 'length', 'durationSeconds']);
|
||||||
|
if (d > 0) return d;
|
||||||
|
// "1:23:45" / "12:34"
|
||||||
|
const raw = item?.duration ?? item?.length ?? '';
|
||||||
|
const s = String(raw || '');
|
||||||
|
if (s.includes(':')) {
|
||||||
|
const parts = s.split(':').map((p) => Number(p));
|
||||||
|
if (parts.length && parts.every((p) => Number.isFinite(p))) {
|
||||||
|
return parts.reduce((acc, p) => acc * 60 + p, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemPublishedTs(item) {
|
||||||
|
const raw = item?.publishedAt ?? item?.uploadedDate ?? item?.uploadDate
|
||||||
|
?? item?.published_at ?? item?.uploaded ?? item?.timestamp ?? item?.created_time;
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||||
|
// Les scrapes yt-dlp donnent un timestamp en secondes.
|
||||||
|
return raw > 1e12 ? raw : raw * 1000;
|
||||||
|
}
|
||||||
|
if (typeof raw === 'string' && raw.trim()) {
|
||||||
|
const parsed = Date.parse(raw);
|
||||||
|
if (!Number.isNaN(parsed)) return parsed;
|
||||||
|
const asNum = Number(raw);
|
||||||
|
if (Number.isFinite(asNum) && asNum > 0) return asNum > 1e12 ? asNum : asNum * 1000;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLiveItem(item) {
|
||||||
|
if (!item) return false;
|
||||||
|
if (item.isLive === true) return true;
|
||||||
|
return String(item.type || '').toLowerCase() === 'live';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isShortItem(item) {
|
||||||
|
if (!item) return false;
|
||||||
|
if (item.isShort === true) return true;
|
||||||
|
if (String(item.kind || '').toLowerCase() === 'clip') return true;
|
||||||
|
const d = itemDurationSec(item);
|
||||||
|
return d > 0 && d <= 70 && !/short/i.test(String(item.title || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isChannelItem(item) {
|
||||||
|
return String(item?.type || '').toLowerCase() === 'channel' && item?.isLive !== true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le provider expose-t-il un signal de type exploitable ? */
|
||||||
|
function hasTypeSignal(item) {
|
||||||
|
if (!item) return false;
|
||||||
|
if (item.isLive === true || item.isLive === false) return true;
|
||||||
|
if (item.isShort === true || item.isShort === false) return true;
|
||||||
|
if (String(item.type || '').trim()) return true;
|
||||||
|
if (String(item.kind || '').trim()) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un item respecte-t-il les filtres ? (champs absents => jamais écarté) */
|
||||||
|
export function matchesFilters(item, filters) {
|
||||||
|
const n = normalizeFilters(filters);
|
||||||
|
if (n.type === 'all' && n.duration === 'all' && n.period === 'all') return true;
|
||||||
|
|
||||||
|
const bounds = durationBounds(n.duration);
|
||||||
|
if (bounds) {
|
||||||
|
const d = itemDurationSec(item);
|
||||||
|
if (d > 0 && (d < bounds.min || d >= bounds.max)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAge = periodMs(n.period);
|
||||||
|
if (maxAge > 0) {
|
||||||
|
const ts = itemPublishedTs(item);
|
||||||
|
if (ts > 0 && (Date.now() - ts) > maxAge) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n.type === 'all') return true;
|
||||||
|
if (!hasTypeSignal(item)) return true; // provider muet : on ne peut pas exclure
|
||||||
|
if (n.type === 'live') return isLiveItem(item);
|
||||||
|
if (n.type === 'channel') return isChannelItem(item);
|
||||||
|
if (n.type === 'shorts') return isShortItem(item);
|
||||||
|
if (n.type === 'video') {
|
||||||
|
return !isLiveItem(item) && !isChannelItem(item) && !isShortItem(item);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affine une liste de résultats pour un provider donné. Renvoie la liste
|
||||||
|
* d'origine quand le provider ne sait pas post-filtrer cette dimension.
|
||||||
|
* @param {string} providerId
|
||||||
|
* @param {any[]} items
|
||||||
|
* @param {any} filters
|
||||||
|
*/
|
||||||
|
export function applySearchFilters(providerId, items, filters) {
|
||||||
|
const n = normalizeFilters(filters);
|
||||||
|
if (!Array.isArray(items) || items.length === 0) return items || [];
|
||||||
|
if (n.type === 'all' && n.duration === 'all' && n.period === 'all') return items;
|
||||||
|
const dims = POST_FILTER_DIMS[providerId] || [];
|
||||||
|
const wanted = ['period', 'duration', 'type'].filter((d) => dims.includes(d) && n[d] && n[d] !== 'all');
|
||||||
|
if (wanted.length === 0) return items;
|
||||||
|
// `channel` : hors YouTube, on ne filtre pas (sinon résultat vide).
|
||||||
|
if (wanted.includes('type') && n.type === 'channel' && providerId !== 'yt') return items;
|
||||||
|
return items.filter((it) => matchesFilters(it, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mapping provider
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtres youtubei.js (`yt.search`) : les enums protos sont des noms explicites
|
||||||
|
* (UNDER_THREE_MINS…), pas des alias 'short'/'medium'/'long'.
|
||||||
|
* @param {any} filters
|
||||||
|
*/
|
||||||
|
export function innertubeSearchFilters(filters) {
|
||||||
|
const n = normalizeFilters(filters);
|
||||||
|
const out = {};
|
||||||
|
if (n.period !== 'all' && ['today', 'week', 'month', 'year'].includes(n.period)) {
|
||||||
|
out.upload_date = n.period;
|
||||||
|
}
|
||||||
|
if (n.type === 'video' || n.type === 'channel') out.type = n.type;
|
||||||
|
if (n.type === 'shorts') out.type = 'shorts';
|
||||||
|
// `live` n'est pas un SearchType : YouTube l'expose comme feature.
|
||||||
|
if (n.type === 'live') out.features = ['live'];
|
||||||
|
if (n.duration !== 'all') {
|
||||||
|
out.duration = n.duration === 'short'
|
||||||
|
? 'UNDER_THREE_MINS'
|
||||||
|
: n.duration === 'medium' ? 'THREE_TO_TWENTY_MINS' : 'OVER_TWENTY_MINS';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paramètres additionnels pour la Data API v3 (`youtube/v3/search`).
|
||||||
|
* `type` : 'shorts'/'live' n'existent pas côté API -> laissés au post-filtrage.
|
||||||
|
*/
|
||||||
|
export function apiSearchParams(filters) {
|
||||||
|
const n = normalizeFilters(filters);
|
||||||
|
const out = {};
|
||||||
|
if (n.type === 'video' || n.type === 'channel') out.type = n.type;
|
||||||
|
if (n.duration !== 'all') out.videoDuration = n.duration;
|
||||||
|
const maxAge = periodMs(n.period);
|
||||||
|
if (maxAge > 0) out.publishedAfter = new Date(Date.now() - maxAge).toISOString();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Filtres de recherche — tests unitaires (offline, aucun réseau).
|
||||||
|
// Run with: npm run test:filters
|
||||||
|
// Couverture : normalisation, bornes, post-filtrage par provider, mapping
|
||||||
|
// InnerTube / Data API v3.
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeFilters, parseSearchFilters, activeFilters, filtersCacheKey,
|
||||||
|
periodMs, durationBounds, matchesFilters, applySearchFilters,
|
||||||
|
innertubeSearchFilters, apiSearchParams, itemDurationSec, itemPublishedTs,
|
||||||
|
} from '../search-filters.mjs';
|
||||||
|
|
||||||
|
function expect(cond, msg) {
|
||||||
|
if (!cond) throw new Error(`Assertion failed: ${msg}`);
|
||||||
|
}
|
||||||
|
function eq(actual, expected, msg) {
|
||||||
|
const a = JSON.stringify(actual);
|
||||||
|
const b = JSON.stringify(expected);
|
||||||
|
if (a !== b) throw new Error(`Assertion failed: ${msg} (expected ${b}, got ${a})`);
|
||||||
|
}
|
||||||
|
function logOk(msg) { console.log(`✓ ${msg}`); }
|
||||||
|
|
||||||
|
// ---------- Normalisation ----------
|
||||||
|
|
||||||
|
eq(normalizeFilters(null), { type: 'all', duration: 'all', period: 'all', sort: 'relevance' }, 'null filters -> defaults');
|
||||||
|
eq(
|
||||||
|
normalizeFilters({ type: 'LIVE', duration: 'moyen', period: 'nope', sort: 'views' }),
|
||||||
|
{ type: 'live', duration: 'all', period: 'all', sort: 'views' },
|
||||||
|
'values are case-insensitive, unknown values fall back per key (no cross-contamination)',
|
||||||
|
);
|
||||||
|
eq(
|
||||||
|
parseSearchFilters({ type: 'shorts', duration: 'short', period: 'week', sort: 'date', junk: 'x' }),
|
||||||
|
{ type: 'shorts', duration: 'short', period: 'week', sort: 'date' },
|
||||||
|
'parseSearchFilters reads the Express query',
|
||||||
|
);
|
||||||
|
eq(
|
||||||
|
parseSearchFilters({ type: 'plaid' }).type,
|
||||||
|
'all',
|
||||||
|
'unknown type is ignored (never a 400)',
|
||||||
|
);
|
||||||
|
eq(activeFilters({ type: 'live', period: 'all' }), { type: 'live' }, 'activeFilters keeps only non-defaults');
|
||||||
|
eq(
|
||||||
|
filtersCacheKey({ type: 'live', sort: 'date' }),
|
||||||
|
'live.all.all.date',
|
||||||
|
'cache key signature is stable and ordered',
|
||||||
|
);
|
||||||
|
logOk('normalizeFilters / parseSearchFilters / activeFilters / filtersCacheKey');
|
||||||
|
|
||||||
|
// ---------- Bornes ----------
|
||||||
|
|
||||||
|
eq(periodMs('all'), 0, 'period all -> no bound');
|
||||||
|
expect(periodMs('hour') === 3600 * 1000, 'hour bound');
|
||||||
|
expect(periodMs('week') === 7 * 24 * 3600 * 1000, 'week bound');
|
||||||
|
eq(durationBounds('short'), { min: 1, max: 240 }, 'short duration bound');
|
||||||
|
eq(durationBounds('medium'), { min: 240, max: 1200 }, 'medium duration bound');
|
||||||
|
eq(durationBounds('long'), { min: 1200, max: Infinity }, 'long duration bound');
|
||||||
|
eq(durationBounds('all'), null, 'no duration bound');
|
||||||
|
logOk('periodMs / durationBounds');
|
||||||
|
|
||||||
|
// ---------- Lectures de métadonnées ----------
|
||||||
|
|
||||||
|
eq(itemDurationSec({ duration: 120 }), 120, 'numeric duration');
|
||||||
|
eq(itemDurationSec({ duration: 'PT1H2M' }), 0, 'ISO duration ignored (not a field format)');
|
||||||
|
eq(itemDurationSec({ duration: '1:02:03' }), 3723, 'hh:mm:ss duration parsed');
|
||||||
|
eq(itemDurationSec({}), 0, 'missing duration -> 0');
|
||||||
|
expect(itemPublishedTs({ publishedAt: new Date().toISOString() }) > 0, 'ISO publishedAt parsed');
|
||||||
|
expect(itemPublishedTs({ timestamp: 1700000000 }) === 1700000000000, 'yt-dlp seconds timestamps up-scaled');
|
||||||
|
eq(itemPublishedTs({}), 0, 'missing date -> 0');
|
||||||
|
logOk('itemDurationSec / itemPublishedTs');
|
||||||
|
|
||||||
|
// ---------- Post-filtrage ----------
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
// `type: 'video'` par défaut = payload réaliste d'un provider qui expose le type.
|
||||||
|
const mk = (over) => ({
|
||||||
|
id: Math.random().toString(36).slice(2),
|
||||||
|
title: 'x',
|
||||||
|
type: 'video',
|
||||||
|
duration: 100,
|
||||||
|
publishedAt: new Date(now - 60 * 1000).toISOString(),
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(matchesFilters(mk({ duration: 100 }), { duration: 'short' }), 'short keeps 100s');
|
||||||
|
expect(!matchesFilters(mk({ duration: 300 }), { duration: 'short' }), 'short drops 5min');
|
||||||
|
expect(matchesFilters(mk({ duration: 300 }), { duration: 'medium' }), 'medium keeps 5min');
|
||||||
|
expect(matchesFilters(mk({ duration: 3600 }), { duration: 'long' }), 'long keeps 1h');
|
||||||
|
expect(matchesFilters(mk({ duration: null }), { duration: 'short' }), 'missing duration is never dropped');
|
||||||
|
expect(
|
||||||
|
matchesFilters(mk({ publishedAt: new Date(now - 3 * 3600 * 1000).toISOString() }), { period: 'today' }),
|
||||||
|
'today keeps 3h ago',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
!matchesFilters(mk({ publishedAt: new Date(now - 10 * 24 * 3600 * 1000).toISOString() }), { period: 'week' }),
|
||||||
|
'week drops 10 days ago',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
matchesFilters(mk({ publishedAt: new Date(now - 3 * 24 * 3600 * 1000).toISOString() }), { period: 'week' }),
|
||||||
|
'week keeps 3 days ago',
|
||||||
|
);
|
||||||
|
expect(matchesFilters(mk({ publishedAt: null }), { period: 'today' }), 'missing date is never dropped');
|
||||||
|
expect(matchesFilters(mk({ isLive: true }), { type: 'live' }), 'live type keeps isLive items');
|
||||||
|
expect(!matchesFilters(mk({}), { type: 'live' }), 'live type drops regular videos');
|
||||||
|
expect(matchesFilters(mk({ isShort: true, duration: 40 }), { type: 'shorts' }), 'shorts keeps flagged items');
|
||||||
|
expect(matchesFilters(mk({ kind: 'clip' }), { type: 'shorts' }), 'shorts keeps clips');
|
||||||
|
// Item sans aucun signal de type : on ne peut pas conclure -> jamais écarté.
|
||||||
|
expect(matchesFilters(mk({ duration: 0, type: undefined }), { type: 'live' }), 'item without type signal is kept');
|
||||||
|
expect(matchesFilters(mk({ duration: 0, type: undefined }), { type: 'shorts' }), 'item without type signal is kept for shorts');
|
||||||
|
expect(
|
||||||
|
matchesFilters(mk({ duration: 500 }), { type: 'video' }),
|
||||||
|
'video type keeps plain videos',
|
||||||
|
);
|
||||||
|
expect(!matchesFilters(mk({ isLive: true }), { type: 'video' }), 'video type drops live items');
|
||||||
|
logOk('matchesFilters (durée / période / type)');
|
||||||
|
|
||||||
|
// ---------- applySearchFilters par provider ----------
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
mk({ duration: 100, isShort: true }),
|
||||||
|
mk({ duration: 1800 }),
|
||||||
|
mk({ isLive: true, duration: 60 }),
|
||||||
|
mk({ duration: 0, publishedAt: null }), // métadonnées absentes : jamais écarté
|
||||||
|
];
|
||||||
|
|
||||||
|
eq(
|
||||||
|
applySearchFilters('yt', items, { duration: 'short' }).length,
|
||||||
|
3,
|
||||||
|
'yt post-filters duration (100s + 60s live kept, 1800s dropped, unknown kept)',
|
||||||
|
);
|
||||||
|
eq(
|
||||||
|
applySearchFilters('tw', items, { duration: 'short' }).length,
|
||||||
|
items.length,
|
||||||
|
'twitch does not post-filter duration (no reliable value)',
|
||||||
|
);
|
||||||
|
eq(
|
||||||
|
applySearchFilters('dm', items, { type: 'channel' }).length,
|
||||||
|
items.length,
|
||||||
|
'channel filter is not applied outside YouTube (would empty results)',
|
||||||
|
);
|
||||||
|
eq(
|
||||||
|
applySearchFilters('yt', items, { type: 'channel' }).length,
|
||||||
|
0,
|
||||||
|
'yt channel filter drops videos',
|
||||||
|
);
|
||||||
|
eq(
|
||||||
|
applySearchFilters('yt', items, {}).length,
|
||||||
|
items.length,
|
||||||
|
'no filter -> list untouched (same reference semantics)',
|
||||||
|
);
|
||||||
|
eq(
|
||||||
|
applySearchFilters('yt', [], { type: 'live' }),
|
||||||
|
[],
|
||||||
|
'empty list stays empty',
|
||||||
|
);
|
||||||
|
logOk('applySearchFilters (dimensions par provider)');
|
||||||
|
|
||||||
|
// ---------- Mapping providers ----------
|
||||||
|
|
||||||
|
eq(innertubeSearchFilters({ period: 'week' }), { upload_date: 'week' }, 'period -> upload_date');
|
||||||
|
eq(innertubeSearchFilters({ period: 'hour' }), {}, 'hour has no InnerTube enum (post-filter only)');
|
||||||
|
eq(innertubeSearchFilters({ type: 'shorts' }), { type: 'shorts' }, 'type shorts');
|
||||||
|
eq(innertubeSearchFilters({ type: 'live' }), { features: ['live'] }, 'live is a feature, not a SearchType');
|
||||||
|
eq(innertubeSearchFilters({ type: 'channel' }), { type: 'channel' }, 'type channel');
|
||||||
|
eq(
|
||||||
|
innertubeSearchFilters({ duration: 'short' }),
|
||||||
|
{ duration: 'UNDER_THREE_MINS' },
|
||||||
|
'short -> UNDER_THREE_MINS proto enum',
|
||||||
|
);
|
||||||
|
eq(
|
||||||
|
innertubeSearchFilters({ duration: 'medium' }),
|
||||||
|
{ duration: 'THREE_TO_TWENTY_MINS' },
|
||||||
|
'medium -> THREE_TO_TWENTY_MINS proto enum',
|
||||||
|
);
|
||||||
|
eq(
|
||||||
|
innertubeSearchFilters({ duration: 'long' }),
|
||||||
|
{ duration: 'OVER_TWENTY_MINS' },
|
||||||
|
'long -> OVER_TWENTY_MINS proto enum',
|
||||||
|
);
|
||||||
|
eq(innertubeSearchFilters({}), {}, 'no filters -> empty object');
|
||||||
|
|
||||||
|
eq(apiSearchParams({ type: 'video' }), { type: 'video' }, 'api type video');
|
||||||
|
eq(apiSearchParams({ duration: 'long' }), { videoDuration: 'long' }, 'api videoDuration');
|
||||||
|
eq(apiSearchParams({ type: 'shorts' }), {}, 'api has no shorts type (post-filter)');
|
||||||
|
expect(
|
||||||
|
typeof apiSearchParams({ period: 'today' }).publishedAfter === 'string',
|
||||||
|
'api publishedAfter ISO date',
|
||||||
|
);
|
||||||
|
logOk('innertubeSearchFilters / apiSearchParams');
|
||||||
|
|
||||||
|
console.log('\nAll search-filters tests passed.');
|
||||||
@@ -155,6 +155,33 @@ function scenario(name, fn) {
|
|||||||
// 2b) Invalid providers fall back to all
|
// 2b) Invalid providers fall back to all
|
||||||
await scenario('invalid providers param falls back to the full registry', async () => validateProvidersFallback());
|
await scenario('invalid providers param falls back to the full registry', async () => validateProvidersFallback());
|
||||||
|
|
||||||
|
// 2c) Filtres de recherche : ils sont renvoyés tels quels, valeurs inconnues ignorées
|
||||||
|
await scenario('search filters are echoed back and unknown values ignored', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/search?q=test&providers=yt,dm&pageSize=3&type=live&duration=short&period=week&sort=date`);
|
||||||
|
assert(res.ok, `search responds 200 (got ${res.status})`);
|
||||||
|
const body = await res.json();
|
||||||
|
assert(body.filters && body.filters.type === 'live', `type filter echoed (got ${JSON.stringify(body.filters)})`);
|
||||||
|
assert(body.filters.duration === 'short', 'duration filter echoed');
|
||||||
|
assert(body.filters.period === 'week', 'period filter echoed');
|
||||||
|
assert(body.sort === 'date', 'sort comes from the filters');
|
||||||
|
|
||||||
|
// Valeurs inconnues : ignorées, jamais 400.
|
||||||
|
const bad = await fetch(`${baseUrl}/api/search?q=test&providers=dm&type=plaid&period=yesterday&duration=epic`);
|
||||||
|
assert(bad.ok, `unknown filter values do not fail (got ${bad.status})`);
|
||||||
|
const badBody = await bad.json();
|
||||||
|
assert(!badBody.filters || Object.keys(badBody.filters).length === 0,
|
||||||
|
`unknown filters are dropped (got ${JSON.stringify(badBody.filters)})`);
|
||||||
|
assert(badBody.sort === 'relevance', 'sort falls back to relevance');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2d) Un filtre ne doit jamais renvoyer d'erreur 500 (post-filtrage sûr)
|
||||||
|
await scenario('filters never break the fan-out (200 + groups)', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/search?q=test&pageSize=3&period=today&duration=long`);
|
||||||
|
assert(res.ok, `search responds 200 with filters (got ${res.status})`);
|
||||||
|
const body = await res.json();
|
||||||
|
assert(body.groups && typeof body.groups === 'object', 'groups present with filters');
|
||||||
|
});
|
||||||
|
|
||||||
// 3) Preference: defaultProviders persisted and returned after re-login
|
// 3) Preference: defaultProviders persisted and returned after re-login
|
||||||
await scenario('default providers preference round-trip (persisted per user)', async () => {
|
await scenario('default providers preference round-trip (persisted per user)', async () => {
|
||||||
await patchPreferences(token, { defaultProviders: ['ru', 'od'] });
|
await patchPreferences(token, { defaultProviders: ['ru', 'od'] });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Step 18 — tests offline InnerTube (AUCUN réseau : mappers purs + modes).
|
// Step 18 — tests offline InnerTube (AUCUN réseau : mappers purs + modes).
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { mapVideoNode, mapLockupView, mapNodes, mapCaptionTracks, parseViewsText, parseDurationLabel } from '../providers/youtube-innertube.mjs';
|
import { mapVideoNode, mapLockupView, mapNodes, mapShelfNodes, mapCaptionTracks, parseViewsText, parseDurationLabel } from '../providers/youtube-innertube.mjs';
|
||||||
import { pickTrack, orderedTracks } from '../transcript.mjs';
|
import { pickTrack, orderedTracks } from '../transcript.mjs';
|
||||||
import { getSearchMode, YT_SEARCH_MODES } from '../providers/youtube-common.mjs';
|
import { getSearchMode, YT_SEARCH_MODES } from '../providers/youtube-common.mjs';
|
||||||
|
|
||||||
@@ -96,7 +96,50 @@ function ok(msg) { console.log(`✓ ${msg}`); }
|
|||||||
ok('mapLockupView watch-next');
|
ok('mapLockupView watch-next');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) modes innertube connus
|
// 5) mapShelfNodes + ShortsLockupView : les Shorts d'une recherche « Shorts »
|
||||||
|
// n'ont ni `id` ni `title` classiques (endpoint + overlay_metadata), et
|
||||||
|
// vivent dans un GridShelfView -> sans ce complément la recherche renvoie 0.
|
||||||
|
{
|
||||||
|
// Forme réelle observée (youtubei.js v18, ShortsLockupView)
|
||||||
|
const shortsNode = (id) => ({
|
||||||
|
type: 'ShortsLockupView',
|
||||||
|
entity_id: `shorts-shelf-item-${id}`,
|
||||||
|
on_tap_endpoint: { name: 'reelWatchEndpoint', payload: { videoId: id } },
|
||||||
|
overlay_metadata: {
|
||||||
|
primary_text: { text: `Short ${id}` },
|
||||||
|
secondary_text: { text: '219 M de vues' },
|
||||||
|
},
|
||||||
|
thumbnail: [{ url: 'http://t/low.jpg', width: 100 }, { url: 'http://t/high.jpg', width: 1080 }],
|
||||||
|
});
|
||||||
|
const mapped = mapVideoNode(shortsNode('s1'));
|
||||||
|
assert.equal(mapped.id, 's1', 'l\'id du Short est lu dans on_tap_endpoint');
|
||||||
|
assert.equal(mapped.title, 'Short s1', 'le titre vient de overlay_metadata');
|
||||||
|
assert.equal(mapped.isShort, true, 'un Short est marqué isShort');
|
||||||
|
assert.equal(mapped.views, 219000000, 'les vues viennent de la 2e ligne d\'overlay');
|
||||||
|
assert.equal(mapped.thumbnail, 'http://t/high.jpg', 'la plus grande vignette est retenue');
|
||||||
|
// Forme « brute » (avant parsing youtubei.js) également gérée
|
||||||
|
assert.equal(mapVideoNode({
|
||||||
|
type: 'ShortsLockupView',
|
||||||
|
on_tap: { innertubeCommand: { reelWatchEndpoint: { videoId: 'raw1' } } },
|
||||||
|
overlay_metadata: { primary_text: { text: 'Raw' } },
|
||||||
|
}).id, 'raw1', 'forme brute (on_tap) gérée');
|
||||||
|
|
||||||
|
const feed = {
|
||||||
|
results: [
|
||||||
|
{ type: 'Video', id: 'regular1', title: { text: 'Vidéo normale' } },
|
||||||
|
{ type: 'GridShelfView', contents: [shortsNode('s1'), shortsNode('s2'), { type: 'ChipCloud' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const shelf = mapShelfNodes(feed);
|
||||||
|
assert.equal(shelf.length, 2, 'les 2 Shorts du shelf sont mappés');
|
||||||
|
assert.deepEqual(shelf.map((s) => s.id).sort(), ['s1', 's2']);
|
||||||
|
assert.equal(mapShelfNodes({ results: [{ type: 'Video', id: 'v' }] }).length, 0, 'hors shelf, rien à faire');
|
||||||
|
assert.deepEqual(mapShelfNodes(null), []);
|
||||||
|
assert.deepEqual(mapShelfNodes({}), []);
|
||||||
|
ok('mapShelfNodes + ShortsLockupView (Shorts dans un shelf)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) modes innertube connus
|
||||||
{
|
{
|
||||||
assert.ok(YT_SEARCH_MODES.includes('innertube-first'));
|
assert.ok(YT_SEARCH_MODES.includes('innertube-first'));
|
||||||
assert.ok(YT_SEARCH_MODES.includes('innertube-only'));
|
assert.ok(YT_SEARCH_MODES.includes('innertube-only'));
|
||||||
@@ -107,7 +150,7 @@ function ok(msg) { console.log(`✓ ${msg}`); }
|
|||||||
ok('modes innertube-first/only');
|
ok('modes innertube-first/only');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6) mapCaptionTracks : forme InnerTube -> pseudo dump yt-dlp, interop pickTrack
|
// 7) mapCaptionTracks : forme InnerTube -> pseudo dump yt-dlp, interop pickTrack
|
||||||
{
|
{
|
||||||
const cap = mapCaptionTracks([
|
const cap = mapCaptionTracks([
|
||||||
{ base_url: 'https://x/t?caps=asr&x=1', language_code: 'en', kind: 'asr', name: { text: 'English (auto-generated)' } },
|
{ base_url: 'https://x/t?caps=asr&x=1', language_code: 'en', kind: 'asr', name: { text: 'English (auto-generated)' } },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||||
|
import { withFilterParams } from './filter-params';
|
||||||
|
|
||||||
// TODO: Make SearchResult generic
|
// TODO: Make SearchResult generic
|
||||||
export class DmAdapter implements ProviderAdapter<VideoItem> {
|
export class DmAdapter implements ProviderAdapter<VideoItem> {
|
||||||
@@ -12,6 +13,9 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
|
|||||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'dm');
|
let httpParams = new HttpParams().set('q', params.q).set('providers', 'dm');
|
||||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||||
|
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
|
||||||
|
// le sait, affinés en post-traitement sinon.
|
||||||
|
httpParams = withFilterParams(httpParams, params.filters);
|
||||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||||
const providerError = typeof res?.errors?.dm?.message === 'string' ? res.errors.dm.message : undefined;
|
const providerError = typeof res?.errors?.dm?.message === 'string' ? res.errors.dm.message : undefined;
|
||||||
const items: VideoItem[] = (res?.groups?.dm || []).map((it: any) => ({
|
const items: VideoItem[] = (res?.groups?.dm || []).map((it: any) => ({
|
||||||
@@ -25,7 +29,10 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
|
|||||||
publishedAt: undefined, // Dailymotion API does not provide published date in search results
|
publishedAt: undefined, // Dailymotion API does not provide published date in search results
|
||||||
channelExternalId: it.channelId || undefined,
|
channelExternalId: it.channelId || undefined,
|
||||||
channelAvatarUrl: it.uploaderAvatar || undefined,
|
channelAvatarUrl: it.uploaderAvatar || undefined,
|
||||||
}));
|
// Signaux de type pour le raffinement client des filtres.
|
||||||
|
type: typeof it.type === 'string' ? it.type : undefined,
|
||||||
|
isShort: it.isShort === true ? true : undefined,
|
||||||
|
} as VideoItem));
|
||||||
return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0, providerError };
|
return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0, providerError };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { HttpParams } from '@angular/common/http';
|
||||||
|
import { activeFilters, type SearchFilters } from '../filters';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute les filtres de recherche à une `HttpParams` d'adapter.
|
||||||
|
* Les valeurs par défaut ne sont pas envoyées (URL/backend plus lisibles, cache
|
||||||
|
* serveur plus efficace : deux recherches sans filtre partagent la même entrée).
|
||||||
|
*/
|
||||||
|
export function withFilterParams(params: HttpParams, filters: Partial<SearchFilters> | null | undefined): HttpParams {
|
||||||
|
const active = activeFilters(filters);
|
||||||
|
let out = params;
|
||||||
|
if (active.type) out = out.set('type', active.type);
|
||||||
|
if (active.duration) out = out.set('duration', active.duration);
|
||||||
|
if (active.period) out = out.set('period', active.period);
|
||||||
|
if (active.sort) out = out.set('sort', active.sort);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||||
|
import { withFilterParams } from './filter-params';
|
||||||
|
|
||||||
export class OdAdapter implements ProviderAdapter<VideoItem> {
|
export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||||
key = 'od' as const;
|
key = 'od' as const;
|
||||||
@@ -11,6 +12,9 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
|
|||||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'od');
|
let httpParams = new HttpParams().set('q', params.q).set('providers', 'od');
|
||||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||||
|
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
|
||||||
|
// le sait, affinés en post-traitement sinon.
|
||||||
|
httpParams = withFilterParams(httpParams, params.filters);
|
||||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||||
const providerError = typeof res?.errors?.od?.message === 'string' ? res.errors.od.message : undefined;
|
const providerError = typeof res?.errors?.od?.message === 'string' ? res.errors.od.message : undefined;
|
||||||
const items: VideoItem[] = (res?.groups?.od || []).map((it: any) => {
|
const items: VideoItem[] = (res?.groups?.od || []).map((it: any) => {
|
||||||
@@ -34,6 +38,9 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
|
|||||||
// Nom de chaîne LBRY (ex. @MaChaine) : le backend /api/channels/od
|
// Nom de chaîne LBRY (ex. @MaChaine) : le backend /api/channels/od
|
||||||
// sait le résoudre avec ou sans préfixe @.
|
// sait le résoudre avec ou sans préfixe @.
|
||||||
channelExternalId: it.uploaderName || undefined,
|
channelExternalId: it.uploaderName || undefined,
|
||||||
|
// Signaux de type pour le raffinement client des filtres.
|
||||||
|
type: typeof it.type === 'string' ? it.type : undefined,
|
||||||
|
isShort: it.isShort === true ? true : undefined,
|
||||||
} as VideoItem;
|
} as VideoItem;
|
||||||
});
|
});
|
||||||
return { items, total: Array.isArray(res?.groups?.od) ? res.groups.od.length : 0, providerError };
|
return { items, total: Array.isArray(res?.groups?.od) ? res.groups.od.length : 0, providerError };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||||
|
import { withFilterParams } from './filter-params';
|
||||||
|
|
||||||
export class PtAdapter implements ProviderAdapter<VideoItem> {
|
export class PtAdapter implements ProviderAdapter<VideoItem> {
|
||||||
key = 'pt' as const;
|
key = 'pt' as const;
|
||||||
@@ -11,6 +12,9 @@ export class PtAdapter implements ProviderAdapter<VideoItem> {
|
|||||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'pt');
|
let httpParams = new HttpParams().set('q', params.q).set('providers', 'pt');
|
||||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||||
|
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
|
||||||
|
// le sait, affinés en post-traitement sinon.
|
||||||
|
httpParams = withFilterParams(httpParams, params.filters);
|
||||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||||
const providerError = typeof res?.errors?.pt?.message === 'string' ? res.errors.pt.message : undefined;
|
const providerError = typeof res?.errors?.pt?.message === 'string' ? res.errors.pt.message : undefined;
|
||||||
const items: VideoItem[] = (res?.groups?.pt || []).map((it: any) => {
|
const items: VideoItem[] = (res?.groups?.pt || []).map((it: any) => {
|
||||||
@@ -31,7 +35,10 @@ export class PtAdapter implements ProviderAdapter<VideoItem> {
|
|||||||
viewCount: undefined,
|
viewCount: undefined,
|
||||||
publishedAt: undefined,
|
publishedAt: undefined,
|
||||||
channelExternalId,
|
channelExternalId,
|
||||||
};
|
// Signaux de type pour le raffinement client des filtres.
|
||||||
|
type: typeof it.type === 'string' ? it.type : undefined,
|
||||||
|
isShort: it.isShort === true ? true : undefined,
|
||||||
|
} as VideoItem;
|
||||||
});
|
});
|
||||||
return { items, total: Array.isArray(res?.groups?.pt) ? res.groups.pt.length : 0, providerError };
|
return { items, total: Array.isArray(res?.groups?.pt) ? res.groups.pt.length : 0, providerError };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||||
|
import { withFilterParams } from './filter-params';
|
||||||
|
|
||||||
export class RuAdapter implements ProviderAdapter<VideoItem> {
|
export class RuAdapter implements ProviderAdapter<VideoItem> {
|
||||||
key = 'ru' as const;
|
key = 'ru' as const;
|
||||||
@@ -11,6 +12,9 @@ export class RuAdapter implements ProviderAdapter<VideoItem> {
|
|||||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'ru');
|
let httpParams = new HttpParams().set('q', params.q).set('providers', 'ru');
|
||||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||||
|
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
|
||||||
|
// le sait, affinés en post-traitement sinon.
|
||||||
|
httpParams = withFilterParams(httpParams, params.filters);
|
||||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||||
const providerError = typeof res?.errors?.ru?.message === 'string' ? res.errors.ru.message : undefined;
|
const providerError = typeof res?.errors?.ru?.message === 'string' ? res.errors.ru.message : undefined;
|
||||||
const items: VideoItem[] = (res?.groups?.ru || []).map((it: any) => ({
|
const items: VideoItem[] = (res?.groups?.ru || []).map((it: any) => ({
|
||||||
@@ -22,7 +26,10 @@ export class RuAdapter implements ProviderAdapter<VideoItem> {
|
|||||||
thumbnailUrl: it.thumbnail,
|
thumbnailUrl: it.thumbnail,
|
||||||
viewCount: typeof it.views === 'number' ? it.views : undefined,
|
viewCount: typeof it.views === 'number' ? it.views : undefined,
|
||||||
publishedAt: it.publishedAt,
|
publishedAt: it.publishedAt,
|
||||||
}));
|
// Signaux de type (Shorts / direct) pour le raffinement client.
|
||||||
|
type: typeof it.type === 'string' ? it.type : undefined,
|
||||||
|
isShort: it.isShort === true ? true : undefined,
|
||||||
|
} as VideoItem));
|
||||||
return { items, total: Array.isArray(res?.groups?.ru) ? res.groups.ru.length : 0, providerError };
|
return { items, total: Array.isArray(res?.groups?.ru) ? res.groups.ru.length : 0, providerError };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||||
|
import { withFilterParams } from './filter-params';
|
||||||
|
|
||||||
export class TwAdapter implements ProviderAdapter<VideoItem> {
|
export class TwAdapter implements ProviderAdapter<VideoItem> {
|
||||||
key = 'tw' as const;
|
key = 'tw' as const;
|
||||||
@@ -11,6 +12,9 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
|
|||||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'tw');
|
let httpParams = new HttpParams().set('q', params.q).set('providers', 'tw');
|
||||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||||
|
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
|
||||||
|
// le sait, affinés en post-traitement sinon.
|
||||||
|
httpParams = withFilterParams(httpParams, params.filters);
|
||||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||||
const providerError = typeof res?.errors?.tw?.message === 'string' ? res.errors.tw.message : undefined;
|
const providerError = typeof res?.errors?.tw?.message === 'string' ? res.errors.tw.message : undefined;
|
||||||
const items: VideoItem[] = (res?.groups?.tw || []).map((it: any) => {
|
const items: VideoItem[] = (res?.groups?.tw || []).map((it: any) => {
|
||||||
@@ -41,6 +45,7 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
|
|||||||
type: typeof it.type === 'string' ? it.type : undefined,
|
type: typeof it.type === 'string' ? it.type : undefined,
|
||||||
isLive: typeof it.isLive === 'boolean' ? it.isLive : undefined,
|
isLive: typeof it.isLive === 'boolean' ? it.isLive : undefined,
|
||||||
kind: typeof it.kind === 'string' ? it.kind : undefined,
|
kind: typeof it.kind === 'string' ? it.kind : undefined,
|
||||||
|
isShort: it.isShort === true ? true : undefined,
|
||||||
game: typeof it.game === 'string' ? it.game : undefined,
|
game: typeof it.game === 'string' ? it.game : undefined,
|
||||||
} as VideoItem;
|
} as VideoItem;
|
||||||
}).filter((v: VideoItem) => v.id != null && String(v.id).length > 0 && (v.thumbnailUrl || v.channelAvatarUrl));
|
}).filter((v: VideoItem) => v.id != null && String(v.id).length > 0 && (v.thumbnailUrl || v.channelAvatarUrl));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
|
||||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||||
|
import { withFilterParams } from './filter-params';
|
||||||
|
|
||||||
export class YtAdapter implements ProviderAdapter<VideoItem> {
|
export class YtAdapter implements ProviderAdapter<VideoItem> {
|
||||||
key = 'yt' as const;
|
key = 'yt' as const;
|
||||||
@@ -11,6 +12,9 @@ export class YtAdapter implements ProviderAdapter<VideoItem> {
|
|||||||
let httpParams = new HttpParams().set('q', params.q).set('providers', 'yt');
|
let httpParams = new HttpParams().set('q', params.q).set('providers', 'yt');
|
||||||
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
|
||||||
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
if (params.sort) httpParams = httpParams.set('sort', params.sort);
|
||||||
|
// Filtres (type / durée / période) : appliqués côté serveur quand le provider
|
||||||
|
// le sait, affinés en post-traitement sinon.
|
||||||
|
httpParams = withFilterParams(httpParams, params.filters);
|
||||||
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
|
||||||
const providerError = typeof res?.errors?.yt?.message === 'string' ? res.errors.yt.message : undefined;
|
const providerError = typeof res?.errors?.yt?.message === 'string' ? res.errors.yt.message : undefined;
|
||||||
const items: VideoItem[] = (res?.groups?.yt || []).map((it: any) => ({
|
const items: VideoItem[] = (res?.groups?.yt || []).map((it: any) => ({
|
||||||
@@ -25,7 +29,12 @@ export class YtAdapter implements ProviderAdapter<VideoItem> {
|
|||||||
// ID chaîne conservé : rend le logo/nom cliquables vers /channel
|
// ID chaîne conservé : rend le logo/nom cliquables vers /channel
|
||||||
// (l'avatar est résolu via /api/channels, l'API search n'en fournit pas).
|
// (l'avatar est résolu via /api/channels, l'API search n'en fournit pas).
|
||||||
channelExternalId: it.channelExternalId || it.channelId || undefined,
|
channelExternalId: it.channelExternalId || it.channelId || undefined,
|
||||||
}));
|
// Signaux de type : indispensables au raffinement client des filtres
|
||||||
|
// (Shorts / direct / chaîne) quand le serveur les a déjà appliqués.
|
||||||
|
type: typeof it.type === 'string' ? it.type : undefined,
|
||||||
|
isLive: typeof it.isLive === 'boolean' ? it.isLive : undefined,
|
||||||
|
isShort: it.isShort === true ? true : undefined,
|
||||||
|
} as VideoItem));
|
||||||
return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0, providerError };
|
return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0, providerError };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface SearchResponseV1 {
|
|||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
sort?: 'relevance' | 'date' | 'views';
|
sort?: 'relevance' | 'date' | 'views';
|
||||||
|
/** Filtres appliqués côté serveur (les valeurs par défaut sont omises). */
|
||||||
|
filters?: Record<string, string>;
|
||||||
/** Per-provider backend failures, keyed by provider id. */
|
/** Per-provider backend failures, keyed by provider id. */
|
||||||
errors?: Partial<Record<ProviderId, string>>;
|
errors?: Partial<Record<ProviderId, string>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,437 @@
|
|||||||
|
/**
|
||||||
|
* Modèle de filtres de recherche — partagé par la barre de recherche, le panneau
|
||||||
|
* de filtres, la page de résultats et les adapters.
|
||||||
|
*
|
||||||
|
* Les filtres sont *un* objet plat (`SearchFilters`) : ils voyagent dans l'URL
|
||||||
|
* (`?type=…&duration=…&period=…&sort=…`), dans la requête `/api/search` et dans
|
||||||
|
* l'état de `SearchService`. Toute valeur inconnue retombe sur 'all' (jamais de
|
||||||
|
* 400, jamais de liste vide côté rendu : un filtre typo doit être ignoré).
|
||||||
|
*/
|
||||||
|
import { isShortVideo } from '../shared/utils/video-kind';
|
||||||
|
|
||||||
|
/** Type de contenu attendu. */
|
||||||
|
export type FilterType = 'all' | 'video' | 'shorts' | 'live' | 'channel';
|
||||||
|
/** Borne de durée (alignée sur YouTube : court < 4 min, moyen 4-20 min, long > 20 min). */
|
||||||
|
export type FilterDuration = 'all' | 'short' | 'medium' | 'long';
|
||||||
|
/** Période de mise en ligne. */
|
||||||
|
export type FilterPeriod = 'all' | 'hour' | 'today' | 'week' | 'month' | 'year';
|
||||||
|
/** Tri. */
|
||||||
|
export type FilterSort = 'relevance' | 'date' | 'views';
|
||||||
|
|
||||||
|
export interface SearchFilters {
|
||||||
|
type: FilterType;
|
||||||
|
duration: FilterDuration;
|
||||||
|
period: FilterPeriod;
|
||||||
|
sort: FilterSort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FILTER_TYPES: FilterType[] = ['all', 'video', 'shorts', 'live', 'channel'];
|
||||||
|
export const FILTER_DURATIONS: FilterDuration[] = ['all', 'short', 'medium', 'long'];
|
||||||
|
export const FILTER_PERIODS: FilterPeriod[] = ['all', 'hour', 'today', 'week', 'month', 'year'];
|
||||||
|
export const FILTER_SORTS: FilterSort[] = ['relevance', 'date', 'views'];
|
||||||
|
|
||||||
|
export const DEFAULT_FILTERS: SearchFilters = {
|
||||||
|
type: 'all',
|
||||||
|
duration: 'all',
|
||||||
|
period: 'all',
|
||||||
|
sort: 'relevance',
|
||||||
|
};
|
||||||
|
|
||||||
|
function pick<T extends string>(raw: unknown, allowed: T[], fallback: T): T {
|
||||||
|
const s = String(raw ?? '').trim().toLowerCase();
|
||||||
|
return (allowed as string[]).includes(s) ? (s as T) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalise n'importe quelle entrée (URL, patch, payload serveur) en filtres valides. */
|
||||||
|
export function normalizeFilters(raw: Partial<SearchFilters> | null | undefined): SearchFilters {
|
||||||
|
const src = raw || {};
|
||||||
|
return {
|
||||||
|
['type']: pick(src['type'], FILTER_TYPES, 'all'),
|
||||||
|
['duration']: pick(src['duration'], FILTER_DURATIONS, 'all'),
|
||||||
|
['period']: pick(src['period'], FILTER_PERIODS, 'all'),
|
||||||
|
['sort']: pick(src['sort'], FILTER_SORTS, 'relevance'),
|
||||||
|
} as SearchFilters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nombre de filtres réellement actifs (le tri par défaut ne compte pas). */
|
||||||
|
export function activeFilterCount(f: Partial<SearchFilters> | null | undefined): number {
|
||||||
|
const n = normalizeFilters(f);
|
||||||
|
let count = 0;
|
||||||
|
if (n.type !== 'all') count++;
|
||||||
|
if (n.duration !== 'all') count++;
|
||||||
|
if (n.period !== 'all') count++;
|
||||||
|
if (n.sort !== 'relevance') count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filtersEqual(a: Partial<SearchFilters> | null, b: Partial<SearchFilters> | null): boolean {
|
||||||
|
const x = normalizeFilters(a);
|
||||||
|
const y = normalizeFilters(b);
|
||||||
|
return x.type === y.type && x.duration === y.duration && x.period === y.period && x.sort === y.sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Les filtres « non par défaut » seuls — pour l'URL et le cache. */
|
||||||
|
export function activeFilters(f: Partial<SearchFilters> | null | undefined): Partial<SearchFilters> {
|
||||||
|
const n = normalizeFilters(f);
|
||||||
|
const out: Partial<SearchFilters> = {};
|
||||||
|
if (n.type !== 'all') out.type = n.type;
|
||||||
|
if (n.duration !== 'all') out.duration = n.duration;
|
||||||
|
if (n.period !== 'all') out.period = n.period;
|
||||||
|
if (n.sort !== 'relevance') out.sort = n.sort;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query params (les valeurs par défaut sont omises : URL lisible et partageable). */
|
||||||
|
export function filtersToQueryParams(f: Partial<SearchFilters> | null | undefined): Record<string, string> {
|
||||||
|
const a = activeFilters(f);
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
if (a.type) out['type'] = a.type;
|
||||||
|
if (a.duration) out['duration'] = a.duration;
|
||||||
|
if (a.period) out['period'] = a.period;
|
||||||
|
if (a.sort) out['sort'] = a.sort;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lecture inverse depuis une `ParamMap` (ou un objet de query params). */
|
||||||
|
export function filtersFromQueryParams(params: {
|
||||||
|
get(key: string): string | null;
|
||||||
|
has?(key: string): boolean;
|
||||||
|
} | Record<string, unknown> | null | undefined): SearchFilters {
|
||||||
|
const read = (key: string): string | null => {
|
||||||
|
if (!params) return null;
|
||||||
|
if (typeof (params as any).get === 'function') {
|
||||||
|
try { return (params as any).get(key) ?? null; } catch { return null; }
|
||||||
|
}
|
||||||
|
const rec = params as Record<string, unknown>;
|
||||||
|
const v = rec?.[key];
|
||||||
|
if (v == null) return null;
|
||||||
|
return Array.isArray(v) ? String(v[0] ?? '') : String(v);
|
||||||
|
};
|
||||||
|
return normalizeFilters({
|
||||||
|
type: read('type') as FilterType | undefined,
|
||||||
|
duration: read('duration') as FilterDuration | undefined,
|
||||||
|
period: read('period') as FilterPeriod | undefined,
|
||||||
|
sort: read('sort') as FilterSort | undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Durée de la période en ms (0 = pas de borne). */
|
||||||
|
export function periodMs(period: FilterPeriod): number {
|
||||||
|
const HOUR = 3600 * 1000;
|
||||||
|
const DAY = 24 * HOUR;
|
||||||
|
switch (period) {
|
||||||
|
case 'hour': return HOUR;
|
||||||
|
case 'today': return DAY;
|
||||||
|
case 'week': return 7 * DAY;
|
||||||
|
case 'month': return 30 * DAY;
|
||||||
|
case 'year': return 365 * DAY;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bornes de durée en secondes (null = pas de borne). */
|
||||||
|
export function durationBounds(duration: FilterDuration): { min: number; max: number } | null {
|
||||||
|
switch (duration) {
|
||||||
|
case 'short': return { min: 1, max: 240 };
|
||||||
|
case 'medium': return { min: 240, max: 1200 };
|
||||||
|
case 'long': return { min: 1200, max: Number.POSITIVE_INFINITY };
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Affichage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type FilterKey = keyof SearchFilters;
|
||||||
|
|
||||||
|
export interface FilterOption<T extends string = string> {
|
||||||
|
value: T;
|
||||||
|
/** Clé i18n (voir i18n.service.ts) + repli FR. */
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Une option d'un groupe de filtres, avec son état sélectionné. */
|
||||||
|
export interface FilterChoice<T extends string = string> extends FilterOption<T> {
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function opt<T extends string>(value: T, key: string, label: string, hint?: string): FilterOption<T> {
|
||||||
|
return hint ? { value, key, label, hint } : { value, key, label };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Groupes de filtres dans l'ordre d'affichage du panneau. */
|
||||||
|
export function filterGroups(f: Partial<SearchFilters> | null | undefined): Array<{
|
||||||
|
key: string;
|
||||||
|
labelKey: string;
|
||||||
|
label: string;
|
||||||
|
multi: boolean;
|
||||||
|
options: FilterChoice[];
|
||||||
|
}> {
|
||||||
|
const n = normalizeFilters(f);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'type', labelKey: 'filters.type', label: 'Type de contenu', multi: false,
|
||||||
|
options: [
|
||||||
|
opt('all', 'filters.type.all', 'Tout'),
|
||||||
|
opt('video', 'filters.type.video', 'Vidéos'),
|
||||||
|
opt('shorts', 'filters.type.shorts', 'Shorts / Clips'),
|
||||||
|
opt('live', 'filters.type.live', 'En direct'),
|
||||||
|
opt('channel', 'filters.type.channel', 'Chaînes'),
|
||||||
|
].map(o => ({ ...o, selected: n.type === o.value })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'period', labelKey: 'filters.period', label: 'Période', multi: false,
|
||||||
|
options: [
|
||||||
|
opt('all', 'filters.period.all', "N'importe quand"),
|
||||||
|
opt('hour', 'filters.period.hour', 'Dernière heure'),
|
||||||
|
opt('today', 'filters.period.today', "Aujourd'hui"),
|
||||||
|
opt('week', 'filters.period.week', 'Cette semaine'),
|
||||||
|
opt('month', 'filters.period.month', 'Ce mois'),
|
||||||
|
opt('year', 'filters.period.year', 'Cette année'),
|
||||||
|
].map(o => ({ ...o, selected: n.period === o.value })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'duration', labelKey: 'filters.duration', label: 'Durée', multi: false,
|
||||||
|
options: [
|
||||||
|
opt('all', 'filters.duration.all', 'Toutes'),
|
||||||
|
opt('short', 'filters.duration.short', 'Courte < 4 min'),
|
||||||
|
opt('medium', 'filters.duration.medium', 'Moyenne 4-20 min'),
|
||||||
|
opt('long', 'filters.duration.long', 'Longue > 20 min'),
|
||||||
|
].map(o => ({ ...o, selected: n.duration === o.value })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sort', labelKey: 'filters.sort', label: 'Trier par', multi: false,
|
||||||
|
options: [
|
||||||
|
opt('relevance', 'filters.sort.relevance', 'Pertinence'),
|
||||||
|
opt('date', 'filters.sort.date', 'Date'),
|
||||||
|
opt('views', 'filters.sort.views', 'Vues'),
|
||||||
|
].map(o => ({ ...o, selected: n.sort === o.value })),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résumé court (une ligne) des filtres actifs, pour le bouton sur mobile. */
|
||||||
|
export function filtersSummary(f: Partial<SearchFilters> | null | undefined, groups = filterGroups(f)): string {
|
||||||
|
const n = normalizeFilters(f);
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (n.type !== 'all') parts.push(groups[0].options.find(o => o.value === n.type)?.label || n.type);
|
||||||
|
if (n.period !== 'all') parts.push(groups[1].options.find(o => o.value === n.period)?.label || n.period);
|
||||||
|
if (n.duration !== 'all') parts.push(groups[2].options.find(o => o.value === n.duration)?.label || n.duration);
|
||||||
|
return parts.length ? parts.join(' · ') : 'Filtres';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Syntaxe « opérateur » dans la requête : `linux today: long: live:`
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aliases de jetons d'opérateur, tolérés en français et en anglais.
|
||||||
|
* Un jeton n'est consommé que s'il est *connu* : `https:`, `12:30` ou un
|
||||||
|
* pseudo-inconnu restent dans la requête telle quelle.
|
||||||
|
*/
|
||||||
|
const TOKEN_ALIASES: Record<string, { key: FilterKey; value: string }> = {
|
||||||
|
// période
|
||||||
|
'heure': { key: 'period', value: 'hour' },
|
||||||
|
'hour': { key: 'period', value: 'hour' },
|
||||||
|
'aujourdhui': { key: 'period', value: 'today' },
|
||||||
|
'today': { key: 'period', value: 'today' },
|
||||||
|
'jour': { key: 'period', value: 'today' },
|
||||||
|
'day': { key: 'period', value: 'today' },
|
||||||
|
'semaine': { key: 'period', value: 'week' },
|
||||||
|
'week': { key: 'period', value: 'week' },
|
||||||
|
'mois': { key: 'period', value: 'month' },
|
||||||
|
'month': { key: 'period', value: 'month' },
|
||||||
|
'annee': { key: 'period', value: 'year' },
|
||||||
|
'année': { key: 'period', value: 'year' },
|
||||||
|
'year': { key: 'period', value: 'year' },
|
||||||
|
// durée
|
||||||
|
'court': { key: 'duration', value: 'short' },
|
||||||
|
'courte': { key: 'duration', value: 'short' },
|
||||||
|
'short': { key: 'duration', value: 'short' },
|
||||||
|
'moyen': { key: 'duration', value: 'medium' },
|
||||||
|
'moyenne': { key: 'duration', value: 'medium' },
|
||||||
|
'medium': { key: 'duration', value: 'medium' },
|
||||||
|
'long': { key: 'duration', value: 'long' },
|
||||||
|
'longue': { key: 'duration', value: 'long' },
|
||||||
|
// type
|
||||||
|
'live': { key: 'type', value: 'live' },
|
||||||
|
'direct': { key: 'type', value: 'live' },
|
||||||
|
'video': { key: 'type', value: 'video' },
|
||||||
|
'videos': { key: 'type', value: 'video' },
|
||||||
|
'vidéo': { key: 'type', value: 'video' },
|
||||||
|
'vidéos': { key: 'type', value: 'video' },
|
||||||
|
'shorts': { key: 'type', value: 'shorts' },
|
||||||
|
'clip': { key: 'type', value: 'shorts' },
|
||||||
|
'clips': { key: 'type', value: 'shorts' },
|
||||||
|
'chaine': { key: 'type', value: 'channel' },
|
||||||
|
'chaîne': { key: 'type', value: 'channel' },
|
||||||
|
'chaines': { key: 'type', value: 'channel' },
|
||||||
|
'chaînes': { key: 'type', value: 'channel' },
|
||||||
|
'channel': { key: 'type', value: 'channel' },
|
||||||
|
'channels': { key: 'type', value: 'channel' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Jeton = mot (lettres accentuées autorisées) suivi de « : » en fin d'espace/mot. */
|
||||||
|
const TOKEN_RE = /(^|\s)([a-zA-Zà-ÿ]{2,12})\s*:(?=\s|$)/g;
|
||||||
|
|
||||||
|
export interface ParsedQuery {
|
||||||
|
/** Requête nettoyée des jetons d'opérateur reconnus. */
|
||||||
|
query: string;
|
||||||
|
/** Jeton inconnu rencontré (l'utilisateur a peut-être fait une faute). */
|
||||||
|
unknownTokens: string[];
|
||||||
|
/** Filtres déduits des jetons. */
|
||||||
|
filters: Partial<SearchFilters>;
|
||||||
|
/** Jeton le plus recently aperçu (pour l'auto-complétion `:`). */
|
||||||
|
activeToken: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait les opérateurs de la requête (`live:`, `today:`, `long:`, …) et les
|
||||||
|
* convertit en filtres. Le dernier jeton rencontré gagne, la requête est
|
||||||
|
* renvoyée nettoyée.
|
||||||
|
*/
|
||||||
|
export function parseQueryTokens(input: string): ParsedQuery {
|
||||||
|
const source = String(input ?? '');
|
||||||
|
const filters: Partial<SearchFilters> = {};
|
||||||
|
const unknownTokens: string[] = [];
|
||||||
|
let activeToken: string | null = null;
|
||||||
|
|
||||||
|
// 1) Jeton en cours de frappe (pas encore suivi de « : ») → pas d'extraction,
|
||||||
|
// on garde la requête telle quelle.
|
||||||
|
const trailing = /(^|\s)([a-zA-Zà-ÿ]{1,12})$/.exec(source);
|
||||||
|
if (trailing) activeToken = trailing[2].toLowerCase();
|
||||||
|
|
||||||
|
const cleaned = source.replace(TOKEN_RE, (whole, lead: string, word: string) => {
|
||||||
|
const alias = TOKEN_ALIASES[String(word).toLowerCase()];
|
||||||
|
if (!alias) {
|
||||||
|
unknownTokens.push(String(word).toLowerCase());
|
||||||
|
return whole;
|
||||||
|
}
|
||||||
|
(filters as any)[alias.key] = alias.value;
|
||||||
|
return lead ? ' ' : '';
|
||||||
|
}).replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
return { query: cleaned, unknownTokens, filters, activeToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suggestions de jetons d'opérateur (autocomplétion après « mot : »). */
|
||||||
|
export function operatorTokenSuggestions(partial: string, limit = 8): string[] {
|
||||||
|
const p = String(partial ?? '').trim().toLowerCase();
|
||||||
|
const names = Object.keys(TOKEN_ALIASES);
|
||||||
|
const filtered = p ? names.filter(n => n.startsWith(p)) : names;
|
||||||
|
// Un seul alias par couple (clé,valeur) pour éviter 3 variantes de « short ».
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const n of filtered) {
|
||||||
|
const sig = `${TOKEN_ALIASES[n].key}:${TOKEN_ALIASES[n].value}`;
|
||||||
|
if (seen.has(sig)) continue;
|
||||||
|
seen.add(sig);
|
||||||
|
out.push(n);
|
||||||
|
if (out.length >= limit) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prédicat client (réfraction des résultats déjà filtrés par le serveur)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function itemDurationSec(item: any): number {
|
||||||
|
const raw = item?.durationSec ?? item?.duration ?? item?.length ?? 0;
|
||||||
|
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : 0;
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
// "1:23:45" / "12:34"
|
||||||
|
if (raw.includes(':')) {
|
||||||
|
const parts = raw.split(':').map(p => Number(p));
|
||||||
|
if (parts.every(p => Number.isFinite(p))) {
|
||||||
|
const sec = parts.reduce((acc, p) => acc * 60 + p, 0);
|
||||||
|
return sec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parsed = Number(raw);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemPublishedTs(item: any): number {
|
||||||
|
const raw = item?.publishedAt ?? item?.uploadedDate ?? item?.uploadDate ?? item?.published_at ?? item?.uploaded ?? null;
|
||||||
|
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : 0;
|
||||||
|
if (typeof raw === 'string' && raw.trim()) {
|
||||||
|
const parsed = Date.parse(raw);
|
||||||
|
if (!Number.isNaN(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLiveItem(item: any): boolean {
|
||||||
|
if (!item) return false;
|
||||||
|
if (item.isLive === true) return true;
|
||||||
|
return String(item.type || '').toLowerCase() === 'live';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isShortItem(item: any): boolean {
|
||||||
|
if (!item) return false;
|
||||||
|
return isShortVideo({
|
||||||
|
duration: itemDurationSec(item),
|
||||||
|
type: typeof item.type === 'string' ? item.type : undefined,
|
||||||
|
provider: typeof item.provider === 'string' ? item.provider : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isChannelItem(item: any): boolean {
|
||||||
|
return String(item?.type || '').toLowerCase() === 'channel' && item?.isLive !== true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isClipItem(item: any): boolean {
|
||||||
|
return String(item?.kind || '').toLowerCase() === 'clip';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le provider expose-t-il un signal de type exploitable ? Si non, on ne peut
|
||||||
|
* pas conclure : mieux vaut garder le résultat (le serveur a déjà filtré) que
|
||||||
|
* de vider la liste.
|
||||||
|
*/
|
||||||
|
function hasTypeSignal(item: any): boolean {
|
||||||
|
if (!item) return false;
|
||||||
|
if (item.isLive === true || item.isLive === false) return true;
|
||||||
|
if (item.isShort === true || item.isShort === false) return true;
|
||||||
|
if (String(item.type || '').trim()) return true;
|
||||||
|
if (String(item.kind || '').trim()) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prédicat de réfinement client. Règle importante : un champ *absent* ne fait
|
||||||
|
* jamais disparaître un résultat (les providers ne renvoient pas tous les
|
||||||
|
* métadonnées) ; seule une valeur présente et hors borne est écartée.
|
||||||
|
*/
|
||||||
|
export function matchesFilters(item: any, filters: Partial<SearchFilters> | null | undefined): boolean {
|
||||||
|
const n = normalizeFilters(filters);
|
||||||
|
if (n.type === 'all' && n.duration === 'all' && n.period === 'all') return true;
|
||||||
|
|
||||||
|
const bounds = durationBounds(n.duration);
|
||||||
|
if (bounds) {
|
||||||
|
const d = itemDurationSec(item);
|
||||||
|
if (d > 0 && (d < bounds.min || d >= bounds.max)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAge = periodMs(n.period);
|
||||||
|
if (maxAge > 0) {
|
||||||
|
const ts = itemPublishedTs(item);
|
||||||
|
if (ts > 0 && (Date.now() - ts) > maxAge) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n.type === 'all') return true;
|
||||||
|
if (!hasTypeSignal(item)) return true; // provider muet : on ne peut pas exclure
|
||||||
|
if (n.type === 'live') return isLiveItem(item);
|
||||||
|
if (n.type === 'channel') return isChannelItem(item);
|
||||||
|
if (n.type === 'shorts') return isShortItem(item) || isClipItem(item);
|
||||||
|
if (n.type === 'video') {
|
||||||
|
return !isLiveItem(item) && !isChannelItem(item) && !isClipItem(item) && !isShortItem(item);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export type ProviderId = 'yt'|'dm'|'tw'|'pt'|'od'|'ru';
|
export type ProviderId = 'yt'|'dm'|'tw'|'pt'|'od'|'ru';
|
||||||
|
import type { SearchFilters } from './filters';
|
||||||
|
|
||||||
export interface SearchItem {
|
export interface SearchItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,9 +27,8 @@ export interface ProviderSearchParams {
|
|||||||
q: string;
|
q: string;
|
||||||
pageToken?: string; // or page
|
pageToken?: string; // or page
|
||||||
sort?: 'relevance'|'date'|'views'|'duration';
|
sort?: 'relevance'|'date'|'views'|'duration';
|
||||||
time?: 'today'|'7d'|'30d'|'all';
|
/** Filtres utilisateur (type de contenu, durée, période). Vides = aucun filtre. */
|
||||||
length?: 'short'|'medium'|'long'|'all';
|
filters?: Partial<SearchFilters>;
|
||||||
type?: 'video'|'live'|'shorts'|'short'|'recorded'|'channel'|'all';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderAdapter<T = SearchItem> {
|
export interface ProviderAdapter<T = SearchItem> {
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import 'zone.js/node';
|
import 'zone.js/node';
|
||||||
import '@angular/compiler';
|
import '@angular/compiler';
|
||||||
|
// Les schedulers internes d'Angular sont exportés sous leur nom ɵ (le nom
|
||||||
|
// public `ChangeDetectionScheduler` n'existe pas dans le barrel ESM : l'import
|
||||||
|
// échouait au link sur les runtimes ESM stricts).
|
||||||
import { Injector, runInInjectionContext, ɵChangeDetectionScheduler as ChangeDetectionScheduler, ɵEffectScheduler as EffectScheduler } from '@angular/core';
|
import { Injector, runInInjectionContext, ɵChangeDetectionScheduler as ChangeDetectionScheduler, ɵEffectScheduler as EffectScheduler } from '@angular/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 11 — Unit tests for the unified search UX components.
|
* Step 11 - Unit tests for the unified search UX components.
|
||||||
*
|
*
|
||||||
* ts-node compiles with isolated transpilation (no decorator output => no ɵfac),
|
* ts-node compiles with isolated transpilation (no decorator output => no ?fac),
|
||||||
* so TestBed cannot instantiate Angular classes here. We import dynamically and
|
* so TestBed cannot instantiate Angular classes here. We import dynamically and
|
||||||
* construct components inside a minimal injection context with fakes.
|
* construct components inside a minimal injection context with fakes.
|
||||||
|
*
|
||||||
|
* Coverage added with the filter panel rework:
|
||||||
|
* - keyboard navigation of the suggestion panel (↑ ↓ Home End PageUp/Dn Tab
|
||||||
|
* Enter Escape, wrapping) and the '@' popover,
|
||||||
|
* - focus-out closes the panels (blur bookkeeping),
|
||||||
|
* - the filter panel (draft, roving tabindex, apply payload),
|
||||||
|
* - operator tokens (`live:`, `today:`, …) folded into filters on submit.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function fmtError(e: unknown): string {
|
function fmtError(e: unknown): string {
|
||||||
@@ -37,15 +47,43 @@ function sleep(ms: number): Promise<void> {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Faux KeyboardEvent : le composant n'appelle que preventDefault/stopPropagation. */
|
||||||
|
function key(k: string, opts: { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean } = {}): any {
|
||||||
|
let defaultPrevented = false;
|
||||||
|
let propagationStopped = false;
|
||||||
|
return {
|
||||||
|
key: k,
|
||||||
|
altKey: !!opts.altKey,
|
||||||
|
ctrlKey: !!opts.ctrlKey,
|
||||||
|
metaKey: !!opts.metaKey,
|
||||||
|
shiftKey: !!opts.shiftKey,
|
||||||
|
preventDefault: () => { defaultPrevented = true; },
|
||||||
|
stopPropagation: () => { propagationStopped = true; },
|
||||||
|
stopImmediatePropagation: () => { propagationStopped = true; },
|
||||||
|
get isDefaultPrevented() { return defaultPrevented; },
|
||||||
|
get isPropagationStopped() { return propagationStopped; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const [{ SearchBoxComponent }, { ProviderPickerComponent }, { SearchService }, { UserService }, { HistoryService }, { TelemetryService }, { PROVIDERS }] = await Promise.all([
|
const [
|
||||||
|
{ SearchBoxComponent },
|
||||||
|
{ SearchFilterPanelComponent },
|
||||||
|
{ SearchService },
|
||||||
|
{ UserService },
|
||||||
|
{ HistoryService },
|
||||||
|
{ TelemetryService },
|
||||||
|
{ PROVIDERS },
|
||||||
|
{ normalizeFilters, parseQueryTokens, operatorTokenSuggestions },
|
||||||
|
] = await Promise.all([
|
||||||
import('../../components/search/search-box.component'),
|
import('../../components/search/search-box.component'),
|
||||||
import('../../components/search/provider-picker.component'),
|
import('../../components/search/search-filter-panel.component'),
|
||||||
import('./search.service'),
|
import('./search.service'),
|
||||||
import('../../services/user.service'),
|
import('../../services/user.service'),
|
||||||
import('../../services/history.service'),
|
import('../../services/history.service'),
|
||||||
import('../../services/telemetry.service'),
|
import('../../services/telemetry.service'),
|
||||||
import('../core/providers/provider-registry'),
|
import('../core/providers/provider-registry'),
|
||||||
|
import('./filters'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// --- Fakes for SearchBox dependencies ---
|
// --- Fakes for SearchBox dependencies ---
|
||||||
@@ -53,6 +91,7 @@ function sleep(ms: number): Promise<void> {
|
|||||||
const makeSearchServiceFake = (initialProviders: any = 'all') => ({
|
const makeSearchServiceFake = (initialProviders: any = 'all') => ({
|
||||||
q$: { value: '' },
|
q$: { value: '' },
|
||||||
providers$: { value: initialProviders },
|
providers$: { value: initialProviders },
|
||||||
|
filters$: { value: normalizeFilters(null) },
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeUsersServiceFake = (prefs: any = null) => ({
|
const makeUsersServiceFake = (prefs: any = null) => ({
|
||||||
@@ -139,59 +178,332 @@ function sleep(ms: number): Promise<void> {
|
|||||||
const box = newBox();
|
const box = newBox();
|
||||||
|
|
||||||
const yt = PROVIDERS[0].id;
|
const yt = PROVIDERS[0].id;
|
||||||
const ev = {
|
const ev = key('1', { altKey: true });
|
||||||
key: '1',
|
|
||||||
altKey: true,
|
|
||||||
ctrlKey: false,
|
|
||||||
metaKey: false,
|
|
||||||
shiftKey: false,
|
|
||||||
preventDefault: () => {},
|
|
||||||
stopImmediatePropagation: () => {},
|
|
||||||
} as unknown as KeyboardEvent;
|
|
||||||
box.handleKeydown(ev);
|
box.handleKeydown(ev);
|
||||||
assertEqual(box.selected(), [yt], 'Alt+1 toggles the first chip (yt) on');
|
assertEqual(box.selected(), [yt], 'Alt+1 toggles the first chip (yt) on');
|
||||||
box.handleKeydown(ev);
|
box.handleKeydown(key('1', { altKey: true }));
|
||||||
assertEqual(box.selected(), [], 'Alt+1 again toggles the chip off');
|
assertEqual(box.selected(), [], 'Alt+1 again toggles the chip off');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ProviderPicker toggles ---
|
// --- SearchBox: panneau de suggestions au clavier ---
|
||||||
|
|
||||||
function testPickerDraftSelection(): void {
|
/** Force l'ouverture du panneau avec `n` suggestions locales. */
|
||||||
const picker = new ProviderPickerComponent();
|
function openSuggestions(box: any, query: string, count = 3): void {
|
||||||
picker.selected = 'all';
|
box.queryUpdate(query);
|
||||||
assert(picker.isChecked('yt'), 'All mode checks every provider');
|
// Le panneau s'ouvre quand `suggestFlat()` n'est pas vide : on l'ouvre à la
|
||||||
|
// main (le réseau est absent dans les tests) puis on vérifie le clavier.
|
||||||
|
box.suggestionsOpen.set(true);
|
||||||
|
void count;
|
||||||
|
}
|
||||||
|
|
||||||
picker.toggle('yt');
|
function testSuggestionKeyboardNavigation(): void {
|
||||||
|
const box = newBox();
|
||||||
|
openSuggestions(box, 'lin', 3);
|
||||||
|
const total = box.suggestFlat().length + 1; // +1 = ligne « Rechercher … »
|
||||||
|
assert(total > 1, 'suggestion panel has rows');
|
||||||
|
|
||||||
|
// Rien de sélectionné au départ : ↓ entre par le haut.
|
||||||
|
assertEqual(box.suggestIndex(), -1, 'no row is highlighted on open');
|
||||||
|
box.handleKeydown(key('ArrowDown'));
|
||||||
|
assertEqual(box.suggestIndex(), 0, 'ArrowDown highlights the first row (search as typed)');
|
||||||
|
box.handleKeydown(key('ArrowDown'));
|
||||||
|
assertEqual(box.suggestIndex(), 1, 'ArrowDown moves to the next row');
|
||||||
|
box.handleKeydown(key('ArrowUp'));
|
||||||
|
assertEqual(box.suggestIndex(), 0, 'ArrowUp goes back');
|
||||||
|
// Home / End
|
||||||
|
box.handleKeydown(key('End'));
|
||||||
|
assertEqual(box.suggestIndex(), total - 1, 'End jumps to the last row');
|
||||||
|
box.handleKeydown(key('Home'));
|
||||||
|
assertEqual(box.suggestIndex(), 0, 'Home jumps to the first row');
|
||||||
|
// PageDown / PageUp
|
||||||
|
box.handleKeydown(key('PageDown'));
|
||||||
|
assert(box.suggestIndex() > 0, 'PageDown moves forward');
|
||||||
|
box.handleKeydown(key('PageUp'));
|
||||||
|
assertEqual(box.suggestIndex(), 0, 'PageUp moves back');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testSuggestionKeyboardWrapsAndEscape(): void {
|
||||||
|
const box = newBox();
|
||||||
|
openSuggestions(box, 'lin', 3);
|
||||||
|
const total = box.suggestFlat().length + 1;
|
||||||
|
|
||||||
|
// Wrapping : on remonte de la première ligne -> dernière ligne
|
||||||
|
box.handleKeydown(key('ArrowUp'));
|
||||||
|
assertEqual(box.suggestIndex(), total - 1, 'ArrowUp from the top wraps to the last row');
|
||||||
|
box.handleKeydown(key('ArrowDown'));
|
||||||
|
assertEqual(box.suggestIndex(), 0, 'ArrowDown from the bottom wraps to the first row');
|
||||||
|
|
||||||
|
// Échap ferme le panneau sans vider la saisie
|
||||||
|
const ev = key('Escape');
|
||||||
|
box.handleKeydown(ev);
|
||||||
|
assert(box.suggestionsOpen() === false, 'Escape closes the suggestion panel');
|
||||||
|
assertEqual(box.query(), 'lin', 'Escape keeps the typed text');
|
||||||
|
assert(ev.isDefaultPrevented === true, 'Escape preventDefault (no browser default)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testSuggestionEnterSubmitsRow(): void {
|
||||||
|
const box = newBox();
|
||||||
|
openSuggestions(box, 'lin', 2);
|
||||||
|
const submitted: any[] = [];
|
||||||
|
box.submitted.subscribe((v: any) => submitted.push(v));
|
||||||
|
|
||||||
|
// Entrée sans surbrillance = recherche brute
|
||||||
|
box.handleKeydown(key('Enter'));
|
||||||
|
assertEqual(submitted.length, 1, 'Enter without highlight runs the typed search');
|
||||||
|
assertEqual(submitted[0].q, 'lin', 'the raw query is submitted');
|
||||||
|
assert(box.suggestionsOpen() === false, 'the panel closes on submit');
|
||||||
|
|
||||||
|
// Entrée sur la ligne 0 = « Rechercher … »
|
||||||
|
openSuggestions(box, 'lin', 2);
|
||||||
|
box.suggestIndex.set(0);
|
||||||
|
box.handleKeydown(key('Enter'));
|
||||||
|
assertEqual(submitted.length, 2, 'Enter on row 0 re-runs the typed search');
|
||||||
|
assertEqual(submitted[1].q, 'lin', 'row 0 submits the query as typed');
|
||||||
|
|
||||||
|
// Entrée sur une suggestion = cette suggestion
|
||||||
|
openSuggestions(box, 'lin', 2);
|
||||||
|
box.suggestIndex.set(1);
|
||||||
|
box.handleKeydown(key('Enter'));
|
||||||
|
assertEqual(submitted.length, 3, 'Enter on a suggestion submits it');
|
||||||
|
assertEqual(submitted[2].q, box.query(), 'the input holds the picked suggestion');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testSuggestionTabCompletes(): void {
|
||||||
|
const box = newBox();
|
||||||
|
openSuggestions(box, 'lin', 3);
|
||||||
|
const submitted: any[] = [];
|
||||||
|
box.submitted.subscribe((v: any) => submitted.push(v));
|
||||||
|
const ev = key('Tab');
|
||||||
|
box.handleKeydown(ev);
|
||||||
|
assert(ev.isDefaultPrevented === true, 'Tab is intercepted while the panel is open');
|
||||||
|
assertEqual(submitted.length, 0, 'Tab completes without searching');
|
||||||
|
assert(box.suggestionsOpen() === true, 'the panel stays open after a Tab completion');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testEscapeClearsQueryWhenNothingOpen(): void {
|
||||||
|
const box = newBox();
|
||||||
|
box.queryUpdate('linux');
|
||||||
|
box.handleKeydown(key('Escape'));
|
||||||
|
assertEqual(box.query(), '', 'Escape with no panel open clears the query');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SearchBox: focus (le panneau se ferme quand le focus sort) ---
|
||||||
|
|
||||||
|
async function testFocusOutClosesPanels(): Promise<void> {
|
||||||
|
const box = newBox();
|
||||||
|
openSuggestions(box, 'lin', 3);
|
||||||
|
assert(box.suggestionsOpen() === true, 'panel is open before the focus leaves');
|
||||||
|
|
||||||
|
// relatedTarget hors du formulaire -> fermeture
|
||||||
|
const form = { contains: (el: any) => el === inside };
|
||||||
|
const inside = { id: 'inside' };
|
||||||
|
box.formRef = { nativeElement: form } as any;
|
||||||
|
const ev: any = { relatedTarget: { id: 'elsewhere' } };
|
||||||
|
box.onFocusOut(ev);
|
||||||
|
await sleep(20);
|
||||||
|
assert(box.suggestionsOpen() === false, 'focusout with an outside target closes the panel');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testFocusOutKeepsPanelInside(): Promise<void> {
|
||||||
|
const box = newBox();
|
||||||
|
openSuggestions(box, 'lin', 3);
|
||||||
|
const inside = { id: 'inside' };
|
||||||
|
box.formRef = { nativeElement: { contains: (el: any) => el === inside } } as any;
|
||||||
|
box.onFocusOut({ relatedTarget: inside } as any);
|
||||||
|
await sleep(20);
|
||||||
|
assert(box.suggestionsOpen() === true, 'focusout toward a node inside the form keeps the panel open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testFocusInCancelsPendingClose(): Promise<void> {
|
||||||
|
const box = newBox();
|
||||||
|
openSuggestions(box, 'lin', 3);
|
||||||
|
const inside = { id: 'inside' };
|
||||||
|
box.formRef = { nativeElement: { contains: () => false } } as any;
|
||||||
|
box.onFocusOut({ relatedTarget: null } as any); // blur « sale » -> timer armé
|
||||||
|
box.onFocusIn({} as any); // le focus revient
|
||||||
|
await sleep(20);
|
||||||
|
assert(box.suggestionsOpen() === true, 'focusin cancels the pending close (no flicker)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testPointerDownKeepsFocusInInput(): void {
|
||||||
|
const box = newBox();
|
||||||
|
const ev: any = { preventDefault: () => { ev.defaultPrevented = true; } };
|
||||||
|
box.onPanelPointerDown(ev);
|
||||||
|
assert(ev.defaultPrevented === true, 'pointerdown inside a panel is neutralized (input keeps focus)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SearchBox: opérateurs et filtres ---
|
||||||
|
|
||||||
|
function testOperatorTokensFoldIntoFilters(): void {
|
||||||
|
const parsed = parseQueryTokens('linux today: long: live:');
|
||||||
|
assertEqual(parsed.query, 'linux', 'operators are stripped from the query');
|
||||||
|
assertEqual(parsed.filters, { period: 'today', duration: 'long', type: 'live' }, 'operators map to filters');
|
||||||
|
// Un mot inconnu reste dans la requête (pas de typo destructive)
|
||||||
|
const unknown = parseQueryTokens('tuto android:');
|
||||||
|
assertEqual(unknown.query, 'tuto android:', 'unknown token stays in the query');
|
||||||
|
assertEqual(unknown.unknownTokens, ['android'], 'unknown token is reported');
|
||||||
|
// Une URL / heure ne doit pas être interprétée
|
||||||
|
assertEqual(parseQueryTokens('visite https://x.com').query, 'visite https://x.com', 'URLs are untouched');
|
||||||
|
assert(operatorTokenSuggestions('li').includes('live'), 'operator autocompletion offers live:');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testSubmitAppliesOperatorTokens(): void {
|
||||||
|
const box = newBox();
|
||||||
|
const submitted: any[] = [];
|
||||||
|
box.submitted.subscribe((v: any) => submitted.push(v));
|
||||||
|
box.queryUpdate('concert live:');
|
||||||
|
box.onSubmit({ preventDefault: () => {} } as any);
|
||||||
|
assertEqual(submitted.length, 1, 'search is submitted');
|
||||||
|
assertEqual(submitted[0].q, 'concert', 'the operator is not part of the query');
|
||||||
|
assertEqual(submitted[0].filters, normalizeFilters({ type: 'live' }), 'the operator became a filter');
|
||||||
|
assertEqual(box.activeFilterCount(), 1, 'one filter is active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testActiveFilterChipsAndRemoval(): void {
|
||||||
|
const box = newBox();
|
||||||
|
box.queryUpdate('chat');
|
||||||
|
box.applyPanel({
|
||||||
|
selection: 'all',
|
||||||
|
filters: normalizeFilters({ type: 'live', duration: 'short', period: 'week' }),
|
||||||
|
rememberDefault: false,
|
||||||
|
});
|
||||||
|
assertEqual(box.activeFilterCount(), 3, 'three filters are active');
|
||||||
|
assertEqual(box.activeFilterChips().map((c: any) => c.key), ['type', 'period', 'duration'], 'chips list every active filter');
|
||||||
|
box.removeFilter('duration');
|
||||||
|
assertEqual(box.activeFilterCount(), 2, 'removing a chip updates the counter');
|
||||||
|
box.clearAllFilters();
|
||||||
|
assertEqual(box.activeFilterCount(), 0, 'clear-all resets every filter');
|
||||||
|
assertEqual(box.activeFilterChips().length, 0, 'no chip left');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testApplyPanelPersistsProvidersAndFilters(): void {
|
||||||
|
const box = newBox();
|
||||||
|
const submitted: any[] = [];
|
||||||
|
box.submitted.subscribe((v: any) => submitted.push(v));
|
||||||
|
box.queryUpdate('rust');
|
||||||
|
box.applyPanel({
|
||||||
|
selection: ['yt', 'tw'],
|
||||||
|
filters: normalizeFilters({ period: 'month' }),
|
||||||
|
rememberDefault: false,
|
||||||
|
});
|
||||||
|
assertEqual(box.providersForRequest(), ['yt', 'tw'], 'sources are applied');
|
||||||
|
assertEqual(box.filters().period, 'month', 'filters are applied');
|
||||||
|
assert(box.panelOpen() === false, 'the panel closes after apply');
|
||||||
|
assertEqual(submitted.length, 1, 'an in-flight search is re-run with the new filters');
|
||||||
|
assertEqual(submitted[0].filters.period, 'month', 'the submitted payload carries the filters');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testPanelDoesNotRelaunchSearchWithoutQuery(): void {
|
||||||
|
const box = newBox();
|
||||||
|
const submitted: any[] = [];
|
||||||
|
box.submitted.subscribe((v: any) => submitted.push(v));
|
||||||
|
box.applyPanel({
|
||||||
|
selection: 'all',
|
||||||
|
filters: normalizeFilters({ type: 'shorts' }),
|
||||||
|
rememberDefault: false,
|
||||||
|
});
|
||||||
|
assertEqual(submitted.length, 0, 'no search is launched while the query is empty');
|
||||||
|
assertEqual(box.filters().type, 'shorts', 'filters are still remembered');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Panneau de filtres ---
|
||||||
|
|
||||||
|
function newPanel(providers: any = 'all', filters: any = null, history: string[] = []): any {
|
||||||
|
const panel = new SearchFilterPanelComponent();
|
||||||
|
panel.open = true;
|
||||||
|
panel.history = history;
|
||||||
|
if (providers !== undefined) panel.selected = providers;
|
||||||
|
if (filters !== undefined) panel.filters = filters;
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function testPanelDraftSelection(): void {
|
||||||
|
const panel = newPanel('all');
|
||||||
|
assert(panel.isChecked('yt'), 'All mode checks every provider');
|
||||||
|
|
||||||
|
panel.toggle('yt');
|
||||||
const yt = 'yt';
|
const yt = 'yt';
|
||||||
assert(picker.isChecked(yt) === false, 'unchecking from All removes only that provider');
|
assert(panel.isChecked(yt) === false, 'unchecking from All removes only that provider');
|
||||||
assert(picker.isChecked('dm'), 'other providers stay checked after unchecking one');
|
assert(panel.isChecked('dm'), 'other providers stay checked after unchecking one');
|
||||||
assertEqual(picker.allMode(), false, 'All mode turns off after unchecking');
|
assertEqual(panel.allMode(), false, 'All mode turns off after unchecking');
|
||||||
|
|
||||||
picker.toggle(yt);
|
panel.toggle(yt);
|
||||||
assert(picker.isChecked(yt), 're-checking adds the provider back');
|
assert(panel.isChecked(yt), 're-checking adds the provider back');
|
||||||
|
|
||||||
let emitted: any = null;
|
let emitted: any = null;
|
||||||
picker.apply.subscribe((v: any) => { emitted = v; });
|
panel.apply.subscribe((v: any) => { emitted = v; });
|
||||||
picker.onApply();
|
panel.onApply();
|
||||||
assert(Array.isArray(emitted?.selection), 'apply emits a list selection');
|
assert(Array.isArray(emitted?.selection), 'apply emits a list selection');
|
||||||
assertEqual(emitted.selection.length, PROVIDERS.length, 'apply emits every provider when all are checked');
|
assertEqual(emitted.selection.length, PROVIDERS.length, 'apply emits every provider when all are checked');
|
||||||
|
assertEqual(emitted.filters, normalizeFilters(null), 'apply emits normalized (default) filters');
|
||||||
|
|
||||||
picker.toggleAllMode();
|
panel.toggleAllMode();
|
||||||
picker.onApply();
|
panel.onApply();
|
||||||
assertEqual(emitted.selection, 'all', 'apply emits all after toggleAllMode');
|
assertEqual(emitted.selection, 'all', 'apply emits all after toggleAllMode');
|
||||||
assert(emitted.rememberDefault === false, 'rememberDefault defaults to false');
|
assert(emitted.rememberDefault === false, 'rememberDefault defaults to false');
|
||||||
}
|
}
|
||||||
|
|
||||||
function testPickerRememberDefault(): void {
|
function testPanelFiltersAndRememberDefault(): void {
|
||||||
const picker = new ProviderPickerComponent();
|
const panel = newPanel(['yt'], null);
|
||||||
picker.selected = ['yt'];
|
panel.rememberDefault.set(true);
|
||||||
picker.rememberDefault.set(true);
|
|
||||||
let emitted: any = null;
|
let emitted: any = null;
|
||||||
picker.apply.subscribe((v: any) => { emitted = v; });
|
panel.apply.subscribe((v: any) => { emitted = v; });
|
||||||
picker.toggle('dm');
|
panel.toggle('dm');
|
||||||
picker.onApply();
|
panel.selectFilter('type', 'live');
|
||||||
|
panel.selectFilter('period', 'week');
|
||||||
|
panel.selectFilter('duration', 'short');
|
||||||
|
panel.selectFilter('sort', 'date');
|
||||||
|
panel.onApply();
|
||||||
assertEqual(emitted.selection, ['yt', 'dm'], 'draft selection is emitted on apply');
|
assertEqual(emitted.selection, ['yt', 'dm'], 'draft selection is emitted on apply');
|
||||||
assert(emitted.rememberDefault === true, 'rememberDefault flag is emitted');
|
assert(emitted.rememberDefault === true, 'rememberDefault flag is emitted');
|
||||||
|
assertEqual(emitted.filters, { type: 'live', duration: 'short', period: 'week', sort: 'date' }, 'every filter dimension is emitted');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testPanelRovingTabindexFollowsSelection(): void {
|
||||||
|
const panel = newPanel('all', normalizeFilters({ type: 'live', period: 'month' }));
|
||||||
|
// L'onglet actif d'un groupe démarre sur l'option sélectionnée.
|
||||||
|
assertEqual(panel.activeIndex('type'), panel.optionIndexOf(panel.groups()[0], 'live'), 'type group starts on the selected option');
|
||||||
|
assertEqual(panel.activeIndex('period'), panel.optionIndexOf(panel.groups()[1], 'month'), 'period group starts on the selected option');
|
||||||
|
// Une sélection déplace l'onglet actif.
|
||||||
|
panel.selectFilter('type', 'shorts');
|
||||||
|
assertEqual(panel.activeIndex('type'), panel.optionIndexOf(panel.groups()[0], 'shorts'), 'selecting moves the roving tabindex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testPanelKeyboardRovingAndEscape(): void {
|
||||||
|
const panel = newPanel('all');
|
||||||
|
const focused: any[] = [];
|
||||||
|
// Sans DOM, `focusTab` ne trouve rien : on vérifie l'index mis à jour.
|
||||||
|
(panel as any).dialogRef = { nativeElement: { querySelector: () => null, focus: () => {} } };
|
||||||
|
|
||||||
|
const target: any = { closest: () => ({ getAttribute: () => 'type' }) };
|
||||||
|
const ev: any = key('ArrowRight');
|
||||||
|
(ev as any).target = target;
|
||||||
|
panel.onDialogKeydown(ev);
|
||||||
|
assert(ev.isDefaultPrevented === true, 'ArrowRight is handled (no page scroll)');
|
||||||
|
assertEqual(panel.activeIndex('type'), 1, 'ArrowRight moves to the next option of the group');
|
||||||
|
assertEqual(focused.length, 0, 'no crash without a real DOM');
|
||||||
|
void focused;
|
||||||
|
|
||||||
|
// Début / fin de groupe
|
||||||
|
(panel as any).dialogRef = { nativeElement: { querySelector: () => null, focus: () => {} } };
|
||||||
|
const end: any = key('End');
|
||||||
|
(end as any).target = target;
|
||||||
|
panel.onDialogKeydown(end);
|
||||||
|
assert(panel.activeIndex('type') > 1, 'End jumps to the last option');
|
||||||
|
|
||||||
|
// Échap ferme
|
||||||
|
let closed = 0;
|
||||||
|
panel.close.subscribe(() => { closed++; });
|
||||||
|
panel.onDialogKeydown(key('Escape'));
|
||||||
|
assertEqual(closed, 1, 'Escape closes the panel');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testPanelReset(): void {
|
||||||
|
const panel = newPanel(['yt'], normalizeFilters({ type: 'live' }));
|
||||||
|
panel.toggle('dm');
|
||||||
|
panel.reset();
|
||||||
|
assertEqual(panel.allMode(), true, 'reset returns to All sources');
|
||||||
|
assertEqual(panel.draftFilters(), normalizeFilters(null), 'reset clears every filter');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tests: Array<[string, () => void | Promise<void>]> = [
|
const tests: Array<[string, () => void | Promise<void>]> = [
|
||||||
@@ -199,18 +511,35 @@ function sleep(ms: number): Promise<void> {
|
|||||||
['SearchBox applyAtOption strips term and toggles chip', testApplyAtOptionStripsTermAndTogglesChip],
|
['SearchBox applyAtOption strips term and toggles chip', testApplyAtOptionStripsTermAndTogglesChip],
|
||||||
['SearchBox applies defaultProviders preference', testDefaultProvidersPreferenceApplied],
|
['SearchBox applies defaultProviders preference', testDefaultProvidersPreferenceApplied],
|
||||||
['SearchBox Alt+1..6 shortcut toggles chips', testAltShortcutsToggleChips],
|
['SearchBox Alt+1..6 shortcut toggles chips', testAltShortcutsToggleChips],
|
||||||
['ProviderPicker draft selection + apply', testPickerDraftSelection],
|
['SearchBox suggestion panel: ↑ ↓ Home End PageUp/Down', testSuggestionKeyboardNavigation],
|
||||||
['ProviderPicker rememberDefault flag', testPickerRememberDefault],
|
['SearchBox suggestion panel: wrapping + Escape', testSuggestionKeyboardWrapsAndEscape],
|
||||||
|
['SearchBox suggestion panel: Enter validates the highlighted row', testSuggestionEnterSubmitsRow],
|
||||||
|
['SearchBox suggestion panel: Tab completes without searching', testSuggestionTabCompletes],
|
||||||
|
['SearchBox Escape clears the query when nothing is open', testEscapeClearsQueryWhenNothingOpen],
|
||||||
|
['SearchBox focusout closes the panel when focus leaves', testFocusOutClosesPanels],
|
||||||
|
['SearchBox focusout keeps the panel when focus stays inside', testFocusOutKeepsPanelInside],
|
||||||
|
['SearchBox focusin cancels the pending close', testFocusInCancelsPendingClose],
|
||||||
|
['SearchBox pointerdown inside a panel does not steal focus', testPointerDownKeepsFocusInInput],
|
||||||
|
['SearchBox operator tokens (live:, today:, long:)', testOperatorTokensFoldIntoFilters],
|
||||||
|
['SearchBox submit turns operators into filters', testSubmitAppliesOperatorTokens],
|
||||||
|
['SearchBox active filter chips + removal', testActiveFilterChipsAndRemoval],
|
||||||
|
['SearchBox applyPanel persists sources/filters and re-runs', testApplyPanelPersistsProvidersAndFilters],
|
||||||
|
['SearchBox applyPanel without query does not search', testPanelDoesNotRelaunchSearchWithoutQuery],
|
||||||
|
['FilterPanel draft selection + apply', testPanelDraftSelection],
|
||||||
|
['FilterPanel filters + rememberDefault', testPanelFiltersAndRememberDefault],
|
||||||
|
['FilterPanel roving tabindex follows the selection', testPanelRovingTabindexFollowsSelection],
|
||||||
|
['FilterPanel arrow keys + Escape', testPanelKeyboardRovingAndEscape],
|
||||||
|
['FilterPanel reset', testPanelReset],
|
||||||
];
|
];
|
||||||
|
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
for (const [name, fn] of tests) {
|
for (const [name, fn] of tests) {
|
||||||
try {
|
try {
|
||||||
await fn();
|
await fn();
|
||||||
console.log(`✓ ${name}`);
|
console.log(`V ${name}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failures += 1;
|
failures += 1;
|
||||||
console.error(`✗ ${name}`);
|
console.error(`? ${name}`);
|
||||||
console.error(error instanceof Error ? error.stack : fmtError(error));
|
console.error(error instanceof Error ? error.stack : fmtError(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { OdAdapter } from './adapters/od';
|
|||||||
import { RuAdapter } from './adapters/ru';
|
import { RuAdapter } from './adapters/ru';
|
||||||
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from './models';
|
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from './models';
|
||||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||||
|
import { DEFAULT_FILTERS, activeFilters, normalizeFilters, type SearchFilters } from './filters';
|
||||||
|
|
||||||
export type SuggestionItem = SuggestionItemV1;
|
export type SuggestionItem = SuggestionItemV1;
|
||||||
export type SearchResponse = SearchResponseV1;
|
export type SearchResponse = SearchResponseV1;
|
||||||
@@ -28,6 +29,11 @@ export class SearchService {
|
|||||||
readonly page$ = new BehaviorSubject<number>(1);
|
readonly page$ = new BehaviorSubject<number>(1);
|
||||||
readonly pageSize$ = new BehaviorSubject<number>(24);
|
readonly pageSize$ = new BehaviorSubject<number>(24);
|
||||||
readonly sort$ = new BehaviorSubject<'relevance' | 'date' | 'views' | 'duration'>('relevance');
|
readonly sort$ = new BehaviorSubject<'relevance' | 'date' | 'views' | 'duration'>('relevance');
|
||||||
|
/**
|
||||||
|
* Filtres de recherche (type / durée / période / tri). Source de vérité des
|
||||||
|
* filtres UI : le tri y est aussi reflété (`sort$` reste pour compat).
|
||||||
|
*/
|
||||||
|
readonly filters$ = new BehaviorSubject<SearchFilters>({ ...DEFAULT_FILTERS });
|
||||||
|
|
||||||
// In-memory cache 60s per (provider, q, params)
|
// In-memory cache 60s per (provider, q, params)
|
||||||
private cache = new Map<CacheKey, CacheEntry<VideoItem>>();
|
private cache = new Map<CacheKey, CacheEntry<VideoItem>>();
|
||||||
@@ -43,7 +49,7 @@ export class SearchService {
|
|||||||
ru: new RuAdapter(this.http)
|
ru: new RuAdapter(this.http)
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly params$ = combineLatest([this.q$, this.providers$, this.page$, this.pageSize$, this.sort$]).pipe(
|
readonly params$ = combineLatest([this.q$, this.providers$, this.page$, this.pageSize$, this.sort$, this.filters$]).pipe(
|
||||||
debounceTime(120),
|
debounceTime(120),
|
||||||
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
|
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
|
||||||
);
|
);
|
||||||
@@ -51,11 +57,13 @@ export class SearchService {
|
|||||||
// Public request stream: aggregates groups by provider, runs adapters in parallel with timeout/abort/retry
|
// Public request stream: aggregates groups by provider, runs adapters in parallel with timeout/abort/retry
|
||||||
readonly request$ = this.params$.pipe(
|
readonly request$ = this.params$.pipe(
|
||||||
filter(([q]) => typeof q === 'string' && q.trim().length >= 2),
|
filter(([q]) => typeof q === 'string' && q.trim().length >= 2),
|
||||||
switchMap(([q, prov, page, pageSize, sort]) => from(this.runAdapters({
|
switchMap(([q, prov, page, pageSize, sort, filters]) => from(this.runAdapters({
|
||||||
q: String(q),
|
q: String(q),
|
||||||
pageToken: String(page || 1),
|
pageToken: String(page || 1),
|
||||||
sort: (sort as any) || 'relevance',
|
sort: (sort as any) || 'relevance',
|
||||||
// global filter knobs could be added here: time/length/type in the future
|
// Filtres globaux (type / durée / période) : envoyés à chaque adapter,
|
||||||
|
// appliqués nativement par le provider quand c'est possible.
|
||||||
|
filters: normalizeFilters(filters),
|
||||||
}, prov)))
|
}, prov)))
|
||||||
,
|
,
|
||||||
// Map provider results to the format expected by the UI
|
// Map provider results to the format expected by the UI
|
||||||
@@ -88,7 +96,7 @@ export class SearchService {
|
|||||||
const tasks = active.map(async (pid) => {
|
const tasks = active.map(async (pid) => {
|
||||||
const adapter = this.adapters[pid];
|
const adapter = this.adapters[pid];
|
||||||
if (!adapter) return [pid, { items: [] } as SearchResult<VideoItem>] as const;
|
if (!adapter) return [pid, { items: [] } as SearchResult<VideoItem>] as const;
|
||||||
const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}`;
|
const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}|${this.filterKey(params.filters)}`;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cached = this.cache.get(key);
|
const cached = this.cache.get(key);
|
||||||
if (cached && (now - cached.t) < this.cacheTtlMs) {
|
if (cached && (now - cached.t) < this.cacheTtlMs) {
|
||||||
@@ -127,6 +135,26 @@ export class SearchService {
|
|||||||
setPageSize(size: number) { this.pageSize$.next(Math.min(50, Math.max(1, Math.floor(size || 24)))); }
|
setPageSize(size: number) { this.pageSize$.next(Math.min(50, Math.max(1, Math.floor(size || 24)))); }
|
||||||
setSort(sort: 'relevance' | 'date' | 'views' | 'duration') {
|
setSort(sort: 'relevance' | 'date' | 'views' | 'duration') {
|
||||||
const allowed: Array<'relevance' | 'date' | 'views' | 'duration'> = ['relevance', 'date', 'views', 'duration'];
|
const allowed: Array<'relevance' | 'date' | 'views' | 'duration'> = ['relevance', 'date', 'views', 'duration'];
|
||||||
this.sort$.next(allowed.includes(sort) ? sort : 'relevance');
|
const next = allowed.includes(sort) ? sort : 'relevance';
|
||||||
|
this.sort$.next(next);
|
||||||
|
// Le tri vit aussi dans `filters` (source de vérité des filtres) : on garde
|
||||||
|
// les deux BehaviorSubjects synchronisés sans boucle (next -> next).
|
||||||
|
const current = this.filters$.value;
|
||||||
|
const mapped = next === 'duration' ? 'relevance' : next;
|
||||||
|
if (current.sort !== mapped) this.filters$.next({ ...current, sort: mapped as SearchFilters['sort'] });
|
||||||
}
|
}
|
||||||
|
/** Applique un patch de filtres (normalisé) et synchronise `sort$`. */
|
||||||
|
setFilters(patch: Partial<SearchFilters> | null | undefined) {
|
||||||
|
const next = normalizeFilters({ ...this.filters$.value, ...(patch || {}) });
|
||||||
|
if (JSON.stringify(next) === JSON.stringify(this.filters$.value)) return;
|
||||||
|
this.filters$.next(next);
|
||||||
|
if (this.sort$.value !== next.sort) this.sort$.next(next.sort as any);
|
||||||
|
}
|
||||||
|
resetFilters() { this.setFilters({ ...DEFAULT_FILTERS }); }
|
||||||
|
private filterKey(filters: Partial<SearchFilters> | null | undefined): string {
|
||||||
|
const n = normalizeFilters(filters);
|
||||||
|
return `${n.type}.${n.duration}.${n.period}.${n.sort}`;
|
||||||
|
}
|
||||||
|
/** Filtres actifs seulement (pour l'URL / le partage). */
|
||||||
|
activeFilters(): Partial<SearchFilters> { return activeFilters(this.filters$.value); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface VideoItem {
|
|||||||
// Live status (only providers exposing it, e.g. Twitch, set these)
|
// Live status (only providers exposing it, e.g. Twitch, set these)
|
||||||
type?: string; // e.g. 'live' | 'channel' | 'video'
|
type?: string; // e.g. 'live' | 'channel' | 'video'
|
||||||
isLive?: boolean; // true when actually broadcasting now
|
isLive?: boolean; // true when actually broadcasting now
|
||||||
|
isShort?: boolean; // true when the provider flagged a Short/clip
|
||||||
kind?: string; // e.g. Twitch 'vod' | 'clip'
|
kind?: string; // e.g. Twitch 'vod' | 'clip'
|
||||||
game?: string; // e.g. Twitch game/category name
|
game?: string; // e.g. Twitch game/category name
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,8 @@
|
|||||||
<div class="hidden md:flex flex-1 min-w-0 justify-center px-2 lg:px-4">
|
<div class="hidden md:flex flex-1 min-w-0 justify-center px-2 lg:px-4">
|
||||||
<div class="relative w-full max-w-2xl lg:max-w-4xl">
|
<div class="relative w-full max-w-2xl lg:max-w-4xl">
|
||||||
<app-search-box [placeholder]="('search.placeholder' | t)"
|
<app-search-box [placeholder]="('search.placeholder' | t)"
|
||||||
(submitted)="onSearchBoxSubmit($event)"></app-search-box>
|
(submitted)="onSearchBoxSubmit($event)"
|
||||||
|
(filtersChange)="onSearchFiltersChange($event)"></app-search-box>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -132,7 +133,8 @@
|
|||||||
[class.hidden]="!mobileSearchOpen()">
|
[class.hidden]="!mobileSearchOpen()">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<app-search-box [placeholder]="('search.placeholder' | t)"
|
<app-search-box [placeholder]="('search.placeholder' | t)"
|
||||||
(submitted)="onSearchBoxSubmit($event)"></app-search-box>
|
(submitted)="onSearchBoxSubmit($event)"
|
||||||
|
(filtersChange)="onSearchFiltersChange($event)"></app-search-box>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, inject, signal, Input, Output, EventEmitter, HostListener, ViewChild, ViewChildren, QueryList, ElementRef, DestroyRef, AfterViewInit } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject, signal, Input, Output, EventEmitter, HostListener, ViewChild, ViewChildren, QueryList, ElementRef, DestroyRef, AfterViewInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Router, RouterLink, NavigationEnd } from '@angular/router';
|
import { Router, RouterLink, NavigationEnd, ActivatedRoute } from '@angular/router';
|
||||||
import { InstanceService, Provider } from '../../services/instance.service';
|
import { InstanceService, Provider } from '../../services/instance.service';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
@@ -11,6 +11,7 @@ import { I18nService } from '../../services/i18n.service';
|
|||||||
import { ThemesService } from '../../services/themes.service';
|
import { ThemesService } from '../../services/themes.service';
|
||||||
import { HistoryService, SearchHistoryItem } from '../../services/history.service';
|
import { HistoryService, SearchHistoryItem } from '../../services/history.service';
|
||||||
import { SearchBoxComponent } from '../search/search-box.component';
|
import { SearchBoxComponent } from '../search/search-box.component';
|
||||||
|
import { filtersToQueryParams, type SearchFilters } from '../../app/search/filters';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-header',
|
selector: 'app-header',
|
||||||
@@ -28,6 +29,7 @@ import { SearchBoxComponent } from '../search/search-box.component';
|
|||||||
})
|
})
|
||||||
export class HeaderComponent implements AfterViewInit {
|
export class HeaderComponent implements AfterViewInit {
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
instances = inject(InstanceService);
|
instances = inject(InstanceService);
|
||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private userService = inject(UserService);
|
private userService = inject(UserService);
|
||||||
@@ -139,8 +141,8 @@ export class HeaderComponent implements AfterViewInit {
|
|||||||
return items;
|
return items;
|
||||||
});
|
});
|
||||||
|
|
||||||
// New SearchBox submit handler (navigate with providers list)
|
// New SearchBox submit handler (navigate with providers list + filters)
|
||||||
onSearchBoxSubmit(evt: { q: string; providers: ("yt"|"dm"|"tw"|"pt"|"od"|"ru")[] | 'all' }) {
|
onSearchBoxSubmit(evt: { q: string; providers: ("yt"|"dm"|"tw"|"pt"|"od"|"ru")[] | 'all'; filters?: SearchFilters }) {
|
||||||
const q = (evt?.q || '').trim();
|
const q = (evt?.q || '').trim();
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
const provider = this.selectedProvider();
|
const provider = this.selectedProvider();
|
||||||
@@ -152,6 +154,9 @@ export class HeaderComponent implements AfterViewInit {
|
|||||||
// Explicitly include all providers so SearchComponent uses unified multi-provider mode
|
// Explicitly include all providers so SearchComponent uses unified multi-provider mode
|
||||||
qp.providers = 'yt,dm,tw,pt,od,ru';
|
qp.providers = 'yt,dm,tw,pt,od,ru';
|
||||||
}
|
}
|
||||||
|
// Filtres (type / durée / période / tri) : seulement les valeurs actives,
|
||||||
|
// pour garder une URL lisible et partageable.
|
||||||
|
Object.assign(qp, filtersToQueryParams(evt?.filters));
|
||||||
if (theme) qp.theme = theme;
|
if (theme) qp.theme = theme;
|
||||||
try { console.debug('[Header] onSearchBoxSubmit -> navigate', qp); } catch {}
|
try { console.debug('[Header] onSearchBoxSubmit -> navigate', qp); } catch {}
|
||||||
this.suggestionsOpen.set(false);
|
this.suggestionsOpen.set(false);
|
||||||
@@ -159,6 +164,25 @@ export class HeaderComponent implements AfterViewInit {
|
|||||||
this.router.navigate(['/search'], { queryParams: qp });
|
this.router.navigate(['/search'], { queryParams: qp });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtres modifiés sans relancer de recherche : on réécrit l'URL courante
|
||||||
|
* pour que la page /search reste partageable (et se recharge côté serveur).
|
||||||
|
*/
|
||||||
|
onSearchFiltersChange(filters: SearchFilters): void {
|
||||||
|
if (!this.router.url.startsWith('/search')) return;
|
||||||
|
const current: any = { ...this.route.snapshot.queryParams };
|
||||||
|
delete current['type'];
|
||||||
|
delete current['duration'];
|
||||||
|
delete current['period'];
|
||||||
|
delete current['sort'];
|
||||||
|
Object.assign(current, filtersToQueryParams(filters));
|
||||||
|
this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: current,
|
||||||
|
replaceUrl: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Mobile chrome actions ----------------------------------------------
|
// --- Mobile chrome actions ----------------------------------------------
|
||||||
toggleMobileSearch(): void {
|
toggleMobileSearch(): void {
|
||||||
const next = !this.mobileSearchOpen();
|
const next = !this.mobileSearchOpen();
|
||||||
@@ -604,7 +628,8 @@ export class HeaderComponent implements AfterViewInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl/Cmd+K focuses search
|
// Ctrl/Cmd+K focuses search. Le panneau de filtres (Ctrl/Cmd+Maj+F) est
|
||||||
|
// entièrement piloté par <app-search-box>.
|
||||||
if ((ev.key === 'k' || ev.key === 'K') && (ev.ctrlKey || ev.metaKey)) {
|
if ((ev.key === 'k' || ev.key === 'K') && (ev.ctrlKey || ev.metaKey)) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (!this.mobileSearchOpen() && typeof window !== 'undefined' && window.innerWidth < 768) {
|
if (!this.mobileSearchOpen() && typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
<div class="fixed inset-0 z-[9999] bg-black/60" (click)="close.emit()">
|
|
||||||
<div class="fixed inset-0 grid place-items-center p-4" [style.paddingBottom]="'max(1rem, var(--safe-bottom))'">
|
|
||||||
<div #dialog
|
|
||||||
class="dialog-scroll flex w-full max-w-lg flex-col overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-2xl"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="providersTitle"
|
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
tabindex="0"
|
|
||||||
(keydown)="onDialogKeydown($event)">
|
|
||||||
<div class="sticky top-0 z-10 shrink-0 border-b border-slate-700 bg-slate-900 p-4 flex items-center justify-between gap-3">
|
|
||||||
<h2 id="providersTitle" class="text-slate-100 font-semibold">Providers</h2>
|
|
||||||
<button type="button" (click)="close.emit()" class="chip px-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer (Esc)">Esc</button>
|
|
||||||
</div>
|
|
||||||
<div class="p-4 space-y-3">
|
|
||||||
<input type="text" [ngModel]="filter()" (ngModelChange)="filter.set($event)" placeholder="filter providers…" aria-label="Filtrer les fournisseurs" class="w-full min-w-0 px-3 py-2 rounded bg-slate-800 text-slate-100 placeholder-slate-400 border border-slate-700"/>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<label class="inline-flex items-center gap-2 text-slate-200 py-1">
|
|
||||||
<input type="checkbox" [checked]="allMode()" (change)="toggleAllMode()"/>
|
|
||||||
<span>All</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
||||||
<label *ngFor="let p of providers()" class="flex items-center gap-2 px-3 py-3 rounded border border-slate-700 bg-slate-800 text-slate-100">
|
|
||||||
<input type="checkbox" class="shrink-0" [checked]="isChecked(p.id)" (change)="toggle(p.id)"/>
|
|
||||||
<span class="text-xs font-semibold shrink-0" [class]="p.colorClass">{{ p.shortLabel }}</span>
|
|
||||||
<span class="min-w-0 truncate text-sm">{{ p.displayName }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label class="mt-2 inline-flex items-center gap-2 text-slate-300 py-1">
|
|
||||||
<input type="checkbox" [ngModel]="rememberDefault()" (ngModelChange)="rememberDefault.set($event)" />
|
|
||||||
<span>Remember as default</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="sticky bottom-0 z-10 shrink-0 border-t border-slate-700 bg-slate-900 p-4 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end">
|
|
||||||
<button type="button" (click)="close.emit()" class="px-4 py-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Cancel</button>
|
|
||||||
<button type="button" (click)="onApply()" class="px-4 py-3 rounded bg-red-600 hover:bg-red-500 text-white">Apply filters</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { Component, EventEmitter, Output, Input, signal, computed, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { ProviderId, PROVIDERS, PROVIDER_MAP } from '../../app/core/providers/provider-registry';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-provider-picker',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, FormsModule],
|
|
||||||
templateUrl: './provider-picker.component.html'
|
|
||||||
})
|
|
||||||
export class ProviderPickerComponent implements AfterViewInit, OnDestroy {
|
|
||||||
@Input() open = false;
|
|
||||||
// Incoming selection: 'all' or a list of provider ids
|
|
||||||
@Input() set selected(value: ProviderId[] | 'all') {
|
|
||||||
// Reset the draft each time the picker opens with a new incoming selection
|
|
||||||
this._draft.set(value === 'all' ? [] : Array.isArray(value) ? [...value] : []);
|
|
||||||
this._allMode.set(value === 'all');
|
|
||||||
}
|
|
||||||
@Output() close = new EventEmitter<void>();
|
|
||||||
@Output() apply = new EventEmitter<{ selection: ProviderId[] | 'all'; rememberDefault: boolean }>();
|
|
||||||
|
|
||||||
filter = signal('');
|
|
||||||
rememberDefault = signal(false);
|
|
||||||
|
|
||||||
// Draft state: working copy while the modal is open
|
|
||||||
private _draft = signal<ProviderId[]>([]);
|
|
||||||
private _allMode = signal(true);
|
|
||||||
readonly draft = computed(() => this._draft());
|
|
||||||
readonly allMode = computed(() => this._allMode() && this._draft().length === 0);
|
|
||||||
|
|
||||||
@ViewChild('dialog', { static: false }) dialogRef?: ElementRef<HTMLDivElement>;
|
|
||||||
|
|
||||||
readonly providers = computed(() => {
|
|
||||||
const q = this.filter().toLowerCase();
|
|
||||||
return PROVIDERS.filter(p => !q || p.displayName.toLowerCase().includes(q) || p.shortLabel.toLowerCase().includes(q));
|
|
||||||
});
|
|
||||||
|
|
||||||
toggleAllMode() {
|
|
||||||
this._allMode.set(true);
|
|
||||||
this._draft.set([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
isChecked(id: ProviderId): boolean {
|
|
||||||
if (this.allMode()) return true;
|
|
||||||
return this._draft().includes(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle(id: ProviderId) {
|
|
||||||
if (this.allMode()) {
|
|
||||||
// Leaving All mode by unchecking one provider: start from every provider
|
|
||||||
// then remove the unchecked one ("all except this one")
|
|
||||||
const set = new Set(PROVIDERS.map(p => p.id));
|
|
||||||
set.delete(id);
|
|
||||||
this._draft.set(Array.from(set));
|
|
||||||
this._allMode.set(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const set = new Set(this._draft());
|
|
||||||
if (set.has(id)) {
|
|
||||||
set.delete(id);
|
|
||||||
} else {
|
|
||||||
set.add(id);
|
|
||||||
}
|
|
||||||
this._draft.set(Array.from(set));
|
|
||||||
this._allMode.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
onApply() {
|
|
||||||
const selection: ProviderId[] | 'all' = this.allMode() ? 'all' : this._draft();
|
|
||||||
this.apply.emit({ selection, rememberDefault: this.rememberDefault() });
|
|
||||||
}
|
|
||||||
|
|
||||||
ngAfterViewInit() {
|
|
||||||
// Focus the dialog for accessibility
|
|
||||||
queueMicrotask(() => {
|
|
||||||
try { this.dialogRef?.nativeElement?.focus(); } catch {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy() {
|
|
||||||
try { document.body.style.overflow = ''; } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focus trap within the dialog (Tab / Shift+Tab)
|
|
||||||
onDialogKeydown(ev: KeyboardEvent) {
|
|
||||||
if (ev.key === 'Escape') {
|
|
||||||
ev.preventDefault();
|
|
||||||
this.close.emit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ev.key !== 'Tab') return;
|
|
||||||
const root = this.dialogRef?.nativeElement;
|
|
||||||
if (!root) return;
|
|
||||||
const focusables = Array.from(root.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
|
|
||||||
.filter(el => !el.hasAttribute('disabled'));
|
|
||||||
if (focusables.length === 0) return;
|
|
||||||
const first = focusables[0];
|
|
||||||
const last = focusables[focusables.length - 1];
|
|
||||||
const active = document.activeElement as HTMLElement | null;
|
|
||||||
if (!ev.shiftKey && active === last) {
|
|
||||||
ev.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
} else if (ev.shiftKey && (active === first || active === root)) {
|
|
||||||
ev.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +1,205 @@
|
|||||||
<form class="w-full min-w-0 relative" novalidate (submit)="onSubmit($event)" role="search" aria-label="Recherche">
|
<!-- Barre de recherche.
|
||||||
<!-- Input row -->
|
- combobox ARIA : le panneau de suggestions est la listbox contrôlée par
|
||||||
<div class="flex items-center gap-2 rounded-lg ring-1 ring-slate-600/50 bg-slate-800/70 px-2 py-1.5 sm:px-3 sm:py-2">
|
l'input (aria-expanded / aria-activedescendant) ;
|
||||||
<!-- Provider chips: only when there is genuinely room for them (>= lg).
|
- `focusin` / `focusout` sur le <form> : le panneau se referme dès que le
|
||||||
On smaller screens the "@" picker below is the equivalent entry point. -->
|
focus sort de la barre (clic ailleurs, Tab au-delà, perte de focus) ;
|
||||||
<div class="hidden lg:flex items-center gap-1.5 shrink-0">
|
- `pointerdown` neutralisé sur les panneaux : cliquer une suggestion ne
|
||||||
<button type="button"
|
vole pas le focus à l'input (sinon la fermeture au blur se déclenche). -->
|
||||||
(click)="toggleAll()"
|
<form #searchForm
|
||||||
class="chip text-xs px-2.5 rounded border transition-colors"
|
class="w-full min-w-0 relative"
|
||||||
[ngClass]="{
|
novalidate
|
||||||
'bg-slate-200 text-slate-800 border-slate-300': providerMode()==='all',
|
(submit)="onSubmit($event)"
|
||||||
'bg-slate-700 text-slate-100 border-slate-600': providerMode()!=='all'
|
(focusin)="onFocusIn($event)"
|
||||||
}"
|
(focusout)="onFocusOut($event)"
|
||||||
[attr.aria-pressed]="providerMode()==='all'"
|
role="search"
|
||||||
title="Tous les fournisseurs">
|
aria-label="Recherche">
|
||||||
All
|
<!-- Ancre des panneaux : ils se positionnent juste sous le champ, pas sous
|
||||||
</button>
|
les pastilles de filtres. -->
|
||||||
<ng-container *ngFor="let c of chips; let i = index">
|
<div class="relative">
|
||||||
<button type="button" (click)="toggleChip(c.id)" (keydown.space)="$event.preventDefault(); toggleChip(c.id)" (keydown.enter)="$event.preventDefault(); toggleChip(c.id)" tabindex="0" role="button"
|
<div class="flex items-center gap-2 rounded-lg ring-1 ring-slate-600/50 bg-slate-800/70 px-2 py-1.5 sm:px-3 sm:py-2"
|
||||||
class="chip text-xs px-2.5 rounded border transition-colors"
|
[class.ring-2]="suggestionsOpen() || atOpen()"
|
||||||
[ngClass]="{
|
[class.ring-red-500]="suggestionsOpen() || atOpen()">
|
||||||
'bg-slate-700 text-slate-100 border-slate-600': !(selected().includes(c.id)),
|
|
||||||
'bg-slate-200 text-slate-800 border-slate-300': selected().includes(c.id)
|
|
||||||
}"
|
|
||||||
[attr.aria-pressed]="selected().includes(c.id)"
|
|
||||||
[title]="'Inclure ' + c.label + ' (Alt+' + (i + 1) + ')'">
|
|
||||||
<span [class]="c.colorClass">{{ c.label }}</span>
|
|
||||||
</button>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Text input: combobox pattern for the inline '@' autocomplete -->
|
<!-- Champ texte -->
|
||||||
<input #qInput type="search"
|
<input #qInput type="search"
|
||||||
class="min-w-0 flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-1 sm:px-2 py-2"
|
class="min-w-0 flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-1 sm:px-2 py-2"
|
||||||
[placeholder]="placeholder"
|
[placeholder]="placeholder"
|
||||||
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)" (keydown.enter)="onSubmit($event)" (focus)="onSuggestInputFocus()" name="q"
|
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)"
|
||||||
autocomplete="off" aria-label="Rechercher des vidéos" enterkeyhint="search"
|
(focus)="onSuggestInputFocus()"
|
||||||
role="combobox" aria-expanded="false" aria-autocomplete="list"
|
name="q"
|
||||||
|
autocomplete="off" autocapitalize="off" spellcheck="false"
|
||||||
|
aria-label="Rechercher des vidéos" enterkeyhint="search"
|
||||||
|
role="combobox" aria-autocomplete="list" aria-haspopup="listbox"
|
||||||
[attr.aria-expanded]="atOpen() || suggestionsOpen()"
|
[attr.aria-expanded]="atOpen() || suggestionsOpen()"
|
||||||
[attr.aria-controls]="atOpen() ? 'atOptionsList' : (suggestionsOpen() ? 'sgOptionsList' : null)"
|
[attr.aria-controls]="atOpen() ? 'atOptionsList' : (suggestionsOpen() ? 'sgOptionsList' : null)"
|
||||||
[attr.aria-activedescendant]="atOpen() && atOptions().length > 0 ? 'atOption-' + atIndex() : (suggestionsOpen() && suggestIndex() >= 0 ? 'sgOption-' + suggestIndex() : null)"/>
|
[attr.aria-activedescendant]="atOpen() && atOptions().length > 0
|
||||||
|
? 'atOption-' + atIndex()
|
||||||
|
: (suggestionsOpen() && suggestIndex() >= 0 ? 'sgOption-' + suggestIndex() : null)"/>
|
||||||
|
|
||||||
<!-- Provider picker trigger: shows the active selection as a summary so the
|
<!-- Effacer la saisie -->
|
||||||
user always knows where they are searching. -->
|
<button *ngIf="(query() || '').length > 0" type="button" (click)="clearQuery()"
|
||||||
<button type="button" (click)="openPicker()"
|
(pointerdown)="onPanelPointerDown($event)"
|
||||||
class="chip shrink-0 text-xs font-medium px-2.5 rounded bg-slate-700 text-slate-100 hover:bg-slate-600"
|
class="chip shrink-0 rounded p-1.5 text-slate-400 hover:bg-slate-700 hover:text-slate-100"
|
||||||
|
aria-label="Effacer la recherche">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41Z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Bouton filtres (sources + type + période + durée + tri).
|
||||||
|
Raccourcis : Ctrl/Cmd+K ou Ctrl/Cmd+Maj+F. -->
|
||||||
|
<button type="button" (click)="togglePanel()"
|
||||||
|
(pointerdown)="onPanelPointerDown($event)"
|
||||||
|
class="chip relative inline-flex shrink-0 items-center gap-1.5 rounded bg-slate-700 px-2.5 py-1.5 text-xs font-medium text-slate-100 hover:bg-slate-600"
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
[attr.aria-label]="'Fournisseurs : ' + providerSummary()">
|
[attr.aria-expanded]="panelOpen()"
|
||||||
<span class="max-w-[5.5rem] truncate lg:hidden">{{ providerSummary() }}</span>
|
[attr.aria-label]="'Filtres de recherche' + (activeFilterCount() > 0 ? ' (' + activeFilterCount() + ' actifs)' : '')"
|
||||||
<span class="hidden lg:inline" aria-hidden="true">@</span>
|
[title]="'Filtres : ' + filterSummary() + ' (Ctrl+Maj+F)'">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M3 17v2h6v-2H3ZM3 5v2h10V5H3Zm10 16v-2h8v-2h-8v-2h-2v6h2ZM7 9v2H3v2h4v2h2V9H7Zm14 4v-2H11v2h10Zm-6-4h2V7h4V5h-4V3h-2v6Z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Filtres</span>
|
||||||
|
<span *ngIf="activeFilterCount() > 0"
|
||||||
|
class="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-bold text-white">
|
||||||
|
{{ activeFilterCount() }}
|
||||||
|
</span>
|
||||||
|
<span *ngIf="providerCount() > 0"
|
||||||
|
class="hidden rounded bg-slate-600 px-1 text-[10px] font-bold text-slate-100 lg:inline"
|
||||||
|
[title]="'Sources sélectionnées'">{{ providerCount() }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="submit" class="chip ml-auto shrink-0 inline-flex items-center gap-1.5 rounded bg-red-600 px-3 text-sm text-white hover:bg-red-500"
|
<button type="submit" class="chip ml-auto shrink-0 inline-flex items-center gap-1.5 rounded bg-red-600 px-3 text-sm text-white hover:bg-red-500"
|
||||||
(click)="onSubmit($event)"
|
(click)="onSubmit($event)"
|
||||||
[attr.title]="(query() || '').trim().length < 2 ? 'Tapez au moins 2 caractères' : null">
|
[attr.title]="(query() || '').trim().length < 2 ? 'Tapez au moins 2 caractères' : 'Rechercher (Entrée)'">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 lg:hidden" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
<path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5Zm-6 0A4.5 4.5 0 1 1 14 9.5 4.49 4.49 0 0 1 9.5 14Z"/>
|
<path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5Zm-6 0A4.5 4.5 0 1 1 14 9.5 4.49 4.49 0 0 1 9.5 14Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="hidden lg:inline">Search</span>
|
<span class="hidden lg:inline">Rechercher</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Provider Picker Modal -->
|
<!-- Panneaux ancrés sous le champ (avant les pastilles de filtres) -->
|
||||||
<app-provider-picker *ngIf="pickerOpen()"
|
|
||||||
[open]="pickerOpen()"
|
|
||||||
[selected]="providersForRequest()"
|
|
||||||
(close)="closePicker()"
|
|
||||||
(apply)="applyPicker($event)"></app-provider-picker>
|
|
||||||
|
|
||||||
<!-- Inline @autocomplete popover -->
|
<!-- Autocomplétion des opérateurs (`linux live:`) -->
|
||||||
<div *ngIf="atOpen() && atOptions().length > 0"
|
<div *ngIf="operatorSuggestions().length > 0"
|
||||||
class="absolute z-50 mt-1 left-0 right-0 max-h-[60dvh] overflow-y-auto rounded-lg shadow-xl"
|
class="absolute z-40 mt-1 left-0 right-0 rounded-lg border border-slate-700 bg-slate-900 p-1 shadow-xl"
|
||||||
style="top: 100%">
|
style="top: 100%">
|
||||||
<ul id="atOptionsList" role="listbox" aria-label="Fournisseurs"
|
<button *ngFor="let op of operatorSuggestions()" type="button"
|
||||||
class="max-h-[60dvh] overflow-y-auto"
|
(click)="applyOperatorToken(op)"
|
||||||
[ngClass]="{ 'bg-slate-900 border border-slate-700 text-slate-200': true }">
|
(pointerdown)="onPanelPointerDown($event)"
|
||||||
|
class="w-full rounded px-3 py-2 text-left font-mono text-xs text-slate-300 hover:bg-slate-800">
|
||||||
|
{{ op }}:
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Popover « @provider » (reste prioritaire sur les suggestions) -->
|
||||||
|
<div *ngIf="atOpen() && atOptions().length > 0"
|
||||||
|
class="absolute z-50 mt-1 left-0 right-0 max-h-[60dvh] overflow-y-auto rounded-lg border border-slate-700 bg-slate-900 text-slate-200 shadow-xl"
|
||||||
|
style="top: 100%"
|
||||||
|
(pointerdown)="onPanelPointerDown($event)">
|
||||||
|
<ul id="atOptionsList" role="listbox" aria-label="Fournisseurs">
|
||||||
<li *ngFor="let opt of atOptions(); let i = index" role="option"
|
<li *ngFor="let opt of atOptions(); let i = index" role="option"
|
||||||
[attr.aria-selected]="i === atIndex()"
|
[attr.aria-selected]="i === atIndex()"
|
||||||
[id]="'atOption-' + i">
|
[id]="'atOption-' + i">
|
||||||
<button type="button" (click)="applyAtOption(opt.id)"
|
<button type="button" (click)="applyAtOption(opt.id)"
|
||||||
class="w-full text-left px-3 py-3 text-sm"
|
(mouseenter)="atIndex.set(i)"
|
||||||
[ngClass]="{
|
(keydown)="onAtOptionKeydown($event, i)"
|
||||||
'bg-slate-800': i === atIndex(),
|
class="w-full px-3 py-3 text-left text-sm"
|
||||||
'hover:bg-slate-800': i !== atIndex()
|
[ngClass]="{ 'bg-slate-800': i === atIndex(), 'hover:bg-slate-800': i !== atIndex() }">
|
||||||
}">
|
|
||||||
@ {{ opt.label }}
|
@ {{ opt.label }}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 15: query typeahead panel (hidden while the @ popover is open) -->
|
<!-- Panneau de suggestions : ligne 0 = « Rechercher <q> », puis les suggestions -->
|
||||||
<div *ngIf="suggestionsOpen() && !atOpen() && suggestFlat().length > 0"
|
<div *ngIf="suggestionsOpen() && !atOpen() && suggestFlat().length > 0"
|
||||||
class="absolute z-40 mt-1 left-0 right-0 max-h-[60dvh] overflow-y-auto rounded-lg shadow-xl bg-slate-900 border border-slate-700 text-slate-200"
|
class="absolute z-40 mt-1 left-0 right-0 max-h-[60dvh] overflow-y-auto rounded-lg border border-slate-700 bg-slate-900 text-slate-200 shadow-xl"
|
||||||
style="top: 100%">
|
style="top: 100%"
|
||||||
<ul id="sgOptionsList" role="listbox" aria-label="Suggestions de recherche"
|
(pointerdown)="onPanelPointerDown($event)">
|
||||||
class="max-h-[60dvh] overflow-y-auto py-1">
|
<ul id="sgOptionsList" role="listbox" aria-label="Suggestions de recherche" class="py-1">
|
||||||
|
<li *ngIf="(query() || '').trim().length >= 2" role="option"
|
||||||
|
[attr.aria-selected]="suggestIndex() === 0"
|
||||||
|
id="sgOption-0"
|
||||||
|
class="border-b border-slate-800">
|
||||||
|
<button type="button" (click)="searchAsTyped()"
|
||||||
|
(mouseenter)="suggestIndex.set(0)"
|
||||||
|
(keydown)="onSuggestOptionKeydown($event, 0)"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm"
|
||||||
|
[ngClass]="suggestIndex() === 0 ? 'bg-slate-800' : 'hover:bg-slate-800'">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-slate-400" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5Z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="min-w-0 flex-1 truncate">
|
||||||
|
Rechercher <span class="font-semibold text-slate-100">{{ query() }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li *ngFor="let item of suggestFlat(); let i = index" role="option"
|
<li *ngFor="let item of suggestFlat(); let i = index" role="option"
|
||||||
[attr.aria-selected]="i === suggestIndex()"
|
[attr.aria-selected]="suggestIndex() === i + 1"
|
||||||
[id]="'sgOption-' + i">
|
[id]="'sgOption-' + (i + 1)">
|
||||||
<button type="button" (click)="applySuggestion(item.text, true)"
|
<button type="button" (click)="applySuggestion(item.text, true)"
|
||||||
(mouseenter)="suggestIndex.set(i)"
|
(mouseenter)="suggestIndex.set(i + 1)"
|
||||||
class="w-full flex items-center gap-2 text-left px-3 py-3 text-sm">
|
(keydown)="onSuggestOptionKeydown($event, i + 1)"
|
||||||
<span *ngIf="!item.provider" class="text-slate-500 shrink-0" title="Recherche récente">🕘</span>
|
class="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm"
|
||||||
<span *ngIf="item.provider" class="shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded bg-slate-700 text-slate-300">{{ providerBadge(item.provider) }}</span>
|
[ngClass]="suggestIndex() === i + 1 ? 'bg-slate-800' : 'hover:bg-slate-800'">
|
||||||
<span class="min-w-0 flex-1 truncate" [title]="item.text"><span>{{ suggestHighlight(item.text).pre }}</span><mark *ngIf="suggestHighlight(item.text).match" class="bg-transparent text-inherit font-semibold underline decoration-red-500">{{ suggestHighlight(item.text).match }}</mark><span>{{ suggestHighlight(item.text).post }}</span></span>
|
<span *ngIf="item.kind === 'recent'" class="shrink-0 text-slate-500" title="Recherche récente" aria-hidden="true">🕘</span>
|
||||||
|
<span *ngIf="item.kind === 'provider'" class="shrink-0 rounded bg-slate-700 px-1.5 py-0.5 text-[10px] font-bold text-slate-300">{{ providerBadge(item.provider) }}</span>
|
||||||
|
<span *ngIf="item.kind === 'local'" class="shrink-0 text-slate-600" title="Complétion" aria-hidden="true">+</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate" [title]="item.text">
|
||||||
|
<span>{{ suggestHighlight(item.text).pre }}</span><mark *ngIf="suggestHighlight(item.text).match" class="bg-transparent font-semibold text-inherit underline decoration-red-500">{{ suggestHighlight(item.text).match }}</mark><span>{{ suggestHighlight(item.text).post }}</span>
|
||||||
|
</span>
|
||||||
|
<span *ngIf="suggestIndex() === i + 1" class="hidden shrink-0 text-[10px] text-slate-500 sm:inline">Entrée</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div *ngIf="suggestLoading()" class="px-3 py-2 text-xs text-slate-500 border-t border-slate-800">Chargement…</div>
|
<div class="flex items-center justify-between gap-2 border-t border-slate-800 px-3 py-1.5 text-[11px] text-slate-500">
|
||||||
</div>
|
<span *ngIf="suggestLoading()">Chargement des suggestions…</span>
|
||||||
|
<span *ngIf="!suggestLoading()">↑ ↓ naviguer · Entrée valider · Échap fermer</span>
|
||||||
<!-- Quick Menu (Ctrl/Cmd+K) -->
|
|
||||||
<div *ngIf="quickOpen()" class="quick-menu-backdrop" role="presentation" (click)="closeQuickMenu()">
|
|
||||||
<div #quickDialog class="quick-menu-dialog" role="dialog" aria-modal="true" aria-labelledby="quickMenuTitle" (click)="$event.stopPropagation()" (keydown)="onQuickMenuKeydown($event)">
|
|
||||||
<div class="p-3 border-b border-slate-700 flex items-center justify-between gap-3">
|
|
||||||
<h2 id="quickMenuTitle" class="text-slate-100 font-semibold">Quick Menu</h2>
|
|
||||||
<button type="button" (click)="closeQuickMenu()" class="chip px-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" aria-label="Fermer le menu (Esc)">Esc</button>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 overflow-y-auto min-h-0 flex-1">
|
|
||||||
<!-- History column -->
|
|
||||||
<div class="p-4 md:border-r md:border-slate-700">
|
|
||||||
<div class="text-slate-300 text-sm mb-2">History</div>
|
|
||||||
<ul class="space-y-1 max-h-64 overflow-y-auto">
|
|
||||||
<li *ngFor="let it of recentSearches()">
|
|
||||||
<button type="button" class="w-full text-left px-3 py-3 rounded hover:bg-slate-800 text-slate-200 break-anywhere"
|
|
||||||
(click)="queryUpdate(it.query || ''); closeQuickMenu()">{{ it.query }}</button>
|
|
||||||
</li>
|
|
||||||
<li *ngIf="(recentSearches()?.length || 0) === 0" class="text-slate-500 text-sm">No recent searches.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<!-- Providers column -->
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="flex items-center justify-between gap-3 mb-2">
|
|
||||||
<div class="text-slate-300 text-sm">Providers</div>
|
|
||||||
<button type="button" class="chip text-xs px-3 rounded bg-slate-700 hover:bg-slate-600 text-slate-100"
|
|
||||||
(click)="quickToggleAll()">All</button>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
||||||
<label *ngFor="let c of chips" class="flex items-center gap-2 px-3 py-3 rounded border border-slate-700 bg-slate-800 text-slate-100">
|
|
||||||
<input type="checkbox" [checked]="selected().includes(c.id)" (change)="quickToggle(c.id)"/>
|
|
||||||
<span class="text-sm truncate" [class]="c.colorClass">{{ c.label }}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 text-right">
|
|
||||||
<button type="button" class="w-full sm:w-auto px-4 py-3 rounded bg-red-600 hover:bg-red-500 text-white"
|
|
||||||
(click)="applyQuickMenu({ selection: selected().length ? selected() : 'all', rememberDefault: false })">Apply filters</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div><!-- /ancre des panneaux -->
|
||||||
|
|
||||||
|
<!-- Pastilles des filtres actifs : rappel visuel + retrait en un clic -->
|
||||||
|
<div *ngIf="activeFilterChips().length > 0 || providerCount() > 0"
|
||||||
|
class="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||||
|
<span *ngIf="providerCount() > 0" class="text-[11px] text-slate-400">Sources :</span>
|
||||||
|
<ng-container *ngIf="providerCount() > 0">
|
||||||
|
<span *ngFor="let pid of selected()"
|
||||||
|
class="chip inline-flex items-center gap-1 rounded-full border border-slate-600 bg-slate-800 px-2 py-0.5 text-[11px] text-slate-200">
|
||||||
|
{{ providerBadge(pid) }}
|
||||||
|
<button type="button" (click)="toggleChip(pid)"
|
||||||
|
(pointerdown)="onPanelPointerDown($event)"
|
||||||
|
class="rounded-full px-1 text-slate-400 hover:text-red-400" [attr.aria-label]="'Retirer ' + pid">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngFor="let chip of activeFilterChips()">
|
||||||
|
<button type="button" (click)="removeFilter(chip.key)"
|
||||||
|
(pointerdown)="onPanelPointerDown($event)"
|
||||||
|
class="chip inline-flex items-center gap-1 rounded-full border border-red-500/60 bg-red-600/20 px-2 py-0.5 text-[11px] text-red-200 hover:bg-red-600/40"
|
||||||
|
[attr.aria-label]="'Retirer le filtre ' + chip.label">
|
||||||
|
{{ chip.label }} <span aria-hidden="true">✕</span>
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
<button *ngIf="activeFilterChips().length > 0" type="button" (click)="clearAllFilters()"
|
||||||
|
(pointerdown)="onPanelPointerDown($event)"
|
||||||
|
class="chip rounded-full px-2 py-0.5 text-[11px] text-slate-400 hover:text-slate-200 underline">
|
||||||
|
Tout effacer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panneau de filtres (dialog) -->
|
||||||
|
<app-search-filter-panel *ngIf="panelOpen()"
|
||||||
|
#filterPanel
|
||||||
|
[open]="panelOpen()"
|
||||||
|
[selected]="providersForRequest()"
|
||||||
|
[filters]="filters()"
|
||||||
|
[query]="query()"
|
||||||
|
[history]="historyQueries()"
|
||||||
|
(close)="closePanel()"
|
||||||
|
(apply)="applyPanel($event)"
|
||||||
|
(pickHistory)="pickHistory($event)"></app-search-filter-panel>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Component, EventEmitter, Output, Input, signal, computed, effect, inject, HostListener, ViewChild, ElementRef } from '@angular/core';
|
import { Component, EventEmitter, Output, Input, signal, computed, effect, inject, HostListener, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registry';
|
import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registry';
|
||||||
import { ProviderPickerComponent } from './provider-picker.component';
|
import { SearchFilterPanelComponent, type FilterPanelApply } from './search-filter-panel.component';
|
||||||
import { SearchService } from '../../app/search/search.service';
|
import { SearchService } from '../../app/search/search.service';
|
||||||
import { UserService, type UserPreferences } from '../../services/user.service';
|
import { UserService, type UserPreferences } from '../../services/user.service';
|
||||||
import { HistoryService, type SearchHistoryItem } from '../../services/history.service';
|
import { HistoryService, type SearchHistoryItem } from '../../services/history.service';
|
||||||
@@ -12,14 +12,27 @@ import { debounceTime, distinctUntilChanged, switchMap, catchError, finalize } f
|
|||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { SuggestService } from '../../app/search/suggest.service';
|
import { SuggestService } from '../../app/search/suggest.service';
|
||||||
import { mergeGroups, highlightParts, dedupeStrings, buildLocalQuerySuggestions, normSuggest } from '../../app/search/suggest.util';
|
import { mergeGroups, highlightParts, dedupeStrings, buildLocalQuerySuggestions, normSuggest } from '../../app/search/suggest.util';
|
||||||
// No overlays; we render fixed-position modals with high z-index
|
import {
|
||||||
|
DEFAULT_FILTERS, activeFilterCount, filtersEqual, filtersSummary, filterGroups,
|
||||||
|
normalizeFilters, parseQueryTokens, operatorTokenSuggestions,
|
||||||
|
type SearchFilters,
|
||||||
|
} from '../../app/search/filters';
|
||||||
|
|
||||||
const AT_QUERY_RE = /(^|\s)@([a-z]{1,24})$/i;
|
const AT_QUERY_RE = /(^|\s)@([a-z]{1,24})$/i;
|
||||||
|
|
||||||
/** One row of the Step 15 typeahead panel (provider null = recent search). */
|
/** Une ligne du panneau de suggestions. */
|
||||||
export interface SuggestFlatItem {
|
export interface SuggestFlatItem {
|
||||||
text: string;
|
text: string;
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
|
/** 'recent' | 'provider' | 'local' — sert à l'icône et au tri d'affichage. */
|
||||||
|
kind: 'recent' | 'provider' | 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload émis quand une recherche est lancée. */
|
||||||
|
export interface SearchSubmit {
|
||||||
|
q: string;
|
||||||
|
providers: ProviderId[] | 'all';
|
||||||
|
filters: SearchFilters;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -30,14 +43,15 @@ export interface SuggestFlatItem {
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ProviderPickerComponent
|
SearchFilterPanelComponent
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
SearchService, // Fournir le service au niveau du composant
|
SearchService, // Fournir le service au niveau du composant
|
||||||
UserService, // S'assurer que UserService est fourni
|
UserService, // S'assurer que UserService est fourni
|
||||||
HistoryService // S'assurer que HistoryService est fourni
|
HistoryService // S'assurer que HistoryService est fourni
|
||||||
],
|
],
|
||||||
templateUrl: './search-box.component.html'
|
templateUrl: './search-box.component.html',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class SearchBoxComponent {
|
export class SearchBoxComponent {
|
||||||
/**
|
/**
|
||||||
@@ -53,11 +67,15 @@ export class SearchBoxComponent {
|
|||||||
// Optional: keeps unit tests (which don't provide it) working with a null guard
|
// Optional: keeps unit tests (which don't provide it) working with a null guard
|
||||||
private suggestSvc = inject(SuggestService, { optional: true });
|
private suggestSvc = inject(SuggestService, { optional: true });
|
||||||
@ViewChild('qInput', { static: false }) qInputRef?: ElementRef<HTMLInputElement>;
|
@ViewChild('qInput', { static: false }) qInputRef?: ElementRef<HTMLInputElement>;
|
||||||
@ViewChild('quickDialog', { static: false }) quickDialogRef?: ElementRef<HTMLDivElement>;
|
@ViewChild('searchForm', { static: false }) formRef?: ElementRef<HTMLFormElement>;
|
||||||
|
@ViewChild('filterPanel', { static: false }) panelRef?: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
// Input/Output bindings
|
// Input/Output bindings
|
||||||
@Input() placeholder: string = 'Search videos…';
|
@Input() placeholder: string = 'Search videos…';
|
||||||
@Output() submitted = new EventEmitter<{ q: string; providers: ProviderId[] | 'all' }>();
|
@Output() submitted = new EventEmitter<SearchSubmit>();
|
||||||
|
/** Émis quand les filtres changent (sans recherche) — la page /search peut
|
||||||
|
* réécrire l'URL pour rester partageable. */
|
||||||
|
@Output() filtersChange = new EventEmitter<SearchFilters>();
|
||||||
// Emits debounced query changes for autosuggest
|
// Emits debounced query changes for autosuggest
|
||||||
@Output() searchChange = new EventEmitter<string>();
|
@Output() searchChange = new EventEmitter<string>();
|
||||||
|
|
||||||
@@ -65,8 +83,11 @@ export class SearchBoxComponent {
|
|||||||
query = signal('');
|
query = signal('');
|
||||||
providerMode = signal<'all' | 'custom'>('all');
|
providerMode = signal<'all' | 'custom'>('all');
|
||||||
selected = signal<ProviderId[]>([]);
|
selected = signal<ProviderId[]>([]);
|
||||||
pickerOpen = signal(false);
|
/** Panneau de filtres (remplace l'ancien sélecteur de providers). */
|
||||||
|
panelOpen = signal(false);
|
||||||
suggestionsOpen = signal(false);
|
suggestionsOpen = signal(false);
|
||||||
|
/** Filtres courants (type / durée / période / tri). */
|
||||||
|
filters = signal<SearchFilters>({ ...DEFAULT_FILTERS });
|
||||||
// Inline @autocomplete state (atOpen is derived from the query, atOptions is pure)
|
// Inline @autocomplete state (atOpen is derived from the query, atOptions is pure)
|
||||||
atOpen = signal(false);
|
atOpen = signal(false);
|
||||||
atIndex = signal(0);
|
atIndex = signal(0);
|
||||||
@@ -78,32 +99,40 @@ export class SearchBoxComponent {
|
|||||||
.filter(p => p.shortLabel.toLowerCase().includes(term) || p.displayName.toLowerCase().includes(term))
|
.filter(p => p.shortLabel.toLowerCase().includes(term) || p.displayName.toLowerCase().includes(term))
|
||||||
.map(p => ({ id: p.id, label: p.displayName }));
|
.map(p => ({ id: p.id, label: p.displayName }));
|
||||||
});
|
});
|
||||||
// Quick menu modal (Ctrl/Cmd+K)
|
|
||||||
quickOpen = signal(false);
|
|
||||||
recentSearches = signal<SearchHistoryItem[]>([]);
|
recentSearches = signal<SearchHistoryItem[]>([]);
|
||||||
|
|
||||||
// Step 15 — query typeahead (suggestions de requêtes, avant validation).
|
// Step 15 — query typeahead (suggestions de requêtes, avant validation).
|
||||||
// `suggestionsOpen` doubles as the typeahead panel flag (closed when @ popover opens).
|
|
||||||
suggestLoading = signal(false);
|
suggestLoading = signal(false);
|
||||||
suggestGroups = signal<Record<string, string[]>>({});
|
suggestGroups = signal<Record<string, string[]>>({});
|
||||||
recentSuggest = signal<string[]>([]);
|
recentSuggest = signal<string[]>([]);
|
||||||
suggestIndex = signal(-1);
|
suggestIndex = signal(-1);
|
||||||
|
/** Rappelé après chaque frappe : sert à détecter une perte de focus. */
|
||||||
|
private blurTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private allRecentCache: string[] = [];
|
private allRecentCache: string[] = [];
|
||||||
private lastRecentFetch = 0;
|
private lastRecentFetch = 0;
|
||||||
private suggestShownFor: string | null = null;
|
private suggestShownFor: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panneau de suggestions : historique → suggestions providers → complétions
|
||||||
|
* locales. Toujours trié « ce qui commence par la frappe d'abord », et jamais
|
||||||
|
* la requête brute elle-même (sinon Entrée ne ferait rien).
|
||||||
|
*/
|
||||||
readonly suggestFlat = computed((): SuggestFlatItem[] => {
|
readonly suggestFlat = computed((): SuggestFlatItem[] => {
|
||||||
const q = this.query();
|
const q = this.query();
|
||||||
// Tier 1 — recherches récentes (dédupliquées, une seule fois chacune).
|
// Tier 1 — recherches récentes (dédupliquées, une seule fois chacune).
|
||||||
const recents = (this.recentSuggest() || []).slice(0, 5).map((t) => ({ text: t, provider: null as string | null }));
|
const recents: SuggestFlatItem[] = (this.recentSuggest() || []).slice(0, 5)
|
||||||
|
.map((t) => ({ text: t, provider: null as string | null, kind: 'recent' as const }));
|
||||||
const seen = new Set(recents.map((r) => normSuggest(r.text)));
|
const seen = new Set(recents.map((r) => normSuggest(r.text)));
|
||||||
// Tier 2 — suggestions providers, dédupliquées entre providers (mergeGroups)
|
// Tier 2 — suggestions providers, dédupliquées entre providers (mergeGroups)
|
||||||
// et contre l'historique.
|
// et contre l'historique.
|
||||||
const merged = mergeGroups(this.suggestGroups() || {}).filter((m) => {
|
const merged: SuggestFlatItem[] = mergeGroups(this.suggestGroups() || {})
|
||||||
const k = normSuggest(m.text);
|
.filter((m) => {
|
||||||
if (seen.has(k)) return false;
|
const k = normSuggest(m.text);
|
||||||
seen.add(k);
|
if (seen.has(k)) return false;
|
||||||
return true;
|
seen.add(k);
|
||||||
});
|
return true;
|
||||||
|
})
|
||||||
|
.map((m) => ({ text: m.text, provider: m.provider, kind: 'provider' as const }));
|
||||||
// Tri « style YouTube/Google » : les complétions qui commencent par la
|
// Tri « style YouTube/Google » : les complétions qui commencent par la
|
||||||
// frappe d'abord, puis le reste (tri stable : l'ordre providers est gardé).
|
// frappe d'abord, puis le reste (tri stable : l'ordre providers est gardé).
|
||||||
const nq = normSuggest(q);
|
const nq = normSuggest(q);
|
||||||
@@ -123,7 +152,7 @@ export class SearchBoxComponent {
|
|||||||
const k = normSuggest(t);
|
const k = normSuggest(t);
|
||||||
if (seen.has(k)) continue;
|
if (seen.has(k)) continue;
|
||||||
seen.add(k);
|
seen.add(k);
|
||||||
out.push({ text: t, provider: null });
|
out.push({ text: t, provider: null, kind: 'local' });
|
||||||
if (out.length >= 15) break;
|
if (out.length >= 15) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,7 +163,6 @@ export class SearchBoxComponent {
|
|||||||
// Element to restore focus to when a modal closes (a11y)
|
// Element to restore focus to when a modal closes (a11y)
|
||||||
private lastFocused: HTMLElement | null = null;
|
private lastFocused: HTMLElement | null = null;
|
||||||
|
|
||||||
readonly chips = PROVIDERS.map(p => ({ id: p.id, label: p.shortLabel, colorClass: p.colorClass }));
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Focus the query input programmatically (Ctrl/Cmd+K, `/`, mobile search
|
* Focus the query input programmatically (Ctrl/Cmd+K, `/`, mobile search
|
||||||
@@ -149,21 +177,29 @@ export class SearchBoxComponent {
|
|||||||
return this.providerMode() === 'all' ? 'all' : (this.selected().length ? this.selected() : 'all');
|
return this.providerMode() === 'all' ? 'all' : (this.selected().length ? this.selected() : 'all');
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
readonly activeFilterCount = computed(() => activeFilterCount(this.filters()));
|
||||||
* Short human label for the current provider selection. On phones the chip
|
/** Résumé court affiché dans le bouton (mobile). */
|
||||||
* row is hidden, so this label is the only feedback the user gets about
|
readonly filterSummary = computed(() => filtersSummary(this.filters()));
|
||||||
* where the search will be routed.
|
readonly hasActiveFilters = computed(() => this.activeFilterCount() > 0);
|
||||||
*/
|
/** Pastilles des filtres actifs, pour les rappeler sous la barre. */
|
||||||
readonly providerSummary = computed<string>(() => {
|
readonly activeFilterChips = computed(() => {
|
||||||
if (this.providerMode() === 'all') return 'All';
|
const n = normalizeFilters(this.filters());
|
||||||
const sel = this.selected();
|
const groups = filterGroups(n);
|
||||||
if (!sel.length) return 'All';
|
const out: { key: keyof SearchFilters; label: string; i18n: string }[] = [];
|
||||||
if (sel.length === 1) {
|
for (const g of groups) {
|
||||||
const found = PROVIDERS.find((p) => p.id === sel[0]);
|
const k = g.key as keyof SearchFilters;
|
||||||
return found ? found.shortLabel : String(sel[0]).toUpperCase();
|
if (n[k] === 'all' || (k === 'sort' && n.sort === 'relevance')) continue;
|
||||||
|
const opt = g.options.find(o => o.value === n[k]);
|
||||||
|
if (opt) out.push({ key: k, label: opt.label, i18n: opt.key });
|
||||||
}
|
}
|
||||||
return `${sel.length} src`;
|
return out;
|
||||||
});
|
});
|
||||||
|
/** Nombre de sources sélectionnées (pour la pastille du bouton). */
|
||||||
|
readonly providerCount = computed(() => {
|
||||||
|
if (this.providerMode() === 'all') return 0;
|
||||||
|
return this.selected().length;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Flag to avoid clobbering user input after initial hydration from SearchService
|
// Flag to avoid clobbering user input after initial hydration from SearchService
|
||||||
@@ -191,7 +227,9 @@ export class SearchBoxComponent {
|
|||||||
if (hydratedFromService) return; // only hydrate once
|
if (hydratedFromService) return; // only hydrate once
|
||||||
const q = this.search.q$.value;
|
const q = this.search.q$.value;
|
||||||
const prov = this.search.providers$.value;
|
const prov = this.search.providers$.value;
|
||||||
|
const filters = this.search.filters$?.value;
|
||||||
if (typeof q === 'string') this.query.set(q);
|
if (typeof q === 'string') this.query.set(q);
|
||||||
|
if (filters) this.filters.set(normalizeFilters(filters));
|
||||||
if (prov === 'all') {
|
if (prov === 'all') {
|
||||||
this.providerMode.set('all');
|
this.providerMode.set('all');
|
||||||
this.selected.set([]);
|
this.selected.set([]);
|
||||||
@@ -219,28 +257,26 @@ export class SearchBoxComponent {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Focus the first focusable element of the quick menu dialog when it opens (a11y)
|
// Lock body scroll when the filter panel is open (a11y UX)
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (this.quickOpen()) {
|
const open = this.panelOpen();
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
const root = this.quickDialogRef?.nativeElement;
|
|
||||||
const first = root?.querySelector<HTMLElement>('button');
|
|
||||||
(first || root)?.focus();
|
|
||||||
} catch {}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Lock body scroll when a modal is open (a11y UX)
|
|
||||||
effect(() => {
|
|
||||||
const open = this.quickOpen() || this.pickerOpen();
|
|
||||||
try {
|
try {
|
||||||
document.body.style.overflow = open ? 'hidden' : '';
|
document.body.style.overflow = open ? 'hidden' : '';
|
||||||
} catch {}
|
} catch {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Saisie
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Écrit dans l'input sans passer par ngModel (focus conservé). */
|
||||||
|
private setQueryValue(value: string): void {
|
||||||
|
this.query.set(value);
|
||||||
|
try { this.search.q$.next(value); } catch {}
|
||||||
|
try { const el = this.qInputRef?.nativeElement; if (el) el.value = value; } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
// Update query and refresh inline '@provider' autocomplete state
|
// Update query and refresh inline '@provider' autocomplete state
|
||||||
queryUpdate(value: string) {
|
queryUpdate(value: string) {
|
||||||
this.query.set(value);
|
this.query.set(value);
|
||||||
@@ -260,6 +296,17 @@ export class SearchBoxComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Efface l'input (croix du champ) et refocus. */
|
||||||
|
clearQuery(): void {
|
||||||
|
this.queryUpdate('');
|
||||||
|
this.setQueryValue('');
|
||||||
|
this.focusInputSafe();
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusInputSafe(): void {
|
||||||
|
try { this.qInputRef?.nativeElement?.focus({ preventScroll: true }); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Step 15 helpers ---
|
// --- Step 15 helpers ---
|
||||||
|
|
||||||
private fetchSuggest(value: string) {
|
private fetchSuggest(value: string) {
|
||||||
@@ -279,8 +326,7 @@ export class SearchBoxComponent {
|
|||||||
private applySuggestResponse(res: { q: string; groups: Record<string, string[]> } | null) {
|
private applySuggestResponse(res: { q: string; groups: Record<string, string[]> } | null) {
|
||||||
if (!res) {
|
if (!res) {
|
||||||
this.suggestGroups.set({});
|
this.suggestGroups.set({});
|
||||||
this.suggestionsOpen.set(false);
|
this.closeSuggestions();
|
||||||
this.suggestIndex.set(-1);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.suggestGroups.set(res.groups || {});
|
this.suggestGroups.set(res.groups || {});
|
||||||
@@ -295,11 +341,16 @@ export class SearchBoxComponent {
|
|||||||
this.telemetry.track('suggest_shown', { queryLength: key.length, resultCount: flat.length });
|
this.telemetry.track('suggest_shown', { queryLength: key.length, resultCount: flat.length });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.suggestionsOpen.set(false);
|
this.closeSuggestions();
|
||||||
this.suggestIndex.set(-1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Referme le panneau de suggestions et annule la surbrillance. */
|
||||||
|
closeSuggestions(): void {
|
||||||
|
this.suggestionsOpen.set(false);
|
||||||
|
this.suggestIndex.set(-1);
|
||||||
|
}
|
||||||
|
|
||||||
/** Filter cached history synchronously; refresh the cache at most every 10s. */
|
/** Filter cached history synchronously; refresh the cache at most every 10s. */
|
||||||
private refreshRecentSuggest(value: string) {
|
private refreshRecentSuggest(value: string) {
|
||||||
const q = String(value || '').trim().toLowerCase();
|
const q = String(value || '').trim().toLowerCase();
|
||||||
@@ -332,24 +383,55 @@ export class SearchBoxComponent {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fill the input with a suggestion; submit=true also runs the search. */
|
/** Charge l'historique récent (affiché dans le panneau de filtres). */
|
||||||
|
loadRecentHistory(): void {
|
||||||
|
try {
|
||||||
|
this.history.getSearchHistory(12).subscribe({
|
||||||
|
next: (items) => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const clean: SearchHistoryItem[] = [];
|
||||||
|
for (const it of items || []) {
|
||||||
|
const k = normSuggest(it?.query || '');
|
||||||
|
if (!k || seen.has(k)) continue;
|
||||||
|
seen.add(k);
|
||||||
|
clean.push(it);
|
||||||
|
}
|
||||||
|
this.recentSearches.set(clean);
|
||||||
|
},
|
||||||
|
error: () => {},
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remplit l'input avec une suggestion ; submit=true lance aussi la recherche. */
|
||||||
applySuggestion(text: string, submit: boolean) {
|
applySuggestion(text: string, submit: boolean) {
|
||||||
const t = String(text || '').trim();
|
const t = String(text || '').trim();
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
const prov = this.providersForRequest();
|
const prov = this.providersForRequest();
|
||||||
this.telemetry.track('suggest_used', { providers: Array.isArray(prov) ? prov : ['all'], queryLength: t.length });
|
this.telemetry.track('suggest_used', { providers: Array.isArray(prov) ? prov : ['all'], queryLength: t.length });
|
||||||
this.suggestionsOpen.set(false);
|
this.closeSuggestions();
|
||||||
this.suggestIndex.set(-1);
|
this.setQueryValue(t);
|
||||||
this.query.set(t);
|
|
||||||
try { this.search.q$.next(t); } catch {}
|
|
||||||
try { this.qInputRef?.nativeElement && (this.qInputRef.nativeElement.value = t); } catch {}
|
|
||||||
if (submit) {
|
if (submit) {
|
||||||
this.submitted.emit({ q: t, providers: this.providersForRequest() });
|
this.runSearch(t);
|
||||||
this.telemetry.track('search_submit', { providers: Array.isArray(prov) ? prov : ['all'], queryLength: t.length });
|
|
||||||
} else {
|
} else {
|
||||||
this._suggestReq.next(t);
|
this._suggestReq.next(t);
|
||||||
|
// Complétion (Tab) : on rouvre immédiatement le panneau sur les
|
||||||
|
// suggestions locales, sans attendre le réseau.
|
||||||
|
if (this.suggestFlat().length > 0) {
|
||||||
|
this.suggestionsOpen.set(true);
|
||||||
|
this.suggestIndex.set(-1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
try { this.qInputRef?.nativeElement?.focus(); } catch {}
|
this.focusInputSafe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Première ligne du panneau : lance la recherche telle que saisie. */
|
||||||
|
searchAsTyped(): void {
|
||||||
|
const q = (this.query() || '').trim();
|
||||||
|
if (q.length < 2) return;
|
||||||
|
this.closeSuggestions();
|
||||||
|
this.runSearch(q);
|
||||||
|
this.focusInputSafe();
|
||||||
}
|
}
|
||||||
|
|
||||||
suggestHighlight(text: string): { pre: string; match: string; post: string } {
|
suggestHighlight(text: string): { pre: string; match: string; post: string } {
|
||||||
@@ -368,9 +450,158 @@ export class SearchBoxComponent {
|
|||||||
this.refreshRecentSuggest(this.query());
|
this.refreshRecentSuggest(this.query());
|
||||||
if (!this.atOpen() && this.suggestFlat().length > 0) {
|
if (!this.atOpen() && this.suggestFlat().length > 0) {
|
||||||
this.suggestionsOpen.set(true);
|
this.suggestionsOpen.set(true);
|
||||||
|
this.suggestIndex.set(-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Focus : le panneau ne vit que tant que le focus est dans la barre
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le focus sort de la barre (clic ailleurs, Tab au-delà, perte de focus
|
||||||
|
* fenêtre…) -> on referme les panneaux. `relatedTarget` couvre le Tab et le
|
||||||
|
* clic ; le timer couvre le cas où le navigateur ne fournit pas de cible
|
||||||
|
* (clic dans la zone vide de la page, perte de focus de la fenêtre).
|
||||||
|
*/
|
||||||
|
onFocusOut(ev: FocusEvent): void {
|
||||||
|
if (this.panelOpen()) return; // le panneau est un dialogue : il gère son focus
|
||||||
|
const next = ev?.relatedTarget as HTMLElement | null;
|
||||||
|
const host = this.formRef?.nativeElement;
|
||||||
|
if (next && host && host.contains(next)) return;
|
||||||
|
if (this.blurTimer) { clearTimeout(this.blurTimer); this.blurTimer = null; }
|
||||||
|
this.blurTimer = setTimeout(() => {
|
||||||
|
this.blurTimer = null;
|
||||||
|
try {
|
||||||
|
const active = document.activeElement as HTMLElement | null;
|
||||||
|
const root = this.formRef?.nativeElement;
|
||||||
|
if (active && root && root.contains(active)) return;
|
||||||
|
} catch {
|
||||||
|
// Pas de DOM (tests / SSR) : rien à vérifier, on ferme.
|
||||||
|
}
|
||||||
|
this.closeSuggestions();
|
||||||
|
this.atOpen.set(false);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le focus revient dans la barre (re-clic) : on annule le timer pour ne pas
|
||||||
|
* refermer le panneau qui vient d'être rouvert.
|
||||||
|
*/
|
||||||
|
onFocusIn(ev: FocusEvent): void {
|
||||||
|
if (this.blurTimer) { clearTimeout(this.blurTimer); this.blurTimer = null; }
|
||||||
|
void ev;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un clic dans le panneau ne doit pas voler le focus à l'input : on neutralise
|
||||||
|
* le `mousedown` (le bouton garde son état actif, l'input garde le focus).
|
||||||
|
*/
|
||||||
|
onPanelPointerDown(ev: MouseEvent): void {
|
||||||
|
try { ev.preventDefault(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Filtres
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
openPanel(): void {
|
||||||
|
this.saveFocus();
|
||||||
|
this.closeSuggestions();
|
||||||
|
this.atOpen.set(false);
|
||||||
|
this.loadRecentHistory();
|
||||||
|
this.panelOpen.set(true);
|
||||||
|
this.telemetry.track('filter_panel_open', { activeFilters: this.activeFilterCount() });
|
||||||
|
}
|
||||||
|
|
||||||
|
closePanel(): void {
|
||||||
|
if (!this.panelOpen()) return;
|
||||||
|
this.panelOpen.set(false);
|
||||||
|
this.restoreFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePanel(): void {
|
||||||
|
if (this.panelOpen()) this.closePanel();
|
||||||
|
else this.openPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Application du panneau : filtres + sources. */
|
||||||
|
applyPanel(payload: FilterPanelApply): void {
|
||||||
|
if (payload.selection === 'all') {
|
||||||
|
this.providerMode.set('all');
|
||||||
|
this.selected.set([]);
|
||||||
|
} else if (Array.isArray(payload.selection)) {
|
||||||
|
this.providerMode.set('custom');
|
||||||
|
this.selected.set(payload.selection);
|
||||||
|
}
|
||||||
|
const next = normalizeFilters(payload.filters);
|
||||||
|
const changed = !filtersEqual(next, this.filters());
|
||||||
|
this.filters.set(next);
|
||||||
|
if (payload.rememberDefault) {
|
||||||
|
const body: any = { defaultProviders: (payload.selection === 'all') ? [] : payload.selection };
|
||||||
|
try { this.users.updatePreferences(body).subscribe({ next: () => {}, error: () => {} }); } catch {}
|
||||||
|
}
|
||||||
|
const prov = this.providersForRequest();
|
||||||
|
this.telemetry.track('filter_apply', {
|
||||||
|
providers: Array.isArray(prov) ? prov : ['all'],
|
||||||
|
filters: next,
|
||||||
|
rememberDefault: payload.rememberDefault,
|
||||||
|
});
|
||||||
|
this.closePanel();
|
||||||
|
if (!changed) return;
|
||||||
|
this.applyFiltersAndMaybeSearch(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point unique après un changement de filtre : si une recherche est déjà
|
||||||
|
* saisie, on la relance (le parent navigue avec les filtres) ; sinon on
|
||||||
|
* n'émet que `filtersChange` pour que la page courante reste partageable.
|
||||||
|
* (Une seule navigation : les deux ne doivent pas se cumuler.)
|
||||||
|
*/
|
||||||
|
private applyFiltersAndMaybeSearch(next: SearchFilters): void {
|
||||||
|
this.filters.set(next);
|
||||||
|
const q = this.strippedQuery();
|
||||||
|
if (q.length >= 2) {
|
||||||
|
this.runSearch(q);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.filtersChange.emit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retirer une pastille de filtre (sous la barre). */
|
||||||
|
removeFilter(key: keyof SearchFilters): void {
|
||||||
|
const next = normalizeFilters({ ...this.filters(), [key]: key === 'sort' ? 'relevance' : 'all' });
|
||||||
|
if (filtersEqual(next, this.filters())) return;
|
||||||
|
this.applyFiltersAndMaybeSearch(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remise à zéro de tous les filtres (sans toucher aux sources). */
|
||||||
|
clearAllFilters(): void {
|
||||||
|
if (filtersEqual(this.filters(), DEFAULT_FILTERS)) return;
|
||||||
|
this.applyFiltersAndMaybeSearch({ ...DEFAULT_FILTERS });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recherches récentes (chaînes) affichées dans le panneau de filtres. */
|
||||||
|
historyQueries(): string[] {
|
||||||
|
return (this.recentSearches() || [])
|
||||||
|
.map((it) => String(it?.query || '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rappel d'une recherche depuis le panneau de filtres. */
|
||||||
|
pickHistory(text: string): void {
|
||||||
|
const t = String(text || '').trim();
|
||||||
|
if (!t) return;
|
||||||
|
this.closePanel();
|
||||||
|
this.queryUpdate(t);
|
||||||
|
this.setQueryValue(t);
|
||||||
|
this.runSearch(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fournisseurs (raccourcis / popover « @ »)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Pick an option from the inline '@' popover: strip the '@term' and toggle the chip
|
// Pick an option from the inline '@' popover: strip the '@term' and toggle the chip
|
||||||
applyAtOption(id: ProviderId) {
|
applyAtOption(id: ProviderId) {
|
||||||
const q = this.query();
|
const q = this.query();
|
||||||
@@ -382,7 +613,7 @@ export class SearchBoxComponent {
|
|||||||
this.atOpen.set(false);
|
this.atOpen.set(false);
|
||||||
this.toggleChip(id);
|
this.toggleChip(id);
|
||||||
this.telemetry.track('at_autocomplete_use', { providers: [id] });
|
this.telemetry.track('at_autocomplete_use', { providers: [id] });
|
||||||
try { this.qInputRef?.nativeElement?.focus(); } catch {}
|
this.focusInputSafe();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle All chip
|
// Toggle All chip
|
||||||
@@ -405,165 +636,259 @@ export class SearchBoxComponent {
|
|||||||
this.selected.set(Array.from(cur));
|
this.selected.set(Array.from(cur));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single keyboard handler for the whole form (input events bubble here)
|
// ---------------------------------------------------------------------------
|
||||||
|
// Clavier
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler clavier unique de la barre.
|
||||||
|
*
|
||||||
|
* Priorité : 1) popover « @ », 2) panneau de suggestions, 3) surbrillance
|
||||||
|
* d'une option du panneau de filtres (bouton focused), 4) raccourcis globaux.
|
||||||
|
* Entrée n'est jamais traitée deux fois : le formulaire n'a qu'un seul
|
||||||
|
* `keydown` (le `(keydown.enter)` d'avant a été supprimé).
|
||||||
|
*/
|
||||||
handleKeydown(ev: KeyboardEvent) {
|
handleKeydown(ev: KeyboardEvent) {
|
||||||
// Inline @autocomplete navigation (highest priority)
|
// --- 1. Popover « @provider » (priorité maximale) ---
|
||||||
if (this.atOpen()) {
|
if (this.atOpen()) {
|
||||||
const opts = this.atOptions();
|
const opts = this.atOptions();
|
||||||
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.atIndex.set(Math.min(opts.length - 1, this.atIndex() + 1)); return; }
|
const last = Math.max(0, opts.length - 1);
|
||||||
if (ev.key === 'ArrowUp') { ev.preventDefault(); this.atIndex.set(Math.max(0, this.atIndex() - 1)); return; }
|
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.atIndex.set(Math.min(last, this.atIndex() + 1)); this.scrollActiveIntoView(); return; }
|
||||||
|
if (ev.key === 'ArrowUp') { ev.preventDefault(); this.atIndex.set(Math.max(0, this.atIndex() - 1)); this.scrollActiveIntoView(); return; }
|
||||||
if (ev.key === 'Enter') {
|
if (ev.key === 'Enter') {
|
||||||
// Prevent the sibling (keydown.enter) submit handler from also firing
|
|
||||||
ev.stopImmediatePropagation();
|
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
const pick = opts[this.atIndex()];
|
const pick = opts[this.atIndex()];
|
||||||
if (pick) this.applyAtOption(pick.id);
|
if (pick) this.applyAtOption(pick.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.key === 'Escape') { this.atOpen.set(false); return; }
|
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); this.atOpen.set(false); return; }
|
||||||
}
|
}
|
||||||
// Step 15 — typeahead navigation (second priority after the @ popover)
|
|
||||||
|
// --- 2. Panneau de suggestions ---
|
||||||
if (this.suggestionsOpen() && !this.atOpen()) {
|
if (this.suggestionsOpen() && !this.atOpen()) {
|
||||||
const flat = this.suggestFlat();
|
const flat = this.suggestFlat();
|
||||||
if (flat.length > 0) {
|
// Ligne 0 = « Rechercher <q> », les suivantes = suggestions.
|
||||||
const last = flat.length - 1;
|
const total = flat.length + 1;
|
||||||
if (ev.key === 'ArrowDown') {
|
const last = total - 1;
|
||||||
|
const idx = this.suggestIndex();
|
||||||
|
const move = (next: number) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.suggestIndex.set(((next % total) + total) % total);
|
||||||
|
this.scrollActiveIntoView();
|
||||||
|
};
|
||||||
|
if (ev.key === 'ArrowDown') { move(idx >= last ? 0 : idx + 1); return; }
|
||||||
|
if (ev.key === 'ArrowUp') { move(idx <= 0 ? last : idx - 1); return; }
|
||||||
|
if (ev.key === 'Home' && idx >= 0) { ev.preventDefault(); this.suggestIndex.set(0); this.scrollActiveIntoView(); return; }
|
||||||
|
if (ev.key === 'End' && idx >= 0) { ev.preventDefault(); this.suggestIndex.set(last); this.scrollActiveIntoView(); return; }
|
||||||
|
if (ev.key === 'PageDown' && idx >= 0) { move(Math.min(last, idx + 5)); return; }
|
||||||
|
if (ev.key === 'PageUp' && idx >= 0) { move(Math.max(0, idx - 5)); return; }
|
||||||
|
if (ev.key === 'Enter') {
|
||||||
|
// Entrée sur une ligne sélectionnée = validation ; sinon recherche brute.
|
||||||
|
if (idx >= 0 && idx <= last) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this.suggestIndex.set(this.suggestIndex() >= last ? 0 : this.suggestIndex() + 1);
|
ev.stopPropagation();
|
||||||
|
if (idx === 0) this.searchAsTyped();
|
||||||
|
else this.applySuggestion(flat[idx - 1].text, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.key === 'ArrowUp') {
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.onSubmit(ev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Tab complète l'option active (ou la première) sans lancer la recherche.
|
||||||
|
if (ev.key === 'Tab') {
|
||||||
|
const target = idx >= 0 ? idx - 1 : 0;
|
||||||
|
const item = flat[target];
|
||||||
|
if (item) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this.suggestIndex.set(this.suggestIndex() <= 0 ? last : this.suggestIndex() - 1);
|
this.applySuggestion(item.text, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.key === 'Enter') {
|
|
||||||
const idx = this.suggestIndex();
|
|
||||||
if (idx >= 0 && idx <= last) {
|
|
||||||
ev.stopImmediatePropagation();
|
|
||||||
ev.preventDefault();
|
|
||||||
this.applySuggestion(flat[idx].text, true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Tab completes the active (or first) suggestion without searching
|
|
||||||
if (ev.key === 'Tab') {
|
|
||||||
const idx = this.suggestIndex() >= 0 ? this.suggestIndex() : 0;
|
|
||||||
if (flat[idx]) {
|
|
||||||
ev.preventDefault();
|
|
||||||
this.applySuggestion(flat[idx].text, false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Esc closes the panel but keeps the typed text
|
// Échap ferme le panneau mais garde la frappe.
|
||||||
if (ev.key === 'Escape') {
|
if (ev.key === 'Escape') {
|
||||||
this.suggestionsOpen.set(false);
|
ev.preventDefault();
|
||||||
this.suggestIndex.set(-1);
|
ev.stopPropagation();
|
||||||
|
this.closeSuggestions();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Alt+1..6 map to chips by registry order
|
|
||||||
|
// --- 3. Alt+1..6 = cocher/décocher une source ---
|
||||||
if (ev.altKey) {
|
if (ev.altKey) {
|
||||||
const map: Record<string, ProviderId | undefined> = { '1': 'yt', '2': 'dm', '3': 'tw', '4': 'pt', '5': 'od', '6': 'ru' };
|
const map: Record<string, ProviderId | undefined> = { '1': 'yt', '2': 'dm', '3': 'tw', '4': 'pt', '5': 'od', '6': 'ru' };
|
||||||
const pid = map[ev.key];
|
const pid = map[ev.key];
|
||||||
if (pid) { ev.preventDefault(); this.toggleChip(pid); }
|
if (pid) { ev.preventDefault(); this.toggleChip(pid); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 4. Échap : fermer, puis vider ---
|
||||||
if (ev.key === 'Escape') {
|
if (ev.key === 'Escape') {
|
||||||
// Escape closes transient UI; if nothing is open, clear the query
|
ev.stopPropagation();
|
||||||
const hadAny = this.pickerOpen() || this.suggestionsOpen() || this.atOpen() || this.quickOpen();
|
const hadAny = this.panelOpen() || this.suggestionsOpen() || this.atOpen();
|
||||||
this.closePicker();
|
this.closePanel();
|
||||||
this.suggestionsOpen.set(false);
|
this.closeSuggestions();
|
||||||
this.atOpen.set(false);
|
this.atOpen.set(false);
|
||||||
if (!hadAny && (this.query() || '').length > 0) {
|
if (!hadAny && (this.query() || '').length > 0) this.clearQuery();
|
||||||
this.queryUpdate('');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global shortcut Ctrl/Cmd+K to open the Providers quick menu
|
/** Clavier du popover « @ » (boutons) : mêmes règles que l'input. */
|
||||||
|
onAtOptionKeydown(ev: KeyboardEvent, index: number): void {
|
||||||
|
const opts = this.atOptions();
|
||||||
|
const last = Math.max(0, opts.length - 1);
|
||||||
|
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.atIndex.set(Math.min(last, index + 1)); this.focusAtOption(); return; }
|
||||||
|
if (ev.key === 'ArrowUp') { ev.preventDefault(); this.atIndex.set(Math.max(0, index - 1)); this.focusAtOption(); return; }
|
||||||
|
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); this.atOpen.set(false); this.focusInputSafe(); return; }
|
||||||
|
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
const pick = opts[index];
|
||||||
|
if (pick) this.applyAtOption(pick.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clavier d'une ligne du panneau de suggestions (bouton). */
|
||||||
|
onSuggestOptionKeydown(ev: KeyboardEvent, index: number): void {
|
||||||
|
// index 0 = ligne « Rechercher … » ; 1..n = suggestions
|
||||||
|
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
||||||
|
ev.preventDefault();
|
||||||
|
const total = this.suggestFlat().length + 1;
|
||||||
|
const next = (ev.key === 'ArrowDown') ? index + 1 : index - 1;
|
||||||
|
this.suggestIndex.set(Math.max(0, Math.min(total - 1, next)));
|
||||||
|
this.focusInputSafe();
|
||||||
|
this.scrollActiveIntoView();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); this.closeSuggestions(); this.focusInputSafe(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusAtOption(): void {
|
||||||
|
setTimeout(() => {
|
||||||
|
try { document.getElementById('atOption-' + this.atIndex())?.focus(); } catch {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Garde l'option surbrillée visible dans la liste (clavier long). */
|
||||||
|
private scrollActiveIntoView(): void {
|
||||||
|
if (this.suggestIndex() < 0 && this.atIndex() < 0) return;
|
||||||
|
const id = this.suggestionsOpen() && this.suggestIndex() >= 0
|
||||||
|
? 'sgOption-' + this.suggestIndex()
|
||||||
|
: 'atOption-' + this.atIndex();
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el && typeof (el as any).scrollIntoView === 'function') {
|
||||||
|
(el as any).scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raccourcis globaux. Ctrl/Cmd+K reste « focus recherche » (géré par le
|
||||||
|
// header) ; le panneau de filtres prend Ctrl/Cmd+Maj+F.
|
||||||
@HostListener('document:keydown', ['$event'])
|
@HostListener('document:keydown', ['$event'])
|
||||||
onGlobalKey(ev: KeyboardEvent) {
|
onGlobalKey(ev: KeyboardEvent) {
|
||||||
if ((ev.key.toLowerCase() === 'k') && (ev.ctrlKey || ev.metaKey)) {
|
const key = String(ev.key || '').toLowerCase();
|
||||||
|
if (key === 'f' && ev.shiftKey && (ev.ctrlKey || ev.metaKey)) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this.openQuickMenu();
|
ev.stopPropagation();
|
||||||
|
if (this.panelOpen()) this.closePanel();
|
||||||
|
else this.openPanel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.key === 'Escape') {
|
if (ev.key === 'Escape') {
|
||||||
if (this.quickOpen()) {
|
if (this.panelOpen()) { ev.preventDefault(); ev.stopPropagation(); this.closePanel(); }
|
||||||
ev.preventDefault();
|
|
||||||
this.closeQuickMenu();
|
|
||||||
}
|
|
||||||
if (this.pickerOpen()) {
|
|
||||||
ev.preventDefault();
|
|
||||||
this.closePicker();
|
|
||||||
}
|
|
||||||
if (this.atOpen()) {
|
|
||||||
ev.preventDefault();
|
|
||||||
this.atOpen.set(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Soumission
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Requête sans les opérateurs `live:`/`today:`… (déjà convertis en filtres). */
|
||||||
|
private strippedQuery(): string {
|
||||||
|
return parseQueryTokens(this.query() || '').query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point d'entrée unique de la recherche (formulaire, Entrée, suggestion). */
|
||||||
|
private runSearch(rawQuery: string): void {
|
||||||
|
const parsed = parseQueryTokens(rawQuery);
|
||||||
|
// Les opérateurs saisis au clavier deviennent des filtres (et disparaissent
|
||||||
|
// de la requête affichée : ils sont « consommés », donc plus de conflit avec
|
||||||
|
// la suppression manuelle d'un filtre).
|
||||||
|
if (Object.keys(parsed.filters).length > 0) {
|
||||||
|
const merged = normalizeFilters({ ...this.filters(), ...parsed.filters });
|
||||||
|
this.filters.set(merged);
|
||||||
|
this.filtersChange.emit(merged);
|
||||||
|
const clean = parsed.query;
|
||||||
|
if (clean && clean !== String(rawQuery || '').trim()) this.setQueryValue(clean);
|
||||||
|
}
|
||||||
|
const q = parsed.query;
|
||||||
|
if (q.length < 2) return;
|
||||||
|
const prov = this.providersForRequest();
|
||||||
|
this.telemetry.track('search_submit', {
|
||||||
|
providers: Array.isArray(prov) ? prov : ['all'],
|
||||||
|
queryLength: q.length,
|
||||||
|
filters: normalizeFilters(this.filters()),
|
||||||
|
});
|
||||||
|
this.submitted.emit({ q, providers: prov, filters: normalizeFilters(this.filters()) });
|
||||||
|
}
|
||||||
|
|
||||||
onSubmit(ev: Event) {
|
onSubmit(ev: Event) {
|
||||||
try { ev.preventDefault(); } catch {}
|
try { ev.preventDefault(); } catch {}
|
||||||
this.suggestionsOpen.set(false);
|
this.closeSuggestions();
|
||||||
this.suggestIndex.set(-1);
|
this.atOpen.set(false);
|
||||||
let q = (this.query() || '').trim();
|
// On passe la saisie BRUTE à runSearch : c'est lui qui extrait les
|
||||||
if ((!q || q.length === 0) && this.qInputRef?.nativeElement) {
|
// opérateurs (`live:`…) et les convertit en filtres.
|
||||||
try {
|
let raw = (this.query() || '').trim();
|
||||||
const raw = this.qInputRef.nativeElement.value || '';
|
if (!raw && this.qInputRef?.nativeElement) {
|
||||||
q = String(raw).trim();
|
try { raw = String(this.qInputRef.nativeElement.value || '').trim(); } catch {}
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
if (q.length < 2) return;
|
if (parseQueryTokens(raw).query.length < 2) {
|
||||||
|
this.focusInputSafe();
|
||||||
const prov = this.providersForRequest();
|
return;
|
||||||
this.telemetry.track('search_submit', { providers: Array.isArray(prov) ? prov : ['all'], queryLength: q.length });
|
}
|
||||||
// Emit payload to parent (Header/Search page)
|
this.runSearch(raw);
|
||||||
this.submitted.emit({ q, providers: prov });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openPicker() {
|
/**
|
||||||
this.saveFocus();
|
* Autocomplétion des opérateurs : uniquement quand l'utilisateur a tapé un
|
||||||
this.pickerOpen.set(true);
|
* « : » (`linux live:` -> suggestions live/live-only). Pas de bruit pendant
|
||||||
this.telemetry.track('provider_picker_open');
|
* une saisie normale.
|
||||||
}
|
*/
|
||||||
closePicker() {
|
operatorSuggestions(): string[] {
|
||||||
if (!this.pickerOpen()) return;
|
const partial = this.operatorPartial();
|
||||||
this.pickerOpen.set(false);
|
if (partial === null) return [];
|
||||||
this.restoreFocus();
|
return operatorTokenSuggestions(partial, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick menu open/close with focus management (a11y)
|
/** Partie saisie après le dernier « : », ou null si aucun jeton en cours. */
|
||||||
openQuickMenu() {
|
private operatorPartial(): string | null {
|
||||||
this.saveFocus();
|
const m = /(^|\s)[a-zA-Zà-ÿ]{1,12}:([a-zA-Zà-ÿ]{0,12})$/.exec(this.query() || '');
|
||||||
this.quickOpen.set(true);
|
return m ? (m[2] || '') : null;
|
||||||
this.telemetry.track('quick_menu_open');
|
|
||||||
// Load last 15 searches (deduped: same query shown once)
|
|
||||||
try {
|
|
||||||
this.history.getSearchHistory(15).subscribe({
|
|
||||||
next: (items) => {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const clean: SearchHistoryItem[] = [];
|
|
||||||
for (const it of items || []) {
|
|
||||||
const k = normSuggest(it?.query || '');
|
|
||||||
if (!k || seen.has(k)) continue;
|
|
||||||
seen.add(k);
|
|
||||||
clean.push(it);
|
|
||||||
}
|
|
||||||
this.recentSearches.set(clean);
|
|
||||||
},
|
|
||||||
error: () => {},
|
|
||||||
});
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
closeQuickMenu() {
|
|
||||||
if (!this.quickOpen()) return;
|
/** Insère un opérateur proposé et applique le filtre correspondant. */
|
||||||
this.quickOpen.set(false);
|
applyOperatorToken(token: string): void {
|
||||||
this.restoreFocus();
|
const source = String(this.query() || '');
|
||||||
|
const next = source.replace(/([a-zA-Zà-ÿ]{1,12}:)[a-zA-Zà-ÿ]{0,12}$/, (_m, head: string) => `${head}${token}`);
|
||||||
|
this.setQueryValue(next);
|
||||||
|
this.queryUpdate(next);
|
||||||
|
const merged = normalizeFilters({ ...this.filters(), ...parseQueryTokens(next).filters });
|
||||||
|
if (!filtersEqual(merged, this.filters())) {
|
||||||
|
this.filters.set(merged);
|
||||||
|
this.filtersChange.emit(merged);
|
||||||
|
}
|
||||||
|
this.focusInputSafe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Focus internals (modales)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
private saveFocus() {
|
private saveFocus() {
|
||||||
try { this.lastFocused = document.activeElement as HTMLElement | null; } catch { this.lastFocused = null; }
|
try { this.lastFocused = document.activeElement as HTMLElement | null; } catch { this.lastFocused = null; }
|
||||||
}
|
}
|
||||||
@@ -572,62 +897,6 @@ export class SearchBoxComponent {
|
|||||||
this.lastFocused = null;
|
this.lastFocused = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic focus trap used by the quick menu dialog (Tab / Shift+Tab)
|
|
||||||
trapFocus(ev: KeyboardEvent, root: HTMLElement | null | undefined) {
|
|
||||||
if (ev.key !== 'Tab' || !root) return;
|
|
||||||
const focusables = Array.from(root.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
|
|
||||||
.filter(el => !el.hasAttribute('disabled'));
|
|
||||||
if (focusables.length === 0) return;
|
|
||||||
const first = focusables[0];
|
|
||||||
const last = focusables[focusables.length - 1];
|
|
||||||
const active = document.activeElement as HTMLElement | null;
|
|
||||||
if (!ev.shiftKey && active === last) {
|
|
||||||
ev.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
} else if (ev.shiftKey && (active === first || active === root)) {
|
|
||||||
ev.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onQuickMenuKeydown(ev: KeyboardEvent) {
|
|
||||||
this.trapFocus(ev, this.quickDialogRef?.nativeElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accept both legacy payload (array|'all') and new object payload; use any to satisfy strict template binder
|
|
||||||
applyPicker(payload: any) {
|
|
||||||
const isObj = payload && typeof payload === 'object' && !Array.isArray(payload);
|
|
||||||
const prov = (isObj ? payload.selection : payload) as ProviderId[] | 'all';
|
|
||||||
const rememberDefault = isObj ? !!payload.rememberDefault : false;
|
|
||||||
if (prov === 'all') {
|
|
||||||
this.providerMode.set('all');
|
|
||||||
this.selected.set([]);
|
|
||||||
} else if (Array.isArray(prov)) {
|
|
||||||
this.providerMode.set('custom');
|
|
||||||
this.selected.set(prov);
|
|
||||||
}
|
|
||||||
// Persist default selection if requested and user is logged-in (Step 9)
|
|
||||||
if (rememberDefault) {
|
|
||||||
const body: any = { defaultProviders: (prov === 'all') ? [] : prov };
|
|
||||||
try { this.users.updatePreferences(body).subscribe({ next: () => {}, error: () => {} }); } catch {}
|
|
||||||
}
|
|
||||||
this.telemetry.track('provider_apply', { providers: Array.isArray(prov) ? prov : ['all'], rememberDefault });
|
|
||||||
this.closePicker();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick menu close/apply (re-using same payload shape as picker)
|
|
||||||
applyQuickMenu(payload: any) {
|
|
||||||
this.applyPicker(payload);
|
|
||||||
this.closeQuickMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick menu provider toggles
|
|
||||||
quickToggleAll() { this.applyQuickMenu({ selection: 'all', rememberDefault: false }); }
|
|
||||||
quickToggle(id: ProviderId) {
|
|
||||||
const set = new Set(this.selected());
|
|
||||||
if (set.has(id)) set.delete(id); else set.add(id);
|
|
||||||
this.selected.set(Array.from(set));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Internal subject for debouncing query changes
|
// Internal subject for debouncing query changes
|
||||||
private _qChanges = new Subject<string>();
|
private _qChanges = new Subject<string>();
|
||||||
// Internal subject for debounced typeahead fetches (Step 15)
|
// Internal subject for debounced typeahead fetches (Step 15)
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<!-- Panneau de filtres de recherche (remplace le ancien sélecteur de providers).
|
||||||
|
groups/options = mono-sélection (rôle radiogroup), sources = multi-sélection.
|
||||||
|
Chaque option porte data-group/data-index : le roving tabindex et les
|
||||||
|
raccourcis flèches du panel s'appuient dessus. -->
|
||||||
|
<div class="quick-menu-backdrop" role="presentation" (click)="close.emit()">
|
||||||
|
<div #dialog
|
||||||
|
class="quick-menu-dialog filter-panel-dialog"
|
||||||
|
role="dialog" aria-modal="true" aria-labelledby="filterPanelTitle"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
(keydown)="onDialogKeydown($event)"
|
||||||
|
tabindex="-1">
|
||||||
|
|
||||||
|
<!-- En-tête -->
|
||||||
|
<div class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-700 p-4">
|
||||||
|
<h2 id="filterPanelTitle" class="text-base font-semibold text-slate-100">
|
||||||
|
{{ 'filters.title' | t: 'Filtres de recherche' }}
|
||||||
|
</h2>
|
||||||
|
<button type="button" (click)="close.emit()"
|
||||||
|
class="chip shrink-0 rounded bg-slate-700 px-3 py-2 text-slate-100 hover:bg-slate-600"
|
||||||
|
[attr.aria-label]="'filters.close' | t: 'Fermer les filtres (Esc)'">
|
||||||
|
Esc
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-scroll min-h-0 flex-1 space-y-5 p-4">
|
||||||
|
<!-- Recherches récentes : raccourci direct vers une recherche passée -->
|
||||||
|
<section *ngIf="(history || []).length > 0" data-group="history"
|
||||||
|
aria-label="Recherches récentes" class="space-y-2">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
{{ 'filters.recent' | t: 'Recherches récentes' }}
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button *ngFor="let h of history; let i = index" type="button"
|
||||||
|
[attr.data-index]="i"
|
||||||
|
[attr.tabindex]="activeIndex('history') === i ? 0 : -1"
|
||||||
|
(click)="onPickHistory(h)"
|
||||||
|
class="chip max-w-full truncate rounded-full border border-slate-600 bg-slate-800 px-3 py-2 text-left text-sm text-slate-200 hover:bg-slate-700"
|
||||||
|
[title]="h">
|
||||||
|
<span class="truncate">{{ h }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Sources (multi-sélection) -->
|
||||||
|
<section class="space-y-2">
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
{{ 'filters.sources' | t: 'Sources' }}
|
||||||
|
</h3>
|
||||||
|
<span class="text-[11px] text-slate-500">
|
||||||
|
{{ allMode() ? ('filters.sources.allHint' | t: 'Toutes les plateformes') : (draft().length + ' / ' + providers.length) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div data-group="sources" role="group" [attr.aria-label]="'filters.sources' | t: 'Sources'" class="flex flex-wrap gap-2">
|
||||||
|
<button type="button" data-index="0"
|
||||||
|
[attr.tabindex]="activeIndex('sources') === 0 ? 0 : -1"
|
||||||
|
(click)="toggleAllMode()"
|
||||||
|
class="chip rounded-full border px-3 py-2 text-sm"
|
||||||
|
[ngClass]="allMode()
|
||||||
|
? 'border-slate-300 bg-slate-200 text-slate-900'
|
||||||
|
: 'border-slate-600 bg-slate-800 text-slate-200 hover:bg-slate-700'"
|
||||||
|
[attr.aria-pressed]="allMode()">
|
||||||
|
{{ 'filters.sources.all' | t: 'Toutes' }}
|
||||||
|
</button>
|
||||||
|
<button *ngFor="let p of providers; let i = index" type="button"
|
||||||
|
[attr.data-index]="i + 1"
|
||||||
|
[attr.tabindex]="activeIndex('sources') === i + 1 ? 0 : -1"
|
||||||
|
(click)="selectSourceTab(i + 1)"
|
||||||
|
class="chip inline-flex items-center gap-1.5 rounded-full border px-3 py-2 text-sm"
|
||||||
|
[ngClass]="isChecked(p.id)
|
||||||
|
? 'border-slate-300 bg-slate-200 text-slate-900'
|
||||||
|
: 'border-slate-600 bg-slate-800 text-slate-200 hover:bg-slate-700'"
|
||||||
|
[attr.aria-pressed]="isChecked(p.id)"
|
||||||
|
[title]="p.displayName">
|
||||||
|
<span [class]="isChecked(p.id) ? 'opacity-70' : ''">{{ p.shortLabel }}</span>
|
||||||
|
<span class="hidden sm:inline">{{ p.displayName }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="inline-flex items-center gap-2 pt-1 text-xs text-slate-300">
|
||||||
|
<input type="checkbox" [ngModel]="rememberDefault()" (ngModelChange)="rememberDefault.set($event)" />
|
||||||
|
<span>{{ 'filters.sources.remember' | t: 'Mémoriser comme source par défaut' }}</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Filtres (type / période / durée / tri) : un groupe par ligne -->
|
||||||
|
<section *ngFor="let group of groups()" class="space-y-2">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
{{ group.labelKey | t: group.label }}
|
||||||
|
</h3>
|
||||||
|
<div [attr.data-group]="group.key" role="radiogroup"
|
||||||
|
[attr.aria-label]="group.labelKey | t: group.label"
|
||||||
|
class="flex flex-wrap gap-2">
|
||||||
|
<button *ngFor="let opt of group.options; let i = index" type="button"
|
||||||
|
role="radio"
|
||||||
|
[attr.data-index]="i"
|
||||||
|
[attr.tabindex]="activeIndex(group.key) === i ? 0 : -1"
|
||||||
|
(click)="selectFilter(group.key, opt.value)"
|
||||||
|
class="chip rounded-full border px-3 py-2 text-sm"
|
||||||
|
[ngClass]="opt.selected
|
||||||
|
? 'border-red-500 bg-red-600 text-white'
|
||||||
|
: 'border-slate-600 bg-slate-800 text-slate-200 hover:bg-slate-700'"
|
||||||
|
[attr.aria-checked]="opt.selected">
|
||||||
|
{{ opt.key | t: opt.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="pb-1 text-[11px] leading-relaxed text-slate-500">
|
||||||
|
{{ 'filters.shortcutHint' | t: 'Astuce : les opérateurs' }}
|
||||||
|
<span class="font-mono text-slate-400">live:</span>,
|
||||||
|
<span class="font-mono text-slate-400">shorts:</span>,
|
||||||
|
<span class="font-mono text-slate-400">today:</span>,
|
||||||
|
<span class="font-mono text-slate-400">long:</span>
|
||||||
|
{{ 'filters.shortcutHintEnd' | t: 'écrits dans la barre de recherche sont convertis en filtres.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pied : réinitialiser / appliquer -->
|
||||||
|
<div class="flex shrink-0 flex-col-reverse gap-2 border-t border-slate-700 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<button type="button" (click)="reset()"
|
||||||
|
class="chip rounded px-3 py-3 text-sm text-slate-300 hover:bg-slate-800 disabled:opacity-40"
|
||||||
|
[disabled]="!dirty() && allMode()">
|
||||||
|
{{ 'filters.reset' | t: 'Réinitialiser' }}
|
||||||
|
</button>
|
||||||
|
<div class="flex flex-col-reverse gap-2 sm:flex-row">
|
||||||
|
<button type="button" (click)="close.emit()"
|
||||||
|
class="rounded bg-slate-700 px-4 py-3 text-slate-100 hover:bg-slate-600">
|
||||||
|
{{ 'filters.cancel' | t: 'Annuler' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" data-action="apply" (click)="onApply()"
|
||||||
|
class="inline-flex items-center justify-center gap-2 rounded bg-red-600 px-4 py-3 text-white hover:bg-red-500">
|
||||||
|
{{ 'filters.apply' | t: 'Appliquer' }}
|
||||||
|
<span *ngIf="activeCount() > 0" class="rounded bg-red-800 px-1.5 text-xs">{{ activeCount() }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import {
|
||||||
|
Component, EventEmitter, Output, Input, signal, computed,
|
||||||
|
ElementRef, ViewChild, AfterViewInit, OnDestroy, ChangeDetectionStrategy,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||||
|
import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registry';
|
||||||
|
import {
|
||||||
|
DEFAULT_FILTERS, filterGroups, normalizeFilters, activeFilterCount,
|
||||||
|
type SearchFilters, type FilterChoice,
|
||||||
|
} from '../../app/search/filters';
|
||||||
|
|
||||||
|
export interface FilterPanelApply {
|
||||||
|
selection: ProviderId[] | 'all';
|
||||||
|
filters: SearchFilters;
|
||||||
|
rememberDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identifiant interne d'un groupe de filtres (raccourcis clavier). */
|
||||||
|
type GroupKey = 'sources' | 'type' | 'period' | 'duration' | 'sort' | 'history';
|
||||||
|
|
||||||
|
interface RovingGroup {
|
||||||
|
key: GroupKey;
|
||||||
|
/** Nombre d'onglets (tabindex) dans le groupe. */
|
||||||
|
size: number;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panneau de filtres de recherche — remplace l'ancien « picker » de providers.
|
||||||
|
*
|
||||||
|
* Un seul panneau pour *tous* les filtres (sources, type de contenu, période,
|
||||||
|
* durée, tri) + les recherches récentes, pilotable entièrement au clavier :
|
||||||
|
* - Tab / Maj+Tab : navigation normale entre les groupes (piégé dans le dialogue)
|
||||||
|
* - ← / → : option précédente / suivante dans le groupe (roving tabindex)
|
||||||
|
* - ↑ / ↓ : groupe précédent / suivant
|
||||||
|
* - Entrée / Espace : sélectionner l'option
|
||||||
|
* - Échap : fermer
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-search-filter-panel',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, TranslatePipe],
|
||||||
|
templateUrl: './search-filter-panel.component.html',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class SearchFilterPanelComponent implements AfterViewInit, OnDestroy {
|
||||||
|
@Input() open = false;
|
||||||
|
/** Sélection de sources courante ('all' ou une liste d'ids). */
|
||||||
|
@Input() set selected(value: ProviderId[] | 'all') {
|
||||||
|
const all = value === 'all' || !Array.isArray(value) || value.length === 0;
|
||||||
|
this._allMode.set(all);
|
||||||
|
this._draft.set(all ? [] : [...value]);
|
||||||
|
// Onglet actif du groupe « sources » : « Toutes » ou la 1re source cochée.
|
||||||
|
const firstChecked = all ? 0 : (PROVIDERS.findIndex((p) => this._draft().includes(p.id)) + 1);
|
||||||
|
this._active.update((a) => ({ ...a, sources: Math.max(0, firstChecked) }));
|
||||||
|
}
|
||||||
|
/** Filtres courants (valeurs déjà appliquées). */
|
||||||
|
@Input() set filters(value: Partial<SearchFilters> | null | undefined) {
|
||||||
|
const n = normalizeFilters(value);
|
||||||
|
this._filters.set(n);
|
||||||
|
// Chaque groupe démarre sur son option sélectionnée (Tab retrouve le bon point).
|
||||||
|
this._active.update((a) => {
|
||||||
|
const next = { ...a };
|
||||||
|
for (const g of filterGroups(n)) {
|
||||||
|
const idx = g.options.findIndex((o) => o.selected);
|
||||||
|
if (idx >= 0) (next as any)[g.key] = idx;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/** Recherches récentes (raccourci vers une recherche déjà faite). */
|
||||||
|
@Input() history: string[] = [];
|
||||||
|
/** Requête courante (affichée dans le champ de recherche du panneau). */
|
||||||
|
@Input() query = '';
|
||||||
|
|
||||||
|
@Output() close = new EventEmitter<void>();
|
||||||
|
@Output() apply = new EventEmitter<FilterPanelApply>();
|
||||||
|
/** Rappelé quand l'utilisateur choisit une source de recherche récente. */
|
||||||
|
@Output() pickHistory = new EventEmitter<string>();
|
||||||
|
|
||||||
|
// --- brouillon (rien n'est appliqué avant « Appliquer ») ---
|
||||||
|
private _draft = signal<ProviderId[]>([]);
|
||||||
|
private _allMode = signal(true);
|
||||||
|
private _filters = signal<SearchFilters>({ ...DEFAULT_FILTERS });
|
||||||
|
rememberDefault = signal(false);
|
||||||
|
/** Index de l'onglet actif par groupe (roving tabindex). */
|
||||||
|
private _active = signal<Record<GroupKey, number>>({
|
||||||
|
sources: 0, type: 0, period: 0, duration: 0, sort: 0, history: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
@ViewChild('dialog', { static: false }) dialogRef?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
|
readonly providers = PROVIDERS;
|
||||||
|
readonly draft = computed(() => this._draft());
|
||||||
|
readonly allMode = computed(() => this._allMode() && this._draft().length === 0);
|
||||||
|
/** Filtres en cours d'édition (le brouillon). */
|
||||||
|
readonly draftFilters = computed(() => this._filters());
|
||||||
|
/** Groupes de filtres (type / période / durée / tri) avec l'état sélectionné. */
|
||||||
|
readonly groups = computed(() => filterGroups(this._filters()));
|
||||||
|
readonly activeCount = computed(() => activeFilterCount(this._filters()));
|
||||||
|
/** Le panneau a-t-il été modifié depuis l'ouverture ? */
|
||||||
|
readonly dirty = computed(() => {
|
||||||
|
return JSON.stringify(this._filters()) !== JSON.stringify(DEFAULT_FILTERS) || !this.allMode();
|
||||||
|
});
|
||||||
|
/** Nombre total d'onglets du groupe « sources » (Toutes + N providers). */
|
||||||
|
readonly sourcesSize = PROVIDERS.length + 1;
|
||||||
|
|
||||||
|
isChecked(id: ProviderId): boolean {
|
||||||
|
if (this.allMode()) return true;
|
||||||
|
return this._draft().includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Onglet coché/actif du groupe sources (0 = « Toutes »). */
|
||||||
|
isSourceTabActive(index: number): boolean {
|
||||||
|
if (index === 0) return this.allMode();
|
||||||
|
return this.selectedProviderIdAt(index - 1) != null && !this.allMode() && this.isChecked(this.selectedProviderIdAt(index - 1)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectedProviderIdAt(i: number): ProviderId | null {
|
||||||
|
return this.providers[i]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAllMode(): void {
|
||||||
|
this._allMode.set(true);
|
||||||
|
this._draft.set([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(id: ProviderId): void {
|
||||||
|
if (this.allMode()) {
|
||||||
|
// Sortir du mode « Toutes » en décochant une source : on part de la liste
|
||||||
|
// complète puis on retire celle-ci (« toutes sauf celle-là »).
|
||||||
|
const set = new Set(PROVIDERS.map((p) => p.id));
|
||||||
|
set.delete(id);
|
||||||
|
this._draft.set(Array.from(set));
|
||||||
|
this._allMode.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const set = new Set(this._draft());
|
||||||
|
if (set.has(id)) set.delete(id);
|
||||||
|
else set.add(id);
|
||||||
|
this._draft.set(Array.from(set));
|
||||||
|
this._allMode.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sélectionne un onglet du groupe « sources » (index 0 = Toutes). */
|
||||||
|
selectSourceTab(index: number): void {
|
||||||
|
this._active.update((a) => ({ ...a, sources: index }));
|
||||||
|
if (index <= 0) { this.toggleAllMode(); return; }
|
||||||
|
const id = this.selectedProviderIdAt(index - 1);
|
||||||
|
if (id) this.toggle(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sélectionne une option d'un groupe de filtres mono-sélection. */
|
||||||
|
selectFilter(groupKey: string, value: string): void {
|
||||||
|
this._filters.set({ ...this._filters(), [groupKey]: value } as SearchFilters);
|
||||||
|
// Le roving tabindex suit la sélection : Tab retombe sur l'option active.
|
||||||
|
const group = this.groups().find((g) => g.key === groupKey);
|
||||||
|
const idx = group ? this.optionIndexOf(group, value) : 0;
|
||||||
|
if (idx >= 0) this._active.update((a) => ({ ...a, [groupKey as GroupKey]: idx }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Onglet actif (roving tabindex) d'un groupe. */
|
||||||
|
activeIndex(key: string): number {
|
||||||
|
return this._active()[key as GroupKey] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
optionIndexOf(group: { options: readonly FilterChoice[] }, value: string): number {
|
||||||
|
return (group?.options || []).findIndex((o) => o.value === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this._filters.set({ ...DEFAULT_FILTERS });
|
||||||
|
this._allMode.set(true);
|
||||||
|
this._draft.set([]);
|
||||||
|
this.rememberDefault.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
onApply(): void {
|
||||||
|
const selection: ProviderId[] | 'all' = this.allMode() ? 'all' : this._draft();
|
||||||
|
this.apply.emit({ selection, filters: normalizeFilters(this._filters()), rememberDefault: this.rememberDefault() });
|
||||||
|
}
|
||||||
|
|
||||||
|
onPickHistory(text: string): void {
|
||||||
|
this.pickHistory.emit(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Clavier
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private groupOf(target: EventTarget | null): GroupKey | null {
|
||||||
|
const el = (target as HTMLElement | null)?.closest?.('[data-group]');
|
||||||
|
const key = el?.getAttribute('data-group');
|
||||||
|
return (key as GroupKey) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private groupIndex(key: GroupKey): number {
|
||||||
|
const order: GroupKey[] = ['history', 'sources', 'type', 'period', 'duration', 'sort'];
|
||||||
|
return order.indexOf(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusTab(key: GroupKey, index: number): void {
|
||||||
|
this._active.update((a) => ({ ...a, [key]: index }));
|
||||||
|
// Le DOM n'est repeint qu'après le cycle de détection : on attend un tick.
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const el = this.dialogRef?.nativeElement
|
||||||
|
?.querySelector<HTMLElement>(`[data-group="${key}"][data-index="${index}"]`);
|
||||||
|
el?.focus();
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onDialogKeydown(ev: KeyboardEvent): void {
|
||||||
|
if (ev.key === 'Escape') {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.close.emit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.key === 'Tab') { this.trapFocus(ev); return; }
|
||||||
|
const key = this.groupOf(ev.target);
|
||||||
|
if (!key) return;
|
||||||
|
const cur = this._active()[key] ?? 0;
|
||||||
|
const size = this.groupSize(key);
|
||||||
|
const orderIdx = this.groupIndex(key);
|
||||||
|
const move = (delta: number) => {
|
||||||
|
const next = (cur + delta + size) % size;
|
||||||
|
ev.preventDefault();
|
||||||
|
this.focusTab(key, next);
|
||||||
|
};
|
||||||
|
switch (ev.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'ArrowDown':
|
||||||
|
// ↓ en milieu de groupe descend d'un onglet, au dernier il passe au
|
||||||
|
// groupe suivant (comportement « menu » classique, moins de tabulations).
|
||||||
|
if (ev.key === 'ArrowDown' && cur < size - 1) { move(1); return; }
|
||||||
|
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.focusGroupByOrder(orderIdx + 1, cur); return; }
|
||||||
|
move(1);
|
||||||
|
return;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
if (ev.key === 'ArrowLeft' && cur > 0) { move(-1); return; }
|
||||||
|
move(-1);
|
||||||
|
return;
|
||||||
|
case 'ArrowUp': {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.focusGroupByOrder(orderIdx - 1, cur);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'Home': ev.preventDefault(); this.focusTab(key, 0); return;
|
||||||
|
case 'End': ev.preventDefault(); this.focusTab(key, size - 1); return;
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saute au groupe voisin en gardant une position d'onglet cohérente. */
|
||||||
|
private focusGroupByOrder(orderIdx: number, fallbackIndex: number): void {
|
||||||
|
const order: GroupKey[] = ['history', 'sources', 'type', 'period', 'duration', 'sort'];
|
||||||
|
if (orderIdx < 0) return;
|
||||||
|
if (orderIdx >= order.length) {
|
||||||
|
// Après le dernier groupe : on cible le bouton « Appliquer ».
|
||||||
|
setTimeout(() => {
|
||||||
|
try { this.dialogRef?.nativeElement?.querySelector<HTMLElement>('[data-action="apply"]')?.focus(); } catch {}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = order[orderIdx];
|
||||||
|
const size = this.groupSize(key);
|
||||||
|
this.focusTab(key, Math.min(fallbackIndex, size - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private groupSize(key: GroupKey): number {
|
||||||
|
switch (key) {
|
||||||
|
case 'sources': return this.sourcesSize;
|
||||||
|
case 'history': return Math.max(1, (this.history || []).length);
|
||||||
|
default: {
|
||||||
|
const g = this.groups().find((x) => x.key === key);
|
||||||
|
return Math.max(1, g?.options.length || 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Piégeage du focus (Tab / Maj+Tab) à l'intérieur du dialogue. */
|
||||||
|
private trapFocus(ev: KeyboardEvent): void {
|
||||||
|
const root = this.dialogRef?.nativeElement;
|
||||||
|
if (!root) return;
|
||||||
|
const focusables = Array.from(
|
||||||
|
root.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'),
|
||||||
|
).filter((el) => !el.hasAttribute('disabled') && el.getAttribute('tabindex') !== '-1');
|
||||||
|
if (focusables.length === 0) return;
|
||||||
|
const first = focusables[0];
|
||||||
|
const last = focusables[focusables.length - 1];
|
||||||
|
const active = document.activeElement as HTMLElement | null;
|
||||||
|
if (!ev.shiftKey && active === last) { ev.preventDefault(); first.focus(); }
|
||||||
|
else if (ev.shiftKey && (active === first || active === root)) { ev.preventDefault(); last.focus(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Focus
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
// Le dialogue reçoit le focus à l'ouverture (pas le premier bouton : on
|
||||||
|
// laisse l'utilisateur choisir où il atterrit avec Tab / flèches).
|
||||||
|
queueMicrotask(() => {
|
||||||
|
try { this.dialogRef?.nativeElement?.focus(); } catch {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
try { document.body.style.overflow = ''; } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,23 +25,28 @@
|
|||||||
<p class="text-slate-400">{{ 'search.hint' | t }}</p>
|
<p class="text-slate-400">{{ 'search.hint' | t }}</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (hasQuery() && availableTags().length > 0) {
|
|
||||||
<div class="scroll-rail no-scrollbar mb-4 flex items-center gap-2 pb-1">
|
|
||||||
@for (tag of availableTags(); track tag.key) {
|
|
||||||
<button
|
|
||||||
class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
|
||||||
[ngClass]="{
|
|
||||||
'bg-slate-200 text-slate-900 border-slate-200': filterTag() === tag.key,
|
|
||||||
'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700': filterTag() !== tag.key
|
|
||||||
}"
|
|
||||||
(click)="setFilterTag(tag.key)">
|
|
||||||
{{ tag.label }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (hasQuery()) {
|
@if (hasQuery()) {
|
||||||
|
<!-- Pastilles de filtres actifs : rappel immédiat + retrait en un clic.
|
||||||
|
Elles écrivent l'URL, donc la requête serveur repart filtrée. -->
|
||||||
|
@if (activeFilterChips().length > 0) {
|
||||||
|
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-xs uppercase tracking-wide text-slate-400">Filtres</span>
|
||||||
|
@for (chip of activeFilterChips(); track chip.key) {
|
||||||
|
<button type="button" (click)="clearFilter(chip.key)"
|
||||||
|
class="chip inline-flex items-center gap-1 rounded-full border border-red-500/60 bg-red-600/20 px-2.5 py-1 text-xs text-red-200 hover:bg-red-600/40">
|
||||||
|
{{ chip.label }} <span aria-hidden="true">✕</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" (click)="clearAllFilters()"
|
||||||
|
class="chip rounded-full px-2.5 py-1 text-xs text-slate-400 underline hover:text-slate-200">
|
||||||
|
Tout effacer
|
||||||
|
</button>
|
||||||
|
<span class="text-[11px] text-slate-500">
|
||||||
|
{{ filteredResults().length }} / {{ allVideos().length }} résultats
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Pills de type façon directory Twitch : Directs / Vidéos / Clips / Chaînes -->
|
<!-- Pills de type façon directory Twitch : Directs / Vidéos / Clips / Chaînes -->
|
||||||
<div class="scroll-rail no-scrollbar flex items-center gap-2 pb-1" role="tablist" aria-label="Filtrer par type de contenu">
|
<div class="scroll-rail no-scrollbar flex items-center gap-2 pb-1" role="tablist" aria-label="Filtrer par type de contenu">
|
||||||
<button class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
<button class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||||
@@ -59,9 +64,9 @@
|
|||||||
[attr.aria-pressed]="typePill() === 'video'" (click)="setTypePill('video')">
|
[attr.aria-pressed]="typePill() === 'video'" (click)="setTypePill('video')">
|
||||||
Vidéos ({{ typeCounts().video }})
|
Vidéos ({{ typeCounts().video }})
|
||||||
</button>
|
</button>
|
||||||
<button *ngIf="typeCounts().clip > 0 || typePill() === 'short'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
<button *ngIf="typeCounts().clip > 0 || typePill() === 'shorts'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||||
[ngClass]="typePill() === 'short' ? 'bg-red-600 text-white border-red-500' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
[ngClass]="typePill() === 'shorts' ? 'bg-red-600 text-white border-red-500' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||||
[attr.aria-pressed]="typePill() === 'short'" (click)="setTypePill('short')">
|
[attr.aria-pressed]="typePill() === 'shorts'" (click)="setTypePill('shorts')">
|
||||||
Clips ({{ typeCounts().clip }})
|
Clips ({{ typeCounts().clip }})
|
||||||
</button>
|
</button>
|
||||||
<button *ngIf="typeCounts().channel > 0 || typePill() === 'channel'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
<button *ngIf="typeCounts().channel > 0 || typePill() === 'channel'" class="chip shrink-0 px-3 py-2 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||||
@@ -70,61 +75,65 @@
|
|||||||
Chaînes ({{ typeCounts().channel }})
|
Chaînes ({{ typeCounts().channel }})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Barre de filtrage (même UX que la page thème / fournisseur) -->
|
<!-- Barre de filtrage : les 4 dimensions principales sont appliquées par le
|
||||||
|
serveur (elles vivent dans l'URL), la langue reste locale. -->
|
||||||
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-4 my-4">
|
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-4 my-4">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="sortSelect" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.sort' | t }}</label>
|
<label for="sortSelect" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filters.sort' | t: 'Trier par' }}</label>
|
||||||
<select id="sortSelect" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
<select id="sortSelect" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
[value]="sortParam()" (change)="onSortChange($event)">
|
[value]="filters().sort" (change)="onSortChange($event)">
|
||||||
<option value="relevance">{{ 'filter.sort.relevance' | t }}</option>
|
<option value="relevance">{{ 'filters.sort.relevance' | t: 'Pertinence' }}</option>
|
||||||
<option value="date">{{ 'filter.sort.date' | t }}</option>
|
<option value="date">{{ 'filters.sort.date' | t: 'Date' }}</option>
|
||||||
<option value="views">{{ 'filter.sort.views' | t }}</option>
|
<option value="views">{{ 'filters.sort.views' | t: 'Vues' }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="duration-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.duration' | t }}</label>
|
<label for="period-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filters.period' | t: 'Période' }}</label>
|
||||||
<select id="duration-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
<select id="period-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
[value]="durationFilter()" (change)="onDurationChange($event)">
|
[value]="filters().period" (change)="onDateChange($event)">
|
||||||
<option value="any">{{ 'filter.duration.any' | t }}</option>
|
<option value="all">{{ 'filters.period.all' | t: "N'importe quand" }}</option>
|
||||||
<option value="short">{{ 'filter.duration.short' | t }}</option>
|
<option value="hour">{{ 'filters.period.hour' | t: 'Dernière heure' }}</option>
|
||||||
<option value="medium">{{ 'filter.duration.medium' | t }}</option>
|
<option value="today">{{ 'filters.period.today' | t: "Aujourd'hui" }}</option>
|
||||||
<option value="long">{{ 'filter.duration.long' | t }}</option>
|
<option value="week">{{ 'filters.period.week' | t: 'Cette semaine' }}</option>
|
||||||
|
<option value="month">{{ 'filters.period.month' | t: 'Ce mois' }}</option>
|
||||||
|
<option value="year">{{ 'filters.period.year' | t: 'Cette année' }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="type-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.type' | t }}</label>
|
<label for="type-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filters.type' | t: 'Type de contenu' }}</label>
|
||||||
<select id="type-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
<select id="type-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
[value]="typeFilter()" (change)="onTypeChange($event)">
|
[value]="filters().type" (change)="onTypeChange($event)">
|
||||||
<option value="any">{{ 'filter.type.any' | t }}</option>
|
<option value="all">{{ 'filters.type.all' | t: 'Tout' }}</option>
|
||||||
<option value="video">{{ 'filter.type.video' | t }}</option>
|
<option value="video">{{ 'filters.type.video' | t: 'Vidéos' }}</option>
|
||||||
<option value="live">{{ 'filter.type.live' | t }}</option>
|
<option value="shorts">{{ 'filters.type.shorts' | t: 'Shorts / Clips' }}</option>
|
||||||
<option value="recorded">{{ 'filter.type.recorded' | t }}</option>
|
<option value="live">{{ 'filters.type.live' | t: 'En direct' }}</option>
|
||||||
<option value="short">{{ 'filter.type.short' | t }}</option>
|
<option value="channel">{{ 'filters.type.channel' | t: 'Chaînes' }}</option>
|
||||||
<option value="channel">{{ 'filter.type.channel' | t }}</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="language-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.language' | t }}</label>
|
<label for="duration-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filters.duration' | t: 'Durée' }}</label>
|
||||||
|
<select id="duration-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
[value]="filters().duration" (change)="onDurationChange($event)">
|
||||||
|
<option value="all">{{ 'filters.duration.all' | t: 'Toutes' }}</option>
|
||||||
|
<option value="short">{{ 'filters.duration.short' | t: 'Courte < 4 min' }}</option>
|
||||||
|
<option value="medium">{{ 'filters.duration.medium' | t: 'Moyenne 4-20 min' }}</option>
|
||||||
|
<option value="long">{{ 'filters.duration.long' | t: 'Longue > 20 min' }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="language-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.language' | t: 'Langue' }}</label>
|
||||||
<select id="language-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
<select id="language-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
[value]="languageFilter()" (change)="onLanguageChange($event)">
|
[value]="languageFilter()" (change)="onLanguageChange($event)">
|
||||||
<option value="any">{{ 'filter.language.any' | t }}</option>
|
<option value="any">{{ 'filter.language.any' | t: 'Toutes' }}</option>
|
||||||
<option value="en">{{ 'filter.language.en' | t }}</option>
|
<option value="en">{{ 'filter.language.en' | t: 'Anglais' }}</option>
|
||||||
<option value="fr">{{ 'filter.language.fr' | t }}</option>
|
<option value="fr">{{ 'filter.language.fr' | t: 'Français' }}</option>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="date-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.date' | t }}</label>
|
|
||||||
<select id="date-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
[value]="dateFilter()" (change)="onDateChange($event)">
|
|
||||||
<option value="any">{{ 'filter.date.any' | t }}</option>
|
|
||||||
<option value="day">{{ 'filter.date.day' | t }}</option>
|
|
||||||
<option value="week">{{ 'filter.date.week' | t }}</option>
|
|
||||||
<option value="month">{{ 'filter.date.month' | t }}</option>
|
|
||||||
<option value="year">{{ 'filter.date.year' | t }}</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-3 text-[11px] text-slate-500">
|
||||||
|
{{ 'filters.serverHint' | t: 'Type, période et durée sont appliqués par les sources ; la langue filtre les résultats affichés.' }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (error()) {
|
@if (error()) {
|
||||||
|
|||||||
@@ -11,8 +11,13 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
|||||||
import { SearchResultGridComponent } from 'src/app/shared/components/search-result-grid/search-result-grid.component';
|
import { SearchResultGridComponent } from 'src/app/shared/components/search-result-grid/search-result-grid.component';
|
||||||
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
|
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
|
||||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||||
import { isShortVideo } from 'src/app/shared/utils/video-kind';
|
|
||||||
import { SearchService, type SearchResponse } from '../../app/search/search.service';
|
import { SearchService, type SearchResponse } from '../../app/search/search.service';
|
||||||
|
import {
|
||||||
|
DEFAULT_FILTERS, activeFilterCount, filtersToQueryParams, filtersFromQueryParams,
|
||||||
|
normalizeFilters, matchesFilters, filterGroups, itemDurationSec, itemPublishedTs,
|
||||||
|
isLiveItem, isShortItem, isChannelItem, isClipItem,
|
||||||
|
type SearchFilters, type FilterDuration, type FilterPeriod, type FilterType, type FilterSort,
|
||||||
|
} from '../../app/search/filters';
|
||||||
import { UserService, type UserPreferences } from '../../services/user.service';
|
import { UserService, type UserPreferences } from '../../services/user.service';
|
||||||
import { PROVIDERS, type ProviderId } from '../../app/core/providers/provider-registry';
|
import { PROVIDERS, type ProviderId } from '../../app/core/providers/provider-registry';
|
||||||
import { Observable, Subscription } from 'rxjs';
|
import { Observable, Subscription } from 'rxjs';
|
||||||
@@ -46,6 +51,13 @@ export class SearchComponent {
|
|||||||
private defaultProvidersPref = signal<ProviderId[] | null>(null);
|
private defaultProvidersPref = signal<ProviderId[] | null>(null);
|
||||||
pageParam = signal<number>(1);
|
pageParam = signal<number>(1);
|
||||||
sortParam = signal<'relevance' | 'date' | 'views'>('relevance');
|
sortParam = signal<'relevance' | 'date' | 'views'>('relevance');
|
||||||
|
/**
|
||||||
|
* Filtres de recherche. Ils vivent dans l'URL (`?type=&duration=&period=&sort=`)
|
||||||
|
* ET sont poussés dans `SearchService` : le serveur filtre vraiment ses
|
||||||
|
* résultats (InnerTube/Data API quand c'est possible, post-filtrage sinon).
|
||||||
|
* Les filtres locaux ci-dessous ne font plus qu'un dernier raffinement.
|
||||||
|
*/
|
||||||
|
filters = signal<SearchFilters>({ ...DEFAULT_FILTERS });
|
||||||
|
|
||||||
// Unified multi-provider response (grouped suggestions)
|
// Unified multi-provider response (grouped suggestions)
|
||||||
groups = signal<Record<string, any[]>>({});
|
groups = signal<Record<string, any[]>>({});
|
||||||
@@ -168,16 +180,29 @@ export class SearchComponent {
|
|||||||
// Public computed used by the template to avoid referencing private `instances`
|
// Public computed used by the template to avoid referencing private `instances`
|
||||||
selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider());
|
selectedProviderForView = computed(() => this.providerParam() || this.instances.selectedProvider());
|
||||||
|
|
||||||
// Active tag filter. For non-Twitch providers: 'all' | 'short' | 'medium' | 'long' | 'recent'.
|
// Filter bar (same UX as the provider/theme page): the four selects write the
|
||||||
// For Twitch provider: 'twitch_all' | 'twitch_live' | 'twitch_vod'.
|
// URL (server-side filtering) and are mirrored locally as a safety net for
|
||||||
filterTag = signal<string>('all');
|
// providers that ignore a dimension.
|
||||||
|
durationFilter = signal<'any' | FilterDuration>('any');
|
||||||
// Filter bar (same UX as the provider/theme page): client-side selects.
|
typeFilter = signal<'any' | FilterType>('any');
|
||||||
// Server-side sort stays on `sortParam` (URL); these four filter locally.
|
|
||||||
durationFilter = signal<'any' | 'short' | 'medium' | 'long'>('any');
|
|
||||||
typeFilter = signal<'any' | 'video' | 'live' | 'recorded' | 'short' | 'channel'>('any');
|
|
||||||
languageFilter = signal<'any' | 'en' | 'fr'>('any');
|
languageFilter = signal<'any' | 'en' | 'fr'>('any');
|
||||||
dateFilter = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
|
dateFilter = signal<'any' | FilterPeriod>('any');
|
||||||
|
|
||||||
|
/** Filtres actifs (pour les pastilles + l'indicateur « filtré »). */
|
||||||
|
activeFilterCount = computed(() => activeFilterCount(this.filters()));
|
||||||
|
readonly filterGroupsView = computed(() => filterGroups(this.filters()));
|
||||||
|
/** Étiquettes des filtres actifs, prêtes à afficher. */
|
||||||
|
activeFilterChips = computed(() => {
|
||||||
|
const n = this.filters();
|
||||||
|
const out: { key: keyof SearchFilters; label: string }[] = [];
|
||||||
|
for (const g of this.filterGroupsView()) {
|
||||||
|
const k = g.key as keyof SearchFilters;
|
||||||
|
if (n[k] === 'all' || (k === 'sort' && n.sort === 'relevance')) continue;
|
||||||
|
const opt = g.options.find((o) => o.value === n[k]);
|
||||||
|
if (opt) out.push({ key: k, label: opt.label });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
// Unified total results count (sum of groups lengths)
|
// Unified total results count (sum of groups lengths)
|
||||||
unifiedTotal = computed(() => {
|
unifiedTotal = computed(() => {
|
||||||
@@ -201,74 +226,22 @@ export class SearchComponent {
|
|||||||
const list = this.allVideos();
|
const list = this.allVideos();
|
||||||
if (!Array.isArray(list) || list.length === 0) return [];
|
if (!Array.isArray(list) || list.length === 0) return [];
|
||||||
|
|
||||||
const filtered = this.applyFilterBar(this.applyTagFilter(list));
|
const filtered = this.applyFilterBar(list);
|
||||||
return this.sortVideos(filtered);
|
return this.sortVideos(filtered);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter each provider group separately for sectioned rendering
|
// Filter each provider group separately for sectioned rendering
|
||||||
filteredGroups = computed(() => {
|
filteredGroups = computed(() => {
|
||||||
const tagPredicate = this.buildTagPredicate();
|
|
||||||
const barPredicate = this.buildFilterBarPredicate();
|
const barPredicate = this.buildFilterBarPredicate();
|
||||||
const g = this.groups();
|
const g = this.groups();
|
||||||
const result: Record<string, any[]> = {};
|
const result: Record<string, any[]> = {};
|
||||||
|
|
||||||
Object.entries(g || {}).forEach(([pid, list]) => {
|
Object.entries(g || {}).forEach(([pid, list]) => {
|
||||||
result[pid] = Array.isArray(list) ? list.filter((v) => tagPredicate(v) && barPredicate(v)) : [];
|
result[pid] = Array.isArray(list) ? list.filter((v) => barPredicate(v)) : [];
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Available tags computed from current results (only show tags that can apply)
|
|
||||||
availableTags = computed(() => {
|
|
||||||
const list = this.allVideos();
|
|
||||||
if (list.length === 0) return [];
|
|
||||||
|
|
||||||
const tags: { key: string; label: string; show: boolean }[] = [];
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
// Non-Twitch providers: duration + date filters
|
|
||||||
const oneDay = 24 * 60 * 60 * 1000;
|
|
||||||
const sevenDays = 7 * oneDay;
|
|
||||||
const thirtyDays = 30 * oneDay;
|
|
||||||
const oneYear = 365 * oneDay;
|
|
||||||
|
|
||||||
let hasShort = false, hasMedium = false, hasLong = false,
|
|
||||||
hasToday = false, hasThisWeek = false, hasThisMonth = false, hasThisYear = false;
|
|
||||||
|
|
||||||
for (const v of list) {
|
|
||||||
const d = Number(v.durationSec || 0);
|
|
||||||
const uploadTime = v.publishedAt ? new Date(v.publishedAt).getTime() : 0;
|
|
||||||
const timeDiff = now - uploadTime;
|
|
||||||
|
|
||||||
// Duration filters
|
|
||||||
if (d > 0 && d < 4 * 60) hasShort = true;
|
|
||||||
else if (d >= 4 * 60 && d < 20 * 60) hasMedium = true;
|
|
||||||
else if (d >= 20 * 60) hasLong = true;
|
|
||||||
|
|
||||||
// Date filters
|
|
||||||
if (uploadTime > 0) {
|
|
||||||
if (timeDiff <= oneDay) hasToday = true;
|
|
||||||
if (timeDiff <= sevenDays) hasThisWeek = true;
|
|
||||||
if (timeDiff <= thirtyDays) hasThisMonth = true;
|
|
||||||
if (timeDiff <= oneYear) hasThisYear = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add duration filters
|
|
||||||
tags.push({ key: 'all', label: 'Tout', show: list.length > 0 });
|
|
||||||
tags.push({ key: 'short', label: 'Moins de 4 min', show: hasShort });
|
|
||||||
tags.push({ key: 'medium', label: 'De 4 à 20 min', show: hasMedium });
|
|
||||||
tags.push({ key: 'long', label: '20 min et plus', show: hasLong });
|
|
||||||
|
|
||||||
// Add date filters
|
|
||||||
tags.push({ key: 'today', label: 'Aujourd\'hui', show: hasToday });
|
|
||||||
tags.push({ key: 'this_week', label: 'Cette semaine', show: hasThisWeek });
|
|
||||||
tags.push({ key: 'this_month', label: 'Ce mois-ci', show: hasThisMonth });
|
|
||||||
tags.push({ key: 'this_year', label: 'Cette année', show: hasThisYear });
|
|
||||||
|
|
||||||
return tags.filter(t => t.show);
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Update document title when provider or query changes
|
// Update document title when provider or query changes
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -356,32 +329,38 @@ export class SearchComponent {
|
|||||||
this.pageParam.set(pageNum);
|
this.pageParam.set(pageNum);
|
||||||
this.unified.setPage(pageNum);
|
this.unified.setPage(pageNum);
|
||||||
if (isFinite(pageSize)) this.unified.setPageSize(pageSize);
|
if (isFinite(pageSize)) this.unified.setPageSize(pageSize);
|
||||||
const sortNorm = (sort === 'date' || sort === 'views') ? sort : 'relevance';
|
// Filtres (type / durée / période / tri) : lus dans l'URL puis poussés
|
||||||
this.sortParam.set(sortNorm);
|
// dans le service -> la requête serveur change vraiment de périmètre.
|
||||||
this.unified.setSort(sortNorm);
|
const filters = filtersFromQueryParams(pm);
|
||||||
|
this.filters.set(filters);
|
||||||
|
this.sortParam.set(filters.sort as 'relevance' | 'date' | 'views');
|
||||||
|
this.unified.setFilters(filters);
|
||||||
|
// Reflets locaux de la barre de filtres (le `any` = pas de filtre local).
|
||||||
|
this.durationFilter.set(filters.duration);
|
||||||
|
this.typeFilter.set(filters.type);
|
||||||
|
this.dateFilter.set(filters.period);
|
||||||
|
|
||||||
if (q) {
|
if (q) {
|
||||||
this.notice.set(null);
|
this.notice.set(null);
|
||||||
// Reset active tag + filter bar on new query
|
// Reset the language filter on a new query (the other filters are URL
|
||||||
const provider = prov || this.instances.selectedProvider();
|
// driven and must survive a query change).
|
||||||
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
|
|
||||||
this.durationFilter.set('any');
|
|
||||||
this.typeFilter.set('any');
|
|
||||||
this.languageFilter.set('any');
|
this.languageFilter.set('any');
|
||||||
this.dateFilter.set('any');
|
|
||||||
|
const activeProvider = prov || this.instances.selectedProvider();
|
||||||
|
|
||||||
// Use unified search in all cases (URL providers > user default preference > single provider).
|
// Use unified search in all cases (URL providers > user default preference > single provider).
|
||||||
// Normalized to short ids so every adapter resolves (legacy full names like
|
// Normalized to short ids so every adapter resolves (legacy full names like
|
||||||
// 'youtube' would otherwise match no adapter and return nothing).
|
// 'youtube' would otherwise match no adapter and return nothing).
|
||||||
const rawProviders = providersList || this.defaultProvidersPref() || [provider as ProviderId];
|
const rawProviders = providersList || this.defaultProvidersPref() || [activeProvider as ProviderId];
|
||||||
const providersToUse: ProviderId[] | 'all' = rawProviders === 'all'
|
const providersToUse: ProviderId[] | 'all' = rawProviders === 'all'
|
||||||
? 'all'
|
? 'all'
|
||||||
: (rawProviders as unknown[]).map(p => this.toProviderId(p)).filter((p): p is ProviderId => !!p);
|
: (rawProviders as unknown[]).map(p => this.toProviderId(p)).filter((p): p is ProviderId => !!p);
|
||||||
this.unified.setQuery(q);
|
this.unified.setQuery(q);
|
||||||
this.unified.setProviders(providersToUse);
|
this.unified.setProviders(providersToUse);
|
||||||
|
|
||||||
// Determine if this is a new search (signature excludes page)
|
// Determine if this is a new search (signature excludes page but
|
||||||
const signature = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}|${sortNorm}`;
|
// includes the filters: changing a filter restarts the results).
|
||||||
|
const signature = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}|${filters.sort}|${filters.type}.${filters.duration}.${filters.period}`;
|
||||||
const isNewSearch = this.lastSearchSignature !== signature || pageNum === 1;
|
const isNewSearch = this.lastSearchSignature !== signature || pageNum === 1;
|
||||||
this.lastSearchSignature = signature;
|
this.lastSearchSignature = signature;
|
||||||
|
|
||||||
@@ -390,7 +369,7 @@ export class SearchComponent {
|
|||||||
const key = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}`;
|
const key = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}`;
|
||||||
if (this.lastRecordedKey !== key) {
|
if (this.lastRecordedKey !== key) {
|
||||||
this.lastRecordedKey = key;
|
this.lastRecordedKey = key;
|
||||||
const recFilters: any = { provider, providers: Array.isArray(providersToUse) ? providersToUse : 'all' };
|
const recFilters: any = { provider: activeProvider, providers: Array.isArray(providersToUse) ? providersToUse : 'all' };
|
||||||
this.history.recordSearch(q, recFilters).subscribe({
|
this.history.recordSearch(q, recFilters).subscribe({
|
||||||
next: () => {},
|
next: () => {},
|
||||||
error: (err) => console.error('Error recording search:', err)
|
error: (err) => console.error('Error recording search:', err)
|
||||||
@@ -424,28 +403,11 @@ export class SearchComponent {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private durationSeconds(item: any): number {
|
// Lectures de métadonnées déléguées au modèle de filtres partagé
|
||||||
const raw = item?.durationSec ?? item?.duration ?? item?.length ?? 0;
|
// (server/search-filters.mjs côté API, src/app/search/filters.ts côté front :
|
||||||
if (typeof raw === 'number') {
|
// même implémentation = mêmes résultats).
|
||||||
return Number.isFinite(raw) ? raw : 0;
|
private durationSeconds(item: any): number { return itemDurationSec(item); }
|
||||||
}
|
private publishedTimestamp(item: any): number { return itemPublishedTs(item); }
|
||||||
const parsed = Number(raw);
|
|
||||||
return Number.isFinite(parsed) ? parsed : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private publishedTimestamp(item: any): number {
|
|
||||||
const raw = item?.publishedAt ?? item?.uploadedDate ?? item?.uploadDate ?? item?.published_at ?? item?.uploaded ?? null;
|
|
||||||
if (typeof raw === 'number') {
|
|
||||||
return Number.isFinite(raw) ? raw : 0;
|
|
||||||
}
|
|
||||||
if (typeof raw === 'string' && raw.trim()) {
|
|
||||||
const parsed = Date.parse(raw);
|
|
||||||
if (!Number.isNaN(parsed)) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private viewCount(item: any): number {
|
private viewCount(item: any): number {
|
||||||
const raw = item?.viewCount ?? item?.views ?? item?.stats?.views ?? item?.metrics?.views ?? 0;
|
const raw = item?.viewCount ?? item?.views ?? item?.stats?.views ?? item?.metrics?.views ?? 0;
|
||||||
@@ -456,95 +418,13 @@ export class SearchComponent {
|
|||||||
return Number.isFinite(parsed) ? parsed : 0;
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildTagPredicate(): (item: any) => boolean {
|
|
||||||
const tag = this.filterTag();
|
|
||||||
const provider = this.selectedProviderForView();
|
|
||||||
if (provider === 'twitch' || tag === 'all' || tag?.startsWith('twitch')) {
|
|
||||||
return () => true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const oneDay = 24 * 60 * 60 * 1000;
|
|
||||||
const sevenDays = 7 * oneDay;
|
|
||||||
const thirtyDays = 30 * oneDay;
|
|
||||||
const oneYear = 365 * oneDay;
|
|
||||||
|
|
||||||
switch (tag) {
|
|
||||||
case 'short':
|
|
||||||
return (item) => {
|
|
||||||
const d = this.durationSeconds(item);
|
|
||||||
return d > 0 && d < 4 * 60;
|
|
||||||
};
|
|
||||||
case 'medium':
|
|
||||||
return (item) => {
|
|
||||||
const d = this.durationSeconds(item);
|
|
||||||
return d >= 4 * 60 && d < 20 * 60;
|
|
||||||
};
|
|
||||||
case 'long':
|
|
||||||
return (item) => {
|
|
||||||
const d = this.durationSeconds(item);
|
|
||||||
return d >= 20 * 60;
|
|
||||||
};
|
|
||||||
case 'today':
|
|
||||||
return (item) => {
|
|
||||||
const ts = this.publishedTimestamp(item);
|
|
||||||
return ts > 0 && (now - ts) <= oneDay;
|
|
||||||
};
|
|
||||||
case 'this_week':
|
|
||||||
return (item) => {
|
|
||||||
const ts = this.publishedTimestamp(item);
|
|
||||||
return ts > 0 && (now - ts) <= sevenDays;
|
|
||||||
};
|
|
||||||
case 'this_month':
|
|
||||||
return (item) => {
|
|
||||||
const ts = this.publishedTimestamp(item);
|
|
||||||
return ts > 0 && (now - ts) <= thirtyDays;
|
|
||||||
};
|
|
||||||
case 'this_year':
|
|
||||||
return (item) => {
|
|
||||||
const ts = this.publishedTimestamp(item);
|
|
||||||
return ts > 0 && (now - ts) <= oneYear;
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return () => true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyTagFilter(list: any[]): any[] {
|
|
||||||
const predicate = this.buildTagPredicate();
|
|
||||||
return Array.isArray(list) ? list.filter(predicate) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Filter bar (same UX as the provider/theme page) ----
|
// ---- Filter bar (same UX as the provider/theme page) ----
|
||||||
|
// Les 4 dimensions principales vivent dans l'URL (filtres serveur) ; la
|
||||||
private isLiveItem(item: any): boolean {
|
// langue reste purement locale (aucun provider ne filtre par langue).
|
||||||
if (!item) return false;
|
private isLiveItem(item: any): boolean { return isLiveItem(item); }
|
||||||
if (item.isLive === true) return true;
|
private isShortItem(item: any): boolean { return isShortItem(item); }
|
||||||
// Strict: only an explicit 'live' type counts (unlike the shared util,
|
private isChannelItem(item: any): boolean { return isChannelItem(item); }
|
||||||
// offline Twitch channels typed 'channel' must NOT match).
|
private isClipItem(item: any): boolean { return isClipItem(item); }
|
||||||
return String(item.type || '').toLowerCase() === 'live';
|
|
||||||
}
|
|
||||||
|
|
||||||
private isShortItem(item: any): boolean {
|
|
||||||
if (!item) return false;
|
|
||||||
return isShortVideo({
|
|
||||||
duration: this.durationSeconds(item),
|
|
||||||
type: typeof item.type === 'string' ? item.type : undefined,
|
|
||||||
provider: typeof item.provider === 'string' ? item.provider : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private isChannelItem(item: any): boolean {
|
|
||||||
if (!item) return false;
|
|
||||||
// Chaîne Twitch hors-ligne : type explicite 'channel' et jamais en direct.
|
|
||||||
if (String(item.type || '').toLowerCase() === 'channel' && item.isLive !== true) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isClipItem(item: any): boolean {
|
|
||||||
if (!item) return false;
|
|
||||||
return String((item as any)?.kind || '').toLowerCase() === 'clip';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compteurs par type pour les pills façon directory Twitch. */
|
/** Compteurs par type pour les pills façon directory Twitch. */
|
||||||
typeCounts = computed(() => {
|
typeCounts = computed(() => {
|
||||||
@@ -560,71 +440,42 @@ export class SearchComponent {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Pills Tous / Directs / Vidéos / Clips / Chaînes synchronisées avec typeFilter. */
|
/** Pills Tous / Directs / Vidéos / Clips / Chaînes synchronisées avec typeFilter. */
|
||||||
typePill = computed<'all' | 'live' | 'video' | 'short' | 'channel'>(() => {
|
typePill = computed<'all' | 'live' | 'video' | 'shorts' | 'channel'>(() => {
|
||||||
const t = this.typeFilter();
|
const t = this.filters().type;
|
||||||
if (t === 'live') return 'live';
|
if (t === 'live') return 'live';
|
||||||
if (t === 'video' || t === 'recorded') return 'video';
|
if (t === 'video') return 'video';
|
||||||
if (t === 'short') return 'short';
|
if (t === 'shorts') return 'shorts';
|
||||||
if (t === 'channel') return 'channel';
|
if (t === 'channel') return 'channel';
|
||||||
return 'all';
|
return 'all';
|
||||||
});
|
});
|
||||||
|
|
||||||
setTypePill(v: 'all' | 'live' | 'video' | 'short' | 'channel'): void {
|
setTypePill(v: 'all' | 'live' | 'video' | 'shorts' | 'channel'): void {
|
||||||
if (v === 'all') this.typeFilter.set('any');
|
void this.setFilter('type', v === 'all' ? 'all' : v);
|
||||||
else if (v === 'video') this.typeFilter.set('video');
|
|
||||||
else this.typeFilter.set(v);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isFrText(s: string): boolean {
|
||||||
|
return /[éèêàùçîïô]/.test(s) || /\b(le|la|les|un|une)\b/i.test(s);
|
||||||
|
}
|
||||||
|
private isEnText(s: string): boolean {
|
||||||
|
return /\b(the|a|an|and|for)\b/i.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prédicat local : raffine les résultats *déjà filtrés par le serveur* avec
|
||||||
|
* exactement les mêmes règles (durée / période / type) + la langue, qui n'est
|
||||||
|
* pas supportée par les providers.
|
||||||
|
*/
|
||||||
private buildFilterBarPredicate(): (item: any) => boolean {
|
private buildFilterBarPredicate(): (item: any) => boolean {
|
||||||
const duration = this.durationFilter();
|
const filters = this.filters();
|
||||||
const type = this.typeFilter();
|
|
||||||
const lang = this.languageFilter();
|
const lang = this.languageFilter();
|
||||||
const date = this.dateFilter();
|
const hasServerFilter = activeFilterCount(filters) > 0;
|
||||||
|
if (!hasServerFilter && lang === 'any') return () => true;
|
||||||
if (duration === 'any' && type === 'any' && lang === 'any' && date === 'any') {
|
|
||||||
return () => true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const cutoff = date === 'any' ? 0 : {
|
|
||||||
day: now - 24 * 3600 * 1000,
|
|
||||||
week: now - 7 * 24 * 3600 * 1000,
|
|
||||||
month: now - 30 * 24 * 3600 * 1000,
|
|
||||||
year: now - 365 * 24 * 3600 * 1000,
|
|
||||||
}[date] || 0;
|
|
||||||
|
|
||||||
const isFrText = (s: string) => /[éèêàùçîïô]/.test(s) || /\b(le|la|les|un|une)\b/i.test(s);
|
|
||||||
const isEnText = (s: string) => /\b(the|a|an|and|for)\b/i.test(s);
|
|
||||||
|
|
||||||
return (item: any) => {
|
return (item: any) => {
|
||||||
if (duration !== 'any') {
|
if (hasServerFilter && !matchesFilters(item, filters)) return false;
|
||||||
const d = this.durationSeconds(item);
|
if (lang === 'any') return true;
|
||||||
if (duration === 'short' && !(d > 0 && d < 240)) return false;
|
const text = `${item?.title || ''} ${item?.channelName || ''}`.toLowerCase();
|
||||||
if (duration === 'medium' && !(d >= 240 && d < 1200)) return false;
|
if (lang === 'fr') return this.isFrText(text);
|
||||||
if (duration === 'long' && !(d >= 1200)) return false;
|
return this.isEnText(text) && !this.isFrText(text);
|
||||||
}
|
|
||||||
if (type !== 'any') {
|
|
||||||
const live = this.isLiveItem(item);
|
|
||||||
const short = this.isShortItem(item);
|
|
||||||
const channel = this.isChannelItem(item);
|
|
||||||
const clip = this.isClipItem(item);
|
|
||||||
if (type === 'live' && !live) return false;
|
|
||||||
if (type === 'channel' && !channel) return false;
|
|
||||||
if (type === 'short' && !(short || clip) && !live && !channel) {
|
|
||||||
// Les clips Twitch (kind='clip', parfois sans durée) comptent comme Shorts.
|
|
||||||
if (!clip) return false;
|
|
||||||
}
|
|
||||||
if (type === 'short' && (live || channel)) return false;
|
|
||||||
// 'video' / 'recorded' = VODs (ni live, ni short/clip, ni chaîne).
|
|
||||||
if ((type === 'video' || type === 'recorded') && (live || short || clip || channel)) return false;
|
|
||||||
}
|
|
||||||
if (lang !== 'any') {
|
|
||||||
const text = `${item?.title || ''} ${item?.channelName || ''}`.toLowerCase();
|
|
||||||
if (lang === 'fr' && !isFrText(text)) return false;
|
|
||||||
if (lang === 'en' && !(isEnText(text) && !isFrText(text))) return false;
|
|
||||||
}
|
|
||||||
if (cutoff > 0 && this.publishedTimestamp(item) < cutoff) return false;
|
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,19 +484,66 @@ export class SearchComponent {
|
|||||||
return Array.isArray(list) ? list.filter(predicate) : [];
|
return Array.isArray(list) ? list.filter(predicate) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Écrit un filtre : l'URL devient la source de vérité (donc la requête
|
||||||
|
* serveur repart avec le nouveau périmètre) et le signal local suit.
|
||||||
|
*/
|
||||||
|
setFilter(key: keyof SearchFilters, value: string): void {
|
||||||
|
const next = normalizeFilters({ ...this.filters(), [key]: value } as Partial<SearchFilters>);
|
||||||
|
// Reflets des <select> locaux.
|
||||||
|
if (key === 'duration') this.durationFilter.set(next.duration);
|
||||||
|
if (key === 'type') this.typeFilter.set(next.type);
|
||||||
|
if (key === 'period') this.dateFilter.set(next.period);
|
||||||
|
if (key === 'sort') this.sortParam.set(next.sort as 'relevance' | 'date' | 'views');
|
||||||
|
const cleanParams = this.cleanUrlParams({
|
||||||
|
...this.route.snapshot.queryParams,
|
||||||
|
...filtersToQueryParams(next),
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
// Supprime les clés devenues 'all' (elles ne sont plus réinjectées).
|
||||||
|
for (const k of ['type', 'duration', 'period', 'sort']) {
|
||||||
|
if (!filtersToQueryParams(next)[k]) delete (cleanParams as any)[k];
|
||||||
|
}
|
||||||
|
this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: cleanParams,
|
||||||
|
queryParamsHandling: '',
|
||||||
|
replaceUrl: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retire un filtre depuis une pastille. */
|
||||||
|
clearFilter(key: keyof SearchFilters): void {
|
||||||
|
void this.setFilter(key, key === 'sort' ? 'relevance' : 'all');
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAllFilters(): void {
|
||||||
|
const next = { ...DEFAULT_FILTERS };
|
||||||
|
this.filters.set(next);
|
||||||
|
this.durationFilter.set('all');
|
||||||
|
this.typeFilter.set('all');
|
||||||
|
this.dateFilter.set('all');
|
||||||
|
this.sortParam.set('relevance');
|
||||||
|
this.unified.setFilters(next);
|
||||||
|
const cleanParams = this.cleanUrlParams({ ...this.route.snapshot.queryParams, page: 1 });
|
||||||
|
for (const k of ['type', 'duration', 'period', 'sort']) delete (cleanParams as any)[k];
|
||||||
|
this.router.navigate([], {
|
||||||
|
relativeTo: this.route, queryParams: cleanParams, replaceUrl: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onDurationChange(event: Event) {
|
onDurationChange(event: Event) {
|
||||||
this.durationFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
|
void this.setFilter('duration', ((event.target as HTMLSelectElement)?.value as any) || 'all');
|
||||||
}
|
}
|
||||||
onTypeChange(event: Event) {
|
onTypeChange(event: Event) {
|
||||||
this.typeFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
|
void this.setFilter('type', ((event.target as HTMLSelectElement)?.value as any) || 'all');
|
||||||
}
|
}
|
||||||
onLanguageChange(event: Event) {
|
onLanguageChange(event: Event) {
|
||||||
this.languageFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
|
this.languageFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
|
||||||
}
|
}
|
||||||
onDateChange(event: Event) {
|
onDateChange(event: Event) {
|
||||||
this.dateFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
|
void this.setFilter('period', ((event.target as HTMLSelectElement)?.value as any) || 'all');
|
||||||
}
|
}
|
||||||
|
|
||||||
private sortVideos(list: any[]): any[] {
|
private sortVideos(list: any[]): any[] {
|
||||||
if (!Array.isArray(list)) {
|
if (!Array.isArray(list)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -771,30 +669,37 @@ export class SearchComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Submit handler from the embedded SearchBox: keep URL as source of truth
|
// Submit handler from the embedded SearchBox: keep URL as source of truth
|
||||||
onSearchBarSubmit(evt: { q: string; providers: ProviderId[] | 'all' }) {
|
onSearchBarSubmit(evt: { q: string; providers: ProviderId[] | 'all'; filters?: SearchFilters }) {
|
||||||
const q = (evt?.q || '').trim();
|
const q = (evt?.q || '').trim();
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
|
|
||||||
// Créer un nouvel objet de paramètres de requête
|
// Les filtres actifs sont conservés : relancer une recherche ne doit pas
|
||||||
const queryParams: any = {
|
// perdre le périmètre choisi.
|
||||||
q,
|
const filters = evt?.filters ? normalizeFilters(evt.filters) : this.filters();
|
||||||
|
const queryParams: any = {
|
||||||
|
...this.route.snapshot.queryParams,
|
||||||
|
q,
|
||||||
p: this.providerParam() || this.instances.selectedProvider(),
|
p: this.providerParam() || this.instances.selectedProvider(),
|
||||||
page: 1 // reset pagination on new search
|
...filtersToQueryParams(filters),
|
||||||
|
page: 1, // reset pagination on new search
|
||||||
};
|
};
|
||||||
|
for (const k of ['type', 'duration', 'period', 'sort']) {
|
||||||
|
if (!filtersToQueryParams(filters)[k]) delete queryParams[k];
|
||||||
|
}
|
||||||
|
|
||||||
// Gérer les fournisseurs
|
// Gérer les fournisseurs
|
||||||
if (Array.isArray(evt.providers)) {
|
if (Array.isArray(evt.providers)) {
|
||||||
queryParams.providers = evt.providers.join(',');
|
queryParams.providers = evt.providers.join(',');
|
||||||
} else if (evt.providers === 'all') {
|
} else if (evt.providers === 'all') {
|
||||||
queryParams.providers = 'yt,dm,tw,pt,od,ru';
|
queryParams.providers = 'yt,dm,tw,pt,od,ru';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nettoyer les paramètres avant la navigation
|
// Nettoyer les paramètres avant la navigation
|
||||||
const cleanParams = this.cleanUrlParams(queryParams);
|
const cleanParams = this.cleanUrlParams(queryParams);
|
||||||
|
|
||||||
// Utiliser replaceUrl: true pour éviter d'ajouter une entrée dans l'historique
|
// Utiliser replaceUrl: true pour éviter d'ajouter une entrée dans l'historique
|
||||||
// et forcer le rechargement avec les nouveaux paramètres
|
// et forcer le rechargement avec les nouveaux paramètres
|
||||||
this.router.navigate([], {
|
this.router.navigate([], {
|
||||||
relativeTo: this.route,
|
relativeTo: this.route,
|
||||||
queryParams: cleanParams,
|
queryParams: cleanParams,
|
||||||
queryParamsHandling: '',
|
queryParamsHandling: '',
|
||||||
@@ -819,11 +724,6 @@ export class SearchComponent {
|
|||||||
// Vous pouvez ajouter ici la logique de recherche au fur et à mesure si nécessaire
|
// Vous pouvez ajouter ici la logique de recherche au fur et à mesure si nécessaire
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update active filter tag from the template
|
|
||||||
setFilterTag(key: string) {
|
|
||||||
this.filterTag.set(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Infinite-scroll pagination: append the next page WITHOUT touching the
|
// Infinite-scroll pagination: append the next page WITHOUT touching the
|
||||||
// router URL. Navigating (even with replaceUrl) rewrites the location hash
|
// router URL. Navigating (even with replaceUrl) rewrites the location hash
|
||||||
// (HashLocationStrategy) and remounts list state, which throws the user
|
// (HashLocationStrategy) and remounts list state, which throws the user
|
||||||
@@ -866,21 +766,10 @@ export class SearchComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort control
|
// Sort control -> même chemin que les autres filtres (URL + service)
|
||||||
onSortChange(event: Event) {
|
onSortChange(event: Event) {
|
||||||
const value = (event.target as HTMLSelectElement)?.value as 'relevance' | 'date' | 'views' | '';
|
const value = (event.target as HTMLSelectElement)?.value as FilterSort | '';
|
||||||
const sort = (value === 'date' || value === 'views') ? value : 'relevance';
|
void this.setFilter('sort', (value === 'date' || value === 'views') ? value : 'relevance');
|
||||||
const cleanParams = this.cleanUrlParams({
|
|
||||||
...this.route.snapshot.queryParams,
|
|
||||||
sort,
|
|
||||||
page: 1
|
|
||||||
});
|
|
||||||
this.router.navigate([], {
|
|
||||||
relativeTo: this.route,
|
|
||||||
queryParams: cleanParams,
|
|
||||||
queryParamsHandling: '',
|
|
||||||
replaceUrl: true
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CTA handler: navigate to focus on a single provider while preserving q
|
// CTA handler: navigate to focus on a single provider while preserving q
|
||||||
|
|||||||
@@ -127,6 +127,42 @@ const EN: Record<string, string> = {
|
|||||||
'filter.date.week': 'Last week',
|
'filter.date.week': 'Last week',
|
||||||
'filter.date.month': 'Last month',
|
'filter.date.month': 'Last month',
|
||||||
'filter.date.year': 'Last year',
|
'filter.date.year': 'Last year',
|
||||||
|
// Search filters panel (type / period / duration / sort)
|
||||||
|
'filters.title': 'Search filters',
|
||||||
|
'filters.close': 'Close filters (Esc)',
|
||||||
|
'filters.recent': 'Recent searches',
|
||||||
|
'filters.sources': 'Sources',
|
||||||
|
'filters.sources.all': 'All',
|
||||||
|
'filters.sources.allHint': 'All platforms',
|
||||||
|
'filters.sources.remember': 'Remember as default sources',
|
||||||
|
'filters.type': 'Content type',
|
||||||
|
'filters.type.all': 'Anything',
|
||||||
|
'filters.type.video': 'Videos',
|
||||||
|
'filters.type.shorts': 'Shorts / Clips',
|
||||||
|
'filters.type.live': 'Live',
|
||||||
|
'filters.type.channel': 'Channels',
|
||||||
|
'filters.period': 'Upload period',
|
||||||
|
'filters.period.all': 'Any time',
|
||||||
|
'filters.period.hour': 'Last hour',
|
||||||
|
'filters.period.today': 'Today',
|
||||||
|
'filters.period.week': 'This week',
|
||||||
|
'filters.period.month': 'This month',
|
||||||
|
'filters.period.year': 'This year',
|
||||||
|
'filters.duration': 'Duration',
|
||||||
|
'filters.duration.all': 'Any',
|
||||||
|
'filters.duration.short': 'Short < 4 min',
|
||||||
|
'filters.duration.medium': 'Medium 4-20 min',
|
||||||
|
'filters.duration.long': 'Long > 20 min',
|
||||||
|
'filters.sort': 'Sort by',
|
||||||
|
'filters.sort.relevance': 'Relevance',
|
||||||
|
'filters.sort.date': 'Date',
|
||||||
|
'filters.sort.views': 'Views',
|
||||||
|
'filters.reset': 'Reset',
|
||||||
|
'filters.cancel': 'Cancel',
|
||||||
|
'filters.apply': 'Apply',
|
||||||
|
'filters.shortcutHint': 'Tip: the operators',
|
||||||
|
'filters.shortcutHintEnd': 'typed in the search bar become filters.',
|
||||||
|
'filters.serverHint': 'Type, period and duration are applied by the sources; language filters the displayed results.',
|
||||||
// Optional per-theme labels
|
// Optional per-theme labels
|
||||||
'theme.finance-crypto': 'Finance & Crypto',
|
'theme.finance-crypto': 'Finance & Crypto',
|
||||||
'theme.gaming': 'Gaming',
|
'theme.gaming': 'Gaming',
|
||||||
@@ -281,6 +317,42 @@ const FR: Record<string, string> = {
|
|||||||
'filter.date.week': 'Semaine dernière',
|
'filter.date.week': 'Semaine dernière',
|
||||||
'filter.date.month': 'Mois dernier',
|
'filter.date.month': 'Mois dernier',
|
||||||
'filter.date.year': 'Année dernière',
|
'filter.date.year': 'Année dernière',
|
||||||
|
// Panneau de filtres de recherche (type / période / durée / tri)
|
||||||
|
'filters.title': 'Filtres de recherche',
|
||||||
|
'filters.close': 'Fermer les filtres (Esc)',
|
||||||
|
'filters.recent': 'Recherches récentes',
|
||||||
|
'filters.sources': 'Sources',
|
||||||
|
'filters.sources.all': 'Toutes',
|
||||||
|
'filters.sources.allHint': 'Toutes les plateformes',
|
||||||
|
'filters.sources.remember': 'Mémoriser comme sources par défaut',
|
||||||
|
'filters.type': 'Type de contenu',
|
||||||
|
'filters.type.all': 'Tout',
|
||||||
|
'filters.type.video': 'Vidéos',
|
||||||
|
'filters.type.shorts': 'Shorts / Clips',
|
||||||
|
'filters.type.live': 'En direct',
|
||||||
|
'filters.type.channel': 'Chaînes',
|
||||||
|
'filters.period': 'Période de mise en ligne',
|
||||||
|
'filters.period.all': "N'importe quand",
|
||||||
|
'filters.period.hour': 'Dernière heure',
|
||||||
|
'filters.period.today': "Aujourd'hui",
|
||||||
|
'filters.period.week': 'Cette semaine',
|
||||||
|
'filters.period.month': 'Ce mois',
|
||||||
|
'filters.period.year': 'Cette année',
|
||||||
|
'filters.duration': 'Durée',
|
||||||
|
'filters.duration.all': 'Toutes',
|
||||||
|
'filters.duration.short': 'Courte < 4 min',
|
||||||
|
'filters.duration.medium': 'Moyenne 4-20 min',
|
||||||
|
'filters.duration.long': 'Longue > 20 min',
|
||||||
|
'filters.sort': 'Trier par',
|
||||||
|
'filters.sort.relevance': 'Pertinence',
|
||||||
|
'filters.sort.date': 'Date',
|
||||||
|
'filters.sort.views': 'Vues',
|
||||||
|
'filters.reset': 'Réinitialiser',
|
||||||
|
'filters.cancel': 'Annuler',
|
||||||
|
'filters.apply': 'Appliquer',
|
||||||
|
'filters.shortcutHint': 'Astuce : les opérateurs',
|
||||||
|
'filters.shortcutHintEnd': 'écrits dans la barre de recherche deviennent des filtres.',
|
||||||
|
'filters.serverHint': "Type, période et durée sont appliqués par les sources ; la langue filtre les résultats affichés.",
|
||||||
// Optional per-theme labels
|
// Optional per-theme labels
|
||||||
'theme.finance-crypto': 'Finance & Crypto',
|
'theme.finance-crypto': 'Finance & Crypto',
|
||||||
'theme.gaming': 'Jeux vidéo',
|
'theme.gaming': 'Jeux vidéo',
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export type TelemetryEvent =
|
|||||||
| 'provider_apply'
|
| 'provider_apply'
|
||||||
| 'at_autocomplete_use'
|
| 'at_autocomplete_use'
|
||||||
| 'quick_menu_open'
|
| 'quick_menu_open'
|
||||||
|
| 'filter_panel_open'
|
||||||
|
| 'filter_apply'
|
||||||
| 'suggest_shown'
|
| 'suggest_shown'
|
||||||
| 'suggest_used';
|
| 'suggest_used';
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
- [x] Step 14: Update README with search UX section, keyboard shortcuts and usage notes
|
- [x] Step 14: Update README with search UX section, keyboard shortcuts and usage notes
|
||||||
- [x] Step 15: Auto-complétion de la **requête** (typeahead) dans la barre de recherche
|
- [x] Step 15: Auto-complétion de la **requête** (typeahead) dans la barre de recherche
|
||||||
- [x] Step 16: **Transcripts** de vidéos sur la page Watch (API + UI, Phase 1 du doc)
|
- [x] Step 16: **Transcripts** de vidéos sur la page Watch (API + UI, Phase 1 du doc)
|
||||||
|
- [x] Step 19: Panneau de filtres unifié (remplace le ProviderPicker) + filtres serveur + clavier/focus du panneau de suggestions
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -154,7 +155,7 @@ valeur / coût pour l'existant.
|
|||||||
|
|
||||||
| # | Fonction | Description | Effort |
|
| # | Fonction | Description | Effort |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | **Filtres de recherche** | Durée (< 4 min / 4-20 / 20+), type (vidéo/chaîne/playlist), date de mise en ligne, langue. `SearchService.sort$` existe déjà, il manque les params + l'UI en chips + le mapping par adapter | + |
|
| 1 | **Filtres de recherche** | ✅ DONE : modèle partagé (`src/app/search/filters.ts` + `server/search-filters.mjs`), panneau unique (sources / type / période / durée / tri) au clavier, filtres dans l'URL + `/api/search`, appliqués nativement (InnerTube, Data API, `--dateafter`) ou en post-traitement, opérateurs `live:`/`today:`/`long:`, langue encore locale | ~ |
|
||||||
| 2 | **Pagination / infinite scroll** | ✅ DONE (Step 18) : backend pages illimitées via continuations InnerTube (`page=2,3…`, `pageSize` ≤ 50) + front `loadNextPage()` + `<app-infinite-anchor>` + `mergeGroups` + `endReached` (`search.component.ts`) | ~ |
|
| 2 | **Pagination / infinite scroll** | ✅ DONE (Step 18) : backend pages illimitées via continuations InnerTube (`page=2,3…`, `pageSize` ≤ 50) + front `loadNextPage()` + `<app-infinite-anchor>` + `mergeGroups` + `endReached` (`search.component.ts`) | ~ |
|
||||||
| 3 | **Dédup multi-provider** | La même vidéo existe souvent sur Rumble/Odysee/PeerTube : regrouper par titre+durée+chaîne et afficher « aussi disponible sur… » | + |
|
| 3 | **Dédup multi-provider** | La même vidéo existe souvent sur Rumble/Odysee/PeerTube : regrouper par titre+durée+chaîne et afficher « aussi disponible sur… » | + |
|
||||||
| 4 | **Recherche dans les résultats** (« search within results ») | Relancer la requête en restreignant au provider/à la chaîne déjà affichée | ~ |
|
| 4 | **Recherche dans les résultats** (« search within results ») | Relancer la requête en restreignant au provider/à la chaîne déjà affichée | ~ |
|
||||||
@@ -193,7 +194,8 @@ valeur / coût pour l'existant.
|
|||||||
|
|
||||||
## Test commands
|
## Test commands
|
||||||
```bash
|
```bash
|
||||||
npm run test:search # Step 11 — unit tests: SearchService + @ parsing + ProviderPicker
|
npm run test:search # Step 11/19 — unit tests: SearchService, @ parsing, clavier/focus, panneau de filtres
|
||||||
|
npm run test:filters # Step 19 — filtres serveur (offline)
|
||||||
npm run test:search-e2e # Step 12 — e2e scenarios against a real isolated server
|
npm run test:search-e2e # Step 12 — e2e scenarios against a real isolated server
|
||||||
npm run test:preferences # Step 9 — defaultProviders persistence
|
npm run test:preferences # Step 9 — defaultProviders persistence
|
||||||
npm run test:telemetry # Step 13 — telemetry events (insert/list/count)
|
npm run test:telemetry # Step 13 — telemetry events (insert/list/count)
|
||||||
|
|||||||
Reference in New Issue
Block a user