MovixOpenSource/src/components/Header.tsx
Movix 8657a856f8 feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement
feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement

Gros lot de travail synchronisé depuis le repo de dev. Les grands axes :
migration de domaine (movix.date + miroirs), deux nouvelles sources films/séries
(1jour1film, Cinestream) et une n ouvelle source livetv, un fournisseur OAuth « Se connecter avec Movix », une
modération de contenu par IA revue, plus un lot de durcissement mémoire/réseau,
sécurité et perf côté API et front.

## 🌐 Domaines & miroirs

- Domaine canonique `movix.chat`/`movix.golf`/`movix.tax` → **movix.date** partout
  (front, allow-lists CORS/domaine, Discord RPC). Miroir `movix.health` →
  **movix.online** (Footer, Header, pages d'aide CaMarchePlus/Dns/Miroirs). Lien
  Telegram du footer → `mysticsaba.com`. ~10 clés i18n mises à jour.

## 🎬 Sources de streaming (VOD)

- **J1F (1jour1film)** — nouvelle source de bout en bout (`routes/j1f.js`, monté sur
  `/api/j1f`). Résout le domaine tournant via une page `/go/`, décode les blobs base64
  `J1F_SRV`/`j1fEpsData` (Dooplay/WP), ne garde que les players uniques, ignore les
  agrégateurs (frembed/vidsrc/videasy). Cache stale-while-revalidate dédié. Players 1J1F
  lus via l'extension/userscript.
- **Cinestream** — nouvelle source films (`routes/cinestream.js`, scrape Next.js
  `cinestream.info`, match par id TMDB autoritaire). Wiflix bascule ses **films** sur
  Cinestream (bot-shield flemmix pénible sur les films) ; la **TV** reste sur flemmix.
- **Wiflix** — `flemmix.farm` → **`flemmix.golf`** ; handshake cookie
  (`makeWiflixSearchRequest`, `h_check=25`) ; sélecteurs de template corrigés.
- **Cpasmal** — axios+proxy → `makeCpasmalRequest` (CycleTLS JA3) car Cloudflare 403 ;
  recherche GET → POST form (le WAF bloque le GET).
- **Fstream** — cookie anti-bot `fsschal` ; source épisodes JS statique
  (`/static/series/<id>.js`) avant l'AJAX `episodes_p.php` ; fallback `get_seasons.php`.
- **FranceTV** — `fetchSeasonEpisodesViaDeepPage` (pagination JSON « Afficher plus »).
- **Coflix** — `coflix.date` → `coflix.trade` ; logs 429 silencés en double.
- **proxyManager** — cinq helpers de contournement Cloudflare exportés
  (`makeCinestreamRequest`, `make1j1fRequest`, `makeCpasmalRequest`, handshake Wiflix
  `makeWiflixSearchRequest`/`wiflixHandshake`, `classifyCloudflare429` worker-vs-site) ;
  CycleTLS + rotation ProxyScrape, ne throw jamais sur statut HTTP. Coflix/LecteurVideo
  quittent les Cloudflare Workers pour ProxyScrape.

## 📺 Live TV — remplacement de Sosplay/Vavoo par FCTV33

- Source sport **Sosplay → FCTV33** (API RBTV, protobuf brut) réécrite de bout en bout
  dans `liveTvRoutes.js`, **Vavoo retiré** (manifest/catalogue/stream) — tout passe par
  `/api/livetv/*`.
- Pipeline neuf : décodeur protobuf maison, signature `sfver` (bs keys + MD5),
  auto-découverte des domaines API/player tournants (`hubu.ru/fctvlink` + Nuxt SSR /
  `params` rot47, cache 30 min), scraper de matchs (`scrapeFctvMatches` :
  équipes/logos, ligue, pays, statut live, tri live-first), résolution de flux (HLS
  natif d'abord, embed en secours), token CDN (XOR keystream + base64, gate
  `rb-session`).
- Route `GET /api/livetv/fctv/playlist` : proxy m3u8 intelligent (résout le token,
  réécrit les segments, retry sur token périmé, modes `raw` extension / `proxy` VIP).
- **Matchs plus réservés aux VIP** (seul `iptv_` le reste) : flux natif proxifié pour
  les VIP, stub `_fctvLocal` résolu côté client par
  l'extension/userscript (`RESOLVE_FCTV`), fallback embed sinon.
- Front `LiveTV.tsx` : `matches` → `fctv`, UI accordéon par match (logos, badges
  ligue/pays, score/timer live, favori), disclaimer, fix icône `music`. Rotations
  d'hôtes : `witv.team`→`witv.football`, `livetv876.me`→`livetv901.me`.

## 🧩 Extensions · Userscript · Discord RPC

- **FCTV en lecture native** : handlers `SETUP_FCTV_HEADERS`/`RESOLVE_FCTV` (règle DNR
  Referer/Origin/UA sur `/cfall/s*` + résolveur local IP-bindé : protobuf-lite + rot47
  + token XOR).
- **Vavoo retiré** , `cinepulse.lol` → `purstream.mx`.
- Fix extracteur **Sibnet** (`Range: bytes=0-0`) ; fix bug userscript `matchToRegex`
  (patterns ancrés `^…$` cassant `/cfall/s` → dé-ancrés) ; override dev
  `localhost:25565`.
- Bumps de version (Chrome 1.3.3→1.3.8, Firefox 1.5.1→1.5.5, userscript 1.4.0→1.4.7) ;
  PreMid nouveau `clientId` Discord + `regExp` élargi (1.0.3→1.0.4).

## 🔑 OAuth (fournisseur « Se connecter avec Movix ») & codes d'accès

- Refonte du flux d'autorisation OAuth (`OAuthAuthorizePage.tsx`) + routes
  (`routes/authRoutes.js`, `routes/oauth.js`) et stockage des clients OAuth étoffé
  (`utils/oauthClientsDb.js`, `routes/adminOauthApps.js` pour la gestion admin des apps).
- `AccessCodeForm.tsx` retouché en cohérence avec le nouveau flux ; `checkVip.js` ajusté.
- Session centralisée : `accountAuth.ts` expose `clearStoredAuthSession()` /
  `broadcastAuthChange()` (liste `AUTH_KEYS` unifiée), désormais utilisés partout à la
  déconnexion.

## 💬 Modération de contenu (IA)

- Modèle OpenRouter `google/gemini-2.5-flash-lite` → `deepseek/deepseek-v4-flash`
  (`commentsRoutes.js`, `sharedListsRoutes.js`) ; fonctions/logs renommés
  `moderateContentWithGemini` → `moderateContentWithAI` (découplés du nom du modèle).
- Prompt durci (critères INSULTES / DEMANDE_AJOUT resserrés) pour éviter les faux
  positifs sur questions, signalements de bug et plaintes de contenu manquant.
- `AdminComments.tsx` : libellé « par Gemini » → clé i18n générique.

## 🧊 Hydracker / Darkiworld

- **Sortie du debrid** : AllDebrid/RealDebrid supprimés (`utils/hydrackerLive.js` :
  branche provider retirée ; vars `ALLDEBRID_*`/`REALDEBRID_*` sorties du `.env`) —
  hydracker renvoie directement `rawUrl`/`directDL`. Liens servis depuis les snapshots
  SQLite locaux (`utils/darkiworldSqlite.js`, `darkino-backups/`).
- **Anti-scraping** sur `/decode/:id` (`routes/darkiworld.js`) : watchlist + alerte
  webhook Discord (`DISCORD_SCRAPER_WEBHOOK`) et empoisonnement/blocage de réponse pour
  les IP listées (`SCRAPER_BLOCKED_IPS`).
- **Mémoire** : cache d'agents proxy borné (`utils/lruMap.js`) qui détruit les sockets
  des agents évincés.

## 🛠️ Admin — propriété des liens & équipe

- **Propriété des liens** : les liens streaming gagnent l'attribution `added_by` et
  l'ownership est appliquée des deux côtés (uploader = ses liens, admin = tous). GET
  public masqué (`stripLinkOwner`), `GET /admin/streaming-links/:type/:id` authentifié
  conserve le tag ; `DELETE /admin/links` par URL unique ; `PUT /admin/links` masse
  admin-only.
- **Équipe** : `GET/POST/DELETE /admin/admins` + `GET /admin/team/history` (audit),
  identité via `adminIdentity.js` (option `preferProfile` → premier profil Movix). UI
  `AdminTeam.tsx`.
- **Privilèges** (`middleware/auth.js`) : `isAdmin` rejette `role !== 'admin'` (403) —
  les uploaders ne passent plus les routes admin-only. Migration
  `add_role_to_admins.sql`.

## 🎁 Wrapped v2

- Cartes de partage : logique canvas extraite (`utils/wrappedCanvas.ts`,
  `utils/wrappedShareCards.ts`), formats de partage supplémentaires + preview.
- Nouvelles slides : jour record, répartition par jour de semaine, champion de rewatch,
  âge ciné (année de sortie médiane pondérée par le temps de visionnage).
- Perf : données depuis le payload backend (zéro re-fetch TMDB), trailer lazy,
  percentile non bloquant (réchauffe en arrière-plan, lock Redis `NX`), shuffle seedé.

## 🔐 Auth · Profils · VIP

- **Pseudo forcé** : `validateProfileName` (1–32 chars, strip control/zero-width, +
  `maxLength={32}` côté UI Profile/WatchParty) ; les pseudos OAuth trop longs →
  `requiresUsernameChange` + modale bloquante `RequireUsernameChange.tsx`.
- **Sync durci** (`ProfileContext.tsx`, `syncPolicy.js`) : anti-replay cross-user,
  filtrage des ops à clés non-syncables (`access_code`/`access_code_expires` exclues),
  « wipe guard » qui préserve le local sur profil vide inattendu, dédupe des
  `loadProfiles()` concurrents. Logout centralisé (voir OAuth).
- **VIP** : `is_vip` préservé au chargement de profil (corrige un downgrade VIP
  inter-onglets qui relançait la popup pub) ; `vipDonations.js` poll de secours
  `payment-status.php` (via `ipn_token`, colonne `paygate_ipn_token`) → plus d'invoices
  bloquées.

## ▶️ Lecteur (HLS)

- Basse latence opt-in : `isLowLatencyEnabled('movies')` (scope movies/livetv,
  `lowLatencyPref.ts`) au lieu du mode forcé ; `FloatingPlayer` aligné.
- Sous-titres cinep : wrapper m3u8 synthétique pour les URI `.vtt`/`.srt` directes,
  `isSubtitleLoadError` (une piste cassée ne tue plus la vidéo), rendu `<i>/<b>/<u>`.
- Menu sources regroupé par langue (VF/VFQ/VFF/VOSTFR).

## 📡 Cast (AirPlay / Chromecast)

- Retrait du contournement proxy darkibox (`buildApiProxyUrl`) → cast direct de l'URL ;
  `runtime.ts` supprime `API_PROXY_BASE_URL`/`buildApiProxyUrl` devenus morts.
- Paramètre `streamType` (`BUFFERED` par défaut) propagé jusqu'à
  `loadMediaOnCastWithFallback`.

## 📢 Pubs

- Modes de popup (`adPopupMode.ts`) : `normal`, `auto`, `click-anywhere` ; toggle « +18 »
  séparé (`adAdultMode.ts`). `SCRIPT_AD_MODE_ENABLED` → `false`, URL du script changée.

## 🔎 Recherche & priorité des sources

- `searchUtils.ts` : le terme original contourne le filtre « trop générique » (fix
  zéro-résultat sur titres courts : Baki, Gantz).
- `animeSignals.ts` : détection donghua (CN/TW/HK ou `zh` + Animation, hors C-drama).
- `sourceAutoSelect.ts` : `VFF`/`VFQ` normalisés en `vf` (sinon reléguées derrière
  VOSTFR). `sourcePriorityPrefs.ts` : schéma v2 → v3 (nouvel ordre par défaut + `j1f`,
  migration non destructive des ordres personnalisés). `extractionPrefs.ts` : +daddylive.

## 🔒 Sécurité

- Sanitisation d'URL avant rendu en `href` : `CommentsSection.tsx` (liens markdown
  `http(s)://`) et `Greenlight/AdminLinkSubmissions.tsx` — bloque les `javascript:`.

