first commit

This commit is contained in:
2026-05-22 09:17:28 +02:00
commit 26b3ed6994
33 changed files with 2943 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
YOUTUBE_API_KEY=your_youtube_data_api_v3_key_here
NEXT_PUBLIC_API_URL=http://localhost:8000
+26
View File
@@ -0,0 +1,26 @@
# Secrets
.env
# Données générées
data/
# Frontend
node_modules/
.next/
frontend/node_modules/
frontend/.next/
# Python
__pycache__/
*.pyc
*.pyo
.pytest_cache/
*.egg-info/
dist/
build/
.venv/
venv/
# OS
.DS_Store
Thumbs.db
+226
View File
@@ -0,0 +1,226 @@
# YTVeille — Veille automatique YouTube en français
Veille automatique des meilleures vidéos YouTube **en français** sur n'importe quel domaine technique.
Requêtes configurables dynamiquement, scoring multi-critères, dark mode, filtres avancés.
---
## Prérequis
- Docker & Docker Compose
- [Clé YouTube Data API v3](https://console.cloud.google.com/)
---
## Démarrage rapide (local)
```bash
# 1. Cloner le projet
git clone https://github.com/TON_USER/YTVeille.git
cd YTVeille
# 2. Configurer les variables d'environnement
cp .env.example .env
# Puis éditer .env et remplacer "your_youtube_data_api_v3_key_here" par ta clé
# NEXT_PUBLIC_API_URL reste http://localhost:8000 pour un usage local
# 3. Lancer les services
docker compose up -d --build
# 4. Ouvrir l'application
# Frontend : http://localhost:3000
# API docs : http://localhost:8000/docs
```
Le **worker** effectue une 1ère mise à jour au démarrage, puis quotidiennement à 6h UTC.
---
## Architecture
```
├── backend/
│ ├── api/ FastAPI — REST API, endpoints /videos, /refresh, /status, /config
│ ├── scoring/ Algorithme de scoring multi-critères (sur 100)
│ └── worker/ APScheduler — cron quotidien + pipeline fetch→score→persist
├── frontend/ Next.js 14 — Dashboard UI (filtres + cards + dark mode)
├── data/ videos.json, config.json, quota_status.json (volume Docker partagé)
└── docker-compose.yml
```
---
## Score des vidéos (0100)
| Critère | Poids |
|---|---|
| Vues pondérées par ancienneté | 25 pts |
| Ratio likes / vues | 20 pts |
| Mots-clés techniques détectés | 25 pts |
| Durée ≥ 10 min | 10 pts |
| Présence de chapitrage | 10 pts |
| Nombre de topics distincts | 10 pts |
---
## Variables d'environnement
| Variable | Description |
|---|---|
| `YOUTUBE_API_KEY` | Clé YouTube Data API v3 (obligatoire) |
| `DATA_PATH` | Chemin du fichier JSON (défaut : `/app/data/videos.json`) |
| `NEXT_PUBLIC_API_URL` | URL publique de l'API appelée par le navigateur (défaut : `http://localhost:8000`) |
> **Important** : `NEXT_PUBLIC_API_URL` est compilée dans le bundle JavaScript au moment du `docker build`.
> Elle doit pointer vers l'IP/domaine **public** du serveur, pas le hostname Docker interne.
---
## Commandes utiles
```bash
# Voir les logs en temps réel
docker compose logs -f
# Forcer une mise à jour manuelle
curl -X POST http://localhost:8000/api/refresh
# Voir le statut de l'API (quota, vidéos, refresh en cours)
curl http://localhost:8000/api/status | jq .
# Lancer les tests unitaires (backend)
cd backend && pip install -e ".[dev]" && pytest scoring/tests/ -v
```
---
## Guide de déploiement sur un serveur distant
### 1. Provisionner un VPS
N'importe quel fournisseur : **OVH** (~3€/mois), Hetzner, DigitalOcean, AWS EC2...
OS recommandé : **Ubuntu 22.04 LTS**
### 2. Installer Docker sur le serveur
```bash
# Se connecter en SSH
ssh user@TON_IP_SERVEUR
# Installer Docker
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Déconnecte-toi et reconnecte-toi pour appliquer le groupe
exit
ssh user@TON_IP_SERVEUR
```
### 3. Copier le projet sur le serveur
**Option A — via Git (recommandé) :**
```bash
# Sur le serveur
git clone https://github.com/TON_USER/YTVeille.git
cd YTVeille
```
**Option B — via rsync depuis ta machine locale :**
```bash
# Depuis ta machine locale
rsync -av --exclude='node_modules' --exclude='.next' --exclude='data' \
/chemin/local/YTVeille/ \
user@TON_IP_SERVEUR:~/YTVeille/
```
### 4. Créer le fichier `.env` sur le serveur
```bash
# Sur le serveur, dans le dossier YTVeille/
cat > .env << 'EOF'
YOUTUBE_API_KEY=ta_cle_youtube_api
NEXT_PUBLIC_API_URL=http://TON_IP_SERVEUR:8000
EOF
```
> `NEXT_PUBLIC_API_URL` doit être l'IP ou le domaine **public** du serveur sur le port 8000,
> car c'est le navigateur du visiteur qui appelle l'API directement.
### 5. Ouvrir les ports du pare-feu
```bash
sudo ufw allow 22 # SSH
sudo ufw allow 3000 # Frontend
sudo ufw allow 8000 # API
sudo ufw enable
```
### 6. Lancer l'application
```bash
cd ~/YTVeille
docker compose up -d --build
docker compose ps # vérifier que tout tourne
```
L'application est accessible sur `http://TON_IP_SERVEUR:3000`
### 7. (Optionnel) Domaine + HTTPS avec nginx
Si tu as un nom de domaine (ex: `ytveille.monsite.com`) :
**Installer nginx et certbot :**
```bash
sudo apt install nginx certbot python3-certbot-nginx -y
```
**Créer la configuration nginx** (`/etc/nginx/sites-available/ytveille`) :
```nginx
server {
server_name ytveille.monsite.com;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /api/ {
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
**Activer et sécuriser :**
```bash
sudo ln -s /etc/nginx/sites-available/ytveille /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
# Générer le certificat SSL (HTTPS gratuit)
sudo certbot --nginx -d ytveille.monsite.com
```
**Mettre à jour le `.env`** avec l'URL HTTPS :
```bash
# Modifier NEXT_PUBLIC_API_URL pour utiliser le domaine
NEXT_PUBLIC_API_URL=https://ytveille.monsite.com
# Puis rebuilder le frontend
docker compose up -d --build frontend
```
> Avec nginx, les ports 3000 et 8000 ne sont plus exposés directement :
> tout passe par le port 443 (HTTPS) via le reverse proxy.
---
## Quota YouTube API
L'API YouTube Data v3 est limitée à **10 000 unités/jour** (chaque recherche coûte 100 unités,
soit ~100 recherches max). En cas de dépassement :
- Un banner d'avertissement s'affiche automatiquement dans l'application
- Le quota se renouvelle chaque jour à **minuit heure du Pacifique** (~8h9h UTC)
- Pour obtenir plus de quota : [console.cloud.google.com](https://console.cloud.google.com) → API & Services → Quotas
+17
View File
@@ -0,0 +1,17 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir \
"fastapi>=0.110.0" \
"uvicorn[standard]>=0.29.0" \
"httpx>=0.27.0" \
"pydantic>=2.6.0" \
"apscheduler>=3.10.4"
COPY . .
EXPOSE 8000
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
+1
View File
@@ -0,0 +1 @@
# Backend Python packages init
+192
View File
@@ -0,0 +1,192 @@
"""
API FastAPI — Veille YouTube Kubernetes.
"""
import logging
from datetime import datetime, timezone
from typing import Optional
from fastapi import FastAPI, Query, HTTPException, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from .models import Video, VideoList, RefreshResult, RefreshRequest
from .storage import load_videos, save_videos, get_last_updated, load_config, save_config, load_quota_status, save_quota_status
from .youtube_client import fetch_all_videos, QuotaExceededError
from scoring.scorer import score_video
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
app = FastAPI(
title="YTVeille",
description="API de veille automatique des meilleures vidéos YouTube en français",
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
_refresh_running = False
def _run_refresh() -> RefreshResult:
"""Pipeline : fetch → score → persist."""
import asyncio
config = load_config()
queries = config.get("queries")
raw = asyncio.run(fetch_all_videos(queries))
logger.info("Vidéos récupérées : %d", len(raw))
scored = []
for v in raw:
s, topics = score_video(v)
v["score"] = s
v["topics"] = topics
scored.append(v)
# Trier par score décroissant
scored.sort(key=lambda x: x["score"], reverse=True)
save_videos(scored)
logger.info("Vidéos sauvegardées : %d", len(scored))
return RefreshResult(
fetched=len(raw),
scored=len(scored),
stored=len(scored),
timestamp=datetime.now(timezone.utc),
)
@app.get("/api/videos", response_model=VideoList)
def list_videos(
q: Optional[str] = Query(None),
min_score: float = Query(0.0, ge=0, le=100),
topic: Optional[str] = Query(None),
source_query: list[str] = Query(default=[]),
days: int = Query(30, ge=1, le=90),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
):
"""Liste paginée des vidéos avec filtres."""
all_videos = load_videos()
# Filtre texte (titre, chaîne, tags YouTube)
if q:
q_lower = q.lower()
all_videos = [
v for v in all_videos
if q_lower in v.get("title", "").lower()
or q_lower in v.get("channel", "").lower()
or any(q_lower in t.lower() for t in v.get("tags", []))
]
# Filtre par requête source (OR entre les requêtes sélectionnées)
if source_query:
all_videos = [
v for v in all_videos
if any(sq in v.get("source_queries", []) for sq in source_query)
]
# Filtre score
filtered = [v for v in all_videos if v.get("score", 0) >= min_score]
# Filtre topic
if topic:
filtered = [v for v in filtered if topic in v.get("topics", [])]
# Filtre date
from datetime import timedelta
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
result = []
for v in filtered:
pub = v.get("published_at", "")
try:
dt = datetime.fromisoformat(str(pub).replace("Z", "+00:00"))
if dt >= cutoff:
result.append(v)
except Exception:
result.append(v)
total = len(result)
start = (page - 1) * page_size
page_items = result[start : start + page_size]
return VideoList(
total=total,
page=page,
page_size=page_size,
items=[Video(**v) for v in page_items],
)
@app.get("/api/videos/{video_id}", response_model=Video)
def get_video(video_id: str):
"""Détail d'une vidéo par ID."""
all_videos = load_videos()
for v in all_videos:
if v["id"] == video_id:
return Video(**v)
raise HTTPException(status_code=404, detail="Vidéo non trouvée")
@app.get("/api/config")
def get_config():
"""Retourne la configuration de recherche actuelle."""
return load_config()
@app.post("/api/refresh", response_model=RefreshResult)
def refresh(background_tasks: BackgroundTasks, body: Optional[RefreshRequest] = None):
"""Déclenche une mise à jour. Si body.queries fourni, sauvegarde la config."""
global _refresh_running
if _refresh_running:
raise HTTPException(status_code=409, detail="Un refresh est déjà en cours")
if body and body.queries:
save_config({"queries": body.queries})
_refresh_running = True
def _wrapped():
global _refresh_running
try:
_run_refresh()
save_quota_status(False)
except QuotaExceededError:
save_quota_status(True)
logger.warning("Quota YouTube API dépassé — refresh abandonné")
except Exception as exc:
logger.error("Erreur lors du refresh : %s", exc)
finally:
_refresh_running = False
background_tasks.add_task(_wrapped)
return RefreshResult(
fetched=0,
scored=0,
stored=0,
timestamp=datetime.now(timezone.utc),
)
@app.get("/api/status")
def status():
"""État de l'API et date de dernière mise à jour."""
last = get_last_updated()
videos = load_videos()
quota = load_quota_status()
return {
"status": "ok",
"video_count": len(videos),
"last_updated": last.isoformat() if last else None,
"refresh_running": _refresh_running,
"queries": load_config().get("queries", []),
"quota_exceeded": quota.get("exceeded", False),
"quota_exceeded_at": quota.get("exceeded_at"),
}
+46
View File
@@ -0,0 +1,46 @@
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
class Video(BaseModel):
id: str
title: str
channel: str
published_at: datetime
duration_seconds: int
view_count: int
like_count: int
thumbnail_url: str
youtube_url: str
tags: List[str] = []
has_chapters: bool = False
score: float = 0.0
topics: List[str] = []
source_queries: List[str] = []
class VideoList(BaseModel):
total: int
page: int
page_size: int
items: List[Video]
class FilterParams(BaseModel):
min_score: float = 0.0
topic: Optional[str] = None
days: int = 30
page: int = 1
page_size: int = 20
class RefreshRequest(BaseModel):
queries: Optional[List[str]] = None
class RefreshResult(BaseModel):
fetched: int
scored: int
stored: int
timestamp: datetime
+92
View File
@@ -0,0 +1,92 @@
"""
Persistance des vidéos dans un fichier JSON.
Écriture atomique pour éviter la corruption.
"""
import json
import os
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
DATA_PATH = Path(os.environ.get("DATA_PATH", "/app/data/videos.json"))
CONFIG_PATH = DATA_PATH.parent / "config.json"
QUOTA_PATH = DATA_PATH.parent / "quota_status.json"
DEFAULT_QUERIES = [
"Kubernetes production français",
"Kubernetes architecture français",
"Kubernetes retour d'expérience",
"Kubernetes incident production français",
"Kubernetes scaling français",
"Kubernetes observabilité",
"Kubernetes tutoriel français",
"Kubernetes déploiement français",
]
def _ensure_dir() -> None:
DATA_PATH.parent.mkdir(parents=True, exist_ok=True)
def load_quota_status() -> dict:
"""Charge l'état du quota YouTube API."""
if not QUOTA_PATH.exists():
return {"exceeded": False, "exceeded_at": None}
with QUOTA_PATH.open("r", encoding="utf-8") as f:
return json.load(f)
def save_quota_status(exceeded: bool) -> None:
"""Persiste l'état du quota YouTube API."""
_ensure_dir()
tmp = QUOTA_PATH.with_suffix(".tmp")
data = {
"exceeded": exceeded,
"exceeded_at": datetime.now(timezone.utc).isoformat() if exceeded else None,
}
with tmp.open("w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
tmp.replace(QUOTA_PATH)
def load_config() -> dict:
"""Charge la configuration de recherche (queries)."""
if not CONFIG_PATH.exists():
return {"queries": DEFAULT_QUERIES}
with CONFIG_PATH.open("r", encoding="utf-8") as f:
return json.load(f)
def save_config(config: dict) -> None:
"""Sauvegarde la configuration de recherche."""
_ensure_dir()
tmp = CONFIG_PATH.with_suffix(".tmp")
with tmp.open("w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
tmp.replace(CONFIG_PATH)
def load_videos() -> list[dict[str, Any]]:
"""Charge la liste des vidéos depuis le fichier JSON."""
if not DATA_PATH.exists():
return []
with DATA_PATH.open("r", encoding="utf-8") as f:
return json.load(f)
def save_videos(videos: list[dict[str, Any]]) -> None:
"""Sauvegarde atomique des vidéos (écriture via fichier temporaire)."""
_ensure_dir()
tmp = DATA_PATH.with_suffix(".tmp")
with tmp.open("w", encoding="utf-8") as f:
json.dump(videos, f, ensure_ascii=False, indent=2, default=str)
tmp.replace(DATA_PATH)
def get_last_updated() -> datetime | None:
"""Retourne la date de dernière modification du fichier de données."""
if not DATA_PATH.exists():
return None
return datetime.fromtimestamp(DATA_PATH.stat().st_mtime)
+243
View File
@@ -0,0 +1,243 @@
"""
Client YouTube Data API v3.
Recherche multi-mots-clés de vidéos Kubernetes en français.
"""
import os
import re
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
logger = logging.getLogger(__name__)
class QuotaExceededError(Exception):
"""Levée quand le quota journalier YouTube API est dépassé (HTTP 403)."""
YOUTUBE_API_BASE = "https://www.googleapis.com/youtube/v3"
SEARCH_QUERIES = [
"Kubernetes production français",
"Kubernetes architecture français",
"Kubernetes retour d'expérience",
"Kubernetes incident production français",
"Kubernetes scaling français",
"Kubernetes observabilité",
"Kubernetes tutoriel français",
"Kubernetes déploiement français",
]
def _get_api_key() -> str:
key = os.environ.get("YOUTUBE_API_KEY", "")
if not key:
raise RuntimeError("YOUTUBE_API_KEY manquante dans les variables d'environnement")
return key
def _iso_days_ago(days: int = 90) -> str:
dt = datetime.now(timezone.utc) - timedelta(days=days)
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
def _parse_duration(iso_duration: str) -> int:
"""Convertit ISO 8601 duration (PT1H2M3S) en secondes."""
pattern = r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?"
m = re.match(pattern, iso_duration or "")
if not m:
return 0
h = int(m.group(1) or 0)
mn = int(m.group(2) or 0)
s = int(m.group(3) or 0)
return h * 3600 + mn * 60 + s
def _has_chapters(description: str) -> bool:
"""Détecte la présence de chapitrage (timestamps 0:00 dans la description)."""
return bool(re.search(r"^\s*\d+:\d+", description or "", re.MULTILINE))
_FRENCH_WORDS = {
"avec", "pour", "dans", "sur", "cette", "votre", "notre", "mais", "très",
"comme", "nous", "vous", "comment", "voici", "voilà", "pourquoi", "mise",
"une", "des", "les", "est", "par", "qui", "que", "aux", "tout", "son",
"leur", "aussi", "bien", "chez", "vers", "sous", "avoir", "être", "faire",
}
_FR_ACCENT_RE = re.compile(r"[éèêëàâùûîïôçœæ]", re.IGNORECASE)
def _is_likely_french(snippet: dict) -> bool:
"""Retourne True si la vidéo est vraisemblablement en français."""
audio_lang = (snippet.get("defaultAudioLanguage") or "").lower()
default_lang = (snippet.get("defaultLanguage") or "").lower()
title = snippet.get("title", "")
first_desc = (snippet.get("description") or "")[:400]
# Langue explicitement déclarée française
if audio_lang.startswith("fr") or default_lang.startswith("fr"):
return True
# Accents français dans le titre → signal fort
if _FR_ACCENT_RE.search(title):
return True
# Langue explicitement anglaise (ou autre non-fr) → rejeter
if audio_lang and not audio_lang.startswith("fr"):
return False
if default_lang and not default_lang.startswith("fr"):
return False
# Langue inconnue : détecter via accents ou mots français dans la description
if _FR_ACCENT_RE.search(first_desc):
return True
words = set(f"{title} {first_desc}".lower().split())
return bool(words & _FRENCH_WORDS)
_STOPWORDS = {
"en", "de", "du", "le", "la", "les", "un", "une", "des", "et", "ou",
"pour", "sur", "avec", "dans", "par", "au", "aux", "ce", "qui", "que",
"français", "french", "fr", "how", "to", "the", "and", "with",
}
def _extract_keywords(query: str) -> list[str]:
"""Extrait les mots-clés significatifs d'une requête (mots > 2 chars, hors stopwords)."""
return [w for w in query.lower().split() if len(w) > 2 and w not in _STOPWORDS]
def _is_relevant(video: dict, source_queries: list[str]) -> bool:
"""Retourne True si au moins un mot-clé de la requête source est dans le titre, les tags ou la description."""
title = video.get("title", "").lower()
tags = " ".join(video.get("tags", [])).lower()
desc = video.get("_desc_preview", "").lower()
text = f"{title} {tags} {desc}"
for query in source_queries:
for kw in _extract_keywords(query):
if kw in text:
return True
return False
def _add_french_hint(query: str) -> str:
"""Ajoute 'français' à la requête si aucun indicateur de langue n'est présent."""
lower = query.lower()
if any(w in lower for w in ["français", "francais", "french", " fr "]):
return query
return f"{query} français"
async def search_videos(query: str, client: httpx.AsyncClient, api_key: str) -> list[str]:
"""Retourne une liste d'IDs vidéos pour une requête donnée."""
params = {
"part": "id",
"q": _add_french_hint(query),
"type": "video",
"relevanceLanguage": "fr",
"regionCode": "FR",
"publishedAfter": _iso_days_ago(90),
"maxResults": 25,
"key": api_key,
}
resp = await client.get(f"{YOUTUBE_API_BASE}/search", params=params, timeout=15.0)
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
if e.response.status_code == 403:
raise QuotaExceededError("Quota YouTube API journalier dépassé (HTTP 403)") from e
raise
data = resp.json()
return [item["id"]["videoId"] for item in data.get("items", [])]
async def get_video_details(video_ids: list[str], client: httpx.AsyncClient, api_key: str) -> list[dict[str, Any]]:
"""Retourne les détails enrichis pour une liste d'IDs vidéos."""
if not video_ids:
return []
# Batches de 50 (limite API)
results = []
for i in range(0, len(video_ids), 50):
batch = video_ids[i : i + 50]
params = {
"part": "snippet,contentDetails,statistics",
"id": ",".join(batch),
"key": api_key,
}
resp = await client.get(f"{YOUTUBE_API_BASE}/videos", params=params, timeout=15.0)
resp.raise_for_status()
results.extend(resp.json().get("items", []))
videos = []
for item in results:
snippet = item.get("snippet", {})
stats = item.get("statistics", {})
details = item.get("contentDetails", {})
duration_s = _parse_duration(details.get("duration", ""))
description = snippet.get("description", "")
if not _is_likely_french(snippet):
continue
title = snippet.get("title", "")
videos.append({
"id": item["id"],
"title": title,
"channel": snippet.get("channelTitle", ""),
"published_at": snippet.get("publishedAt", ""),
"duration_seconds": duration_s,
"view_count": int(stats.get("viewCount", 0)),
"like_count": int(stats.get("likeCount", 0)),
"thumbnail_url": snippet.get("thumbnails", {}).get("high", {}).get("url", ""),
"youtube_url": f"https://www.youtube.com/watch?v={item['id']}",
"tags": snippet.get("tags", [])[:20],
"has_chapters": _has_chapters(description),
"_desc_preview": description[:600], # temporaire, nettoyé après filtrage
})
return videos
async def fetch_all_videos(queries: list[str] | None = None) -> list[dict[str, Any]]:
"""Lance la recherche sur tous les mots-clés, déduplique par ID et tracke les requêtes sources."""
if queries is None:
queries = SEARCH_QUERIES
api_key = _get_api_key()
# Map video_id → liste des requêtes qui l'ont renvoyé
id_to_queries: dict[str, list[str]] = {}
async with httpx.AsyncClient() as client:
for query in queries:
try:
ids = await search_videos(query, client, api_key)
for vid_id in ids:
id_to_queries.setdefault(vid_id, []).append(query)
logger.info("Requête '%s'%d IDs", query, len(ids))
except QuotaExceededError:
logger.warning("Quota YouTube dépassé — arrêt des requêtes restantes")
raise
except Exception as exc:
logger.error("Erreur pour la requête '%s': %s", query, exc)
if not id_to_queries:
return []
all_videos = await get_video_details(list(id_to_queries.keys()), client, api_key)
# Attacher les requêtes sources à chaque vidéo
for video in all_videos:
video["source_queries"] = id_to_queries.get(video["id"], [])
# Filtrer les vidéos non pertinentes (titre/tags/description sans aucun mot-clé de la requête)
before = len(all_videos)
all_videos = [v for v in all_videos if _is_relevant(v, v["source_queries"])]
logger.info("Filtre pertinence : %d%d vidéos", before, len(all_videos))
# Supprimer le champ temporaire avant persistance
for v in all_videos:
v.pop("_desc_preview", None)
return all_videos
+29
View File
@@ -0,0 +1,29 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "ytveille-backend"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.110.0",
"uvicorn[standard]>=0.29.0",
"httpx>=0.27.0",
"pydantic>=2.6.0",
"apscheduler>=3.10.4",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.27.0",
]
[tool.setuptools.packages.find]
where = ["."]
[tool.pytest.ini_options]
testpaths = ["scoring/tests"]
python_files = ["test_*.py"]
+1
View File
@@ -0,0 +1 @@
# Scoring package
+54
View File
@@ -0,0 +1,54 @@
"""
Mots-clés techniques Kubernetes pour le scoring sémantique.
Organisés par thème / topic.
"""
from typing import Dict, List
TOPIC_KEYWORDS: Dict[str, List[str]] = {
"incident": [
"incident", "post-mortem", "postmortem", "panne", "outage",
"crash", "debug", "root cause", "rca", "blameless",
],
"architecture": [
"architecture", "diagram", "schema", "multi-cluster", "multi cluster",
"federation", "service mesh", "sidecar", "istio", "linkerd",
],
"observabilité": [
"prometheus", "grafana", "alertmanager", "loki", "tracing",
"jaeger", "opentelemetry", "metrics", "slo", "sla", "sli",
"observabilité", "monitoring", "dashboards",
],
"sécurité": [
"rbac", "pod security", "opa", "gatekeeper", "falco",
"network policy", "secret", "vault", "trivy", "kubescape",
],
"ci_cd": [
"argocd", "argo cd", "flux", "fluxcd", "helm", "kustomize",
"gitops", "pipeline", "ci/cd", "tekton", "jenkins",
],
"scaling": [
"hpa", "vpa", "keda", "scalabilité", "scaling", "autoscaling",
"horizontal", "vertical", "cluster autoscaler", "cost",
],
"migration": [
"migration", "upgrade", "mise à jour", "version", "deprecation",
"zero downtime", "rolling update", "canary", "blue green",
],
"storage": [
"pvc", "persistent volume", "csi", "storage class", "statefulset",
"rook", "ceph", "nfs", "longhorn", "backup", "velero",
],
}
# Liste plate pour détection rapide
ALL_KEYWORDS: List[str] = [kw for kws in TOPIC_KEYWORDS.values() for kw in kws]
# Mots-clés avancés (bonus score élevé)
ADVANCED_KEYWORDS: List[str] = [
"post-mortem", "postmortem", "root cause", "blameless",
"service mesh", "istio", "linkerd", "opentelemetry",
"rbac", "opa", "gatekeeper", "falco",
"argocd", "gitops", "keda", "hpa",
"multi-cluster", "federation", "zero downtime",
"slo", "sla", "sli", "cluster autoscaler",
]
+115
View File
@@ -0,0 +1,115 @@
"""
Algorithme de scoring des vidéos YouTube Kubernetes.
Score normalisé sur 100.
Critères :
- Vues pondérées par ancienneté : 25 pts
- Ratio likes / vues : 20 pts
- Mots-clés techniques détectés : 25 pts
- Durée >= 10 min : 10 pts
- Présence de chapitrage : 10 pts
- Nombre de topics détectés : 10 pts
"""
from __future__ import annotations
import math
from datetime import datetime, timezone
from typing import TYPE_CHECKING, List, Tuple
from .keywords import TOPIC_KEYWORDS, ADVANCED_KEYWORDS
if TYPE_CHECKING:
from api.models import Video
def _detect_topics(text: str) -> List[str]:
"""Retourne les topics détectés dans un texte (titre + tags)."""
text_lower = text.lower()
topics = []
for topic, keywords in TOPIC_KEYWORDS.items():
if any(kw in text_lower for kw in keywords):
topics.append(topic)
return topics
def _keyword_score(text: str) -> float:
"""Score basé sur les mots-clés techniques (0-25)."""
text_lower = text.lower()
# Nombre de mots-clés avancés trouvés
advanced_hits = sum(1 for kw in ADVANCED_KEYWORDS if kw in text_lower)
# Nombre de mots-clés totaux
from .keywords import ALL_KEYWORDS
total_hits = sum(1 for kw in ALL_KEYWORDS if kw in text_lower)
# On plafonne à 5 hits avancés et 10 hits totaux
score = min(advanced_hits / 5, 1.0) * 15 + min(total_hits / 10, 1.0) * 10
return round(score, 2)
def _view_score(view_count: int, age_days: float) -> float:
"""Vues pondérées par ancienneté (0-25)."""
if age_days <= 0:
age_days = 1
# Vues par jour, log-normalisé
vpd = view_count / age_days
# Référence : 1000 vues/jour = score max
score = min(math.log1p(vpd) / math.log1p(1000), 1.0) * 25
return round(score, 2)
def _like_ratio_score(like_count: int, view_count: int) -> float:
"""Ratio likes/vues (0-20). Référence : 5% = max."""
if view_count == 0:
return 0.0
ratio = like_count / view_count
score = min(ratio / 0.05, 1.0) * 20
return round(score, 2)
def _duration_score(duration_seconds: int) -> float:
"""10 pts si durée >= 10 min, sinon 0."""
return 10.0 if duration_seconds >= 600 else 0.0
def _chapters_score(has_chapters: bool) -> float:
"""10 pts si la vidéo a des chapitres."""
return 10.0 if has_chapters else 0.0
def _topics_score(topics: list[str]) -> float:
"""10 pts selon le nb de topics distincts (max 3)."""
return round(min(len(topics) / 3, 1.0) * 10, 2)
def score_video(video_data: dict) -> Tuple[float, List[str]]:
"""
Calcule le score d'une vidéo et retourne (score, topics).
video_data doit contenir les clés du modèle Video.
"""
now = datetime.now(timezone.utc)
published_at = video_data["published_at"]
if isinstance(published_at, str):
from datetime import datetime as dt
published_at = dt.fromisoformat(published_at.replace("Z", "+00:00"))
age_days = (now - published_at).total_seconds() / 86400
# Texte analysable : titre + tags
tags_text = " ".join(video_data.get("tags", []))
full_text = f"{video_data['title']} {tags_text}"
topics = _detect_topics(full_text)
raw = (
_view_score(video_data["view_count"], age_days)
+ _like_ratio_score(video_data["like_count"], video_data["view_count"])
+ _keyword_score(full_text)
+ _duration_score(video_data["duration_seconds"])
+ _chapters_score(video_data.get("has_chapters", False))
+ _topics_score(topics)
)
# Normaliser sur 100 (max théorique = 25+20+25+10+10+10 = 100)
final_score = round(min(raw, 100.0), 1)
return final_score, topics
+1
View File
@@ -0,0 +1 @@
# Tests scoring
+89
View File
@@ -0,0 +1,89 @@
"""Tests du scorer Kubernetes."""
import pytest
from datetime import datetime, timezone, timedelta
from scoring.scorer import score_video, _detect_topics, _keyword_score
def _base_video(**kwargs) -> dict:
defaults = {
"id": "test123",
"title": "Kubernetes en production",
"channel": "DevOps France",
"published_at": datetime.now(timezone.utc) - timedelta(days=5),
"duration_seconds": 1800, # 30 min
"view_count": 5000,
"like_count": 250,
"thumbnail_url": "https://example.com/thumb.jpg",
"youtube_url": "https://youtube.com/watch?v=test123",
"tags": ["kubernetes", "production", "devops"],
"has_chapters": True,
}
defaults.update(kwargs)
return defaults
class TestScoreVideo:
def test_score_is_between_0_and_100(self):
v = _base_video()
score, topics = score_video(v)
assert 0 <= score <= 100
def test_long_video_scores_higher_than_short(self):
long_v = _base_video(duration_seconds=1800)
short_v = _base_video(duration_seconds=300)
score_long, _ = score_video(long_v)
score_short, _ = score_video(short_v)
assert score_long > score_short
def test_chapters_increase_score(self):
with_ch = _base_video(has_chapters=True)
without_ch = _base_video(has_chapters=False)
s_with, _ = score_video(with_ch)
s_without, _ = score_video(without_ch)
assert s_with > s_without
def test_advanced_keywords_increase_score(self):
basic = _base_video(title="Kubernetes débutant", tags=[])
advanced = _base_video(
title="Kubernetes post-mortem ArgoCD Prometheus",
tags=["istio", "hpa", "keda"],
)
s_basic, _ = score_video(basic)
s_advanced, _ = score_video(advanced)
assert s_advanced > s_basic
def test_topics_detected(self):
v = _base_video(title="Kubernetes ArgoCD CI/CD pipeline GitOps", tags=["argocd", "fluxcd"])
_, topics = score_video(v)
assert "ci_cd" in topics
def test_incident_topic_detected(self):
v = _base_video(title="Kubernetes post-mortem incident production outage")
_, topics = score_video(v)
assert "incident" in topics
def test_zero_views_handled(self):
v = _base_video(view_count=0, like_count=0)
score, _ = score_video(v)
assert score >= 0
class TestDetectTopics:
def test_returns_list(self):
topics = _detect_topics("Kubernetes monitoring Prometheus")
assert isinstance(topics, list)
def test_empty_text(self):
topics = _detect_topics("")
assert topics == []
class TestKeywordScore:
def test_no_keywords(self):
s = _keyword_score("bonjour le monde")
assert s == 0.0
def test_advanced_keyword(self):
s = _keyword_score("post-mortem kubernetes production")
assert s > 0
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml .
RUN pip install --no-cache-dir \
"fastapi>=0.110.0" \
"uvicorn[standard]>=0.29.0" \
"httpx>=0.27.0" \
"pydantic>=2.6.0" \
"apscheduler>=3.10.4"
COPY . .
CMD ["python", "worker/scheduler.py"]
+1
View File
@@ -0,0 +1 @@
# Worker package
+73
View File
@@ -0,0 +1,73 @@
"""
Worker cron — Veille YouTube Kubernetes.
Mise à jour quotidienne à 6h UTC via APScheduler.
"""
import asyncio
import logging
import os
from datetime import datetime, timezone
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger
# Ajout du répertoire parent au path Python
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from api.storage import save_videos, load_config
from api.youtube_client import fetch_all_videos
from scoring.scorer import score_video
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
def run_pipeline() -> None:
"""Pipeline complet : fetch → score → persist."""
logger.info("=== Démarrage du pipeline de mise à jour ===")
start = datetime.now(timezone.utc)
try:
config = load_config()
queries = config.get("queries")
raw = asyncio.run(fetch_all_videos(queries))
logger.info("Vidéos récupérées : %d", len(raw))
scored = []
for v in raw:
s, topics = score_video(v)
v["score"] = s
v["topics"] = topics
scored.append(v)
scored.sort(key=lambda x: x["score"], reverse=True)
save_videos(scored)
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
logger.info(
"=== Pipeline terminé : %d vidéos en %.1f secondes ===",
len(scored),
elapsed,
)
except Exception as exc:
logger.error("Erreur pipeline : %s", exc, exc_info=True)
if __name__ == "__main__":
# Exécution immédiate au démarrage puis cron quotidien
logger.info("Worker démarré — exécution immédiate puis cron à 6h UTC")
run_pipeline()
scheduler = BlockingScheduler(timezone="UTC")
scheduler.add_job(
run_pipeline,
trigger=CronTrigger(hour=6, minute=0),
id="daily_refresh",
name="Mise à jour quotidienne des vidéos",
)
logger.info("Scheduler configuré : cron 6h00 UTC")
scheduler.start()
+43
View File
@@ -0,0 +1,43 @@
services:
api:
build:
context: ./backend
dockerfile: api/Dockerfile
ports:
- "8000:8000"
environment:
- YOUTUBE_API_KEY=${YOUTUBE_API_KEY}
- DATA_PATH=/app/data/videos.json
volumes:
- ./data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/status"]
interval: 30s
timeout: 10s
retries: 3
worker:
build:
context: ./backend
dockerfile: worker/Dockerfile
environment:
- YOUTUBE_API_KEY=${YOUTUBE_API_KEY}
- DATA_PATH=/app/data/videos.json
volumes:
- ./data:/app/data
depends_on:
- api
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000}
ports:
- "3000:3000"
depends_on:
- api
restart: unless-stopped
+21
View File
@@ -0,0 +1,21 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json ./
RUN npm install
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_API_URL=http://localhost:8000
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["npm", "start"]
+353
View File
@@ -0,0 +1,353 @@
/* ===== DESIGN SYSTEM =====
Palette inspirée Sparklite : rouge primaire, fond clair, typographie propre
*/
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #E8401C;
--primary-dark: #c43415;
--primary-light: #fde8e3;
--bg: #F5F5F7;
--surface: #ffffff;
--surface-dark: #1A1A2E;
--border: #e5e7eb;
--text: #111827;
--text-secondary: #6b7280;
--text-muted: #9ca3af;
--sidebar-width: 240px;
--filter-width: 280px;
--radius: 12px;
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 4px 16px rgba(0,0,0,.06);
--shadow-hover: 0 4px 20px rgba(232,64,28,.15);
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
html.dark {
--primary-light: rgba(232,64,28,.18);
--bg: #0f172a;
--surface: #1e293b;
--surface-dark: #0a0f1e;
--border: #334155;
--text: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--shadow: 0 1px 3px rgba(0,0,0,.3), 0 4px 16px rgba(0,0,0,.25);
--shadow-hover: 0 4px 20px rgba(232,64,28,.25);
}
html, body { height: 100%; font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); }
/* ===== LAYOUT ===== */
.app-shell {
display: grid;
grid-template-columns: var(--filter-width) 1fr;
grid-template-rows: 60px 1fr;
grid-template-areas:
"filters topbar"
"filters main";
height: 100vh;
overflow: hidden;
}
/* ===== TOPBAR ===== */
.topbar {
grid-area: topbar;
display: flex;
align-items: center;
gap: 12px;
padding: 0 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
z-index: 10;
}
.topbar__search {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 999px;
padding: 8px 16px;
font-size: 14px;
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition);
}
.topbar__search:hover { border-color: var(--primary); }
.topbar__search input {
border: none; background: transparent; outline: none; width: 100%; font-size: 14px; color: var(--text);
}
.topbar__btn {
display: flex; align-items: center; gap: 6px;
background: var(--primary); color: white; border: none;
border-radius: 999px; padding: 8px 18px; font-size: 13px; font-weight: 600;
cursor: pointer; transition: var(--transition); white-space: nowrap;
}
.topbar__btn:hover { background: var(--primary-dark); transform: translateY(-1px); box-shadow: var(--shadow-hover); }
.topbar__btn:disabled { opacity: .6; cursor: not-allowed; transform: none; }
.topbar__status { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
.topbar__theme-btn {
width: 36px; height: 36px; border-radius: 999px; border: 1.5px solid var(--border);
background: var(--bg); cursor: pointer; font-size: 16px;
display: flex; align-items: center; justify-content: center;
transition: var(--transition); flex-shrink: 0;
}
.topbar__theme-btn:hover { border-color: var(--primary); transform: translateY(-1px); }
/* ===== SIDEBAR ===== */
.sidebar {
grid-area: sidebar;
display: flex; flex-direction: column;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 20px 0;
overflow-y: auto;
}
.sidebar__logo {
display: flex; align-items: center; gap: 10px;
padding: 0 20px 20px;
font-size: 18px; font-weight: 700; color: var(--text);
border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.sidebar__logo-icon {
width: 36px; height: 36px; background: var(--primary);
border-radius: 10px; display: flex; align-items: center; justify-content: center;
color: white; font-size: 18px;
}
.sidebar__nav { flex: 1; }
.sidebar__nav-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 20px; cursor: pointer;
font-size: 14px; font-weight: 500; color: var(--text-secondary);
transition: var(--transition); border-radius: 0; position: relative;
text-decoration: none; border: none; background: none; width: 100%; text-align: left;
}
.sidebar__nav-item:hover { background: var(--bg); color: var(--text); }
.sidebar__nav-item--active { color: var(--primary); background: var(--primary-light); }
.sidebar__nav-item--active::before {
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
width: 3px; background: var(--primary); border-radius: 0 3px 3px 0;
}
.sidebar__badge {
margin-left: auto; background: var(--primary); color: white;
border-radius: 999px; padding: 2px 8px; font-size: 11px; font-weight: 700;
}
.sidebar__section-title {
font-size: 11px; font-weight: 600; text-transform: uppercase;
color: var(--text-muted); letter-spacing: .08em;
padding: 16px 20px 8px;
}
.sidebar__footer {
padding: 16px 20px 0; border-top: 1px solid var(--border);
}
/* ===== MAIN CONTENT ===== */
.main {
grid-area: main;
overflow-y: auto;
padding: 24px;
display: flex; flex-direction: column; gap: 16px;
}
.main__header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 4px;
}
.main__title { font-size: 20px; font-weight: 700; }
.main__count { font-size: 14px; color: var(--text-secondary); }
.main__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.main__empty {
text-align: center; padding: 80px 20px;
color: var(--text-muted); font-size: 15px;
}
.main__loading {
display: flex; gap: 16px; flex-wrap: wrap;
}
/* ===== VIDEO CARD ===== */
.video-card {
background: var(--surface); border-radius: var(--radius);
box-shadow: var(--shadow); overflow: hidden;
transition: var(--transition); display: flex; flex-direction: column;
text-decoration: none; color: inherit;
}
.video-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-hover); }
.video-card__thumb { position: relative; aspect-ratio: 16/9; overflow: hidden; background: #e5e7eb; }
.video-card__thumb img { width: 100%; height: 100%; object-fit: cover; transition: transform .3s; }
.video-card:hover .video-card__thumb img { transform: scale(1.04); }
.video-card__score {
position: absolute; top: 10px; right: 10px;
background: var(--primary); color: white;
border-radius: 999px; padding: 3px 10px;
font-size: 12px; font-weight: 700; box-shadow: 0 2px 8px rgba(232,64,28,.4);
}
.video-card__duration {
position: absolute; bottom: 8px; right: 8px;
background: rgba(0,0,0,.75); color: white;
border-radius: 4px; padding: 2px 6px; font-size: 11px; font-weight: 600;
}
.video-card__chapters {
position: absolute; bottom: 8px; left: 8px;
background: rgba(0,0,0,.6); color: #fbbf24;
border-radius: 4px; padding: 2px 6px; font-size: 10px; font-weight: 600;
}
.video-card__body { padding: 14px; flex: 1; display: flex; flex-direction: column; gap: 8px; }
.video-card__title {
font-size: 14px; font-weight: 600; line-height: 1.4;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.video-card__meta { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-secondary); flex-wrap: wrap; }
.video-card__meta-item { display: flex; align-items: center; gap: 3px; }
.video-card__topics { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
.topic-tag {
background: var(--primary-light); color: var(--primary);
border-radius: 6px; padding: 2px 8px; font-size: 11px; font-weight: 600;
}
.topic-tag--dark { background: var(--surface-dark); color: #a5b4fc; }
/* Skeleton */
.skeleton {
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: var(--radius);
}
@keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
.skeleton-card { height: 300px; }
/* ===== FILTER PANEL ===== */
.filters {
grid-area: filters;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 24px 20px;
overflow-y: auto;
display: flex; flex-direction: column; gap: 24px;
}
.filters__title { font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 4px; }
.filters__group { display: flex; flex-direction: column; gap: 10px; }
.filters__label { font-size: 12px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: .06em; }
.filters__slider { width: 100%; accent-color: var(--primary); cursor: pointer; }
.filters__score-display {
display: flex; justify-content: space-between; font-size: 13px;
font-weight: 600; color: var(--primary);
}
.filters__chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
border: 1.5px solid var(--border); border-radius: 999px;
padding: 5px 12px; font-size: 12px; font-weight: 500;
cursor: pointer; transition: var(--transition); background: var(--surface); color: var(--text-secondary);
}
.chip:hover { border-color: var(--primary); color: var(--primary); }
.chip--active { background: var(--primary); border-color: var(--primary); color: white; }
.filters__days { display: flex; gap: 6px; }
.day-btn {
flex: 1; padding: 6px 0; border-radius: 8px; border: 1.5px solid var(--border);
font-size: 12px; font-weight: 600; cursor: pointer; transition: var(--transition);
background: var(--surface); color: var(--text-secondary);
}
.day-btn:hover { border-color: var(--primary); color: var(--primary); }
.day-btn--active { background: var(--primary); border-color: var(--primary); color: white; }
.filters__reset {
border: 1.5px solid var(--border); border-radius: 8px; padding: 8px;
font-size: 13px; font-weight: 500; cursor: pointer; transition: var(--transition);
background: transparent; color: var(--text-secondary); width: 100%;
}
.filters__reset:hover { border-color: var(--primary); color: var(--primary); }
/* Brand */
.filters__brand {
display: flex; align-items: center; gap: 10px;
font-size: 18px; font-weight: 700; color: var(--text);
padding-bottom: 20px; border-bottom: 1px solid var(--border);
}
.filters__brand-icon {
width: 36px; height: 36px; background: var(--primary);
border-radius: 10px; display: flex; align-items: center; justify-content: center;
color: white; font-size: 16px;
}
.filters__divider { height: 1px; background: var(--border); }
/* Query list */
.filters__queries { display: flex; flex-direction: column; gap: 6px; }
.query-item {
display: flex; align-items: center; justify-content: space-between;
background: var(--bg); border: 1px solid var(--border);
border-radius: 8px; padding: 6px 10px; gap: 8px;
}
.query-item__text { font-size: 12px; color: var(--text); flex: 1; word-break: break-word; }
.query-item__remove {
background: none; border: none; cursor: pointer; color: var(--text-muted);
font-size: 16px; font-weight: 600; line-height: 1; padding: 0 2px; flex-shrink: 0;
transition: var(--transition);
}
.query-item__remove:hover { color: var(--primary); }
/* Add query */
.filters__add-query { display: flex; gap: 6px; }
.filters__query-input {
flex: 1; border: 1.5px solid var(--border); border-radius: 8px;
padding: 7px 10px; font-size: 12px; outline: none;
background: var(--surface); color: var(--text); transition: var(--transition);
}
.filters__query-input:focus { border-color: var(--primary); }
.filters__add-btn {
width: 32px; height: 32px; border-radius: 8px; border: none;
background: var(--primary); color: white; font-size: 18px; font-weight: 600;
cursor: pointer; display: flex; align-items: center; justify-content: center;
flex-shrink: 0; transition: var(--transition);
}
.filters__add-btn:hover { background: var(--primary-dark); }
/* Launch button */
.filters__launch {
width: 100%; padding: 10px; border-radius: 8px; border: none;
background: var(--primary); color: white; font-size: 13px; font-weight: 700;
cursor: pointer; transition: var(--transition);
}
.filters__launch:hover:not(:disabled) { background: var(--primary-dark); transform: translateY(-1px); box-shadow: var(--shadow-hover); }
.filters__launch:disabled { opacity: .6; cursor: not-allowed; transform: none; }
/* Quota banner */
.main__quota-banner {
background: #fef3c7; color: #92400e;
border: 1px solid #f59e0b; border-radius: var(--radius);
padding: 12px 16px; font-size: 13px; font-weight: 600;
display: flex; align-items: flex-start; gap: 10px; line-height: 1.6;
}
.main__quota-banner span:last-child { font-weight: 400; }
.main__quota-icon { font-size: 18px; flex-shrink: 0; margin-top: 1px; }
html.dark .main__quota-banner {
background: rgba(245,158,11,.1); color: #fbbf24;
border-color: rgba(245,158,11,.3);
}
/* Launching banner */
.main__launching {
background: var(--primary-light); color: var(--primary);
border-radius: var(--radius); padding: 10px 16px;
font-size: 13px; font-weight: 600; text-align: center;
}
/* ===== PAGINATION ===== */
.pagination { display: flex; align-items: center; gap: 8px; justify-content: center; padding: 8px 0; }
.page-btn {
border: 1.5px solid var(--border); border-radius: 8px; padding: 6px 14px;
font-size: 13px; font-weight: 600; cursor: pointer; transition: var(--transition);
background: var(--surface); color: var(--text-secondary);
}
.page-btn:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); }
.page-btn:disabled { opacity: .4; cursor: not-allowed; }
.page-btn--active { background: var(--primary); border-color: var(--primary); color: white; }
/* ===== RESPONSIVE ===== */
@media (max-width: 900px) {
.app-shell { grid-template-columns: 1fr; grid-template-areas: "topbar" "main"; }
.filters { display: none; }
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "YTVeille — Veille automatique YouTube en français",
description:
"Veille automatique des meilleures vidéos YouTube en français sur n'importe quel domaine technique.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr">
<body>{children}</body>
</html>
);
}
+255
View File
@@ -0,0 +1,255 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import TopBar from "@/components/TopBar";
import VideoCard from "@/components/VideoCard";
import FilterPanel from "@/components/FilterPanel";
import { fetchVideos, fetchStatus, fetchConfig, triggerRefresh, type Video, type Filters } from "@/lib/api";
interface FilterState {
min_score: number;
days: number;
}
const DEFAULT_FILTERS: FilterState = { min_score: 0, days: 30 };
const PAGE_SIZE = 18;
export default function HomePage() {
const [videos, setVideos] = useState<Video[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<FilterState>(DEFAULT_FILTERS);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [loading, setLoading] = useState(true);
const [initialQueries, setInitialQueries] = useState<string[]>([]);
const [launching, setLaunching] = useState(false);
const [activeQueryFilters, setActiveQueryFilters] = useState<string[]>([]);
const [dark, setDark] = useState(false);
// Lire la préférence stockée au montage
useEffect(() => {
const stored = localStorage.getItem("theme");
const isDark = stored === "dark" || (!stored && window.matchMedia("(prefers-color-scheme: dark)").matches);
setDark(isDark);
document.documentElement.classList.toggle("dark", isDark);
}, []);
const handleToggleDark = () => {
setDark((prev) => {
const next = !prev;
document.documentElement.classList.toggle("dark", next);
localStorage.setItem("theme", next ? "dark" : "light");
return next;
});
};
const [status, setStatus] = useState<{ video_count: number; last_updated: string | null; refresh_running: boolean; quota_exceeded: boolean; quota_exceeded_at: string | null }>({
video_count: 0,
last_updated: null,
refresh_running: false,
quota_exceeded: false,
quota_exceeded_at: null,
});
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const loadVideos = useCallback(async (f: FilterState, p: number, q: string, sqFilters: string[]) => {
setLoading(true);
try {
const params: Filters = {
q: q || undefined,
min_score: f.min_score,
days: f.days,
page: p,
page_size: PAGE_SIZE,
source_queries: sqFilters.length > 0 ? sqFilters : undefined,
};
const result = await fetchVideos(params);
setVideos(result.items);
setTotal(result.total);
} catch {
setVideos([]);
} finally {
setLoading(false);
}
}, []);
// Charger config + statut au montage
useEffect(() => {
fetchConfig().then((c) => setInitialQueries(c.queries)).catch(() => {});
fetchStatus().then(setStatus).catch(() => {});
}, []);
// Debounce de la recherche texte (300 ms)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
setPage(1);
}, 300);
return () => clearTimeout(timer);
}, [search]);
// Charger vidéos à chaque changement de filtres / page / recherche / chips
useEffect(() => {
loadVideos(filters, page, debouncedSearch, activeQueryFilters);
}, [filters, page, debouncedSearch, activeQueryFilters, loadVideos]);
const handleFilterChange = (partial: Partial<FilterState>) => {
setFilters((prev) => ({ ...prev, ...partial }));
setPage(1);
};
const handleQueryFilterToggle = (q: string) => {
setActiveQueryFilters((prev) =>
prev.includes(q) ? prev.filter((x) => x !== q) : [...prev, q]
);
setPage(1);
};
const handleLaunch = async (queries: string[]) => {
setLaunching(true);
try {
await triggerRefresh(queries);
// Polling jusqu'à la fin du refresh
pollRef.current = setInterval(async () => {
try {
const s = await fetchStatus();
setStatus(s);
if (!s.refresh_running) {
clearInterval(pollRef.current!);
pollRef.current = null;
setLaunching(false);
setPage(1);
loadVideos(filters, 1, debouncedSearch, activeQueryFilters);
}
} catch {
clearInterval(pollRef.current!);
pollRef.current = null;
setLaunching(false);
}
}, 2000);
} catch {
setLaunching(false);
}
};
// Nettoyage du polling au démontage
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []);
const totalPages = Math.ceil(total / PAGE_SIZE);
// Tous les filtres (texte + chips) sont gérés côté backend.
const displayedVideos = videos;
return (
<div className="app-shell">
<TopBar
lastUpdated={status.last_updated}
videoCount={status.video_count}
onSearch={setSearch}
dark={dark}
onToggleDark={handleToggleDark}
/>
<main className="main">
<div className="main__header">
<h1 className="main__title"> YTVeille</h1>
<span className="main__count">{total} vidéo{total > 1 ? "s" : ""}</span>
</div>
{launching && (
<div className="main__launching">
<span> Recherche YouTube en cours, veuillez patienter...</span>
</div>
)}
{status.quota_exceeded && !launching && (
<div className="main__quota-banner">
<span className="main__quota-icon"></span>
<div>
<strong>Quota YouTube API dépassé</strong>
{status.quota_exceeded_at && (
<> depuis le {new Intl.DateTimeFormat("fr-FR", {
day: "numeric", month: "short",
hour: "2-digit", minute: "2-digit",
}).format(new Date(status.quota_exceeded_at))}</>
)}
<br />
<span>Le quota se renouvelle chaque jour à minuit (heure du Pacifique, ~8h9h UTC). Réessayez demain.</span>
</div>
</div>
)}
{loading ? (
<div className="main__grid">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="skeleton skeleton-card" />
))}
</div>
) : displayedVideos.length === 0 ? (
<div className="main__empty">
<p style={{ fontSize: 40, marginBottom: 12 }}></p>
<p>Aucune vidéo trouvée.</p>
<p style={{ marginTop: 8, fontSize: 13 }}>
Configurez vos requêtes et lancez la veille depuis le panneau gauche.
</p>
</div>
) : (
<div className="main__grid">
{displayedVideos.map((v) => (
<VideoCard key={v.id} video={v} />
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<nav className="pagination" aria-label="Pagination">
<button
id="btn-prev-page"
className="page-btn"
disabled={page === 1}
onClick={() => setPage((p) => p - 1)}
>
Précédent
</button>
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
const p = i + 1;
return (
<button
key={p}
id={`btn-page-${p}`}
className={`page-btn${page === p ? " page-btn--active" : ""}`}
onClick={() => setPage(p)}
>
{p}
</button>
);
})}
<button
id="btn-next-page"
className="page-btn"
disabled={page === totalPages}
onClick={() => setPage((p) => p + 1)}
>
Suivant
</button>
</nav>
)}
</main>
<FilterPanel
filters={filters}
onChange={handleFilterChange}
total={total}
initialQueries={initialQueries}
onLaunch={handleLaunch}
launching={launching}
activeQueryFilters={activeQueryFilters}
onQueryFilterToggle={handleQueryFilterToggle}
/>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useState, useEffect } from "react";
interface FilterState {
min_score: number;
days: number;
}
interface Props {
filters: FilterState;
onChange: (f: Partial<FilterState>) => void;
total: number;
initialQueries: string[];
onLaunch: (queries: string[]) => void;
launching: boolean;
activeQueryFilters: string[];
onQueryFilterToggle: (q: string) => void;
}
const DAYS_OPTIONS = [7, 30, 90];
export default function FilterPanel({ filters, onChange, total, initialQueries, onLaunch, launching, activeQueryFilters, onQueryFilterToggle }: Props) {
const [queries, setQueries] = useState<string[]>(initialQueries);
const [inputValue, setInputValue] = useState("");
useEffect(() => {
setQueries(initialQueries);
}, [initialQueries]);
const addQuery = () => {
const q = inputValue.trim();
if (q && !queries.includes(q)) {
setQueries([...queries, q]);
setInputValue("");
}
};
const removeQuery = (i: number) => {
setQueries(queries.filter((_, idx) => idx !== i));
};
return (
<aside className="filters">
{/* Brand */}
<div className="filters__brand">
<div className="filters__brand-icon"></div>
<span>YTVeille</span>
</div>
{/* Requêtes de recherche */}
<div className="filters__group">
<p className="filters__label">Requêtes de recherche</p>
<div className="filters__queries">
{queries.map((q, i) => (
<div key={i} className="query-item">
<span className="query-item__text">{q}</span>
<button
className="query-item__remove"
onClick={() => removeQuery(i)}
title="Supprimer"
>
×
</button>
</div>
))}
</div>
<div className="filters__add-query">
<input
type="text"
placeholder='Ex: "Docker production français"'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addQuery()}
className="filters__query-input"
/>
<button className="filters__add-btn" onClick={addQuery} title="Ajouter">+</button>
</div>
<button
className="filters__launch"
onClick={() => onLaunch(queries)}
disabled={launching || queries.length === 0}
>
{launching ? "⟳ Recherche en cours..." : "▶ Lancer la veille"}
</button>
</div>
<div className="filters__divider" />
{/* Filtrer par requête */}
{queries.length > 0 && (
<div className="filters__group">
<p className="filters__label">Filtrer par requête</p>
<div className="filters__chips">
{queries.map((q, i) => (
<button
key={i}
className={`chip${activeQueryFilters.includes(q) ? " chip--active" : ""}`}
onClick={() => onQueryFilterToggle(q)}
>
{q}
</button>
))}
</div>
</div>
)}
{/* Score minimum */}
<div className="filters__group">
<label className="filters__label" htmlFor="slider-score">Score minimum</label>
<div className="filters__score-display">
<span>0</span>
<span style={{ color: "var(--primary)", fontWeight: 700 }}>{filters.min_score}</span>
<span>100</span>
</div>
<input
id="slider-score"
type="range"
min={0}
max={100}
step={5}
value={filters.min_score}
onChange={(e) => onChange({ min_score: Number(e.target.value) })}
className="filters__slider"
/>
</div>
{/* Période */}
<div className="filters__group">
<p className="filters__label">Période</p>
<div className="filters__days">
{DAYS_OPTIONS.map((d) => (
<button
key={d}
id={`days-${d}`}
className={`day-btn${filters.days === d ? " day-btn--active" : ""}`}
onClick={() => onChange({ days: d })}
>
{d}j
</button>
))}
</div>
</div>
{/* Reset filtres */}
<button
id="btn-reset-filters"
className="filters__reset"
onClick={() => onChange({ min_score: 0, days: 30 })}
>
Réinitialiser les filtres
</button>
<p style={{ fontSize: 12, color: "var(--text-muted)", marginTop: "auto" }}>
{total} vidéo{total > 1 ? "s" : ""} trouvée{total > 1 ? "s" : ""}
</p>
</aside>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
export default function Sidebar() {
const navItems = [
{ icon: "▶", label: "Veille", id: "feed", badge: null, active: true },
{ icon: "🔍", label: "Explorer", id: "explore", badge: null, active: false },
{ icon: "⭐", label: "Favoris", id: "favorites", badge: null, active: false },
];
return (
<aside className="sidebar">
<div className="sidebar__logo">
<div className="sidebar__logo-icon"></div>
<span>K8s Veille</span>
</div>
<nav className="sidebar__nav">
{navItems.map((item) => (
<button
key={item.id}
id={`nav-${item.id}`}
className={`sidebar__nav-item${item.active ? " sidebar__nav-item--active" : ""}`}
>
<span>{item.icon}</span>
<span>{item.label}</span>
{item.badge && <span className="sidebar__badge">{item.badge}</span>}
</button>
))}
<p className="sidebar__section-title">Topics</p>
{["🔥 Incident", "🏗️ Architecture", "📊 Observabilité", "⚙️ CI/CD", "📈 Scaling"].map((t) => (
<button key={t} className="sidebar__nav-item" style={{ fontSize: "13px" }}>
{t}
</button>
))}
</nav>
<div className="sidebar__footer">
<button className="sidebar__nav-item" style={{ width: "100%", borderRadius: 8 }}>
Paramètres
</button>
</div>
</aside>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useState } from "react";
import { triggerRefresh } from "@/lib/api";
interface Props {
lastUpdated: string | null;
videoCount: number;
onSearch: (q: string) => void;
dark: boolean;
onToggleDark: () => void;
}
export default function TopBar({ lastUpdated, videoCount, onSearch, dark, onToggleDark }: Props) {
const [refreshing, setRefreshing] = useState(false);
const [inputValue, setInputValue] = useState("");
const commitSearch = () => onSearch(inputValue);
const handleRefresh = async () => {
commitSearch();
setRefreshing(true);
try {
await triggerRefresh();
setTimeout(() => window.location.reload(), 3000);
} catch {
// silently fail
} finally {
setTimeout(() => setRefreshing(false), 3000);
}
};
return (
<header className="topbar">
<div className="topbar__search">
<span>🔍</span>
<input
id="search-input"
type="text"
placeholder="Rechercher dans les vidéos... (Entrée pour valider)"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && commitSearch()}
/>
</div>
{lastUpdated && (
<p className="topbar__status">
{videoCount} vidéos · mis à jour {new Intl.RelativeTimeFormat("fr", { numeric: "auto" }).format(
-Math.round((Date.now() - new Date(lastUpdated).getTime()) / 3600000),
"hours"
)}
</p>
)}
<button
className="topbar__theme-btn"
onClick={onToggleDark}
title={dark ? "Passer en mode clair" : "Passer en mode sombre"}
aria-label="Basculer le thème"
>
{dark ? "☀️" : "🌙"}
</button>
<button
id="btn-refresh"
className="topbar__btn"
onClick={handleRefresh}
disabled={refreshing}
>
{refreshing ? "⟳ Actualisation..." : "⟳ Actualiser"}
</button>
</header>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import type { Video } from "@/lib/api";
import { formatDuration, formatDate, TOPICS } from "@/lib/api";
import Image from "next/image";
interface Props {
video: Video;
}
export default function VideoCard({ video }: Props) {
const scoreColor =
video.score >= 70 ? "var(--primary)" : video.score >= 40 ? "#f59e0b" : "#6b7280";
return (
<a
href={video.youtube_url}
target="_blank"
rel="noopener noreferrer"
className="video-card"
id={`video-${video.id}`}
aria-label={`Voir la vidéo : ${video.title}`}
>
{/* Thumbnail */}
<div className="video-card__thumb">
{video.thumbnail_url ? (
<Image
src={video.thumbnail_url}
alt={video.title}
fill
style={{ objectFit: "cover" }}
unoptimized
/>
) : (
<div style={{ background: "#e5e7eb", width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 36 }}></div>
)}
{/* Score badge */}
<span className="video-card__score" style={{ background: scoreColor }}>
{video.score.toFixed(0)}/100
</span>
{/* Duration */}
<span className="video-card__duration">{formatDuration(video.duration_seconds)}</span>
{/* Chapters indicator */}
{video.has_chapters && (
<span className="video-card__chapters">📑 Chapitres</span>
)}
</div>
{/* Body */}
<div className="video-card__body">
<h3 className="video-card__title">{video.title}</h3>
<div className="video-card__meta">
<span className="video-card__meta-item">📺 {video.channel}</span>
<span className="video-card__meta-item">📅 {formatDate(video.published_at)}</span>
<span className="video-card__meta-item">👁 {video.view_count.toLocaleString("fr-FR")}</span>
{video.like_count > 0 && (
<span className="video-card__meta-item">👍 {video.like_count.toLocaleString("fr-FR")}</span>
)}
</div>
{/* Topics */}
{video.topics.length > 0 && (
<div className="video-card__topics">
{video.topics.slice(0, 3).map((t) => (
<span key={t} className="topic-tag">
{TOPICS[t] ?? t}
</span>
))}
</div>
)}
</div>
</a>
);
}
+94
View File
@@ -0,0 +1,94 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
export interface Video {
id: string;
title: string;
channel: string;
published_at: string;
duration_seconds: number;
view_count: number;
like_count: number;
thumbnail_url: string;
youtube_url: string;
tags: string[];
has_chapters: boolean;
score: number;
topics: string[];
source_queries: string[];
}
export interface VideoList {
total: number;
page: number;
page_size: number;
items: Video[];
}
export interface Filters {
q?: string;
min_score?: number;
topic?: string;
source_queries?: string[];
days?: number;
page?: number;
page_size?: number;
}
export async function fetchVideos(filters: Filters = {}): Promise<VideoList> {
const params = new URLSearchParams();
if (filters.q) params.set("q", filters.q);
if (filters.min_score !== undefined) params.set("min_score", String(filters.min_score));
if (filters.topic) params.set("topic", filters.topic);
if (filters.source_queries?.length) filters.source_queries.forEach((sq) => params.append("source_query", sq));
if (filters.days) params.set("days", String(filters.days));
if (filters.page) params.set("page", String(filters.page));
if (filters.page_size) params.set("page_size", String(filters.page_size));
const res = await fetch(`${API_BASE}/api/videos?${params}`, { next: { revalidate: 300 } });
if (!res.ok) throw new Error("Erreur lors du chargement des vidéos");
return res.json();
}
export async function triggerRefresh(queries?: string[]): Promise<void> {
const body = queries ? JSON.stringify({ queries }) : undefined;
await fetch(`${API_BASE}/api/refresh`, {
method: "POST",
headers: body ? { "Content-Type": "application/json" } : {},
body,
});
}
export async function fetchConfig(): Promise<{ queries: string[] }> {
const res = await fetch(`${API_BASE}/api/config`, { cache: "no-store" });
if (!res.ok) throw new Error("Erreur config");
return res.json();
}
export async function fetchStatus(): Promise<{ video_count: number; last_updated: string | null; refresh_running: boolean; quota_exceeded: boolean; quota_exceeded_at: string | null }> {
const res = await fetch(`${API_BASE}/api/status`, { cache: "no-store" });
if (!res.ok) throw new Error("Erreur status");
return res.json();
}
export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}h${String(m).padStart(2, "0")}`;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function formatDate(iso: string): string {
return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "short", year: "numeric" }).format(new Date(iso));
}
export const TOPICS: Record<string, string> = {
incident: "🔥 Incident",
architecture: "🏗️ Architecture",
"observabilité": "📊 Observabilité",
"sécurité": "🔒 Sécurité",
ci_cd: "⚙️ CI/CD",
scaling: "📈 Scaling",
migration: "🔄 Migration",
storage: "💾 Storage",
};
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
+13
View File
@@ -0,0 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "i.ytimg.com",
},
],
},
};
module.exports = nextConfig;
+499
View File
@@ -0,0 +1,499 @@
{
"name": "k8s-veille-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "k8s-veille-frontend",
"version": "1.0.0",
"dependencies": {
"next": "14.2.3",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "^5"
}
},
"node_modules/@next/env": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz",
"integrity": "sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz",
"integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz",
"integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz",
"integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz",
"integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz",
"integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz",
"integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz",
"integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz",
"integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz",
"integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
"integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"tslib": "^2.4.0"
}
},
"node_modules/@types/node": {
"version": "20.19.33",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001774",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
"integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/next": {
"version": "14.2.3",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz",
"integrity": "sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
"license": "MIT",
"dependencies": {
"@next/env": "14.2.3",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
"graceful-fs": "^4.2.11",
"postcss": "8.4.31",
"styled-jsx": "5.1.1"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.3",
"@next/swc-darwin-x64": "14.2.3",
"@next/swc-linux-arm64-gnu": "14.2.3",
"@next/swc-linux-arm64-musl": "14.2.3",
"@next/swc-linux-x64-gnu": "14.2.3",
"@next/swc-linux-x64-musl": "14.2.3",
"@next/swc-win32-arm64-msvc": "14.2.3",
"@next/swc-win32-ia32-msvc": "14.2.3",
"@next/swc-win32-x64-msvc": "14.2.3"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.41.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/styled-jsx": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
"integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "ytveille-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "14.2.3",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "^5"
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"types": [
"node"
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}