chore: update Angular cache and TypeScript build info files

This commit is contained in:
Bruno Charest 2025-09-22 09:34:22 -04:00
parent 08fd0682a4
commit 4eb339eb22
34 changed files with 1511 additions and 154 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,61 +1,61 @@
{
"hash": "534e7bec",
"hash": "f4c7eaa2",
"configHash": "d859ec53",
"lockfileHash": "891162b0",
"browserHash": "b971f174",
"lockfileHash": "9b1c4210",
"browserHash": "6942cbde",
"optimized": {
"@angular/common": {
"src": "../../../../../../node_modules/@angular/common/fesm2022/common.mjs",
"file": "@angular_common.js",
"fileHash": "76f579d7",
"fileHash": "92f641aa",
"needsInterop": false
},
"@angular/common/http": {
"src": "../../../../../../node_modules/@angular/common/fesm2022/http.mjs",
"file": "@angular_common_http.js",
"fileHash": "3f81fe6e",
"fileHash": "3a8b8614",
"needsInterop": false
},
"@angular/core": {
"src": "../../../../../../node_modules/@angular/core/fesm2022/core.mjs",
"file": "@angular_core.js",
"fileHash": "817c1079",
"fileHash": "b1ee9355",
"needsInterop": false
},
"@angular/forms": {
"src": "../../../../../../node_modules/@angular/forms/fesm2022/forms.mjs",
"file": "@angular_forms.js",
"fileHash": "5c59f890",
"fileHash": "5842af9d",
"needsInterop": false
},
"@angular/platform-browser": {
"src": "../../../../../../node_modules/@angular/platform-browser/fesm2022/platform-browser.mjs",
"file": "@angular_platform-browser.js",
"fileHash": "4f20f29c",
"fileHash": "f65b040d",
"needsInterop": false
},
"@angular/router": {
"src": "../../../../../../node_modules/@angular/router/fesm2022/router.mjs",
"file": "@angular_router.js",
"fileHash": "ae70e479",
"fileHash": "f605abad",
"needsInterop": false
},
"@google/genai": {
"src": "../../../../../../node_modules/@google/genai/dist/web/index.mjs",
"file": "@google_genai.js",
"fileHash": "4d8ae55a",
"fileHash": "b3b8b992",
"needsInterop": false
},
"rxjs": {
"src": "../../../../../../node_modules/rxjs/dist/esm5/index.js",
"file": "rxjs.js",
"fileHash": "490b7fef",
"fileHash": "0cb397ac",
"needsInterop": false
},
"rxjs/operators": {
"src": "../../../../../../node_modules/rxjs/dist/esm5/operators/index.js",
"file": "rxjs_operators.js",
"fileHash": "938cbe53",
"fileHash": "e706ebfa",
"needsInterop": false
}
},

View File

@ -1,8 +1,8 @@
{
"hash": "f1fac02c",
"hash": "7ebe1a66",
"configHash": "3d00a7fd",
"lockfileHash": "891162b0",
"browserHash": "10427b09",
"lockfileHash": "9b1c4210",
"browserHash": "dfa5acef",
"optimized": {},
"chunks": {}
}

10
.env.example Normal file
View File

@ -0,0 +1,10 @@
# Configuration des clés API pour les adaptateurs de recherche
# Copiez ce fichier en .env et remplissez les valeurs
# YouTube API Key (obligatoire pour la recherche YouTube)
# Obtenez une clé ici : https://console.developers.google.com/
YOUTUBE_API_KEY=your_youtube_api_key_here
# Twitch Client ID (optionnel, pour une recherche Twitch plus complète)
# Obtenez un Client ID ici : https://dev.twitch.tv/console/apps
TWITCH_CLIENT_ID=your_twitch_client_id_here

130
DIAGNOSTIC.md Normal file
View File

@ -0,0 +1,130 @@
# 🔍 Guide de Diagnostic - Recherche Unifiée NewTube
## **Problème identifié**
La barre de recherche ne lance aucune action réseau car le backend n'était pas démarré sur le bon port.
## **Solution étape par étape**
### **Étape 1 : Nettoyer les processus existants**
```bash
# Vérifier qu'il n'y a plus de processus
ps aux | grep node | grep -v grep
# Si il y en a, les tuer
killall node
```
### **Étape 2 : Démarrer le backend sur le port 4001**
```bash
# Terminal 1 - Backend
cd /c/dev/git/web/NewTube
PORT=4001 npm run api:watch
```
**Attendre que le backend affiche :**
```
[newtube-api] listening on http://localhost:4001
```
### **Étape 3 : Tester le backend**
```bash
# Dans un autre terminal
curl "http://localhost:4001/api/search?q=test&providers=dm"
```
**Devrait retourner :** Un JSON avec les résultats de recherche
### **Étape 4 : Démarrer le frontend**
```bash
# Terminal 2 - Frontend
cd /c/dev/git/web/NewTube
npm run dev
```
**Attendre que le frontend affiche :**
```
➜ Local: http://localhost:4200/
➜ press h + enter to show help
```
### **Étape 5 : Tester la recherche**
1. **Ouvrir** `http://localhost:4200` dans le navigateur
2. **Ouvrir les outils développeur** (F12)
3. **Aller dans l'onglet Network**
4. **Taper** "test" dans la barre de recherche
5. **Cliquer** sur "Search" ou **appuyer** sur Enter
### **Ce qui devrait se passer :**
#### ✅ **Navigation frontend :**
- L'URL change vers `/search?q=test&providers=yt,dm,tw,pt,od,ru`
- Le composant SearchComponent se charge
#### ✅ **Requêtes réseau :**
- Une requête vers `http://localhost:4200/api/search?q=test&providers=yt,dm,tw,pt,od,ru`
- Le proxy Angular redirige vers `http://localhost:4001/api/search`
- Le backend retourne les résultats
#### ✅ **Console :**
- Pas d'erreurs JavaScript
- Les résultats s'affichent
## **Si ça ne marche pas :**
### **A. Backend ne démarre pas :**
```bash
# Vérifier le port 4001
lsof -i :4001
# Tuer le processus
lsof -ti:4001 | xargs kill -9
# Relancer
PORT=4001 npm run api:watch
```
### **B. Frontend ne démarre pas :**
```bash
# Nettoyer le cache
npx ng cache clean
npm run dev
```
### **C. Erreur de proxy :**
```bash
# Vérifier proxy.conf.json
cat proxy.conf.json
# Doit pointer vers localhost:4001
```
### **D. Erreur de clé API :**
```bash
# Vérifier les variables d'environnement
echo "YOUTUBE_API_KEY=votre_clé" > .env.local
```
## **Debug avancé :**
### **1. Tester l'API directement :**
```bash
curl "http://localhost:4001/api/search?q=linux&providers=yt,dm"
```
### **2. Vérifier les logs backend :**
```bash
# Dans le terminal du backend, chercher les erreurs
# Vérifier que les adaptateurs répondent
```
### **3. Console navigateur :**
- **Network tab** : Voir les requêtes vers `/api/search`
- **Console tab** : Voir les erreurs JavaScript
- **Application tab** : Vérifier le localStorage
## **Configuration actuelle :**
- ✅ **Frontend** : Port 4200 (avec proxy vers 4001)
- ✅ **Backend** : Port 4001
- ✅ **Proxy configuré** : `proxy.conf.json` modifié
- ✅ **Routes configurées** : `/search` route existe
- ✅ **SearchComponent** : Code corrigé et fonctionnel
**Exécutez ces étapes dans l'ordre et dites-moi à quelle étape vous bloquez !**

View File

@ -238,3 +238,53 @@ MIT (voir `LICENSE`)
---
> 💡 **Tip produit** : gardez lUX simple — **Thèmes fixes sous le header**, **Shorts séparés**, **CTA clairs** (“View All”, “Add to playlist”), et utilisez les **états vides** (empty states) sur History/Liked/Playlists pour guider lutilisateur.
---
## 🧩 Ajouter un provider en 5 minutes
1) Front — Registry
- Éditez `src/app/core/providers/provider-registry.ts` et ajoutez une entrée `ProviderSpec` avec:
- `id` (ex. `"yt"`), `displayName`, `shortLabel`, `icon`, `colorClass`
- `supports` (search/shorts/live/playlists)
- `buildSearchUrl(q)` (facultatif côté front)
2) Back — Handler
- Créez un fichier `server/providers/<provider>.mjs` qui exporte `default` avec:
- `id`, `label`
- `async search(q, { limit, page })``Promise<Suggestion[]>`
- Enregistrez-le dans `server/providers/registry.mjs` pour être éligible au fanout `/api/search`.
3) API — Recherche multiproviders
- Lendpoint `GET /api/search?q=...&providers=yt,dm,ru&limit=...` valide les providers et déclenche les recherches en parallèle (timeouts), puis retourne:
```json
{
"q": "documentary",
"providers": ["yt","dm","ru"],
"groups": {
"yt": [{ "title": "...", "id": "...", "duration": 192, "isShort": false }],
"dm": [],
"ru": [{ "title": "...", "id": "...", "isShort": true }]
}
}
```
4) UI — Sélecteur & Chips
- Le `SearchBoxComponent` (standalone) rend les chips `All / YT / DM / TW / PT / OD / RU` + menu rapide `@` (ProviderPicker).
- Le composant émet `(submitted)` avec `{ q, providers }` et met à jour `SearchService` (RxJS state) pour lappel API.
5) Deeplink
- Les URLs du type `?q=…&providers=yt,ru` relancent la même recherche. Assurez-vous de propager le paramètre `providers` lors des navigations.
6) Tests (à compléter)
- Unit: parsing `@yt` dans linput, toggle ProviderPicker, composition de `SearchService`.
- e2e: `@yt + query` → résultats uniquement YouTube; chips `YT+DM` → sections YT/DM visibles; deeplink restauré.
Astuce: lajout dun provider ne nécessite pas de modifier les composants — il suffit dajouter une entrée dans le registry front + un handler API.