## ⚙️ Infra & perf

- **server.js** : recyclage périodique des workers (12h) + `[memstats]` + hook
  `SIGUSR2` → heap-snapshot.
- **app.js** : monte `/api/j1f`, retire la route statique `/oauth-icons`, passe
  `makeCpasmalRequest` au router Cpasmal, `coflix.date` → `coflix.trade`.
- **lazyWithRetry.ts / main.tsx / ErrorBoundary.tsx** : récupération chunk-load réécrite
  (retry + reload budgété), garde `removeChild/insertBefore`, écran soft « Mise à jour »
  au lieu du crash-report.
- **UI** : `TopProgressBar` écrit `transform` directement sur un ref (compositor-only,
  plus de re-render) ; `blur-text` relâche `will-change` après l'animation.
- **proxiesembed/server.py** : `curl_cffi` (JA3) pour `cinep-proxy` + retry SOCKS5 sur
  403 ; lookup clé VIP réaligné sur `checkVip.js` ; log WARNING par défaut ; SOCKS5
  forcé IPv4 ; wrapper VTT/SRT pour hls.js ; re-label `application/zip` → `video/mp2t` ;
  `cinepulse.lol` → `purstream.mx`.

## 🧹 Nettoyage & suppressions

- **Front** : carte promo « Loadix » sur Download (remplace l'encart beta),
  « 1jour1film » ajouté à « C'est quoi Movix », fallback Darkino/Nightflix gutté en
  no-op (`api.movix.chat` décommissionné), classes CSS mortes retirées, fix
  `fetchPriority` → `fetchpriority`.
- **Backend** : scrapers/utilitaires morts retirés — `routes/dragiv.js`,
  `routes/topstream.js`, `routes/proxy.js`, `utils/cloudflareSession.js`,
  `utils/hydrackerBatch.js`, `utils/hydrackerQueue.js'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:22:13 +02:00

788 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import Snowfall from 'react-snowfall';
import { useLocation, useNavigate } from 'react-router-dom';
import { PrefetchLink as Link } from '@/routing/PrefetchLink';
import { Film, Search, Menu, X, Star, Tv2, Users, Clapperboard, Bell, Tv, Lightbulb, Network, List, Radio, Unlock, ChevronDown, ExternalLink, LayoutGrid, Settings, Dices, Sparkles, HelpCircle, Github } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import ProfileMenu from './ProfileMenu';
import NotificationsPopup from './NotificationsPopup';
import { getUnreadNotificationsCount, getNotificationsDisabled } from '../services/apiNotificationService';
import { encodeId } from '../utils/idEncoder';
import { useSearch } from '../context/SearchContext';
import { isUserVip } from '../utils/authUtils';
import { useTranslation } from 'react-i18next';
import { SquareBackground } from './ui/square-background';
import { APRIL_FOOLS_ADMIN_PATH, isAprilFoolsAdminEnabled } from '../utils/aprilFools';
// Couleurs pour les cards du mega menu
const cardColors: Record<string, { bg: string; text: string; border: string }> = {
purple: { bg: 'bg-purple-500/10', text: 'text-purple-400', border: 'border-purple-500/20' },
green: { bg: 'bg-green-500/10', text: 'text-green-400', border: 'border-green-500/20' },
blue: { bg: 'bg-blue-500/10', text: 'text-blue-400', border: 'border-blue-500/20' },
orange: { bg: 'bg-orange-500/10', text: 'text-orange-400', border: 'border-orange-500/20' },
indigo: { bg: 'bg-indigo-500/10', text: 'text-indigo-400', border: 'border-indigo-500/20' },
pink: { bg: 'bg-pink-500/10', text: 'text-pink-400', border: 'border-pink-500/20' },
red: { bg: 'bg-red-500/10', text: 'text-red-400', border: 'border-red-500/20' },
sky: { bg: 'bg-sky-500/10', text: 'text-sky-400', border: 'border-sky-500/20' },
yellow: { bg: 'bg-yellow-500/10', text: 'text-yellow-400', border: 'border-yellow-500/20' },
gray: { bg: 'bg-white/5', text: 'text-gray-400', border: 'border-white/10' },
};
interface ExploreItem {
name: string;
path: string;
icon: React.ReactNode;
color: string;
desc: string;
external?: boolean;
}
interface ExploreGroup {
title: string;
items: ExploreItem[];
}
const Header: React.FC = () => {
const { t } = useTranslation();
const [isExploreOpen, setIsExploreOpen] = useState(false);
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
const [isSnowfallActive, setIsSnowfallActive] = useState(() => {
return sessionStorage.getItem('snowfall_active') === 'true';
});
const [showAutocomplete, setShowAutocomplete] = useState(false);
useEffect(() => {
sessionStorage.setItem('snowfall_active', String(isSnowfallActive));
}, [isSnowfallActive]);
useEffect(() => {
const handleSnowfallToggle = () => {
setIsSnowfallActive(sessionStorage.getItem('snowfall_active') === 'true');
};
window.addEventListener('snowfall_toggled', handleSnowfallToggle);
return () => window.removeEventListener('snowfall_toggled', handleSnowfallToggle);
}, []);
const [showNotifications, setShowNotifications] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const [notificationsDisabled, setNotificationsDisabled] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isVip, setIsVip] = useState(false);
const [headerQuery, setHeaderQuery] = useState('');
const searchInputRef = useRef<HTMLInputElement>(null);
const mobileSearchInputRef = useRef<HTMLInputElement>(null);
const autocompleteRef = useRef<HTMLDivElement>(null);
const notificationsRef = useRef<HTMLDivElement>(null);
const exploreRef = useRef<HTMLDivElement>(null);
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const location = useLocation();
const navigate = useNavigate();
const isAprilFoolsAdminVisible = isAprilFoolsAdminEnabled(location.search);
const {
autocompleteSuggestions,
loadingAutocomplete,
fetchAutocompleteSuggestions,
clearAutocompleteSuggestions
} = useSearch();
// 4 items principaux visibles dans le header
const mainNavItems = useMemo(() => [
{ name: t('nav.movies'), path: '/movies', icon: <Clapperboard size={16} />, isActive: location.pathname === '/movies' },
{ name: t('nav.tvShows'), path: '/tv-shows', icon: <Tv2 size={16} />, isActive: location.pathname === '/tv-shows' },
{ name: t('nav.anime'), path: '/anime', icon: <Sparkles size={16} />, isActive: location.pathname === '/anime' },
{ name: t('nav.search'), path: '/search', icon: <Search size={16} />, isActive: location.pathname === '/search' },
], [t, location.pathname]);
// Groupes du mega menu — 4 colonnes équilibrées, tout sur 1 ligne
const exploreGroups: ExploreGroup[] = useMemo(() => [
{
title: t('nav.movies'),
items: [
{ name: t('nav.collections'), path: '/collections', icon: <Film size={20} />, color: 'purple', desc: t('nav.collectionsDesc') },
{ name: t('nav.top10'), path: '/top10', icon: <Star size={20} />, color: 'yellow', desc: t('nav.top10Desc') },
{ name: t('nav.roulette'), path: '/roulette', icon: <Dices size={20} />, color: 'red', desc: t('roulette.navDesc') },
{ name: t('nav.cinegraph'), path: '/cinegraph', icon: <Network size={20} />, color: 'blue', desc: t('nav.cinegraphDesc') },
]
},
{
title: t('nav.social'),
items: [
{ name: t('nav.sharedLists'), path: '/list-catalog', icon: <List size={20} />, color: 'indigo', desc: t('nav.sharedListsDesc') },
{ name: t('nav.suggestions'), path: '/suggestion', icon: <Star size={20} />, color: 'pink', desc: t('nav.suggestionsDesc') },
{ name: t('nav.greenlight'), path: '/wishboard', icon: <Lightbulb size={20} />, color: 'green', desc: t('nav.greenlightDesc') },
]
},
{
title: t('nav.live'),
items: [
{ name: t('nav.watchParty'), path: '/watchparty/list', icon: <Users size={20} />, color: 'orange', desc: t('nav.watchPartyDesc') },
{ name: t('nav.liveTV'), path: '/live-tv', icon: <Tv size={20} />, color: 'red', desc: t('nav.liveTVDesc') },
...(isVip ? [{ name: t('nav.francetv'), path: '/ftv', icon: <Radio size={20} />, color: 'sky' as const, desc: t('nav.francetvDesc') }] : []),
]
},
{
title: t('nav.more'),
items: [
...(isVip ? [{ name: t('nav.debrid'), path: '/debrid', icon: <Unlock size={20} />, color: 'yellow' as const, desc: t('nav.debridDesc') }] : []),
{ name: t('nav.settings'), path: '/settings', icon: <Settings size={20} />, color: 'gray', desc: t('nav.settingsDesc') },
{ name: t('nav.helpHub'), path: '/help', icon: <HelpCircle size={20} />, color: 'indigo', desc: t('nav.helpHubDesc') },
{ name: t('nav.github'), path: 'https://github.com/movixcorp/MovixOpenSource', icon: <Github size={20} />, color: 'gray', desc: t('nav.githubDesc'), external: true },
{ name: t('footer.ourUrls'), path: 'https://movix.online', icon: <ExternalLink size={20} />, color: 'gray', desc: t('nav.officialLinksDesc'), external: true },
]
},
].filter(g => g.items.length > 0), [t, isVip]);
// Auth
useEffect(() => {
const checkAuth = () => {
const auth = localStorage.getItem('auth');
const discordAuth = localStorage.getItem('discord_auth');
const googleAuth = localStorage.getItem('google_auth');
const bip39Auth = localStorage.getItem('bip39_auth');
const isVipUser = isUserVip();
const isAuth = discordAuth === 'true' || googleAuth === 'true' || bip39Auth === 'true' || !!auth;
setIsAuthenticated(isAuth);
setIsVip(isVipUser);
};
checkAuth();
window.addEventListener('storage', checkAuth);
return () => window.removeEventListener('storage', checkAuth);
}, []);
// Notifications
const refreshUnreadCount = useCallback(async () => {
if (!isAuthenticated) { setUnreadCount(0); return; }
try {
const count = await getUnreadNotificationsCount();
setUnreadCount(count);
} catch {
setUnreadCount(0);
}
}, [isAuthenticated]);
useEffect(() => {
if (isAuthenticated) {
refreshUnreadCount();
getNotificationsDisabled().then(setNotificationsDisabled);
const interval = setInterval(refreshUnreadCount, 30000);
return () => clearInterval(interval);
} else {
setUnreadCount(0);
setNotificationsDisabled(false);
}
}, [refreshUnreadCount, isAuthenticated]);
useEffect(() => {
const handler = (e: Event) => {
setNotificationsDisabled((e as CustomEvent).detail);
};
window.addEventListener('notifications_disabled_changed', handler);
return () => window.removeEventListener('notifications_disabled_changed', handler);
}, []);
// Click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node) && searchInputRef.current && !searchInputRef.current.contains(event.target as Node)) {
setShowAutocomplete(false);
}
if (showNotifications && notificationsRef.current) {
const target = event.target as HTMLElement;
if (target.closest('[data-notification-button]') || target.closest('[data-notifications-popup]')) return;
if (!notificationsRef.current.contains(target)) setShowNotifications(false);
}
// Fermer mega menu desktop au clic extérieur
if (isExploreOpen && exploreRef.current) {
const target = event.target as HTMLElement;
if (!exploreRef.current.contains(target) && !target.closest('[data-explore-trigger]')) {
setIsExploreOpen(false);
}
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showNotifications, isExploreOpen]);
// Bloquer le scroll (html + body + Lenis) quand le menu fullscreen est ouvert
useEffect(() => {
const lenis = (window as any).lenis;
const isMobile = window.innerWidth < 1024;
if (isExploreOpen && isMobile) {
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.inset = '0';
document.body.style.width = '100%';
if (lenis) lenis.destroy();
} else {
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.inset = '';
document.body.style.width = '';
// Réinitialiser Lenis si nécessaire
if (!lenis && isMobile) {
window.dispatchEvent(new CustomEvent('settings_smooth_scroll_changed'));
}
}
return () => {
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.inset = '';
document.body.style.width = '';
if (!(window as any).lenis) {
window.dispatchEvent(new CustomEvent('settings_smooth_scroll_changed'));
}
};
}, [isExploreOpen]);
// Reset on navigation
useEffect(() => {
clearAutocompleteSuggestions();
setShowAutocomplete(false);
setIsExploreOpen(false);
setIsMobileSearchOpen(false);
setShowNotifications(false);
if (location.pathname !== '/search') setHeaderQuery('');
}, [location.pathname]);
// Autocomplete dropdown position
const prevDropdownPosRef = useRef<{ top: number; left: number } | null>(null);
const dropdownRafIdRef = useRef<number | null>(null);
useEffect(() => {
if (!showAutocomplete) return;
const activeInput = isMobileSearchOpen ? mobileSearchInputRef.current : searchInputRef.current;
if (!activeInput) return;
const calcPos = () => {
const inputRect = activeInput.getBoundingClientRect();
const dropdownWidth = Math.min(400, window.innerWidth * 0.92);
let left = inputRect.left + inputRect.width / 2 - dropdownWidth / 2;
const margin = 8;
if (left + dropdownWidth > window.innerWidth - margin) left = window.innerWidth - margin - dropdownWidth;
if (left < margin) left = margin;
const top = inputRect.bottom + 8;
if (dropdownRafIdRef.current != null) cancelAnimationFrame(dropdownRafIdRef.current);
dropdownRafIdRef.current = requestAnimationFrame(() => {
dropdownRafIdRef.current = null;
const prev = prevDropdownPosRef.current;
if (prev && prev.top === top && prev.left === left) return;
prevDropdownPosRef.current = { top, left };
setDropdownStyle({ position: 'fixed', top, left, width: dropdownWidth });
});
};
calcPos();
window.addEventListener('resize', calcPos);
window.addEventListener('scroll', calcPos, { capture: true, passive: true });
return () => {
window.removeEventListener('resize', calcPos);
window.removeEventListener('scroll', calcPos, { capture: true } as EventListenerOptions);
if (dropdownRafIdRef.current != null) {
cancelAnimationFrame(dropdownRafIdRef.current);
dropdownRafIdRef.current = null;
}
};
// autocompleteSuggestions intentionally excluded — body doesn't reference it;
// including it churns listener registration on every keystroke
}, [showAutocomplete, isMobileSearchOpen]);
// Focus mobile search input when opened
useEffect(() => {
if (isMobileSearchOpen && mobileSearchInputRef.current) {
mobileSearchInputRef.current.focus();
}
}, [isMobileSearchOpen]);
const handleQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setHeaderQuery(value);
if (value.length >= 2) {
fetchAutocompleteSuggestions(value);
setShowAutocomplete(true);
} else {
clearAutocompleteSuggestions();
setShowAutocomplete(false);
}
};
const handleSelectAutocomplete = (item: any) => {
clearAutocompleteSuggestions();
setShowAutocomplete(false);
setHeaderQuery('');
setIsMobileSearchOpen(false);
navigate(`/${item.media_type}/${encodeId(item.id)}`);
};
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (headerQuery.trim()) {
navigate(`/search?q=${encodeURIComponent(headerQuery)}`);
setHeaderQuery('');
setIsMobileSearchOpen(false);
}
};
const renderExploreCard = (item: ExploreItem) => {
const colors = cardColors[item.color] || cardColors.gray;
const content = (
<div className={`flex flex-col items-center text-center p-5 rounded-2xl border ${colors.border} bg-white/[0.04] active:bg-white/[0.08] transition-all duration-200 cursor-pointer h-full`}>
<div className={`w-14 h-14 rounded-2xl ${colors.bg} ${colors.text} flex items-center justify-center mb-3 border ${colors.border}`}>
{item.icon}
</div>
<span className="text-white text-sm font-semibold mb-1">{item.name}</span>
<span className="text-white/50 text-xs leading-tight">{item.desc}</span>
</div>
);
if (item.external) {
return (
<a key={item.path} href={item.path} target="_blank" rel="noopener noreferrer" onClick={() => setIsExploreOpen(false)}>
{content}
</a>
);
}
return (
<Link key={item.path} to={item.path} onClick={() => setIsExploreOpen(false)}>
{content}
</Link>
);
};
// Toutes les items pour le fullscreen mobile
const allExploreItems = exploreGroups.flatMap(g => g.items);
return (
<>
<header className="!fixed inset-x-0 top-0 w-full z-[11000] transition-all duration-300">
<div className="absolute inset-0 pointer-events-none z-0 bg-gradient-to-b from-black/90 via-black/70 to-transparent" aria-hidden="true" />
<div className="relative z-10">
<div className="max-w-[1400px] 2xl:max-w-[1600px] mx-auto">
<div className="flex items-center h-16 px-4 md:px-6 lg:px-8 gap-3 md:gap-5">
{/* Logo */}
<Link
to="/"
className="text-2xl md:text-3xl font-extrabold flex items-center hover:scale-105 transition-transform duration-300 flex-shrink-0"
onClick={(e) => {
if (location.pathname === '/') {
e.preventDefault();
const lenis = (window as any).lenis;
if (lenis) {
lenis.scrollTo(0, { duration: 1.2 });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
}}
>
<span className="text-red-600 tracking-wider">MOVIX</span>
</Link>
{/* Desktop Nav: 3 items principaux + Explorer */}
<nav className="hidden lg:flex items-center gap-1">
{mainNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-all duration-200 ${
item.isActive
? 'text-white bg-white/10 font-medium'
: 'text-gray-400 hover:text-white hover:bg-white/5'
}`}
>
{item.icon}
<span>{item.name}</span>
</Link>
))}
{isAprilFoolsAdminVisible && (
<Link
to={APRIL_FOOLS_ADMIN_PATH}
className={`ml-1 flex items-center gap-2 rounded-xl border px-3 py-1.5 text-sm transition-all duration-200 ${
location.pathname === APRIL_FOOLS_ADMIN_PATH
? 'border-amber-300/35 bg-amber-300/15 text-white'
: 'border-amber-300/20 bg-amber-300/10 text-amber-100 hover:border-amber-300/35 hover:bg-amber-300/15 hover:text-white'
}`}
>
<span className="relative flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-300/60" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-amber-200" />
</span>
<Settings size={15} />
<span>{t('aprilAdmin.navLabel')}</span>
</Link>
)}
{/* Bouton Explorer */}
<button
data-explore-trigger
onClick={() => setIsExploreOpen(!isExploreOpen)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-all duration-200 border ${
isExploreOpen
? 'text-white bg-white/10 border-white/20'
: 'text-gray-400 hover:text-white border-white/10 hover:border-white/20 hover:bg-white/5'
}`}
>
<LayoutGrid size={15} />
<span>{t('nav.collections') === 'Collections' ? 'Explorer' : 'Explorer'}</span>
<ChevronDown size={14} className={`transition-transform duration-200 ${isExploreOpen ? 'rotate-180' : ''}`} />
</button>
</nav>
{/* Spacer */}
<div className="flex-1" />
{/* Desktop Search */}
<div className="hidden md:block relative w-[14rem] lg:w-[15rem] xl:w-[20rem] 2xl:w-[22rem]">
<form onSubmit={handleSearchSubmit} className="relative">
<input
ref={searchInputRef}
type="text"
value={headerQuery}
onChange={handleQueryChange}
placeholder={t('header.searchPlaceholder')}
className="w-full py-2 pl-9 pr-3 bg-white/5 text-white rounded-xl border border-white/10 focus:outline-none focus:border-red-500/50 focus:ring-1 focus:ring-red-500/30 focus:bg-white/10 placeholder-gray-500 text-sm transition-all"
/>
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
{headerQuery && (
<button
type="button"
onClick={() => { setHeaderQuery(''); clearAutocompleteSuggestions(); setShowAutocomplete(false); }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
>
<X size={14} />
</button>
)}
</form>
</div>
{/* Right section */}
<div className="flex items-center gap-1 sm:gap-1.5">
{isAprilFoolsAdminVisible && (
<Link
to={APRIL_FOOLS_ADMIN_PATH}
aria-label={t('aprilAdmin.mobileAriaLabel')}
className={`relative flex items-center justify-center rounded-xl border p-2 transition-colors lg:hidden ${
location.pathname === APRIL_FOOLS_ADMIN_PATH
? 'border-amber-300/35 bg-amber-300/15 text-white'
: 'border-amber-300/20 bg-amber-300/10 text-amber-100 hover:border-amber-300/35 hover:bg-amber-300/15 hover:text-white'
}`}
>
<Settings size={18} />
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-amber-200 shadow-[0_0_10px_rgba(253,230,138,0.85)]" />
</Link>
)}
{/* Mobile search icon */}
<motion.button
className="md:hidden p-2 text-gray-400 hover:text-white transition-colors"
whileTap={{ scale: 0.9 }}
onClick={() => setIsMobileSearchOpen(true)}
>
<Search size={20} />
</motion.button>
{/* Notifications */}
{isAuthenticated && !notificationsDisabled && (
<div className="relative" ref={notificationsRef}>
<motion.button
data-notification-button
className="relative flex items-center justify-center p-2 text-gray-400 hover:text-white transition-colors"
whileTap={{ scale: 0.9 }}
onClick={() => setShowNotifications(!showNotifications)}
>
<Bell size={20} />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 bg-red-600 text-white text-[10px] rounded-full h-4 min-w-4 flex items-center justify-center px-1 font-bold">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</motion.button>
<AnimatePresence>
{showNotifications && (
<div className="fixed right-4 sm:right-6 top-16 z-50">
<NotificationsPopup
onClose={() => setShowNotifications(false)}
onNotificationUpdate={refreshUnreadCount}
/>
</div>
)}
</AnimatePresence>
</div>
)}
{/* Profile */}
<div className="flex items-center cursor-pointer relative shrink-0">
<ProfileMenu />
</div>
{/* Mobile/Tablet: Burger → ouvre fullscreen explore */}
<motion.button
className="lg:hidden p-1.5 text-gray-400 hover:text-white transition-colors"
whileTap={{ scale: 0.9 }}
onClick={() => setIsExploreOpen(!isExploreOpen)}
data-explore-trigger
>
{isExploreOpen ? <X size={22} /> : <Menu size={22} />}
</motion.button>
</div>
</div>
</div>
</div>
{/* Desktop Mega Menu Dropdown */}
<AnimatePresence>
{isExploreOpen && (
<motion.div
ref={exploreRef}
className="hidden lg:block absolute top-full left-0 right-0 z-[11001]"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2 }}
>
<div className="max-w-[1400px] 2xl:max-w-[1600px] mx-auto px-8 pt-3 pb-2">
<SquareBackground
squareSize={40}
borderColor="rgba(239, 68, 68, 0.08)"
className="border border-white/10 rounded-2xl shadow-2xl shadow-black/50 bg-black"
>
<div className="p-8">
<div className="grid grid-cols-4 gap-10">
{exploreGroups.map((group) => (
<div key={group.title}>
<h4 className="text-xs font-semibold text-white/30 uppercase tracking-wider mb-4 px-1">{group.title}</h4>
<div className="flex flex-col gap-3">
{group.items.map((item) => {
const colors = cardColors[item.color] || cardColors.gray;
const inner = (
<div className={`flex items-center gap-4 px-5 py-5 rounded-xl border ${colors.border} bg-white/[0.03] hover:${colors.bg} hover:scale-[1.02] transition-all duration-200 cursor-pointer`}>
<div className={`w-12 h-12 rounded-xl ${colors.bg} ${colors.text} flex items-center justify-center flex-shrink-0 border ${colors.border}`}>
{item.icon}
</div>
<div className="min-w-0">
<div className="text-white text-sm font-medium">{item.name}</div>
<div className="text-white/40 text-xs truncate">{item.desc}</div>
</div>
</div>
);
if (item.external) {
return <a key={item.path} href={item.path} target="_blank" rel="noopener noreferrer" onClick={() => setIsExploreOpen(false)}>{inner}</a>;
}
return <Link key={item.path} to={item.path} onClick={() => setIsExploreOpen(false)}>{inner}</Link>;
})}
</div>
</div>
))}
</div>
</div>
</SquareBackground>
</div>
</motion.div>
)}
</AnimatePresence>
</header>
{/* Fullscreen Explore Menu (Mobile/Tablet) */}
<AnimatePresence>
{isExploreOpen && (
<motion.div
className="lg:hidden fixed inset-0 z-[10999]"
style={{ touchAction: 'pan-y' }}
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 40 }}
transition={{ duration: 0.3, ease: [0.32, 0.72, 0, 1] }}
>
<SquareBackground
squareSize={48}
borderColor="rgba(239, 68, 68, 0.2)"
className="h-full bg-black"
>
{/* Glow effects — radial-gradient au lieu de blur-[100px].
Le blur 100px sur un 400×400 coûte ~3-5ms/frame en composit GPU
tant que le menu est ouvert (coût ∝ rayon²). Le radial-gradient
donne visuellement le même halo doux sans toucher au filter
pipeline → ~0ms. */}
<div
className="absolute top-0 left-1/2 -translate-x-1/2 w-[600px] h-[600px] pointer-events-none"
style={{
background:
'radial-gradient(circle, rgba(220, 38, 38, 0.18) 0%, rgba(220, 38, 38, 0.08) 35%, transparent 70%)',
}}
/>
<div className="absolute bottom-0 left-0 right-0 h-[200px] bg-gradient-to-t from-red-950/15 to-transparent pointer-events-none" />
{/* Contenu scrollable */}
<div
className="relative h-full overflow-y-auto"
style={{ WebkitOverflowScrolling: 'touch', overscrollBehavior: 'contain' }}
>
{/* Spacer pour le header */}
<div className="h-20" />
<div className="px-5 pb-12 pt-2">
{/* Items principaux en haut */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
{mainNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={() => setIsExploreOpen(false)}
className={`flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-medium transition-all border whitespace-nowrap ${
item.isActive
? 'bg-red-600/20 border-red-500/30 text-white'
: 'bg-white/[0.06] border-white/[0.08] text-gray-300 active:bg-white/10'
}`}
>
{item.icon}
<span>{item.name}</span>
</Link>
))}
</div>
{/* Grille de cards */}
<div className="grid grid-cols-2 gap-4">
{allExploreItems.map((item) => renderExploreCard(item))}
</div>
</div>
</div>
</SquareBackground>
</motion.div>
)}
</AnimatePresence>
{/* Mobile Search Overlay */}
<AnimatePresence>
{isMobileSearchOpen && (
<motion.div
className="md:hidden fixed inset-x-0 top-0 z-[11002] bg-black/95 p-4"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
>
<form onSubmit={handleSearchSubmit} className="flex items-center gap-3">
<div className="flex-1 relative">
<input
ref={mobileSearchInputRef}
type="text"
value={headerQuery}
onChange={handleQueryChange}
placeholder={t('header.searchPlaceholder')}
className="w-full py-3 pl-10 pr-4 bg-white/10 text-white rounded-xl border border-white/15 focus:outline-none focus:border-red-500/50 placeholder-gray-500 text-sm"
/>
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
</div>
<button
type="button"
onClick={() => { setIsMobileSearchOpen(false); setHeaderQuery(''); clearAutocompleteSuggestions(); setShowAutocomplete(false); }}
className="p-2 text-gray-400 hover:text-white"
>
<X size={22} />
</button>
</form>
</motion.div>
)}
</AnimatePresence>
{/* Autocomplete dropdown (shared between desktop & mobile search) */}
<AnimatePresence>
{showAutocomplete && autocompleteSuggestions.length > 0 && (
<motion.div
ref={autocompleteRef}
className="bg-black/95 border border-white/15 rounded-xl shadow-2xl z-[12000] overflow-hidden"
style={dropdownStyle}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<div className="py-2">
{loadingAutocomplete ? (
<div className="flex justify-center items-center py-4">
<div className="w-5 h-5 border-2 border-gray-600 border-t-red-600 rounded-full animate-spin" />
</div>
) : (
<ul>
{autocompleteSuggestions.map((item: any) => (
<li key={item.id} className="px-2">
<button
className="w-full flex items-center py-2 px-3 hover:bg-white/5 rounded-lg transition-colors"
onClick={() => handleSelectAutocomplete(item)}
>
<div className="flex-shrink-0 w-10 h-15 rounded overflow-hidden mr-3">
{item.poster_path ? (
<img
src={`https://image.tmdb.org/t/p/w92${item.poster_path}`}
alt={item.title || item.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gray-800 flex items-center justify-center">
<Film size={20} className="text-gray-500" />
</div>
)}
</div>
<div className="text-left flex-1 min-w-0">
<div className="font-medium text-white text-sm line-clamp-1">{item.title || item.name}</div>
<div className="text-xs text-gray-400 flex items-center">
<span className="capitalize">{item.media_type === 'movie' ? t('nav.movies') : t('nav.tvShows')}</span>
{item.vote_average > 0 && (
<>
<span className="mx-1.5"></span>
<span className="flex items-center">
<Star size={12} className="text-yellow-500 mr-0.5" />
{item.vote_average.toFixed(1)}
</span>
</>
)}
{(item.release_date || item.first_air_date) && (
<>
<span className="mx-1.5"></span>
<span>{new Date(item.release_date || item.first_air_date).getFullYear()}</span>
</>
)}
</div>
</div>
</button>
</li>
))}
<li className="mt-1 pt-1 border-t border-white/10">
<Link
to={`/search?q=${encodeURIComponent(headerQuery)}`}
className="block w-full text-center py-2 text-sm text-gray-400 hover:text-white"
onClick={() => { setShowAutocomplete(false); setHeaderQuery(''); setIsMobileSearchOpen(false); }}
>
{t('search.seeAllResults')}
</Link>
</li>
</ul>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Spacer */}
<div className="w-full h-0" aria-hidden="true" />
{isSnowfallActive && (
<Snowfall
style={{
position: 'fixed',
width: '100vw',
height: '100vh',
zIndex: 2147483647,
pointerEvents: 'none',
}}
/>
)}
</>
);
};
export default Header;