From 26b3ed6994db3fb4cd1c774d087bfbd0d6c523f1 Mon Sep 17 00:00:00 2001 From: Ismail SEBBANE Date: Fri, 22 May 2026 09:17:28 +0200 Subject: [PATCH] first commit --- .env.example | 2 + .gitignore | 26 ++ README.md | 226 ++++++++++++ backend/api/Dockerfile | 17 + backend/api/__init__.py | 1 + backend/api/main.py | 192 +++++++++++ backend/api/models.py | 46 +++ backend/api/storage.py | 92 +++++ backend/api/youtube_client.py | 243 +++++++++++++ backend/pyproject.toml | 29 ++ backend/scoring/__init__.py | 1 + backend/scoring/keywords.py | 54 +++ backend/scoring/scorer.py | 115 ++++++ backend/scoring/tests/__init__.py | 1 + backend/scoring/tests/test_scorer.py | 89 +++++ backend/worker/Dockerfile | 15 + backend/worker/__init__.py | 1 + backend/worker/scheduler.py | 73 ++++ docker-compose.yml | 43 +++ frontend/Dockerfile | 21 ++ frontend/app/globals.css | 353 +++++++++++++++++++ frontend/app/layout.tsx | 16 + frontend/app/page.tsx | 255 ++++++++++++++ frontend/components/FilterPanel.tsx | 159 +++++++++ frontend/components/Sidebar.tsx | 45 +++ frontend/components/TopBar.tsx | 75 ++++ frontend/components/VideoCard.tsx | 78 +++++ frontend/lib/api.ts | 94 +++++ frontend/next-env.d.ts | 5 + frontend/next.config.js | 13 + frontend/package-lock.json | 499 +++++++++++++++++++++++++++ frontend/package.json | 21 ++ frontend/tsconfig.json | 43 +++ 33 files changed, 2943 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/api/Dockerfile create mode 100644 backend/api/__init__.py create mode 100644 backend/api/main.py create mode 100644 backend/api/models.py create mode 100644 backend/api/storage.py create mode 100644 backend/api/youtube_client.py create mode 100644 backend/pyproject.toml create mode 100644 backend/scoring/__init__.py create mode 100644 backend/scoring/keywords.py create mode 100644 backend/scoring/scorer.py create mode 100644 backend/scoring/tests/__init__.py create mode 100644 backend/scoring/tests/test_scorer.py create mode 100644 backend/worker/Dockerfile create mode 100644 backend/worker/__init__.py create mode 100644 backend/worker/scheduler.py create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/app/globals.css create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/page.tsx create mode 100644 frontend/components/FilterPanel.tsx create mode 100644 frontend/components/Sidebar.tsx create mode 100644 frontend/components/TopBar.tsx create mode 100644 frontend/components/VideoCard.tsx create mode 100644 frontend/lib/api.ts create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.js create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e5e6b16 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +YOUTUBE_API_KEY=your_youtube_data_api_v3_key_here +NEXT_PUBLIC_API_URL=http://localhost:8000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dcc66a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c51192 --- /dev/null +++ b/README.md @@ -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 (0–100) + +| 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** (~8h–9h UTC) +- Pour obtenir plus de quota : [console.cloud.google.com](https://console.cloud.google.com) → API & Services → Quotas diff --git a/backend/api/Dockerfile b/backend/api/Dockerfile new file mode 100644 index 0000000..7ebfaf8 --- /dev/null +++ b/backend/api/Dockerfile @@ -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"] diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..95219d9 --- /dev/null +++ b/backend/api/__init__.py @@ -0,0 +1 @@ +# Backend Python packages init diff --git a/backend/api/main.py b/backend/api/main.py new file mode 100644 index 0000000..7bcaf83 --- /dev/null +++ b/backend/api/main.py @@ -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"), + } diff --git a/backend/api/models.py b/backend/api/models.py new file mode 100644 index 0000000..9e59139 --- /dev/null +++ b/backend/api/models.py @@ -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 diff --git a/backend/api/storage.py b/backend/api/storage.py new file mode 100644 index 0000000..6c79952 --- /dev/null +++ b/backend/api/storage.py @@ -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) diff --git a/backend/api/youtube_client.py b/backend/api/youtube_client.py new file mode 100644 index 0000000..51ded99 --- /dev/null +++ b/backend/api/youtube_client.py @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..8047e89 --- /dev/null +++ b/backend/pyproject.toml @@ -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"] diff --git a/backend/scoring/__init__.py b/backend/scoring/__init__.py new file mode 100644 index 0000000..853c42f --- /dev/null +++ b/backend/scoring/__init__.py @@ -0,0 +1 @@ +# Scoring package diff --git a/backend/scoring/keywords.py b/backend/scoring/keywords.py new file mode 100644 index 0000000..3caf0b2 --- /dev/null +++ b/backend/scoring/keywords.py @@ -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", +] diff --git a/backend/scoring/scorer.py b/backend/scoring/scorer.py new file mode 100644 index 0000000..ee151d7 --- /dev/null +++ b/backend/scoring/scorer.py @@ -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 diff --git a/backend/scoring/tests/__init__.py b/backend/scoring/tests/__init__.py new file mode 100644 index 0000000..c4ddba3 --- /dev/null +++ b/backend/scoring/tests/__init__.py @@ -0,0 +1 @@ +# Tests scoring diff --git a/backend/scoring/tests/test_scorer.py b/backend/scoring/tests/test_scorer.py new file mode 100644 index 0000000..33eaf6a --- /dev/null +++ b/backend/scoring/tests/test_scorer.py @@ -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 diff --git a/backend/worker/Dockerfile b/backend/worker/Dockerfile new file mode 100644 index 0000000..352bc04 --- /dev/null +++ b/backend/worker/Dockerfile @@ -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"] diff --git a/backend/worker/__init__.py b/backend/worker/__init__.py new file mode 100644 index 0000000..b9c8ed7 --- /dev/null +++ b/backend/worker/__init__.py @@ -0,0 +1 @@ +# Worker package diff --git a/backend/worker/scheduler.py b/backend/worker/scheduler.py new file mode 100644 index 0000000..c7d381e --- /dev/null +++ b/backend/worker/scheduler.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..165b7fb --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..bb1204a --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..3874dc9 --- /dev/null +++ b/frontend/app/globals.css @@ -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; } +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..27185e1 --- /dev/null +++ b/frontend/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..98d927a --- /dev/null +++ b/frontend/app/page.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [initialQueries, setInitialQueries] = useState([]); + const [launching, setLaunching] = useState(false); + const [activeQueryFilters, setActiveQueryFilters] = useState([]); + 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 | 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) => { + 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 ( +
+ + +
+
+