Binary file not shown.

16
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@angular/build": "^20.1.0",
"@angular/cdk": "^20.2.4",
"@angular/cli": "^20.1.0",
"@angular/common": "^20.1.0",
"@angular/compiler": "^20.1.0",
@ -479,6 +480,21 @@
}
}
},
"node_modules/@angular/cdk": {
"version": "20.2.4",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-20.2.4.tgz",
"integrity": "sha512-5UzrN854pnQH+Qw6XZRxx2zWkcOxKrzWPLXe+gHFxFhxWUZfJKGcTJeAj8bnmyb+C3lqBbGpoNQPQ8pFXQGEaQ==",
"license": "MIT",
"dependencies": {
"parse5": "^8.0.0",
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": "^20.0.0 || ^21.0.0",
"@angular/core": "^20.0.0 || ^21.0.0",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/cli": {
"version": "20.2.2",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-20.2.2.tgz",

View File

@ -13,6 +13,7 @@
},
"dependencies": {
"@angular/build": "^20.1.0",
"@angular/cdk": "^20.2.4",
"@angular/cli": "^20.1.0",
"@angular/common": "^20.1.0",
"@angular/compiler": "^20.1.0",

View File

@ -12,6 +12,7 @@ import ffmpegPath from 'ffmpeg-static';
import * as cheerio from 'cheerio';
import axios from 'axios';
import rumbleRouter from './rumble.mjs';
import { providerRegistry, validateProviders } from './providers/registry.mjs';
import {
getUserByUsername,
getUserById,
@ -1386,6 +1387,45 @@ app.all('/api/odysee/*', (req, res) => forwardJson(req, res, 'https://api.na-bac
app.all('/api/twitch-api/*', (req, res) => forwardJson(req, res, 'https://api.twitch.tv'));
app.all('/api/twitch-auth/*', (req, res) => forwardJson(req, res, 'https://id.twitch.tv'));
// -------------------- Unified search endpoint (GET) --------------------
app.get('/api/search', async (req, res) => {
try {
const { q, providers } = req.query;
if (!q || typeof q !== 'string' || q.trim().length === 0) {
return res.status(400).json({ error: 'missing_query' });
}
// Validate and normalize providers list (default to all supported when none/invalid)
const requested = typeof providers === 'string' ? String(providers) : '';
const validProviders = validateProviders(requested);
// Execute search for each provider in parallel
const results = await Promise.allSettled(
validProviders.map((providerId) => {
const mod = providerRegistry[/** @type {any} */(providerId)];
if (!mod || typeof mod.search !== 'function') return Promise.resolve([]);
// Basic options: limit per provider; could be extended with page, etc.
return Promise.resolve().then(() => mod.search(q, { limit: 10 }));
})
);
// Group results by provider id
const groups = /** @type {Record<string, any[]>} */ ({});
results.forEach((result, index) => {
const providerId = validProviders[index];
if (result.status === 'fulfilled') {
groups[providerId] = Array.isArray(result.value) ? result.value : [];
} else {
console.warn(`Search failed for provider ${providerId}:`, result.reason);
groups[providerId] = [];
}
});
return res.json({ q, providers: validProviders, groups });
} catch (e) {
return res.status(500).json({ error: 'search_failed', details: String(e?.message || e) });
}
});
// -------------------- Static Frontend (Angular build) --------------------
const distRoot = path.join(process.cwd(), 'dist');
const distBrowser = path.join(distRoot, 'browser');

View File

@ -0,0 +1,44 @@
/**
* Minimal Dailymotion provider handler
*/
const handler = {
id: 'dm',
label: 'Dailymotion',
/**
* @param {string} q
* @param {{ limit: number, page?: number }} opts
* @returns {Promise<Array<any>>}
*/
async search(q, opts) {
const { limit = 10 } = opts;
try {
const response = await fetch(
`https://api.dailymotion.com/videos?` +
new URLSearchParams({
search: q,
limit: Math.min(limit, 100).toString(),
sort: 'relevance'
})
);
if (!response.ok) {
throw new Error(`Dailymotion API error: ${response.status}`);
}
const data = await response.json();
return (data.list || []).map(item => ({
title: item.title,
id: item.id,
url: `https://www.dailymotion.com/video/${item.id}`,
thumbnail: item.thumbnail_360_url || item.thumbnail_180_url || item.thumbnail_url,
uploaderName: item.owner.screenname || item.owner.username,
type: 'video'
}));
} catch (error) {
console.error('Dailymotion search error:', error);
return [];
}
}
};
export default handler;

View File

@ -0,0 +1,44 @@
/**
* Minimal Odysee provider handler
*/
const handler = {
id: 'od',
label: 'Odysee',
/**
* @param {string} q
* @param {{ limit: number, page?: number }} opts
* @returns {Promise<Array<any>>}
*/
async search(q, opts) {
const { limit = 10 } = opts;
try {
const response = await fetch(
`https://lighthouse.odysee.tv/content/search?` +
new URLSearchParams({
query: q,
size: Math.min(limit, 50).toString(),
page: '1'
})
);
if (!response.ok) {
throw new Error(`Odysee API error: ${response.status}`);
}
const data = await response.json();
return (data || []).map(item => ({
title: item.title,
id: item.claimId,
url: `https://odysee.com/${item.canonical_url}`,
thumbnail: item.thumbnail_url,
uploaderName: item.channel_name || item.publisher_name,
type: 'video'
}));
} catch (error) {
console.error('Odysee search error:', error);
return [];
}
}
};
export default handler;

View File

@ -0,0 +1,44 @@
/**
* Minimal PeerTube provider handler
*/
const handler = {
id: 'pt',
label: 'PeerTube',
/**
* @param {string} q
* @param {{ limit: number, page?: number }} opts
* @returns {Promise<Array<any>>}
*/
async search(q, opts) {
const { limit = 10 } = opts;
try {
// Use PeerTube API search
const response = await fetch(
`https://sepiasearch.org/api/v1/search/videos?` +
new URLSearchParams({
search: q,
count: Math.min(limit, 50).toString()
})
);
if (!response.ok) {
throw new Error(`PeerTube API error: ${response.status}`);
}
const data = await response.json();
return (data.data || []).map(item => ({
title: item.name,
id: item.uuid,
url: item.url,
thumbnail: item.thumbnailPath,
uploaderName: item.account.displayName || item.account.name,
type: 'video'
}));
} catch (error) {
console.error('PeerTube search error:', error);
return [];
}
}
};
export default handler;

View File

@ -0,0 +1,39 @@
// Server-side provider registry for search fan-out
// Each provider module should export an object with a `search(q, opts)` function returning Promise<Suggestion[]>
/**
* @typedef {Object} Suggestion
* @property {string} title
* @property {string} id
* @property {number=} duration
* @property {boolean=} isShort
* @property {string=} url
* @property {string=} thumbnail
* @property {string=} uploaderName
* @property {string=} type
*/
/** @typedef {'yt'|'dm'|'tw'|'pt'|'od'|'ru'} ProviderId */
/** @type {Record<ProviderId, { id: ProviderId, label: string, search: (q: string, opts: { limit: number, page?: number }) => Promise<Suggestion[]> }>} */
export const providerRegistry = {
/** @type {any} */ yt: (await import('./youtube.mjs')).default,
/** @type {any} */ dm: (await import('./dailymotion.mjs')).default,
/** @type {any} */ tw: (await import('./twitch.mjs')).default,
/** @type {any} */ pt: (await import('./peertube.mjs')).default,
/** @type {any} */ od: (await import('./odysee.mjs')).default,
/** @type {any} */ ru: (await import('./rumble.mjs')).default,
};
/**
* Validate and normalize a comma separated providers list
* @param {string} input
* @returns {ProviderId[]}
*/
export function validateProviders(input) {
const raw = String(input || '').split(',').map(s => s.trim()).filter(Boolean);
const allowed = /** @type {ProviderId[]} */(['yt','dm','tw','pt','od','ru']);
const filtered = raw.filter(p => allowed.includes(/** @type {any} */(p)));
if (filtered.length === 0) return allowed;
return /** @type {ProviderId[]} */(filtered);
}

View File

@ -0,0 +1,43 @@
/**
* Minimal Rumble provider handler
*/
const handler = {
id: 'ru',
label: 'Rumble',
/**
* @param {string} q
* @param {{ limit: number, page?: number }} opts
* @returns {Promise<Array<any>>}
*/
async search(q, opts) {
const { limit = 10 } = opts;
try {
const response = await fetch(
`https://rumble.com/api/search/videos?` +
new URLSearchParams({
q: q,
size: Math.min(limit, 50).toString()
})
);
if (!response.ok) {
throw new Error(`Rumble API error: ${response.status}`);
}
const data = await response.json();
return (data.videos || []).map(item => ({
title: item.title,
id: item.id,
url: `https://rumble.com${item.url}`,
thumbnail: item.thumbnail,
uploaderName: item.author.name,
type: 'video'
}));
} catch (error) {
console.error('Rumble search error:', error);
return [];
}
}
};
export default handler;

View File

@ -0,0 +1,51 @@
/**
* Minimal Twitch provider handler
*/
const handler = {
id: 'tw',
label: 'Twitch',
/**
* @param {string} q
* @param {{ limit: number, page?: number }} opts
* @returns {Promise<Array<any>>}
*/
async search(q, opts) {
const { limit = 10 } = opts;
try {
// Use Twitch Kraken API (older but public)
const response = await fetch(
`https://api.twitch.tv/kraken/search/streams?` +
new URLSearchParams({
query: q,
limit: Math.min(limit, 25).toString()
}),
{
headers: {
'Accept': 'application/vnd.twitchtv.v5+json',
'Client-ID': process.env.TWITCH_CLIENT_ID || ''
}
}
);
if (!response.ok) {
throw new Error(`Twitch API error: ${response.status}`);
}
const data = await response.json();
return (data.streams || []).map(item => ({
title: item.channel.status || item.channel.display_name,
id: item.channel.name, // Channel name as ID
url: `https://www.twitch.tv/${item.channel.name}`,
thumbnail: item.preview?.medium || item.channel.logo,
uploaderName: item.channel.display_name,
type: 'stream',
isLive: true
}));
} catch (error) {
console.error('Twitch search error:', error);
return [];
}
}
};
export default handler;

View File

@ -0,0 +1,58 @@
/**
* @typedef {Object} Suggestion
* @property {string} title
* @property {string} id
* @property {number=} duration
* @property {boolean=} isShort
* @property {string=} url
* @property {string=} thumbnail
* @property {string=} uploaderName
* @property {string=} type
*/
/** @type {{ id: 'yt', label: string, search: (q: string, opts: { limit: number, page?: number }) => Promise<Suggestion[]> }} */
const handler = {
id: 'yt',
label: 'YouTube',
async search(q, opts) {
const { limit = 10 } = opts;
try {
const API_KEY = process.env.YOUTUBE_API_KEY;
if (!API_KEY) {
throw new Error('YOUTUBE_API_KEY not configured');
}
const response = await fetch(
`https://www.googleapis.com/youtube/v3/search?` +
new URLSearchParams({
part: 'snippet',
q: q,
type: 'video',
maxResults: Math.min(limit, 50).toString(),
key: API_KEY,
order: 'relevance'
})
);
if (!response.ok) {
throw new Error(`YouTube API error: ${response.status}`);
}
const data = await response.json();
return (data.items || []).map(item => ({
title: item.snippet.title,
id: item.id.videoId,
url: `https://www.youtube.com/watch?v=${item.id.videoId}`,
thumbnail: item.snippet.thumbnails?.medium?.url || item.snippet.thumbnails?.default?.url,
uploaderName: item.snippet.channelTitle,
type: 'video'
}));
} catch (error) {
console.error('YouTube search error:', error);
return [];
}
}
};
export default handler;

View File

@ -0,0 +1,40 @@
export type ProviderId = 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru';
export interface ProviderSpec {
id: ProviderId;
displayName: string;
shortLabel: string; // YT, DM, ...
icon: string; // lucide or custom svg name
colorClass: string; // tailwind text-red-500, etc.
supports: { search: boolean; shorts: boolean; live: boolean; playlists: boolean };
buildSearchUrl: (q: string, opts?: { page?: number; limit?: number; providers?: ProviderId[] | 'all' }) => string; // front-only fallback
}
export const PROVIDERS: ProviderSpec[] = [
{ id: 'yt', displayName: 'YouTube', shortLabel: 'YT', icon: 'youtube', colorClass: 'text-red-500',
supports: { search: true, shorts: true, live: true, playlists: true },
buildSearchUrl: (q, opts) => `/api/search?q=${encodeURIComponent(q)}&providers=${encodeURIComponent((opts?.providers && opts.providers !== 'all') ? opts.providers.join(',') : 'yt,dm,tw,pt,od,ru')}`
},
{ id: 'dm', displayName: 'Dailymotion', shortLabel: 'DM', icon: 'dailymotion', colorClass: 'text-blue-500',
supports: { search: true, shorts: false, live: false, playlists: false },
buildSearchUrl: (q, opts) => `/api/search?q=${encodeURIComponent(q)}&providers=${encodeURIComponent((opts?.providers && opts.providers !== 'all') ? opts.providers.join(',') : 'yt,dm,tw,pt,od,ru')}`
},
{ id: 'tw', displayName: 'Twitch', shortLabel: 'TW', icon: 'twitch', colorClass: 'text-purple-500',
supports: { search: true, shorts: false, live: true, playlists: false },
buildSearchUrl: (q, opts) => `/api/search?q=${encodeURIComponent(q)}&providers=${encodeURIComponent((opts?.providers && opts.providers !== 'all') ? opts.providers.join(',') : 'yt,dm,tw,pt,od,ru')}`
},
{ id: 'pt', displayName: 'PeerTube', shortLabel: 'PT', icon: 'peertube', colorClass: 'text-green-500',
supports: { search: true, shorts: false, live: false, playlists: true },
buildSearchUrl: (q, opts) => `/api/search?q=${encodeURIComponent(q)}&providers=${encodeURIComponent((opts?.providers && opts.providers !== 'all') ? opts.providers.join(',') : 'yt,dm,tw,pt,od,ru')}`
},
{ id: 'od', displayName: 'Odysee', shortLabel: 'OD', icon: 'odysee', colorClass: 'text-yellow-500',
supports: { search: true, shorts: false, live: false, playlists: false },
buildSearchUrl: (q, opts) => `/api/search?q=${encodeURIComponent(q)}&providers=${encodeURIComponent((opts?.providers && opts.providers !== 'all') ? opts.providers.join(',') : 'yt,dm,tw,pt,od,ru')}`
},
{ id: 'ru', displayName: 'Rumble', shortLabel: 'RU', icon: 'rumble', colorClass: 'text-orange-500',
supports: { search: true, shorts: false, live: false, playlists: false },
buildSearchUrl: (q, opts) => `/api/search?q=${encodeURIComponent(q)}&providers=${encodeURIComponent((opts?.providers && opts.providers !== 'all') ? opts.providers.join(',') : 'yt,dm,tw,pt,od,ru')}`
},
];
export const PROVIDER_MAP = new Map(PROVIDERS.map(p => [p.id, p]));

View File

@ -0,0 +1,49 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, combineLatest, distinctUntilChanged, debounceTime, map, switchMap } from 'rxjs';
import type { ProviderId } from '../core/providers/provider-registry';
export interface SuggestionItem {
title: string;
id: string;
duration?: number;
isShort?: boolean;
thumbnail?: string;
uploaderName?: string;
url?: string;
type?: string; // 'video' | 'channel' | ...
}
export interface SearchResponse {
q: string;
providers: ProviderId[];
groups: Record<ProviderId, SuggestionItem[]>;
}
@Injectable({ providedIn: 'root' })
export class SearchService {
private http = inject(HttpClient);
readonly q$ = new BehaviorSubject<string>('');
readonly providers$ = new BehaviorSubject<ProviderId[] | 'all'>('all');
readonly params$ = combineLatest([this.q$, this.providers$]).pipe(
debounceTime(120),
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
);
readonly request$ = this.params$.pipe(
switchMap(([q, prov]) => {
const providersParam = (prov === 'all')
? 'yt,dm,tw,pt,od,ru'
: (Array.isArray(prov) && prov.length > 0 ? prov.join(',') : '');
return this.http.get<SearchResponse>('/api/search', {
params: { q, providers: providersParam }
});
})
);
// Convenience setter helpers
setQuery(q: string) { this.q$.next(q || ''); }
setProviders(list: ProviderId[] | 'all') { this.providers$.next(list && (Array.isArray(list) ? list : 'all')); }
}

View File

@ -14,114 +14,10 @@
</a>
</div>
<!-- Center: search (grid-centered, avoids overlap) -->
<form class="justify-self-center mx-auto w-full max-w-2xl md:max-w-3xl lg:max-w-4xl px-4 relative group" (submit)="onSubmitSearch($event, searchInput)">
<input #searchInput type="search" [placeholder]="('search.placeholder' | t)" aria-label="Search"
(focus)="onSearchFocus()" (input)="onSearchInput(searchInput)" (blur)="onSearchBlur()" (keydown)="onSearchKeydown($event, searchInput)"
(search)="onSearchCleared(searchInput)"
[ngClass]="{
'bg-white text-slate-900 placeholder-slate-500 ring-slate-300 hover:bg-slate-50': isLightTheme(),
'bg-slate-700/80 text-slate-200 placeholder-slate-400 ring-slate-600/50 hover:bg-slate-700/70': isDarkTheme(),
'bg-black text-slate-200 placeholder-slate-400 ring-slate-800/50 hover:bg-slate-900': isBlackTheme(),
'bg-blue-950/80 text-blue-100 placeholder-blue-300 ring-blue-900/60 hover:bg-blue-900/70': isBlueTheme()
}"
class="w-full rounded-lg py-2.5 px-4 pl-10 pr-16 focus:outline-none ring-1 focus:ring-2 focus:ring-red-500/70 transition-all duration-200 shadow-sm">
<svg class="w-5 h-5 absolute left-6 md:left-5 top-1/2 -translate-y-1/2"
[ngClass]="{
'text-slate-500': isLightTheme(),
'text-slate-400': isDarkTheme(),
'text-slate-300': isBlackTheme(),
'text-blue-200': isBlueTheme()
}"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<!-- Keyboard shortcut hint (only when no provider indicator) -->
<div *ngIf="!providerContextLabel()" class="absolute right-6 top-1/2 -translate-y-1/2 hidden md:flex items-center gap-1 text-xs select-none"
[ngClass]="{
'text-slate-700/80': isLightTheme(),
'text-slate-300/80': isDarkTheme() || isBlackTheme(),
'text-blue-200/80': isBlueTheme()
}">
<kbd [ngClass]="{
'bg-slate-200/80 border-slate-300 text-slate-700': isLightTheme(),
'bg-slate-700/80 border-slate-600/60': isDarkTheme(),
'bg-slate-900/80 border-slate-800/60 text-slate-300': isBlackTheme(),
'bg-blue-800/80 border-blue-700/60 text-blue-200': isBlueTheme()
}" class="px-1.5 py-0.5 rounded border">Ctrl</kbd>
<span>+</span>
<kbd [ngClass]="{
'bg-slate-200/80 border-slate-300 text-slate-700': isLightTheme(),
'bg-slate-700/80 border-slate-600/60 text-slate-300': isDarkTheme(),
'bg-slate-900/80 border-slate-800/60 text-slate-300': isBlackTheme(),
'bg-blue-800/80 border-blue-700/60 text-blue-200': isBlueTheme()
}" class="px-1.5 py-0.5 rounded border">K</kbd>
<!-- Center: search box with providers -->
<div class="justify-self-center mx-auto w-full max-w-2xl md:max-w-3xl lg:max-w-4xl px-4 relative">
<app-search-box [placeholder]="('search.placeholder' | t)" (submitted)="onSearchBoxSubmit($event)"></app-search-box>
</div>
<!-- Suggestions dropdown -->
@if (suggestionsOpen() && suggestionItems().length > 0) {
<div class="absolute z-50 mt-2 left-1/2 -translate-x-1/2 w-full max-w-2xl md:max-w-3xl lg:max-w-4xl rounded-xl shadow-2xl overflow-hidden"
[ngClass]="{
'bg-white border border-slate-200 text-slate-900': isLightTheme(),
'bg-slate-800/95 border border-slate-700 text-slate-200': isDarkTheme(),
'bg-black/95 border border-slate-900 text-slate-200': isBlackTheme(),
'bg-blue-950/90 border border-blue-800 text-blue-100': isBlueTheme(),
'backdrop-blur-md': !isLightTheme()
}">
<ul class="max-h-[70vh] overflow-y-auto py-1">
@for (item of suggestionItems(); track $index; let i = $index) {
<!-- Divider before first generated suggestion -->
@if (isFirstGenerated(i)) {
<li class="px-4 py-2 text-xs uppercase tracking-wide border-t border-b"
[ngClass]="{
'text-slate-500 border-slate-100': isLightTheme(),
'text-slate-400 border-slate-700': isDarkTheme(),
'text-slate-400 border-slate-800': isBlackTheme(),
'text-blue-300 border-blue-800': isBlueTheme()
}">Suggestions</li>
}
<li (mouseenter)="highlightedIndex.set(i)">
<button type="button" (mousedown)="pickSuggestion(item.text, searchInput)"
class="w-full text-left px-4 py-2 flex items-center gap-3 transition-colors duration-150"
[ngClass]="{
'hover:bg-slate-100': isLightTheme(),
'hover:bg-slate-700/70': isDarkTheme(),
'hover:bg-slate-900': isBlackTheme(),
'hover:bg-blue-800/60': isBlueTheme(),
'bg-slate-200': isLightTheme() && highlightedIndex() === i,
'bg-slate-700/50': isDarkTheme() && highlightedIndex() === i,
'bg-slate-900/70': isBlackTheme() && highlightedIndex() === i,
'bg-blue-800/50': isBlueTheme() && highlightedIndex() === i,
'text-slate-900': isLightTheme(),
'text-slate-200': isDarkTheme() || isBlackTheme(),
'text-blue-100': isBlueTheme()
}">
<!-- Clock for history, magnifier for generated -->
<svg *ngIf="item.source === 'history'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5"
[ngClass]="{
'text-slate-500': isLightTheme(),
'text-slate-400': isDarkTheme(),
'text-slate-300': isBlackTheme(),
'text-blue-200': isBlueTheme()
}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg *ngIf="item.source === 'generated'" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5"
[ngClass]="{
'text-slate-500': isLightTheme(),
'text-slate-400': isDarkTheme(),
'text-slate-300': isBlackTheme(),
'text-blue-200': isBlueTheme()
}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span class="truncate">{{ item.text }}</span>
</button>
</li>
}
</ul>
</div>
}
</form>
<!-- Right: user -->
<div class="justify-self-end flex items-center gap-3 pr-3 md:pr-4">

