chore: update Angular cache and TypeScript build info files
This commit is contained in:
parent
08fd0682a4
commit
4eb339eb22
2
.angular/cache/20.2.2/app/.tsbuildinfo
vendored
2
.angular/cache/20.2.2/app/.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
@ -1,61 +1,61 @@
|
|||||||
{
|
{
|
||||||
"hash": "534e7bec",
|
"hash": "f4c7eaa2",
|
||||||
"configHash": "d859ec53",
|
"configHash": "d859ec53",
|
||||||
"lockfileHash": "891162b0",
|
"lockfileHash": "9b1c4210",
|
||||||
"browserHash": "b971f174",
|
"browserHash": "6942cbde",
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"@angular/common": {
|
"@angular/common": {
|
||||||
"src": "../../../../../../node_modules/@angular/common/fesm2022/common.mjs",
|
"src": "../../../../../../node_modules/@angular/common/fesm2022/common.mjs",
|
||||||
"file": "@angular_common.js",
|
"file": "@angular_common.js",
|
||||||
"fileHash": "76f579d7",
|
"fileHash": "92f641aa",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"@angular/common/http": {
|
"@angular/common/http": {
|
||||||
"src": "../../../../../../node_modules/@angular/common/fesm2022/http.mjs",
|
"src": "../../../../../../node_modules/@angular/common/fesm2022/http.mjs",
|
||||||
"file": "@angular_common_http.js",
|
"file": "@angular_common_http.js",
|
||||||
"fileHash": "3f81fe6e",
|
"fileHash": "3a8b8614",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"@angular/core": {
|
"@angular/core": {
|
||||||
"src": "../../../../../../node_modules/@angular/core/fesm2022/core.mjs",
|
"src": "../../../../../../node_modules/@angular/core/fesm2022/core.mjs",
|
||||||
"file": "@angular_core.js",
|
"file": "@angular_core.js",
|
||||||
"fileHash": "817c1079",
|
"fileHash": "b1ee9355",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"@angular/forms": {
|
"@angular/forms": {
|
||||||
"src": "../../../../../../node_modules/@angular/forms/fesm2022/forms.mjs",
|
"src": "../../../../../../node_modules/@angular/forms/fesm2022/forms.mjs",
|
||||||
"file": "@angular_forms.js",
|
"file": "@angular_forms.js",
|
||||||
"fileHash": "5c59f890",
|
"fileHash": "5842af9d",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"@angular/platform-browser": {
|
"@angular/platform-browser": {
|
||||||
"src": "../../../../../../node_modules/@angular/platform-browser/fesm2022/platform-browser.mjs",
|
"src": "../../../../../../node_modules/@angular/platform-browser/fesm2022/platform-browser.mjs",
|
||||||
"file": "@angular_platform-browser.js",
|
"file": "@angular_platform-browser.js",
|
||||||
"fileHash": "4f20f29c",
|
"fileHash": "f65b040d",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"@angular/router": {
|
"@angular/router": {
|
||||||
"src": "../../../../../../node_modules/@angular/router/fesm2022/router.mjs",
|
"src": "../../../../../../node_modules/@angular/router/fesm2022/router.mjs",
|
||||||
"file": "@angular_router.js",
|
"file": "@angular_router.js",
|
||||||
"fileHash": "ae70e479",
|
"fileHash": "f605abad",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"@google/genai": {
|
"@google/genai": {
|
||||||
"src": "../../../../../../node_modules/@google/genai/dist/web/index.mjs",
|
"src": "../../../../../../node_modules/@google/genai/dist/web/index.mjs",
|
||||||
"file": "@google_genai.js",
|
"file": "@google_genai.js",
|
||||||
"fileHash": "4d8ae55a",
|
"fileHash": "b3b8b992",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"rxjs": {
|
"rxjs": {
|
||||||
"src": "../../../../../../node_modules/rxjs/dist/esm5/index.js",
|
"src": "../../../../../../node_modules/rxjs/dist/esm5/index.js",
|
||||||
"file": "rxjs.js",
|
"file": "rxjs.js",
|
||||||
"fileHash": "490b7fef",
|
"fileHash": "0cb397ac",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"rxjs/operators": {
|
"rxjs/operators": {
|
||||||
"src": "../../../../../../node_modules/rxjs/dist/esm5/operators/index.js",
|
"src": "../../../../../../node_modules/rxjs/dist/esm5/operators/index.js",
|
||||||
"file": "rxjs_operators.js",
|
"file": "rxjs_operators.js",
|
||||||
"fileHash": "938cbe53",
|
"fileHash": "e706ebfa",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"hash": "f1fac02c",
|
"hash": "7ebe1a66",
|
||||||
"configHash": "3d00a7fd",
|
"configHash": "3d00a7fd",
|
||||||
"lockfileHash": "891162b0",
|
"lockfileHash": "9b1c4210",
|
||||||
"browserHash": "10427b09",
|
"browserHash": "dfa5acef",
|
||||||
"optimized": {},
|
"optimized": {},
|
||||||
"chunks": {}
|
"chunks": {}
|
||||||
}
|
}
|
10
.env.example
Normal file
10
.env.example
Normal 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
130
DIAGNOSTIC.md
Normal 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 !**
|
50
README.md
50
README.md
@ -238,3 +238,53 @@ MIT (voir `LICENSE`)
|
|||||||
---
|
---
|
||||||
|
|
||||||
> 💡 **Tip produit** : gardez l’UX 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 l’utilisateur.
|
> 💡 **Tip produit** : gardez l’UX 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 l’utilisateur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 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 fan‑out `/api/search`.
|
||||||
|
|
||||||
|
3) API — Recherche multi‑providers
|
||||||
|
|
||||||
|
- L’endpoint `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 l’appel API.
|
||||||
|
|
||||||
|
5) Deep‑link
|
||||||
|
|
||||||
|
- 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 l’input, toggle ProviderPicker, composition de `SearchService`.
|
||||||
|
- e2e: `@yt + query` → résultats uniquement YouTube; chips `YT+DM` → sections YT/DM visibles; deep‑link restauré.
|
||||||
|
|
||||||
|
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.
|
||||||
|
BIN
db/newtube.db
BIN
db/newtube.db
Binary file not shown.
16
package-lock.json
generated
16
package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/build": "^20.1.0",
|
"@angular/build": "^20.1.0",
|
||||||
|
"@angular/cdk": "^20.2.4",
|
||||||
"@angular/cli": "^20.1.0",
|
"@angular/cli": "^20.1.0",
|
||||||
"@angular/common": "^20.1.0",
|
"@angular/common": "^20.1.0",
|
||||||
"@angular/compiler": "^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": {
|
"node_modules/@angular/cli": {
|
||||||
"version": "20.2.2",
|
"version": "20.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-20.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-20.2.2.tgz",
|
||||||
|
@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/build": "^20.1.0",
|
"@angular/build": "^20.1.0",
|
||||||
|
"@angular/cdk": "^20.2.4",
|
||||||
"@angular/cli": "^20.1.0",
|
"@angular/cli": "^20.1.0",
|
||||||
"@angular/common": "^20.1.0",
|
"@angular/common": "^20.1.0",
|
||||||
"@angular/compiler": "^20.1.0",
|
"@angular/compiler": "^20.1.0",
|
||||||
|
@ -12,6 +12,7 @@ import ffmpegPath from 'ffmpeg-static';
|
|||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import rumbleRouter from './rumble.mjs';
|
import rumbleRouter from './rumble.mjs';
|
||||||
|
import { providerRegistry, validateProviders } from './providers/registry.mjs';
|
||||||
import {
|
import {
|
||||||
getUserByUsername,
|
getUserByUsername,
|
||||||
getUserById,
|
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-api/*', (req, res) => forwardJson(req, res, 'https://api.twitch.tv'));
|
||||||
app.all('/api/twitch-auth/*', (req, res) => forwardJson(req, res, 'https://id.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) --------------------
|
// -------------------- Static Frontend (Angular build) --------------------
|
||||||
const distRoot = path.join(process.cwd(), 'dist');
|
const distRoot = path.join(process.cwd(), 'dist');
|
||||||
const distBrowser = path.join(distRoot, 'browser');
|
const distBrowser = path.join(distRoot, 'browser');
|
||||||
|
44
server/providers/dailymotion.mjs
Normal file
44
server/providers/dailymotion.mjs
Normal 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;
|
44
server/providers/odysee.mjs
Normal file
44
server/providers/odysee.mjs
Normal 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;
|
44
server/providers/peertube.mjs
Normal file
44
server/providers/peertube.mjs
Normal 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;
|
39
server/providers/registry.mjs
Normal file
39
server/providers/registry.mjs
Normal 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);
|
||||||
|
}
|
43
server/providers/rumble.mjs
Normal file
43
server/providers/rumble.mjs
Normal 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;
|
51
server/providers/twitch.mjs
Normal file
51
server/providers/twitch.mjs
Normal 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;
|
58
server/providers/youtube.mjs
Normal file
58
server/providers/youtube.mjs
Normal 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;
|
40
src/app/core/providers/provider-registry.ts
Normal file
40
src/app/core/providers/provider-registry.ts
Normal 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]));
|
49
src/app/search/search.service.ts
Normal file
49
src/app/search/search.service.ts
Normal 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')); }
|
||||||
|
}
|
@ -14,114 +14,10 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: search (grid-centered, avoids overlap) -->
|
<!-- Center: search box with providers -->
|
||||||
<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)">
|
<div class="justify-self-center mx-auto w-full max-w-2xl md:max-w-3xl lg:max-w-4xl px-4 relative">
|
||||||
<input #searchInput type="search" [placeholder]="('search.placeholder' | t)" aria-label="Search"
|
<app-search-box [placeholder]="('search.placeholder' | t)" (submitted)="onSearchBoxSubmit($event)"></app-search-box>
|
||||||
(focus)="onSearchFocus()" (input)="onSearchInput(searchInput)" (blur)="onSearchBlur()" (keydown)="onSearchKeydown($event, searchInput)"
|
</div>
|
||||||
(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>
|
|
||||||
</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 -->
|
<!-- Right: user -->
|
||||||
<div class="justify-self-end flex items-center gap-3 pr-3 md:pr-4">
|
<div class="justify-self-end flex items-center gap-3 pr-3 md:pr-4">
|
||||||
|
@ -10,6 +10,7 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
|||||||
import { I18nService } from '../../services/i18n.service';
|
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';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-header',
|
selector: 'app-header',
|
||||||
@ -20,7 +21,9 @@ import { HistoryService, SearchHistoryItem } from '../../services/history.servic
|
|||||||
RouterLink,
|
RouterLink,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
TranslatePipe
|
TranslatePipe,
|
||||||
|
// New unified search box with provider chips and picker
|
||||||
|
SearchBoxComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class HeaderComponent {
|
export class HeaderComponent {
|
||||||
@ -60,7 +63,7 @@ export class HeaderComponent {
|
|||||||
const lower = q.toLowerCase();
|
const lower = q.toLowerCase();
|
||||||
historyList = fromHistory.filter(txt => txt.toLowerCase().includes(lower)).slice(0, 15);
|
historyList = fromHistory.filter(txt => txt.toLowerCase().includes(lower)).slice(0, 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: Item[] = historyList.map(text => ({ text, source: 'history' }));
|
const items: Item[] = historyList.map(text => ({ text, source: 'history' }));
|
||||||
|
|
||||||
// 2) If we have fewer than 15, fill with generated suggestions
|
// 2) If we have fewer than 15, fill with generated suggestions
|
||||||
@ -85,6 +88,23 @@ export class HeaderComponent {
|
|||||||
return items;
|
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
|
// Generate query suggestions based on the current text and provider
|
||||||
private generateQuerySuggestions(q: string, provider: string | null): string[] {
|
private generateQuerySuggestions(q: string, provider: string | null): string[] {
|
||||||
const base = (q || '').trim();
|
const base = (q || '').trim();
|
||||||
|
40
src/components/search/provider-picker.component.html
Normal file
40
src/components/search/provider-picker.component.html
Normal 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>
|
79
src/components/search/provider-picker.component.ts
Normal file
79
src/components/search/provider-picker.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
105
src/components/search/search-box.component.html
Normal file
105
src/components/search/search-box.component.html
Normal 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>
|
247
src/components/search/search-box.component.ts
Normal file
247
src/components/search/search-box.component.ts
Normal 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 {}
|
||||||
|
});
|
||||||
|
}
|
28
src/components/search/search-suggestions.component.html
Normal file
28
src/components/search/search-suggestions.component.html
Normal 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>
|
18
src/components/search/search-suggestions.component.ts
Normal file
18
src/components/search/search-suggestions.component.ts
Normal 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 = '';
|
||||||
|
}
|
@ -27,6 +27,11 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Unified multi-provider grouped suggestions -->
|
||||||
|
@if (showUnified()) {
|
||||||
|
<app-search-suggestions [groups]="groups()" [query]="q()"></app-search-suggestions>
|
||||||
|
}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
#searchInput
|
#searchInput
|
||||||
type="text"
|
type="text"
|
||||||
|
@ -11,13 +11,16 @@ import { Title } from '@angular/platform-browser';
|
|||||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||||
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
|
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
|
||||||
import { AddToPlaylistComponent } from '../shared/components/add-to-playlist/add-to-playlist.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({
|
@Component({
|
||||||
selector: 'app-search',
|
selector: 'app-search',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
templateUrl: './search.component.html',
|
templateUrl: './search.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent]
|
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent, SearchSuggestionsComponent]
|
||||||
})
|
})
|
||||||
export class SearchComponent {
|
export class SearchComponent {
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
@ -25,6 +28,7 @@ export class SearchComponent {
|
|||||||
private instances = inject(InstanceService);
|
private instances = inject(InstanceService);
|
||||||
private history = inject(HistoryService);
|
private history = inject(HistoryService);
|
||||||
private title = inject(Title);
|
private title = inject(Title);
|
||||||
|
private unified = inject(SearchService);
|
||||||
|
|
||||||
q = signal<string>('');
|
q = signal<string>('');
|
||||||
loading = signal<boolean>(false);
|
loading = signal<boolean>(false);
|
||||||
@ -41,6 +45,11 @@ export class SearchComponent {
|
|||||||
notice = signal<string | null>(null);
|
notice = signal<string | null>(null);
|
||||||
providerParam = signal<Provider | null>(null);
|
providerParam = signal<Provider | null>(null);
|
||||||
themeParam = signal<string | 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);
|
hasQuery = computed(() => this.q().length > 0);
|
||||||
providerLabel = computed(() => {
|
providerLabel = computed(() => {
|
||||||
@ -63,16 +72,51 @@ export class SearchComponent {
|
|||||||
filteredResults = computed(() => {
|
filteredResults = computed(() => {
|
||||||
const tag = this.filterTag();
|
const tag = this.filterTag();
|
||||||
const provider = this.selectedProviderForView();
|
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();
|
const list = this.results();
|
||||||
if (provider === 'twitch') return list; // Not used for Twitch (separate sections)
|
if (provider === 'twitch') return list; // Not used for Twitch (separate sections)
|
||||||
if (tag === 'all') return list;
|
if (tag === 'all') return list;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const oneDay = 24 * 60 * 60 * 1000;
|
const oneDay = 24 * 60 * 60 * 1000;
|
||||||
const sevenDays = 7 * oneDay;
|
const sevenDays = 7 * oneDay;
|
||||||
const thirtyDays = 30 * oneDay;
|
const thirtyDays = 30 * oneDay;
|
||||||
const oneYear = 365 * oneDay;
|
const oneYear = 365 * oneDay;
|
||||||
|
|
||||||
// Duration filters
|
// Duration filters
|
||||||
if (tag === 'short') return list.filter(v => {
|
if (tag === 'short') return list.filter(v => {
|
||||||
const d = Number(v.duration || 0);
|
const d = Number(v.duration || 0);
|
||||||
@ -86,7 +130,7 @@ export class SearchComponent {
|
|||||||
const d = Number(v.duration || 0);
|
const d = Number(v.duration || 0);
|
||||||
return d >= 20 * 60;
|
return d >= 20 * 60;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Date filters
|
// Date filters
|
||||||
if (tag === 'today') return list.filter(v => {
|
if (tag === 'today') return list.filter(v => {
|
||||||
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
|
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
|
||||||
@ -104,7 +148,7 @@ export class SearchComponent {
|
|||||||
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
|
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
|
||||||
return uploadTime > 0 && (now - uploadTime) <= oneYear;
|
return uploadTime > 0 && (now - uploadTime) <= oneYear;
|
||||||
});
|
});
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -113,8 +157,34 @@ export class SearchComponent {
|
|||||||
const provider = this.selectedProviderForView();
|
const provider = this.selectedProviderForView();
|
||||||
const tags: { key: string; label: string; show: boolean }[] = [];
|
const tags: { key: string; label: string; show: boolean }[] = [];
|
||||||
const now = Date.now();
|
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') {
|
if (provider === 'twitch') {
|
||||||
const hasLive = this.twitchChannels().length > 0;
|
const hasLive = this.twitchChannels().length > 0;
|
||||||
const hasVods = this.twitchVods().length > 0;
|
const hasVods = this.twitchVods().length > 0;
|
||||||
@ -132,20 +202,20 @@ export class SearchComponent {
|
|||||||
const sevenDays = 7 * oneDay;
|
const sevenDays = 7 * oneDay;
|
||||||
const thirtyDays = 30 * oneDay;
|
const thirtyDays = 30 * oneDay;
|
||||||
const oneYear = 365 * oneDay;
|
const oneYear = 365 * oneDay;
|
||||||
|
|
||||||
let hasShort = false, hasMedium = false, hasLong = false,
|
let hasShort = false, hasMedium = false, hasLong = false,
|
||||||
hasToday = false, hasThisWeek = false, hasThisMonth = false, hasThisYear = false;
|
hasToday = false, hasThisWeek = false, hasThisMonth = false, hasThisYear = false;
|
||||||
|
|
||||||
for (const v of list) {
|
for (const v of list) {
|
||||||
const d = Number(v.duration || 0);
|
const d = Number(v.duration || 0);
|
||||||
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
|
const uploadTime = typeof v.uploaded === 'number' ? v.uploaded : 0;
|
||||||
const timeDiff = now - uploadTime;
|
const timeDiff = now - uploadTime;
|
||||||
|
|
||||||
// Duration filters
|
// Duration filters
|
||||||
if (d > 0 && d < 4 * 60) hasShort = true;
|
if (d > 0 && d < 4 * 60) hasShort = true;
|
||||||
else if (d >= 4 * 60 && d < 20 * 60) hasMedium = true;
|
else if (d >= 4 * 60 && d < 20 * 60) hasMedium = true;
|
||||||
else if (d >= 20 * 60) hasLong = true;
|
else if (d >= 20 * 60) hasLong = true;
|
||||||
|
|
||||||
// Date filters
|
// Date filters
|
||||||
if (uploadTime > 0) {
|
if (uploadTime > 0) {
|
||||||
if (timeDiff <= oneDay) hasToday = true;
|
if (timeDiff <= oneDay) hasToday = true;
|
||||||
@ -160,13 +230,13 @@ export class SearchComponent {
|
|||||||
tags.push({ key: 'short', label: 'Moins de 4 min', show: hasShort });
|
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: 'medium', label: 'De 4 à 20 min', show: hasMedium });
|
||||||
tags.push({ key: 'long', label: '20 min et plus', show: hasLong });
|
tags.push({ key: 'long', label: '20 min et plus', show: hasLong });
|
||||||
|
|
||||||
// Add date filters
|
// Add date filters
|
||||||
tags.push({ key: 'today', label: 'Aujourd\'hui', show: hasToday });
|
tags.push({ key: 'today', label: 'Aujourd\'hui', show: hasToday });
|
||||||
tags.push({ key: 'this_week', label: 'Cette semaine', show: hasThisWeek });
|
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_month', label: 'Ce mois-ci', show: hasThisMonth });
|
||||||
tags.push({ key: 'this_year', label: 'Cette année', show: hasThisYear });
|
tags.push({ key: 'this_year', label: 'Cette année', show: hasThisYear });
|
||||||
|
|
||||||
return tags.filter(t => t.show);
|
return tags.filter(t => t.show);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -176,24 +246,54 @@ export class SearchComponent {
|
|||||||
this.title.setTitle(this.pageHeading());
|
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)
|
// Listen to query param changes (so subsequent searches update)
|
||||||
this.route.queryParamMap.subscribe((pm) => {
|
this.route.queryParamMap.subscribe((pm) => {
|
||||||
const q = (pm.get('q') || '').trim();
|
const q = (pm.get('q') || '').trim();
|
||||||
this.q.set(q);
|
this.q.set(q);
|
||||||
const prov = (pm.get('provider') as Provider) || null;
|
const prov = (pm.get('provider') as Provider) || null;
|
||||||
const theme = pm.get('theme');
|
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.providerParam.set(prov);
|
||||||
this.themeParam.set(theme);
|
this.themeParam.set(theme);
|
||||||
|
this.providersListParam.set(providersList);
|
||||||
|
|
||||||
if (q) {
|
if (q) {
|
||||||
this.notice.set(null);
|
this.notice.set(null);
|
||||||
// Reset active tag on new query
|
// 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.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 {
|
} else {
|
||||||
this.results.set([]);
|
this.results.set([]);
|
||||||
this.nextCursor.set(null);
|
this.nextCursor.set(null);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
|
this.groups.set({} as any);
|
||||||
|
this.showUnified.set(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -206,8 +306,18 @@ export class SearchComponent {
|
|||||||
untracked(() => {
|
untracked(() => {
|
||||||
// If provider is explicitly specified in query, do not override it with global changes
|
// If provider is explicitly specified in query, do not override it with global changes
|
||||||
if (this.providerParam()) return;
|
if (this.providerParam()) return;
|
||||||
if (this.q()) {
|
if (!this.q()) return;
|
||||||
this.notice.set(null);
|
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();
|
this.reloadSearch();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -354,11 +464,6 @@ export class SearchComponent {
|
|||||||
return qp;
|
return qp;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update active filter tag from the template
|
|
||||||
setFilterTag(key: string) {
|
|
||||||
this.filterTag.set(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
|
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
onSearchEnter(event: Event) {
|
onSearchEnter(event: Event) {
|
||||||
@ -366,8 +471,16 @@ export class SearchComponent {
|
|||||||
keyboardEvent.preventDefault();
|
keyboardEvent.preventDefault();
|
||||||
const query = this.q().trim();
|
const query = this.q().trim();
|
||||||
if (query) {
|
if (query) {
|
||||||
// Utiliser reloadSearch() pour effectuer une nouvelle recherche
|
// Route unified search regardless of providers param
|
||||||
this.reloadSearch();
|
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();
|
this.closeSearchResults();
|
||||||
// Retirer le focus de l'input
|
// Retirer le focus de l'input
|
||||||
if (this.searchInput) {
|
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
|
// Vous pouvez ajouter ici la logique de recherche au fur et à mesure si nécessaire
|
||||||
}
|
}
|
||||||
|
|
||||||
performSearch(query: string) {
|
// Update active filter tag from the template
|
||||||
// Cette méthode sera appelée pour effectuer la recherche
|
setFilterTag(key: string) {
|
||||||
// Vous pouvez ajouter ici la logique de recherche
|
this.filterTag.set(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import type { AuthUser } from './auth.service';
|
|||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
language?: string | null;
|
language?: string | null;
|
||||||
defaultProvider?: string | null;
|
defaultProvider?: string | null;
|
||||||
|
defaultProviders?: string[] | null;
|
||||||
theme?: 'light' | 'dark' | 'black' | 'system' | string | null;
|
theme?: 'light' | 'dark' | 'black' | 'system' | string | null;
|
||||||
videoQuality?: string | null;
|
videoQuality?: string | null;
|
||||||
region?: string | null;
|
region?: string | null;
|
||||||
|
67
test-guide.md
Normal file
67
test-guide.md
Normal 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
29
test-search.bat
Normal 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
23
test-search.sh
Normal 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
27
todo.md
Normal 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
|
Loading…
x
Reference in New Issue
Block a user