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:
+17
-6
@@ -17,6 +17,7 @@ import axios from 'axios';
|
||||
import rumbleRouter from './rumble.mjs';
|
||||
import { providerRegistry, validateProviders } from './providers/registry.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 { pickTrack, parseTrackText, parseVtt, dedupeTranscriptLines, orderedTracks, translatedFallbacks, firstPerLanguage, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError, ensureFmtParam, isEmptyTimedTextBody, isBlobTranscript, mergeTranscriptCandidates } from './transcript.mjs';
|
||||
import {
|
||||
@@ -2522,7 +2523,7 @@ r.post('/telemetry/events', authMiddleware, telemetryLimiter, (req, res) => {
|
||||
const { event, meta } = req.body || {};
|
||||
if (!event || typeof event !== 'string') return res.status(400).json({ error: 'event_required' });
|
||||
// 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' });
|
||||
const row = insertTelemetryEvent({ userId: req.user.id, event, meta: (meta && typeof meta === 'object') ? meta : null });
|
||||
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) => {
|
||||
const since = typeof req.query.since === 'string' ? req.query.since : undefined;
|
||||
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 });
|
||||
}
|
||||
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 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)
|
||||
const allowedSort = new Set(['relevance', 'date', 'views']);
|
||||
let sort = (typeof req.query.sort === 'string' ? req.query.sort.trim().toLowerCase() : '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)
|
||||
const requested = typeof providers === 'string' ? String(providers) : '';
|
||||
const validProviders = validateProviders(requested);
|
||||
@@ -3118,8 +3124,8 @@ app.get('/api/search', async (req, res) => {
|
||||
validProviders.map((providerId) => {
|
||||
const mod = providerRegistry[/** @type {any} */(providerId)];
|
||||
if (!mod || typeof mod.search !== 'function') return Promise.resolve([]);
|
||||
// Basic options include pagination and sort hints
|
||||
return Promise.resolve().then(() => mod.search(q, { limit: pageSize, page: pageNum, sort }));
|
||||
// Basic options include pagination, sort hints and the search filters
|
||||
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) => {
|
||||
const providerId = validProviders[index];
|
||||
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 {
|
||||
console.warn(`Search failed for provider ${providerId}:`, result.reason?.message || result.reason);
|
||||
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) {
|
||||
return res.status(500).json({ error: 'search_failed', details: String(e?.message || e) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user