View File

@ -10,6 +10,7 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
import { I18nService } from '../../services/i18n.service';
import { ThemesService } from '../../services/themes.service';
import { HistoryService, SearchHistoryItem } from '../../services/history.service';
import { SearchBoxComponent } from '../search/search-box.component';
@Component({
selector: 'app-header',
@ -20,7 +21,9 @@ import { HistoryService, SearchHistoryItem } from '../../services/history.servic
RouterLink,
CommonModule,
FormsModule,
TranslatePipe
TranslatePipe,
// New unified search box with provider chips and picker
SearchBoxComponent
]
})
export class HeaderComponent {
@ -85,6 +88,23 @@ export class HeaderComponent {
return items;
});
// New SearchBox submit handler (navigate with providers list)
onSearchBoxSubmit(evt: { q: string; providers: ("yt"|"dm"|"tw"|"pt"|"od"|"ru")[] | 'all' }) {
const q = (evt?.q || '').trim();
if (!q) return;
const provider = this.selectedProvider();
const theme = this.themes.activeSlug();
const qp: any = { q, provider };
if (Array.isArray(evt.providers)) {
qp.providers = evt.providers.join(',');
} else if (evt.providers === 'all') {
// Explicitly include all providers so SearchComponent uses unified multi-provider mode
qp.providers = 'yt,dm,tw,pt,od,ru';
}
if (theme) qp.theme = theme;
this.router.navigate(['/search'], { queryParams: qp });
}
// Generate query suggestions based on the current text and provider
private generateQuerySuggestions(q: string, provider: string | null): string[] {
const base = (q || '').trim();

View File

@ -0,0 +1,40 @@
<div class="fixed inset-0 z-[9999] bg-black/60 flex justify-center items-center" (click)="close.emit()">
<div class="fixed inset-0 grid place-items-center">
<div #dialog
class="w-[90%] max-w-lg bg-slate-900 rounded-xl shadow-2xl border border-slate-700"
role="dialog"
aria-modal="true"
aria-labelledby="providersTitle"
(click)="$event.stopPropagation()"
tabindex="0"
(keydown)="onDialogKeydown($event)">
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
<h2 id="providersTitle" class="text-slate-100 font-semibold">Providers</h2>
<button (click)="close.emit()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Esc</button>
</div>
<div class="p-4 space-y-3">
<input type="text" [ngModel]="filter()" (ngModelChange)="filter.set($event)" placeholder="filter providers…" class="w-full 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">
<input type="checkbox" [checked]="all()" (change)="toggleAll()"/>
<span>All</span>
</label>
</div>
<div class="grid grid-cols-2 gap-2">
<label *ngFor="let p of providers()" class="flex items-center gap-2 px-2 py-2 rounded border border-slate-700 bg-slate-800 text-slate-100">
<input type="checkbox" [checked]="isChecked(p.id)" (change)="toggle(p.id)"/>
<span class="text-xs font-semibold" [class]="p.colorClass">{{ p.shortLabel }}</span>
<span class="text-sm">{{ p.displayName }}</span>
</label>
</div>
<label class="mt-2 inline-flex items-center gap-2 text-slate-300">
<input type="checkbox" [ngModel]="rememberDefault()" (ngModelChange)="rememberDefault.set($event)" />
<span>Remember as default</span>
</label>
</div>
<div class="p-4 border-t border-slate-700 flex items-center justify-end gap-2">
<button (click)="close.emit()" class="px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100">Cancel</button>
<button (click)="onApply()" class="px-3 py-2 rounded bg-red-600 hover:bg-red-500 text-white">Apply filters</button>
</div>
</div>
</div>

View File

@ -0,0 +1,79 @@
import { Component, EventEmitter, Output, Input, signal, computed, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ProviderId, PROVIDERS } 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 {
@Input() open = false;
@Input() selected: ProviderId[] | 'all' = 'all';
@Output() close = new EventEmitter<void>();
@Output() apply = new EventEmitter<{ selection: ProviderId[] | 'all'; rememberDefault: boolean }>();
filter = signal('');
rememberDefault = signal(false);
@ViewChild('dialog', { static: false }) dialogRef?: ElementRef<HTMLDivElement>;
readonly all = computed(() => this.selected === 'all');
readonly providers = computed(() => {
const q = this.filter().toLowerCase();
return PROVIDERS.filter(p => !q || p.displayName.toLowerCase().includes(q) || p.shortLabel.toLowerCase().includes(q));
});
toggleAll() {
this.apply.emit({ selection: 'all', rememberDefault: this.rememberDefault() });
}
isChecked(id: ProviderId): boolean {
if (this.selected === 'all') return true;
return Array.isArray(this.selected) && this.selected.includes(id);
}
toggle(id: ProviderId) {
if (this.selected === 'all') {
this.apply.emit({ selection: [id], rememberDefault: this.rememberDefault() });
return;
}
const set = new Set(this.selected || []);
if (set.has(id)) set.delete(id); else set.add(id);
const next = Array.from(set) as ProviderId[];
this.selected = next;
}
onApply() {
this.apply.emit({ selection: this.selected, rememberDefault: this.rememberDefault() });
}
ngAfterViewInit() {
// Focus the dialog for accessibility
queueMicrotask(() => {
try { this.dialogRef?.nativeElement?.focus(); } catch {}
});
}
// Basic focus trap within the dialog
onDialogKeydown(ev: KeyboardEvent) {
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) {
ev.preventDefault();
last.focus();
}
}
}