▶ YTVeille

+ {total} vidéo{total > 1 ? "s" : ""} +
+ + {launching && ( +
+ ⟳ Recherche YouTube en cours, veuillez patienter... +
+ )} + + {status.quota_exceeded && !launching && ( +
+ ⚠️ +
+ Quota YouTube API dépassé + {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))} + )} +
+ Le quota se renouvelle chaque jour à minuit (heure du Pacifique, ~8h–9h UTC). Réessayez demain. +
+
+ )} + + {loading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : displayedVideos.length === 0 ? ( +
+

+

Aucune vidéo trouvée.

+

+ Configurez vos requêtes et lancez la veille depuis le panneau gauche. +

+
+ ) : ( +
+ {displayedVideos.map((v) => ( + + ))} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( + + )} +
+ + +
+ ); +} diff --git a/frontend/components/FilterPanel.tsx b/frontend/components/FilterPanel.tsx new file mode 100644 index 0000000..235a436 --- /dev/null +++ b/frontend/components/FilterPanel.tsx @@ -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) => 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(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 ( + + ); +} diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx new file mode 100644 index 0000000..3303d11 --- /dev/null +++ b/frontend/components/Sidebar.tsx @@ -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 ( + + ); +} diff --git a/frontend/components/TopBar.tsx b/frontend/components/TopBar.tsx new file mode 100644 index 0000000..58dea96 --- /dev/null +++ b/frontend/components/TopBar.tsx @@ -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 ( +
+
+ 🔍 + setInputValue(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && commitSearch()} + /> +
+ + {lastUpdated && ( +

+ {videoCount} vidéos · mis à jour {new Intl.RelativeTimeFormat("fr", { numeric: "auto" }).format( + -Math.round((Date.now() - new Date(lastUpdated).getTime()) / 3600000), + "hours" + )} +

+ )} + + + + +
+ ); +} diff --git a/frontend/components/VideoCard.tsx b/frontend/components/VideoCard.tsx new file mode 100644 index 0000000..2ddc3bd --- /dev/null +++ b/frontend/components/VideoCard.tsx @@ -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 ( + + {/* Thumbnail */} +
+ {video.thumbnail_url ? ( + {video.title} + ) : ( +
+ )} + + {/* Score badge */} + + {video.score.toFixed(0)}/100 + + + {/* Duration */} + {formatDuration(video.duration_seconds)} + + {/* Chapters indicator */} + {video.has_chapters && ( + 📑 Chapitres + )} +
+ + {/* Body */} +
+

{video.title}

+ +
+ 📺 {video.channel} + 📅 {formatDate(video.published_at)} + 👁 {video.view_count.toLocaleString("fr-FR")} + {video.like_count > 0 && ( + 👍 {video.like_count.toLocaleString("fr-FR")} + )} +
+ + {/* Topics */} + {video.topics.length > 0 && ( +
+ {video.topics.slice(0, 3).map((t) => ( + + {TOPICS[t] ?? t} + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts new file mode 100644 index 0000000..e82e43b --- /dev/null +++ b/frontend/lib/api.ts @@ -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 { + 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 { + 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 = { + incident: "🔥 Incident", + architecture: "🏗️ Architecture", + "observabilité": "📊 Observabilité", + "sécurité": "🔒 Sécurité", + ci_cd: "⚙️ CI/CD", + scaling: "📈 Scaling", + migration: "🔄 Migration", + storage: "💾 Storage", +}; diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..c50e939 --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,13 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "i.ytimg.com", + }, + ], + }, +}; + +module.exports = nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..133858a --- /dev/null +++ b/frontend/package-lock.json @@ -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" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7f7c7de --- /dev/null +++ b/frontend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..ec85f72 --- /dev/null +++ b/frontend/tsconfig.json @@ -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" + ] +} \ No newline at end of file