View File

@ -0,0 +1,105 @@
<form class="w-full relative" (submit)="onSubmit($event)" (keydown)="handleKeydown($event)">
<!-- Input -->
<div class="flex items-center gap-2 rounded-lg ring-1 ring-slate-600/50 bg-slate-800/70 px-3 py-2">
<!-- Provider chips -->
<button type="button"
(click)="toggleAll()"
class="text-xs px-2 py-0.5 rounded border"
[ngClass]="{
'bg-slate-200 text-slate-800 border-slate-300': providerMode()==='all',
'bg-slate-700 text-slate-100 border-slate-600': providerMode()!=='all'
}"
title="Tous les fournisseurs">
All
</button>
<ng-container *ngFor="let c of chips; let i = index">
<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"
class="text-xs px-2 py-0.5 rounded border"
[ngClass]="{
'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)
}"
[title]="'Inclure ' + c.label">
<span [class]="c.colorClass">{{ c.label }}</span>
</button>
</ng-container>
<!-- Text input -->
<input type="search"
class="flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-2"
[placeholder]="placeholder"
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)" name="q"/>
<!-- Actions -->
<button type="button" (click)="openPicker()" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600">@</button>
<button type="submit" class="ml-1 px-3 py-1 rounded bg-red-600 hover:bg-red-500 text-white">Search</button>
</div>
<!-- Provider Picker Modal -->
<app-provider-picker *ngIf="pickerOpen()"
[open]="pickerOpen()"
[selected]="providersForRequest()"
(close)="closePicker()"
(apply)="applyPicker($event)"></app-provider-picker>
<!-- Inline @autocomplete popover -->
<div *ngIf="atOpen() && atOptions().length > 0"
class="absolute z-50 mt-1 left-3 right-3 rounded-lg shadow-xl overflow-hidden"
style="top: 100%">
<ul class="max-h-64 overflow-y-auto"
[ngClass]="{ 'bg-slate-900 border border-slate-700 text-slate-200': true }">
<li *ngFor="let opt of atOptions(); let i = index">
<button type="button" (click)="toggleChip(opt.id); atOpen.set(false)"
class="w-full text-left px-3 py-2 text-sm"
[ngClass]="{
'bg-slate-800': i === atIndex(),
'hover:bg-slate-800': i !== atIndex()
}">
@ {{ opt.label }}
</button>
</li>
</ul>
</div>
<!-- Quick Menu (Ctrl/Cmd+K) -->
<div *ngIf="quickOpen()" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; background-color: rgba(0, 0, 0, 0.6);" role="dialog" aria-modal="true" (click)="closeQuickMenu()">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -0%); width: 90%; max-width: 56rem; max-height: 85vh; border-radius: 0.75rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; background-color: #0f172a; border: 1px solid #334155; display: flex; flex-direction: column;" role="document" aria-labelledby="quickMenuTitle" (click)="$event.stopPropagation()" tabindex="0">
<div class="p-3 border-b border-slate-700 flex items-center justify-between">
<h2 id="quickMenuTitle" class="text-slate-100 font-semibold">Quick Menu</h2>
<button (click)="closeQuickMenu()" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" autofocus>Esc</button>
</div>
<div class="grid grid-cols-2 gap-0 overflow-y-auto">
<!-- History column -->
<div class="p-4 border-r border-slate-700">
<div class="text-slate-300 text-sm mb-2">History</div>
<ul class="space-y-1 max-h-80 overflow-y-auto">
<li *ngFor="let it of recentSearches()">
<button type="button" class="w-full text-left px-2 py-1 rounded hover:bg-slate-800 text-slate-200"
(click)="queryUpdate(it.query || '')">{{ 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 mb-2">
<div class="text-slate-300 text-sm">Providers</div>
<button type="button" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100"
(click)="quickToggleAll()">All</button>
</div>
<div class="grid grid-cols-3 gap-2">
<label *ngFor="let c of chips" class="flex items-center gap-2 px-2 py-2 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-xs font-semibold" [class]="c.colorClass">{{ c.label }}</span>
<span class="text-sm">{{ c.label }}</span>
</label>
</div>
<div class="mt-4 text-right">
<button type="button" class="px-3 py-2 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>
</form>

View File

@ -0,0 +1,247 @@
import { Component, EventEmitter, Output, Input, signal, computed, effect, inject, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registry';
import { ProviderPickerComponent } from './provider-picker.component';
import { SearchService } from '../../app/search/search.service';
import { UserService, type UserPreferences } from '../../services/user.service';
import { HistoryService, type SearchHistoryItem } from '../../services/history.service';
// No overlays; we render fixed-position modals with high z-index
@Component({
selector: 'app-search-box',
standalone: true,
imports: [CommonModule, FormsModule, ProviderPickerComponent],
templateUrl: './search-box.component.html'
})
export class SearchBoxComponent {
private search = inject(SearchService);
private users = inject(UserService);
private history = inject(HistoryService);
// Input/Output bindings
@Input() placeholder: string = 'Search videos…';
@Output() submitted = new EventEmitter<{ q: string; providers: ProviderId[] | 'all' }>();
// Local state signals
query = signal('');
providerMode = signal<'all' | 'custom'>('all');
selected = signal<ProviderId[]>([]);
pickerOpen = signal(false);
suggestionsOpen = signal(false);
// Inline @autocomplete state
atOpen = signal(false);
atIndex = signal(0);
atOptions = computed(() => {
const q = this.query();
const m = /(^|\s)@([a-z]{1,24})$/i.exec(q);
if (!m) return [] as { id: ProviderId; label: string }[];
const term = (m[2] || '').toLowerCase();
const opts = PROVIDERS.filter(p =>
p.shortLabel.toLowerCase().includes(term) || p.displayName.toLowerCase().includes(term)
).map(p => ({ id: p.id, label: p.displayName }));
this.atOpen.set(opts.length > 0);
if (this.atIndex() >= opts.length) this.atIndex.set(0);
return opts as any;
});
// Quick menu modal (Ctrl/Cmd+K)
quickOpen = signal(false);
recentSearches = signal<SearchHistoryItem[]>([]);
readonly chips = PROVIDERS.map(p => ({ id: p.id, label: p.shortLabel, colorClass: p.colorClass }));
// Derived state
readonly providersForRequest = computed<ProviderId[] | 'all'>(() => {
return this.providerMode() === 'all' ? 'all' : (this.selected().length ? this.selected() : 'all');
});
constructor() {
// Reflect SearchService into local state to restore from URL/preference
effect(() => {
const q = this.search.q$.value;
const prov = this.search.providers$.value;
if (typeof q === 'string' && q !== this.query()) this.query.set(q);
if (prov === 'all') {
this.providerMode.set('all');
this.selected.set([]);
} else if (Array.isArray(prov)) {
this.providerMode.set('custom');
this.selected.set(prov);
}
});
// Apply default providers from user preferences if none explicitly set
effect(() => {
const prefs: UserPreferences | null = this.users.preferences();
if (!prefs) return;
const list = (prefs as any).defaultProviders as string[] | undefined;
if (Array.isArray(list) && list.length > 0) {
// Only apply if current mode is 'all' and nothing selected
if (this.providerMode() === 'all' && this.selected().length === 0) {
// Cast to ProviderId[] while trusting server-provided list
this.providerMode.set('custom');
this.selected.set(list as any);
}
}
});
}
// Update query and handle inline '@provider' prefix to open filtered picker
queryUpdate(value: string) {
this.query.set(value);
const m = /(^|\s)@([a-z]{1,12})$/i.exec(value);
if (m) {
const term = m[2] || '';
this.pickerOpen.set(true);
const lower = term.toLowerCase();
const matched = PROVIDERS.filter(p => p.shortLabel.toLowerCase().startsWith(lower) || p.displayName.toLowerCase().includes(lower)).map(p => p.id);
if (matched.length === 1) {
this.toggleChip(matched[0]);
}
}
}
// Toggle All chip
toggleAll() {
if (this.providerMode() === 'all') {
// Switch to none selected (effectively prompt to pick)
this.providerMode.set('custom');
this.selected.set([]);
} else {
this.providerMode.set('all');
this.selected.set([]);
}
}
// Toggle individual chip
toggleChip(id: ProviderId) {
if (this.providerMode() === 'all') this.providerMode.set('custom');
const cur = new Set(this.selected());
if (cur.has(id)) cur.delete(id); else cur.add(id);
const next = Array.from(cur);
this.selected.set(next);
if (next.length === 0) {
// Empty selection warning UX could be handled by parent; keep custom mode
}
}
// Keyboard shortcuts: Alt+1..6 map to chips by order
handleKeydown(ev: KeyboardEvent) {
if (ev.key === '@') {
ev.preventDefault();
this.pickerOpen.set(true);
return;
}
// Inline @autocomplete navigation
if (this.atOpen()) {
const opts = this.atOptions();
if (ev.key === 'ArrowDown') { ev.preventDefault(); this.atIndex.set(Math.min(opts.length - 1, this.atIndex() + 1)); return; }
if (ev.key === 'ArrowUp') { ev.preventDefault(); this.atIndex.set(Math.max(0, this.atIndex() - 1)); return; }
if (ev.key === 'Enter') {
ev.preventDefault();
const pick = opts[this.atIndex()];
if (pick) this.toggleChip(pick.id);
this.atOpen.set(false);
return;
}
if (ev.key === 'Escape') { this.atOpen.set(false); }
}
if (ev.altKey) {
const map: Record<string, ProviderId | undefined> = { '1': 'yt', '2': 'dm', '3': 'tw', '4': 'pt', '5': 'od', '6': 'ru' };
const pid = map[ev.key];
if (pid) { ev.preventDefault(); this.toggleChip(pid); }
}
if (ev.key === 'Escape') {
this.pickerOpen.set(false);
this.suggestionsOpen.set(false);
}
}
// Global shortcut Ctrl/Cmd+K to open the Providers picker
@HostListener('document:keydown', ['$event'])
onGlobalKey(ev: KeyboardEvent) {
if ((ev.key.toLowerCase() === 'k') && (ev.ctrlKey || ev.metaKey)) {
ev.preventDefault();
this.quickOpen.set(true);
// Load last 15 searches
try { this.history.getSearchHistory(15).subscribe({ next: (items) => this.recentSearches.set(items || []), error: () => {} }); } catch {}
}
// Also support Ctrl+Shift+K explicitly
if ((ev.key.toLowerCase() === 'k') && ev.ctrlKey && ev.shiftKey) {
ev.preventDefault();
this.quickOpen.set(true);
}
if (ev.key === 'Escape') {
if (this.quickOpen()) {
ev.preventDefault();
this.closeQuickMenu();
}
if (this.pickerOpen()) {
ev.preventDefault();
this.closePicker();
}
if (this.atOpen()) {
ev.preventDefault();
this.atOpen.set(false);
}
}
}
onSubmit(ev: Event) {
ev.preventDefault();
const q = (this.query() || '').trim();
if (!q) return;
const prov = this.providersForRequest();
// Update service for global listeners
this.search.setQuery(q);
this.search.setProviders(prov);
this.submitted.emit({ q, providers: prov });
}
openPicker() { this.pickerOpen.set(true); }
closePicker() { this.pickerOpen.set(false); }
// 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 {
this.providerMode.set('custom');
this.selected.set(prov);
}
// Persist default selection if requested and user is logged-in
if (rememberDefault) {
const body: any = { defaultProviders: (prov === 'all') ? [] : prov };
try { this.users.updatePreferences(body).subscribe({ next: () => {}, error: () => {} }); } catch {}
}
this.closePicker();
}
// Quick menu close/apply (re-using same payload shape as picker)
closeQuickMenu() { this.quickOpen.set(false); }
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));
}
// Lock body scroll when a modal is open (a11y UX)
private _scrollLock = effect(() => {
const open = this.quickOpen() || this.pickerOpen();
try {
document.body.style.overflow = open ? 'hidden' : '';
} catch {}
});
}

View File

@ -0,0 +1,28 @@
<div class="mt-3 space-y-4">
<!-- Grouped suggestion sections by provider -->
<div *ngFor="let entry of (groups | keyvalue)" class="border border-slate-700 rounded-lg overflow-hidden">
<div class="px-3 py-2 bg-slate-800 text-slate-200 text-sm font-semibold">
{{ entry.key | uppercase }}
</div>
<ul class="divide-y divide-slate-700">
<li *ngFor="let s of (entry.value | slice:0:3); let i = index" class="p-3 flex items-start gap-3">
<img *ngIf="s.thumbnail" [src]="s.thumbnail" class="h-12 w-20 object-cover rounded" alt=""/>
<div class="min-w-0">
<div class="text-slate-100 truncate">{{ s.title }}</div>
<div class="text-slate-400 text-xs">{{ s.uploaderName }}</div>
</div>
</li>
<li *ngIf="(entry.value?.length || 0) === 0" class="p-3 text-slate-500 text-sm">
Aucun résultat pour ce fournisseur.
</li>
</ul>
<div class="px-3 py-2 bg-slate-900 text-right">
<a class="text-xs text-red-300 hover:text-red-200"
[routerLink]="['/search']"
[queryParams]="{ q: query, providers: entry.key }"
[attr.aria-label]="'View all results on ' + (entry.key | uppercase)">
View all on {{ entry.key | uppercase }}
</a>
</div>
</div>
</div>

View File

@ -0,0 +1,18 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import type { ProviderId } from '../../app/core/providers/provider-registry';
import type { SuggestionItem } from '../../app/search/search.service';
@Component({
selector: 'app-search-suggestions',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './search-suggestions.component.html'
})
export class SearchSuggestionsComponent {
@Input() groups: Record<ProviderId, SuggestionItem[]> = {
yt: [], dm: [], tw: [], pt: [], od: [], ru: []
} as any;
@Input() query: string = '';
}

View File

@ -27,6 +27,11 @@
</div>
}
<!-- Unified multi-provider grouped suggestions -->
@if (showUnified()) {
<app-search-suggestions [groups]="groups()" [query]="q()"></app-search-suggestions>
}
<input
#searchInput
type="text"

View File

@ -11,13 +11,16 @@ import { Title } from '@angular/platform-browser';
import { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
import { AddToPlaylistComponent } from '../shared/components/add-to-playlist/add-to-playlist.component';
import { SearchSuggestionsComponent } from './search-suggestions.component';
import { SearchService, type SearchResponse } from '../../app/search/search.service';
import type { ProviderId } from '../../app/core/providers/provider-registry';
@Component({
selector: 'app-search',
standalone: true,
templateUrl: './search.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent]
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent, SearchSuggestionsComponent]
})
export class SearchComponent {
private route = inject(ActivatedRoute);
@ -25,6 +28,7 @@ export class SearchComponent {
private instances = inject(InstanceService);
private history = inject(HistoryService);
private title = inject(Title);
private unified = inject(SearchService);
q = signal<string>('');
loading = signal<boolean>(false);
@ -41,6 +45,11 @@ export class SearchComponent {
notice = signal<string | null>(null);
providerParam = signal<Provider | null>(null);
themeParam = signal<string | null>(null);
providersListParam = signal<ProviderId[] | 'all' | null>(null);
// Unified multi-provider response (grouped suggestions)
groups = signal<Record<ProviderId, any[]>>({} as any);
showUnified = signal<boolean>(false);
hasQuery = computed(() => this.q().length > 0);
providerLabel = computed(() => {
@ -63,6 +72,41 @@ export class SearchComponent {
filteredResults = computed(() => {
const tag = this.filterTag();
const provider = this.selectedProviderForView();
// Use unified results if available
if (this.showUnified()) {
const groups = this.groups();
const list: Video[] = [];
// Convert unified groups to Video objects
Object.entries(groups).forEach(([providerId, items]) => {
items.forEach((item: any) => {
list.push({
videoId: item.id,
title: item.title,
thumbnail: item.thumbnail || '',
uploaderName: item.uploaderName || 'Unknown',
uploaderAvatar: '',
views: 0,
uploaded: Date.now(),
uploadedDate: new Date().toISOString(),
duration: 0,
url: item.url || '',
type: 'video',
provider: providerId as 'youtube'|'dailymotion'|'twitch'|'rumble'|'odysee'|'peertube'
});
});
});
// Apply tag filtering to unified results
if (provider === 'twitch') return list; // Not used for Twitch (separate sections)
if (tag === 'all') return list;
// For now, return all results since we don't have duration/uploaded info from unified search
return list;
}
// Fallback to legacy results
const list = this.results();
if (provider === 'twitch') return list; // Not used for Twitch (separate sections)
if (tag === 'all') return list;
@ -113,7 +157,33 @@ export class SearchComponent {
const provider = this.selectedProviderForView();
const tags: { key: string; label: string; show: boolean }[] = [];
const now = Date.now();
const list = this.results();
// Use unified results if available
const list = this.showUnified() ?
(() => {
const groups = this.groups();
const results: Video[] = [];
Object.entries(groups).forEach(([_, items]) => {
items.forEach((item: any) => {
results.push({
videoId: item.id,
title: item.title,
thumbnail: item.thumbnail || '',
uploaderName: item.uploaderName || 'Unknown',
uploaderAvatar: '',
views: 0,
uploaded: Date.now(),
uploadedDate: new Date().toISOString(),
duration: 0,
url: item.url || '',
type: 'video',
provider: 'youtube' as any
});
});
});
return results;
})() :
this.results();
if (provider === 'twitch') {
const hasLive = this.twitchChannels().length > 0;
@ -176,24 +246,54 @@ export class SearchComponent {
this.title.setTitle(this.pageHeading());
});
// Subscribe once to unified multi-provider results and reflect to UI
this.unified.request$.subscribe({
next: (resp: SearchResponse) => {
this.groups.set(resp.groups as any);
this.loading.set(false);
},
error: () => {
this.groups.set({} as any);
this.loading.set(false);
}
});
// Listen to query param changes (so subsequent searches update)
this.route.queryParamMap.subscribe((pm) => {
const q = (pm.get('q') || '').trim();
this.q.set(q);
const prov = (pm.get('provider') as Provider) || null;
const theme = pm.get('theme');
const providersParam = (pm.get('providers') || '').trim();
// Parse providers CSV if present
let providersList: ProviderId[] | 'all' | null = null;
if (providersParam) {
const arr = providersParam.split(',').map(s => s.trim()).filter(Boolean) as ProviderId[];
providersList = (arr.length > 0) ? arr : 'all';
}
this.providerParam.set(prov);
this.themeParam.set(theme);
this.providersListParam.set(providersList);
if (q) {
this.notice.set(null);
// Reset active tag on new query
const provider = (prov || this.instances.selectedProvider());
const provider = prov || this.instances.selectedProvider();
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
this.reloadSearch();
// Use unified search in all cases
const providersToUse = providersList || [provider as ProviderId];
this.unified.setQuery(q);
this.unified.setProviders(providersToUse);
this.showUnified.set(true);
this.loading.set(true);
this.groups.set({} as any); // Clear previous results
} else {
this.results.set([]);
this.nextCursor.set(null);
this.loading.set(false);
this.groups.set({} as any);
this.showUnified.set(false);
}
});
@ -206,8 +306,18 @@ export class SearchComponent {
untracked(() => {
// If provider is explicitly specified in query, do not override it with global changes
if (this.providerParam()) return;
if (this.q()) {
if (!this.q()) return;
this.notice.set(null);
// If unified mode is active, re-emit providers to trigger a refresh instead of legacy reload
if (this.showUnified()) {
const provParam = this.providersListParam();
const provider = this.instances.selectedProvider();
const providersToUse = (provParam && (provParam === 'all' || (Array.isArray(provParam) && provParam.length > 0)))
? (provParam as any)
: [provider as unknown as ProviderId];
this.unified.setProviders(providersToUse);
this.loading.set(true);
} else {
this.reloadSearch();
}
});
@ -354,11 +464,6 @@ export class SearchComponent {
return qp;
}
// Update active filter tag from the template
setFilterTag(key: string) {
this.filterTag.set(key);
}
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
onSearchEnter(event: Event) {
@ -366,8 +471,16 @@ export class SearchComponent {
keyboardEvent.preventDefault();
const query = this.q().trim();
if (query) {
// Utiliser reloadSearch() pour effectuer une nouvelle recherche
this.reloadSearch();
// Route unified search regardless of providers param
const provParam = this.providersListParam();
const provider = this.providerParam() || this.instances.selectedProvider();
const providersToUse = (provParam && (provParam === 'all' || (Array.isArray(provParam) && provParam.length > 0)))
? (provParam as any)
: [provider as unknown as ProviderId];
this.unified.setQuery(query);
this.unified.setProviders(providersToUse);
this.showUnified.set(true);
this.loading.set(true);
this.closeSearchResults();
// Retirer le focus de l'input
if (this.searchInput) {
@ -389,8 +502,8 @@ export class SearchComponent {
// Vous pouvez ajouter ici la logique de recherche au fur et à mesure si nécessaire
}
performSearch(query: string) {
// Cette méthode sera appelée pour effectuer la recherche
// Vous pouvez ajouter ici la logique de recherche
// Update active filter tag from the template
setFilterTag(key: string) {
this.filterTag.set(key);
}
}

View File

@ -7,6 +7,7 @@ import type { AuthUser } from './auth.service';
export interface UserPreferences {
language?: string | null;
defaultProvider?: string | null;
defaultProviders?: string[] | null;
theme?: 'light' | 'dark' | 'black' | 'system' | string | null;
videoQuality?: string | null;
region?: string | null;

67
test-guide.md Normal file
View File

@ -0,0 +1,67 @@
# Guide de test complet de la recherche unifiée
# Exécutez ces étapes pour vérifier que tout fonctionne
echo "=== 1. Test du backend ==="
echo "Démarrer le serveur backend :"
echo "npm run api:watch"
echo ""
echo "Dans un autre terminal, tester les adaptateurs :"
echo "curl \"http://localhost:4000/api/search?q=test&providers=dm\" | jq"
echo "curl \"http://localhost:4000/api/search?q=linux&providers=yt,dm\" | jq"
echo ""
echo "=== 2. Test du frontend ==="
echo "Démarrer l'application frontend :"
echo "npm start"
echo ""
echo "=== 3. Tests fonctionnels dans le navigateur ==="
echo ""
echo "Test 1 - Recherche simple :"
echo "1. Aller sur http://localhost:4200"
echo "2. Taper 'test' dans la barre de recherche"
echo "3. Cliquer sur 'Search'"
echo "4. Vérifier que l'URL contient ?q=test&providers=yt,dm,tw,pt,od,ru"
echo "5. Vérifier que les résultats s'affichent"
echo ""
echo "Test 2 - Recherche avec providers spécifiques :"
echo "1. Cliquer sur le bouton '@' pour ouvrir le sélecteur"
echo "2. Sélectionner seulement 'YouTube' et 'Dailymotion'"
echo "3. Taper 'music' et cliquer 'Search'"
echo "4. Vérifier que l'URL contient ?q=music&providers=yt,dm"
echo "5. Vérifier que seuls YT et DM sont recherchés"
echo ""
echo "Test 3 - Recherche avec tous les providers :"
echo "1. Cliquer sur 'All' pour tout sélectionner"
echo "2. Taper 'javascript' et cliquer 'Search'"
echo "3. Vérifier que tous les providers sont utilisés"
echo ""
echo "Test 4 - Navigation et état :"
echo "1. Faire une recherche"
echo "2. Actualiser la page (F5)"
echo "3. Vérifier que la recherche se relance automatiquement"
echo "4. Changer de provider dans l'en-tête"
echo "5. Vérifier que la recherche se met à jour"
echo ""
echo "=== 4. Vérifications techniques ==="
echo ""
echo "Console navigateur (F12) :"
echo "- Pas d'erreurs JavaScript"
echo "- Requêtes réseau vers /api/search réussies"
echo "- Les groupes de résultats s'affichent correctement"
echo ""
echo "Onglet Network :"
echo "- Status 200 pour /api/search"
echo "- Payload JSON avec q, providers, groups"
echo "- Headers CORS corrects"
echo ""
echo "=== 5. Si ça ne marche pas ==="
echo ""
echo "Problèmes courants :"
echo "1. Backend non démarré : npm run api:watch"
echo "2. Clé YouTube manquante : ajouter YOUTUBE_API_KEY dans .env"
echo "3. CORS bloqué : vérifier proxy.conf.json"
echo "4. Providers vides : vérifier les logs backend"
echo ""
echo "Debug :"
echo "- Ouvrir http://localhost:4000/api/search?q=test&providers=dm"
echo "- Vérifier la réponse JSON"
echo "- Consulter les logs du serveur

29
test-search.bat Normal file
View File

@ -0,0 +1,29 @@
@echo off
REM Test des adaptateurs de recherche - Version Windows
echo === Test YouTube (nécessite YOUTUBE_API_KEY) ===
curl "http://localhost:4000/api/search?q=test&providers=yt"
echo.
echo === Test Dailymotion (pas de clé API nécessaire) ===
curl "http://localhost:4000/api/search?q=test&providers=dm"
echo.
echo === Test Twitch (pas de clé API nécessaire) ===
curl "http://localhost:4000/api/search?q=gaming&providers=tw"
echo.
echo === Test PeerTube (pas de clé API nécessaire) ===
curl "http://localhost:4000/api/search?q=linux&providers=pt"
echo.
echo === Test Odysee (pas de clé API nécessaire) ===
curl "http://localhost:4000/api/search?q=programming&providers=od"
echo.
echo === Test Rumble (pas de clé API nécessaire) ===
curl "http://localhost:4000/api/search?q=news&providers=ru"
echo.
echo === Test multi-fournisseurs ===
curl "http://localhost:4000/api/search?q=javascript&providers=yt,dm,pt,od,ru"

23
test-search.sh Normal file
View File

@ -0,0 +1,23 @@
# Test des adaptateurs de recherche
# Exécutez ces commandes après avoir redémarré le serveur backend
# Test YouTube (nécessite YOUTUBE_API_KEY)
curl "http://localhost:4000/api/search?q=test&providers=yt" | jq
# Test Dailymotion (pas de clé API nécessaire)
curl "http://localhost:4000/api/search?q=test&providers=dm" | jq
# Test Twitch (pas de clé API nécessaire pour la recherche basique)
curl "http://localhost:4000/api/search?q=gaming&providers=tw" | jq
# Test PeerTube (pas de clé API nécessaire)
curl "http://localhost:4000/api/search?q=linux&providers=pt" | jq
# Test Odysee (pas de clé API nécessaire)
curl "http://localhost:4000/api/search?q=programming&providers=od" | jq
# Test Rumble (pas de clé API nécessaire)
curl "http://localhost:4000/api/search?q=news&providers=ru" | jq
# Test multi-fournisseurs
curl "http://localhost:4000/api/search?q=javascript&providers=yt,dm,pt,od,ru" | jq

27
todo.md Normal file
View File

@ -0,0 +1,27 @@
- [x] Step 1: Audit current codebase for existing search and provider logic on frontend and server
- [x] Step 2: Design and scaffold Provider Registry on frontend and server
- [x] Step 3: Implement SearchService with multi-provider params and HTTP request
- [x] Step 8: Implement backend /api/search with providers param and fan-out to server/providers handlers
- [ ] Step 4: Build SearchBoxComponent with chips, @autocomplete, and keyboard shortcuts
- [ ] Step 5: Build ProviderPickerComponent (Ctrl/⌘+K modal) with filtering and apply
- [ ] Step 6: Build SearchSuggestionsComponent with grouped sections and empty-state handling
- [ ] Step 7: Add router query-param sync and default provider preference loading
- [ ] Step 9: Persist default providers preference in user profile (server + client integration points)
- [ ] Step 10: Add keyboard accessibility and a11y behaviors (Tab/Shift+Tab, Esc)
- [ ] Step 11: Write unit tests (SearchService, ProviderPicker toggle, @ parsing)
- [ ] Step 12: Write basic e2e scenarios for providers filtering and deep-link
- [ ] Step 13: Add minimal telemetry hooks and events
- [ ] Step 14: Update README UX GIF and usage notes
Updated TODO status
Step 4: Build SearchBoxComponent with chips, @autocomplete (initial), and keyboard shortcuts
Step 5: Build ProviderPickerComponent (Ctrl/⌘+K modal) with filtering and apply — finish History column + a11y in modal
Step 6: Build SearchSuggestionsComponent with grouped sections and empty-state handling — add per-provider unsupported rows + deep-links
Step 7: Add router query-param sync and default provider preference loading (initial sync in Search page)
Step 9: Persist default providers preference in user profile (client + server endpoints wired)
Step 10: Add keyboard accessibility and a11y behaviors (Tab/Shift+Tab, Esc) — finish trap/aria in modal
Step 11: Write unit tests (SearchService, ProviderPicker toggle, @ parsing)
Step 12: Write basic e2e scenarios for providers filtering and deep-link
Step 13: Add minimal telemetry hooks and events
Step 14: Update README UX GIF and usage notes