Version mobile de JVChat (fork SVR) — chat seul, optimisé Firefox Android.
// ==UserScript==
// @name JVChat Mobile
// @description Version mobile de JVChat (fork SVR) — chat seul, optimisé Firefox Android.
// @author DarkWizounet
// @namespace JVChat-Mobile
// @license MIT
// @version 7.8.7
// @match https://*.jeuxvideo.com/forums/42-*
// @match https://*.jeuxvideo.com/forums/1-*
// @match https://*.jeuxvideo.com/forums/0-42-*
// @match https://*.jeuxvideo.com/forums/0-19163-*
// @grant GM_xmlhttpRequest
// @connect esports-api.lolesports.com
// @connect feed.lolesports.com
// @connect api.bo3.gg
// ==/UserScript==
const _DBG = false;
function dbg(...a) { if (_DBG) console.log('%c[JVChat]', 'color:#00e5ff;font-weight:bold', ...a); }
function err(...a) { if (_DBG) console.error('%c[JVChat]', 'color:#ff1744;font-weight:bold', ...a); }
// ─────────────────────────────────────────────────────────────
// CACHE AVATARS (en mémoire, évite les rechargements réseau)
// ─────────────────────────────────────────────────────────────
const _avatarCache = new Map(); // url → url (cache simple pour éviter les re-renders)
function cachedAvatar(url) {
if (!url) return Promise.resolve(url);
if (!_avatarCache.has(url)) _avatarCache.set(url, url);
return Promise.resolve(_avatarCache.get(url));
}
// ─────────────────────────────────────────────────────────────
// STYLES
// ─────────────────────────────────────────────────────────────
function addStyle() {
const style = document.createElement('style');
style.textContent = `
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
:root {
--jvc-bg: #18181b;
--jvc-surface-1: #1f1f23;
--jvc-surface-2: #27272a;
--jvc-surface-3: #303035;
--jvc-border: #2e2e33;
--jvc-divider: #242428;
--jvc-text: #e4e4e7;
--jvc-text-dim: #a1a1aa;
--jvc-text-muted: #71717a;
--jvc-orange: #e84d1a;
--jvc-orange-deep: #c73f13;
--jvc-green: #4ade80;
--jvc-blue: #7fb4ff;
--jvc-red: #f87171;
--jvc-header-h: 52px;
--jvc-safe-bottom: env(safe-area-inset-bottom, 0px);
}
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
overflow: hidden;
background: var(--jvc-bg);
height: 100%;
overscroll-behavior: none;
}
body * { font-family: 'Inter', system-ui, sans-serif; }
/* ── LAYOUT ── */
#jvcm-app {
display: flex;
flex-direction: column;
height: 100vh; height: -webkit-fill-available; height: 100dvh;
background: var(--jvc-bg);
overflow: hidden;
position: relative;
}
/* ── HEADER ── */
#jvcm-header {
flex: 0 0 auto;
min-height: var(--jvc-header-h);
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0 0.9rem;
background: var(--jvc-surface-1);
border-bottom: 1px solid var(--jvc-border);
z-index: 10;
}
#jvcm-topic-title {
flex: 1;
font-size: 0.82rem; font-weight: 600;
color: var(--jvc-text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
cursor: pointer;
}
#jvcm-topic-title:active { opacity: 0.7; }
#jvcm-meta { display: flex; align-items: center; gap: 0.35rem; flex-shrink: 0; }
.jvcm-chip {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.2rem 0.5rem;
background: var(--jvc-surface-2); border: 1px solid var(--jvc-border);
border-radius: 4px; font-size: 0.65rem; font-weight: 500;
color: var(--jvc-text-dim); font-variant-numeric: tabular-nums; white-space: nowrap;
}
.jvcm-chip--live::before {
content: ''; width: 5px; height: 5px; border-radius: 50%;
background: var(--jvc-green); box-shadow: 0 0 4px var(--jvc-green);
}
#jvcm-home-btn {
background: none; border: none; color: var(--jvc-text-muted);
font-size: 1.2rem; cursor: pointer; padding: 0.2rem 0.3rem;
line-height: 1; -webkit-tap-highlight-color: transparent; flex-shrink: 0;
}
#jvcm-home-btn:active { color: var(--jvc-text); }
#jvcm-settings-btn {
background: none; border: none; color: var(--jvc-text-muted);
font-size: 1.1rem; cursor: pointer; padding: 0.2rem 0.3rem;
line-height: 1; -webkit-tap-highlight-color: transparent; flex-shrink: 0;
}
#jvcm-settings-btn:active { color: var(--jvc-text); }
/* ── LOADING ── */
#jvcm-loading {
position: absolute; inset: var(--jvc-header-h) 0 0 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 0.75rem; background: var(--jvc-bg); z-index: 50;
}
#jvcm-loading.hidden { display: none; }
.jvcm-spinner {
width: 28px; height: 28px;
border: 3px solid var(--jvc-surface-3);
border-top-color: var(--jvc-orange);
border-radius: 50%; animation: jvcm-spin 0.7s linear infinite;
}
@keyframes jvcm-spin { to { transform: rotate(360deg); } }
.jvcm-loading-label { font-size: 0.75rem; color: var(--jvc-text-muted); }
/* ── MESSAGES ── */
#jvcm-messages {
flex: 1; overflow-y: auto; overflow-x: hidden;
overscroll-behavior: contain; display: flex; flex-direction: column;
padding: 0.4rem 0 0.5rem; -webkit-overflow-scrolling: touch; min-height: 0;
}
#jvcm-messages::-webkit-scrollbar { width: 3px; }
#jvcm-messages::-webkit-scrollbar-track { background: transparent; }
#jvcm-messages::-webkit-scrollbar-thumb { background: var(--jvc-surface-3); border-radius: 2px; }
/* ── SÉPARATEUR NOUVEAUX MESSAGES ── */
.jvcm-new-sep {
flex: 0 0 auto;
display: flex; align-items: center; justify-content: center;
padding: 0.45rem 1rem; font-size: 0.68rem; font-weight: 700;
color: #fff; background: var(--jvc-orange);
letter-spacing: 0.06em; text-transform: uppercase;
cursor: pointer; border: none; width: 100%;
-webkit-tap-highlight-color: transparent;
}
.jvcm-new-sep:active { opacity: 0.85; }
.jvcm-new-sep::before, .jvcm-new-sep::after { display: none; }
.jvcm-message {
display: flex; gap: 0.6rem; padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--jvc-divider); position: relative;
transition: background 120ms ease, transform 80ms ease;
-webkit-tap-highlight-color: transparent; user-select: none;
}
.jvcm-message:nth-child(even) { background: rgba(255,255,255,0.016); }
.jvcm-message.is-pressing { background: rgba(232,77,26,0.08); }
.jvcm-message.mentions-me { background: rgba(232,77,26,0.06) !important; border-left: 2px solid var(--jvc-orange); }
/* Grouped : masquer seulement le pseudo */
.jvcm-message.is-grouped { padding-top: 0.1rem; padding-bottom: 0.1rem; border-bottom: none; }
.jvcm-message.is-grouped .jvcm-username { display: none; }
.jvcm-message.is-grouped .jvcm-avatar { opacity: 0.5; }
/* Swipe hint */
.jvcm-swipe-hint {
position: absolute; left: 0.5rem; top: 50%;
transform: translateY(-50%) scale(0.6); opacity: 0; font-size: 1rem;
pointer-events: none; transition: opacity 120ms ease, transform 120ms ease;
}
.jvcm-message.swipe-ready .jvcm-swipe-hint { opacity: 1; transform: translateY(-50%) scale(1); }
.jvcm-avatar {
width: 34px; height: 34px; border-radius: 50%;
border: 1px solid var(--jvc-border); object-fit: cover; flex-shrink: 0; margin-top: 2px;
}
.jvcm-msg-body { flex: 1; min-width: 0; }
.jvcm-msg-header {
display: flex; align-items: baseline; gap: 0.45rem; margin-bottom: 0.15rem;
}
.jvcm-username { font-size: 0.82rem; font-weight: 700; color: oklch(72% 0.16 var(--pseudo-h, 220)); }
.jvcm-userrank { font-size: 0.58rem; color: var(--jvc-text-muted); font-weight: 400; margin-left: 0.25rem; opacity: 0.8; }
.jvcm-date { font-size: 0.62rem; color: var(--jvc-text-muted); font-variant-numeric: tabular-nums; cursor: default; }
.jvcm-date .jvcm-date-abs { display: none; }
.jvcm-date:hover .jvcm-date-rel, .jvcm-date.show-abs .jvcm-date-rel { display: none; }
.jvcm-date:hover .jvcm-date-abs, .jvcm-date.show-abs .jvcm-date-abs { display: inline; }
.jvcm-msg-status { font-size: 0.62rem; margin-left: 0.2rem; }
.jvcm-msg-status.pending { color: var(--jvc-text-muted); }
.jvcm-msg-status.ok { color: var(--jvc-green); }
.jvcm-msg-status.fail { color: var(--jvc-red); cursor: pointer; }
.jvcm-content { font-size: 0.88rem; line-height: 1.5; color: var(--jvc-text); word-break: break-word; overflow-wrap: anywhere; }
.jvcm-content p { margin: 0 0 0.2rem; }
.jvcm-content p:last-child { margin-bottom: 0; }
.jvcm-content a { color: var(--jvc-blue); text-decoration: none; }
.jvcm-content blockquote, .jvcm-content .message__blockquote {
margin: 0.3rem 0 0.4rem; padding: 0.35rem 0.6rem;
background: var(--jvc-surface-2); border-left: 3px solid var(--jvc-border);
border-radius: 0 3px 3px 0; font-size: 0.78rem; color: var(--jvc-text-dim);
cursor: pointer; transition: border-color 150ms ease, background 150ms ease;
}
.jvcm-content blockquote:hover, .jvcm-content .message__blockquote:hover {
border-left-color: var(--jvc-orange); background: var(--jvc-surface-3);
}
.jvcm-content blockquote p { margin: 0; }
.jvcm-content .message__blockquote blockquote > *:not(.jvcm-collapse-btn) { display: none; }
.jvcm-content .message__blockquote.is-open blockquote > *:not(.jvcm-collapse-btn) { display: revert; }
.jvcm-content .message__blockquote.is-open blockquote > .jvcm-collapse-btn { display: none; }
.jvcm-collapse-btn {
display: inline-flex; align-items: center; padding: 0.2rem 0.5rem;
background: transparent; border: 1px dashed var(--jvc-border);
border-radius: 999px; color: var(--jvc-text-muted);
font-size: 0.65rem; font-family: inherit; cursor: pointer; margin: 0.1rem 0;
}
@keyframes jvcm-flash { 0%,100%{background:transparent} 30%{background:rgba(232,77,26,0.22)} }
.jvcm-message.quote-flash { animation: jvcm-flash 900ms ease; }
/* Animation d'entrée pour les nouveaux messages */
@keyframes jvcm-msg-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.jvcm-message.is-new { animation: jvcm-msg-in 250ms ease; }
.jvcm-content img { max-width: 100%; height: auto; border-radius: 3px; }
.jvcm-message.is-deleted .jvcm-msg-body { opacity: 0.25; filter: grayscale(1); }
.jvcm-message.is-deleted::after {
content: 'Message supprimé'; position: absolute; top: 50%; left: 50%;
transform: translate(-50%,-50%); font-size: 0.72rem; color: var(--jvc-text-muted);
font-weight: 600; pointer-events: none;
}
/* ── SCROLL BUTTON ── */
/* ── INPUT ── */
#jvcm-input-wrap {
flex: 0 0 auto; display: flex; flex-direction: column;
background: var(--jvc-surface-1); border-top: 1px solid var(--jvc-border); z-index: 10;
}
/* Compteur de caractères */
#jvcm-char-count {
font-size: 0.62rem; font-variant-numeric: tabular-nums;
text-align: right; padding: 0.2rem 0.75rem 0;
color: var(--jvc-text-muted); display: none;
}
#jvcm-char-count.warn { color: #fbbf24; display: block; }
#jvcm-char-count.danger { color: var(--jvc-red); display: block; font-weight: 600; }
#jvcm-input-bar {
display: flex; align-items: flex-end; gap: 0.5rem;
padding: 0.5rem 0.75rem; padding-bottom: calc(0.5rem + var(--jvc-safe-bottom));
}
#jvcm-textarea {
flex: 1; min-height: 36px; max-height: 120px;
padding: 0.45rem 0.7rem; background: var(--jvc-surface-2);
border: 1px solid var(--jvc-border); border-radius: 18px;
color: var(--jvc-text); font-size: 0.9rem; font-family: inherit;
line-height: 1.4; resize: none; overflow-y: auto; outline: none;
-webkit-appearance: none; transition: border-color 150ms ease;
}
#jvcm-textarea:focus { border-color: var(--jvc-orange); }
#jvcm-textarea::placeholder { color: var(--jvc-text-muted); }
#jvcm-send-btn {
width: 36px; height: 36px; flex-shrink: 0; display: grid; place-items: center;
background: var(--jvc-orange); border: none; border-radius: 50%; cursor: pointer;
-webkit-tap-highlight-color: transparent; transition: background 120ms ease, transform 100ms ease;
}
#jvcm-send-btn:active { background: var(--jvc-orange-deep); transform: scale(0.92); }
#jvcm-send-btn:disabled { opacity: 0.5; pointer-events: none; }
#jvcm-send-btn svg { width: 16px; height: 16px; fill: #fff; }
#jvcm-clear-btn {
width: 36px; height: 36px; flex-shrink: 0;
display: none; place-items: center;
background: var(--jvc-surface-3); border: 1px solid var(--jvc-border);
border-radius: 50%; cursor: pointer; color: var(--jvc-text-muted);
font-size: 1rem; line-height: 1;
-webkit-tap-highlight-color: transparent;
transition: background 120ms ease, color 120ms ease;
}
#jvcm-clear-btn.visible { display: grid; }
#jvcm-clear-btn:active { background: var(--jvc-surface-2); color: var(--jvc-text); }
/* ── ACTIONS INLINE ── */
.jvcm-actions { display: none; flex-direction: column; align-items: flex-end; gap: 0.2rem; margin-left: auto; flex-shrink: 0; }
.jvcm-message.is-active .jvcm-actions { display: flex; }
.jvcm-actions-row { display: flex; align-items: center; gap: 0.25rem; }
.jvcm-action-btn {
display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.28rem 0.6rem;
background: var(--jvc-surface-2); border: 1px solid var(--jvc-border);
border-radius: 4px; color: var(--jvc-text-dim); font-size: 0.72rem;
font-weight: 500; font-family: inherit; cursor: pointer;
-webkit-tap-highlight-color: transparent; transition: background 100ms ease, color 100ms ease;
}
.jvcm-action-btn:active { background: var(--jvc-surface-3); color: var(--jvc-text); }
.jvcm-action-btn.danger { color: var(--jvc-red); border-color: rgba(248,113,113,0.3); }
.jvcm-action-btn.danger:active { background: rgba(127,29,29,0.4); }
/* ── TOAST ── */
.jvcm-toast {
position: fixed; top: calc(var(--jvc-header-h) + 8px); left: 50%;
transform: translateX(-50%); z-index: 99999;
padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.8rem;
font-weight: 500; color: #fff; background: #1a1a1f;
border: 1px solid var(--jvc-border); box-shadow: 0 4px 20px rgba(0,0,0,0.5);
white-space: nowrap; animation: jvcm-toast-in 200ms ease; pointer-events: none;
}
.jvcm-toast.error { background: #7f1d1d; border-color: var(--jvc-red); }
.jvcm-toast.success { background: #14532d; border-color: var(--jvc-green); }
.jvcm-toast.info { background: #1e3a5f; border-color: var(--jvc-blue); }
.jvcm-toast.warn { background: #78350f; border-color: #fbbf24; }
@keyframes jvcm-toast-in {
from { opacity: 0; transform: translate(-50%, -8px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
/* ── MODAL GÉNÉRIQUE (report + confirm delete) ── */
.jvcm-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 1000;
display: flex; align-items: center; justify-content: center; padding: 1rem;
}
.jvcm-modal-overlay.hidden { display: none; }
.jvcm-modal {
background: var(--jvc-surface-1); border: 1px solid var(--jvc-border);
border-radius: 8px; width: 100%; max-width: 360px;
display: flex; flex-direction: column; overflow: hidden;
}
.jvcm-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 0.875rem 1rem; border-bottom: 1px solid var(--jvc-border);
font-size: 0.9rem; font-weight: 600; color: var(--jvc-text);
}
.jvcm-modal-close {
background: none; border: none; color: var(--jvc-text-muted);
font-size: 1.3rem; cursor: pointer; line-height: 1; padding: 0.2rem;
}
.jvcm-modal-body { padding: 1rem; font-size: 0.85rem; color: var(--jvc-text-dim); line-height: 1.5; }
.jvcm-modal-body strong { color: var(--jvc-text); }
.jvcm-modal-footer {
padding: 0.75rem 1rem; border-top: 1px solid var(--jvc-border);
display: flex; gap: 0.5rem; justify-content: flex-end;
}
.jvcm-modal-cancel {
padding: 0.45rem 1rem; background: var(--jvc-surface-2);
border: 1px solid var(--jvc-border); border-radius: 5px;
color: var(--jvc-text-dim); font-family: inherit; font-size: 0.82rem; cursor: pointer;
}
.jvcm-modal-confirm {
padding: 0.45rem 1rem; background: var(--jvc-orange); border: none;
border-radius: 5px; color: #fff; font-family: inherit;
font-size: 0.82rem; font-weight: 600; cursor: pointer; transition: background 150ms ease;
}
.jvcm-modal-confirm:hover { background: var(--jvc-orange-deep); }
.jvcm-modal-confirm.danger { background: #991b1b; }
.jvcm-modal-confirm.danger:hover { background: #7f1d1d; }
/* ── SETTINGS ── */
#jvcm-settings {
position: fixed; inset: 0; background: var(--jvc-bg); z-index: 200;
display: flex; flex-direction: column;
transform: translateY(100%);
transition: transform 300ms cubic-bezier(.4,0,.2,1);
touch-action: pan-y;
}
#jvcm-settings.open { transform: translateY(0); }
#jvcm-settings-header {
display: flex; align-items: center; padding: 0 1rem;
min-height: var(--jvc-header-h);
background: var(--jvc-surface-1); border-bottom: 1px solid var(--jvc-border);
gap: 0.75rem; font-size: 0.9rem; font-weight: 600; color: var(--jvc-text);
cursor: grab;
}
/* Poignée de drag sur le settings panel */
#jvcm-settings-header::before {
content: ''; display: block; width: 32px; height: 4px;
background: var(--jvc-border); border-radius: 2px;
position: absolute; top: 6px; left: 50%; transform: translateX(-50%);
}
#jvcm-settings-close {
margin-left: auto; background: none; border: none;
color: var(--jvc-text-muted); font-size: 1.4rem;
cursor: pointer; line-height: 1; padding: 0.2rem 0.4rem;
}
#jvcm-settings-body {
flex: 1; overflow-y: auto; padding: 1rem;
display: flex; flex-direction: column; gap: 0.75rem;
}
.jvcm-setting-row {
display: flex; align-items: center; justify-content: space-between;
padding: 0.75rem 0.875rem; background: var(--jvc-surface-1);
border: 1px solid var(--jvc-border); border-radius: 6px; gap: 1rem;
}
.jvcm-setting-label { font-size: 0.85rem; color: var(--jvc-text); }
.jvcm-setting-desc { font-size: 0.7rem; color: var(--jvc-text-muted); margin-top: 0.2rem; }
.jvcm-toggle { position: relative; width: 40px; height: 22px; flex-shrink: 0; }
.jvcm-toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
.jvcm-toggle-track {
position: absolute; inset: 0; background: var(--jvc-surface-3);
border-radius: 999px; border: 1px solid var(--jvc-border);
transition: background 200ms ease; cursor: pointer;
}
.jvcm-toggle-track::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 16px; height: 16px; background: var(--jvc-text-muted);
border-radius: 50%; transition: transform 200ms ease, background 200ms ease;
}
.jvcm-toggle input:checked + .jvcm-toggle-track { background: var(--jvc-orange); border-color: var(--jvc-orange); }
.jvcm-toggle input:checked + .jvcm-toggle-track::after { transform: translateX(18px); background: #fff; }
/* ── MODALE MATCHS ── */
#jvcm-matches-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.75); z-index: 500;
display: flex; align-items: flex-end; justify-content: center;
padding: 0;
}
#jvcm-matches-overlay.hidden { display: none; }
#jvcm-matches-panel {
background: var(--jvc-bg);
border: 1px solid var(--jvc-border);
border-radius: 12px 12px 0 0;
width: 100%; max-width: 520px;
max-height: 85dvh;
display: flex; flex-direction: column;
overflow: hidden;
}
#jvcm-matches-handle {
width: 36px; height: 4px;
background: var(--jvc-border);
border-radius: 2px;
margin: 10px auto 0;
flex-shrink: 0;
}
#jvcm-matches-header {
display: flex; align-items: center;
padding: 0.75rem 1rem 0.5rem;
gap: 0.5rem; flex-shrink: 0;
}
#jvcm-matches-title {
font-size: 0.9rem; font-weight: 700; color: var(--jvc-text); flex: 1;
}
#jvcm-matches-close {
background: none; border: none; color: var(--jvc-text-muted);
font-size: 1.3rem; cursor: pointer; line-height: 1; padding: 0.2rem;
}
#jvcm-matches-refresh {
background: none; border: none; color: var(--jvc-text-muted);
font-size: 1rem; cursor: pointer; padding: 0.2rem 0.4rem;
border-radius: 4px; transition: color 150ms ease;
}
#jvcm-matches-refresh:active { color: var(--jvc-orange); }
#jvcm-matches-refresh.spinning { animation: jvcm-spin 0.7s linear infinite; color: var(--jvc-orange); }
/* Sélection ligues */
#jvcm-leagues-wrap {
padding: 0 1rem 0.5rem;
display: flex; flex-wrap: wrap; gap: 0.35rem;
flex-shrink: 0;
border-bottom: 1px solid var(--jvc-border);
padding-bottom: 0.6rem;
}
.jvcm-league-btn {
padding: 0.25rem 0.6rem;
background: var(--jvc-surface-2);
border: 1px solid var(--jvc-border);
border-radius: 999px;
color: var(--jvc-text-muted);
font-size: 0.68rem; font-weight: 600;
font-family: inherit; cursor: pointer;
transition: all 150ms ease;
-webkit-tap-highlight-color: transparent;
}
.jvcm-league-btn.active {
background: var(--jvc-orange);
border-color: var(--jvc-orange);
color: #fff;
}
/* Liste des matchs */
#jvcm-matches-list {
flex: 1; overflow-y: auto; padding: 0.5rem 0;
}
.jvcm-match-row { padding: 0.55rem 1rem; border-bottom: 1px solid var(--jvc-divider); display: flex; flex-direction: column; gap: 0.25rem; }
.jvcm-match-row:last-child { border-bottom: none; }
.jvcm-match-row[data-state="inProgress"] {
background: rgba(239, 68, 68, 0.04);
border-left: 2px solid rgba(239, 68, 68, 0.5);
}
/* Score game par game */
.jvcm-match-games {
display: flex; align-items: center; gap: 0.25rem;
margin-top: 0.15rem; flex-wrap: wrap;
}
.jvcm-game-dot {
width: 22px; height: 22px;
border-radius: 4px;
font-size: 0.62rem; font-weight: 700;
display: grid; place-items: center;
background: var(--jvc-surface-3);
color: var(--jvc-text-muted);
cursor: default;
transition: opacity 150ms ease;
}
.jvcm-game-dot.clickable {
cursor: pointer;
border: 1px solid var(--jvc-border);
}
.jvcm-game-dot.clickable:active { opacity: 0.7; }
.jvcm-game-dot.selected {
border-color: var(--jvc-orange);
box-shadow: 0 0 0 1px var(--jvc-orange);
}
.jvcm-game-dot.win { background: rgba(74,222,128,0.2); color: var(--jvc-green); }
.jvcm-game-dot.loss { background: rgba(248,113,113,0.15); color: var(--jvc-red); }
.jvcm-game-dot.win-blue { background: rgba(100,149,237,0.25); color: #7fb4ff; border-color: rgba(100,149,237,0.4); }
.jvcm-game-dot.win-red { background: rgba(248,113,113,0.2); color: var(--jvc-red); border-color: rgba(248,113,113,0.4); }
.jvcm-game-dot.live { background: rgba(239,68,68,0.2); color: #ef4444; animation: jvcm-breathe 2.4s ease-in-out infinite; }
.jvcm-match-row-head {
display: flex; align-items: center; gap: 0.4rem;
font-size: 0.58rem; font-weight: 700;
letter-spacing: 0.08em; text-transform: uppercase;
}
.jvcm-match-badge-live { color: #ef4444; display: flex; align-items: center; gap: 0.3rem; }
.jvcm-match-badge-upcoming { color: #fbbf24; text-transform: none; letter-spacing: 0; font-weight: 500; font-size: 0.65rem; }
.jvcm-match-badge-done { color: var(--jvc-text-muted); }
.jvcm-match-dot {
width: 6px; height: 6px; border-radius: 50%;
background: currentColor;
animation: jvcm-breathe 2.4s ease-in-out infinite;
}
@keyframes jvcm-breathe {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.jvcm-match-stage {
color: var(--jvc-text-muted); font-weight: 500;
font-size: 0.6rem; text-transform: none; letter-spacing: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.jvcm-match-row-body {
display: flex; align-items: baseline;
gap: 0.3rem; font-size: 0.82rem;
font-weight: 700; color: var(--jvc-text);
}
.jvcm-match-team {
flex: 1; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.jvcm-match-team:last-child { text-align: right; }
.jvcm-match-team.winner { color: var(--jvc-green); }
.jvcm-match-team.loser { color: var(--jvc-text-muted); }
.jvcm-match-score {
flex-shrink: 0; font-size: 0.78rem;
font-weight: 800; padding: 0 0.4rem;
font-variant-numeric: tabular-nums;
}
.jvcm-match-vs {
flex-shrink: 0; font-size: 0.65rem;
color: var(--jvc-text-muted); padding: 0 0.4rem;
}
#jvcm-matches-empty {
text-align: center; padding: 2rem 1rem;
color: var(--jvc-text-muted); font-size: 0.82rem;
}
#jvcm-matches-loading {
text-align: center; padding: 2rem 1rem;
color: var(--jvc-text-muted); font-size: 0.82rem;
display: flex; flex-direction: column; align-items: center; gap: 0.75rem;
}
/* Stats game terminée (expansion au clic sur dot) */
.jvcm-game-stats {
display: none; flex-direction: column;
margin-top: 0.35rem;
background: rgba(255,255,255,0.03);
border: 1px solid var(--jvc-border);
border-radius: 6px; overflow: hidden;
}
.jvcm-game-stats.open { display: flex; }
.jvcm-game-stats-header {
padding: 0.3rem 0.5rem;
font-size: 0.6rem; font-weight: 700;
color: var(--jvc-text-muted);
text-transform: uppercase; letter-spacing: 0.08em;
border-bottom: 1px solid var(--jvc-border);
display: flex; align-items: center; gap: 0.4rem;
}
.jvcm-game-stats-loading {
padding: 0.6rem; text-align: center;
font-size: 0.65rem; color: var(--jvc-text-muted);
}
/* Détail par lane */
.jvcm-lane-detail {
display: none; flex-direction: column;
border-top: 1px solid rgba(239,68,68,0.15);
}
.jvcm-lane-detail.open { display: flex; }
.jvcm-lane-row {
display: flex; align-items: center;
padding: 0.35rem 0.5rem; gap: 0.3rem;
border-bottom: 1px solid var(--jvc-divider);
font-size: 0.62rem; font-variant-numeric: tabular-nums;
}
.jvcm-lane-row:last-child { border-bottom: none; }
.jvcm-lane-badge {
width: 20px; text-align: center; flex-shrink: 0;
font-size: 0.6rem; color: var(--jvc-text-muted); font-weight: 600;
}
.jvcm-lane-player {
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.05rem;
}
.jvcm-lane-player.right { align-items: flex-end; text-align: right; }
.jvcm-lane-name { font-weight: 600; color: var(--jvc-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 80px; }
.jvcm-lane-champ { color: var(--jvc-text-muted); font-size: 0.58rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 80px; }
.jvcm-lane-kda { color: var(--jvc-text-dim); font-size: 0.6rem; white-space: nowrap; }
.jvcm-lane-gold { color: #fbbf24; font-size: 0.6rem; font-weight: 600; }
.jvcm-lane-mid-col { display: flex; flex-direction: column; align-items: center; gap: 0.1rem; flex-shrink: 0; padding: 0 0.2rem; }
.jvcm-lane-cs { color: var(--jvc-text-muted); font-size: 0.58rem; font-style: italic; }
.jvcm-lane-level { font-size: 0.6rem; color: var(--jvc-text-muted); background: var(--jvc-surface-3); border-radius: 3px; padding: 0.05rem 0.25rem; }
/* Streams */
.jvcm-match-streams {
display: flex; align-items: center; gap: 0.35rem;
margin-top: 0.4rem; flex-wrap: wrap;
}
.jvcm-stream-btn {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.28rem 0.65rem;
background: #9147ff; border: none; border-radius: 4px;
color: #fff; font-size: 0.7rem; font-weight: 600;
font-family: inherit; cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 120ms ease;
text-decoration: none;
}
.jvcm-stream-btn:active { background: #7b35d9; }
.jvcm-stream-select {
padding: 0.28rem 0.5rem;
background: #9147ff; border: none; border-radius: 4px;
color: #fff; font-size: 0.7rem; font-weight: 600;
font-family: inherit; cursor: pointer;
-webkit-appearance: none; outline: none;
}
/* Fenêtre temporelle */
#jvcm-time-range {
padding: 0.5rem 1rem 0.4rem;
border-bottom: 1px solid var(--jvc-border);
display: flex; gap: 1rem; flex-shrink: 0;
}
.jvcm-range-group {
flex: 1; display: flex; flex-direction: column; gap: 0.25rem;
}
.jvcm-range-label {
font-size: 0.6rem; font-weight: 600; color: var(--jvc-text-muted);
text-transform: uppercase; letter-spacing: 0.08em;
display: flex; justify-content: space-between;
}
.jvcm-range-label span { color: var(--jvc-text); font-variant-numeric: tabular-nums; }
.jvcm-range-input {
-webkit-appearance: none; appearance: none;
width: 100%; height: 4px;
background: var(--jvc-surface-3); border-radius: 2px; outline: none;
}
.jvcm-range-input::-webkit-slider-thumb {
-webkit-appearance: none; width: 16px; height: 16px;
border-radius: 50%; background: var(--jvc-orange); cursor: pointer;
}
.jvcm-range-input::-moz-range-thumb {
width: 16px; height: 16px; border: none;
border-radius: 50%; background: var(--jvc-orange); cursor: pointer;
}
/* Bouton matchs dans le header */
#jvcm-matches-btn {
background: none; border: none; color: var(--jvc-text-muted);
font-size: 1rem; cursor: pointer; padding: 0.2rem 0.3rem;
line-height: 1; -webkit-tap-highlight-color: transparent; flex-shrink: 0;
position: relative;
}
#jvcm-matches-btn:active { color: var(--jvc-orange); }
.jvcm-live-badge {
position: absolute; top: -2px; right: -2px;
min-width: 14px; height: 14px; padding: 0 3px;
background: #ef4444; color: #fff;
border-radius: 999px; font-size: 0.5rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
pointer-events: none;
}
`;
document.head.appendChild(style);
}
// ─────────────────────────────────────────────────────────────
// SETTINGS
// ─────────────────────────────────────────────────────────────
class Settings {
constructor() {
this._defs = [
{ key: 'send_on_enter', label: 'Envoyer avec Entrée', desc: 'Sinon, Entrée = saut de ligne', default: false },
{ key: 'swipe_to_quote', label: 'Swipe droite pour citer', desc: 'Glisser un message vers la droite', default: true },
{ key: 'notifications', label: 'Notifications navigateur',desc: 'Alerte quand un nouveau message arrive (hors onglet)',default: false },
{ key: 'vibration', label: 'Vibration sur mention', desc: 'Vibre quand quelqu\'un te cite (Android uniquement)', default: true },
];
}
get(key) {
const raw = localStorage.getItem(`jvcm:setting:${key}`);
const def = this._defs.find(d => d.key === key);
try { return raw !== null ? JSON.parse(raw) : (def?.default ?? false); } catch { return def?.default ?? false; }
}
set(key, val) { try { localStorage.setItem(`jvcm:setting:${key}`, JSON.stringify(val)); } catch {} }
get defs() { return this._defs; }
}
// ─────────────────────────────────────────────────────────────
// HORODATAGE RELATIF
// ─────────────────────────────────────────────────────────────
function parseJVCDate(str) {
if (!str) return null;
const short = str.match(/(\d{2}):(\d{2}):(\d{2})$/);
if (short) {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate(), +short[1], +short[2], +short[3]).getTime();
}
const long = str.match(/(\d+)\s+(\w+)\s+(\d{4}).*?(\d{2}):(\d{2}):(\d{2})/);
if (long) {
const months = { janvier:0,février:1,mars:2,avril:3,mai:4,juin:5,juillet:6,août:7,septembre:8,octobre:9,novembre:10,décembre:11 };
return new Date(+long[3], months[long[2].toLowerCase()] ?? 0, +long[1], +long[4], +long[5], +long[6]).getTime();
}
return null;
}
function relativeTime(ts) {
if (!ts) return '';
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 60) return "à l'instant";
const m = Math.floor(s / 60);
if (m < 60) return `il y a ${m} min`;
const h = Math.floor(m / 60);
if (h < 24) return `il y a ${h}h`;
return `il y a ${Math.floor(h / 24)}j`;
}
// ─────────────────────────────────────────────────────────────
// UTILITAIRES
// ─────────────────────────────────────────────────────────────
function pseudoHue(pseudo) {
let h = 0;
for (let i = 0; i < pseudo.length; i++) h = ((h << 5) - h + pseudo.charCodeAt(i)) | 0;
return Math.abs(h) % 360;
}
function jvCake(str) {
const base16 = '0A12B34C56D78E9F';
const s = str.split(' ')[1];
let lien = '';
for (let i = 0; i < s.length; i += 2)
lien += String.fromCharCode(base16.indexOf(s.charAt(i)) * 16 + base16.indexOf(s.charAt(i + 1)));
return lien;
}
function reverseMessage(node, isInit, isUl) {
let quote = '', prevIsP = false, startsWithSpoil = false;
for (const child of node.childNodes) {
const name = child.nodeName;
switch (name) {
case 'P': quote += reverseMessage(child) + '\n\n'; break;
case 'STRONG': quote += "'''" + reverseMessage(child) + "'''"; break;
case 'EM': quote += "''" + reverseMessage(child) + "''"; break;
case 'U': quote += '<u>' + reverseMessage(child) + '</u>'; break;
case 'S': quote += '<s>' + reverseMessage(child) + '</s>'; break;
case 'BR': quote += '\n'; break;
case 'UL': quote += reverseMessage(child, false, true) + '\n\n'; break;
case 'OL': quote += reverseMessage(child, false, false) + '\n\n'; break;
case 'LI': quote += (isUl ? '* ' : '# ') + reverseMessage(child) + '\n'; break;
case 'IMG': quote += child.alt; break;
case 'A': quote += child.href ? child.href : reverseMessage(child); break;
case 'CODE': quote += '<code>' + child.textContent + '</code>'; break;
case 'PRE': quote += reverseMessage(child) + '\n\n'; break;
case 'BLOCKQUOTE':
quote += (prevIsP ? quote.trimEnd() + '\n' : '') + reverseMessage(child).replace(/^/gm, '> ') + '\n\n';
break;
case 'DIV': case 'SPAN': {
const cl = child.classList;
if (cl.contains('message__spoil')) {
if (!quote) startsWithSpoil = true;
quote += '<spoil>' + reverseMessage(child) + '</spoil>\n\n';
} else if (cl.contains('message__spoilContent')) {
quote += reverseMessage(child);
}
break;
}
case '#text':
if (!isInit || child.textContent.trim() !== '') {
quote += child.textContent;
if (isInit && !quote.endsWith('\n')) quote += '\n';
}
break;
}
prevIsP = (name === 'P');
}
quote = quote.replace(/(\n){3,}/g, '\n\n');
if (startsWithSpoil && isInit) quote = '\n' + quote.trimEnd();
else quote = quote.trim();
if (isInit) quote = quote.replace(/^/gm, '> ');
return quote;
}
function updateFavicon(count) {
const link = document.querySelector('link[rel*="icon"]') || (() => {
const l = document.createElement('link'); l.rel = 'icon'; document.head.appendChild(l); return l;
})();
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 16;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, 16, 16);
if (count > 0) {
const label = count > 99 ? '99+' : String(count);
ctx.fillStyle = '#e84d1a';
ctx.fillRect(0, 0, ctx.measureText(label).width + 4, 11);
ctx.fillStyle = '#fff';
ctx.font = 'bold 9px sans-serif';
ctx.textBaseline = 'bottom';
ctx.fillText(label, 2, 11);
}
link.href = canvas.toDataURL();
};
img.onerror = () => {};
img.src = link.href || '/favicon.ico';
}
// ─────────────────────────────────────────────────────────────
// NOTIFICATIONS
// ─────────────────────────────────────────────────────────────
async function requestNotifPermission() {
if (!('Notification' in window)) return false;
if (Notification.permission === 'granted') return true;
if (Notification.permission === 'denied') return false;
return (await Notification.requestPermission()) === 'granted';
}
function sendNotif(title, body) {
if (!('Notification' in window) || Notification.permission !== 'granted') return;
const n = new Notification(title, { body, icon: '/favicon.ico', tag: 'jvchat' });
n.onclick = () => { window.focus(); n.close(); };
setTimeout(() => n.close(), 6000);
}
// ─────────────────────────────────────────────────────────────
// PARSER
// ─────────────────────────────────────────────────────────────
function parsePage(doc) {
const titleEl = doc.querySelector('#title-display-container');
const title = titleEl ? titleEl.textContent.trim() : '';
let lastPage = 1;
for (const a of doc.querySelectorAll('.pagination .pagination__navigation a')) {
const p = parseInt(a.textContent.trim());
if (!isNaN(p) && p > lastPage) lastPage = p;
}
const cur = doc.querySelector('.pagination__item--current');
if (cur) { const p = parseInt(cur.textContent); if (!isNaN(p) && p > lastPage) lastPage = p; }
for (const a of doc.querySelectorAll('.bloc-liste-num-page span a')) {
const p = parseInt(a.textContent.trim());
if (!isNaN(p) && p > lastPage) lastPage = p;
}
const messages = [];
for (const msg of doc.querySelectorAll('#listMessages .messageUser.js-hybrid-component, #listMessages .messageUser, .bloc-message-forum')) {
if (msg.children.length === 0) continue;
const msgEl = msg.querySelector('.messageUser__msg, .bloc-contenu');
const dateEl = msg.querySelector('.messageUser__date, .bloc-date-msg');
const labelEl = msg.querySelector('.messageUser__label, .bloc-pseudo-msg');
if (!msgEl || !dateEl || !labelEl) continue;
let avatarUrl = 'https://image.jeuxvideo.com/avatar/default.jpg';
const av = msg.querySelector('img.avatar__image, .bloc-avatar img');
if (av) avatarUrl = av.src;
const username = labelEl.textContent.trim();
const rankEl = msg.querySelector('.messageUser__memberGroup, .messageUser__rank, .bloc-grade-msg, .grade-pseudo');
const userRank = rankEl?.textContent.trim() || '';
const id = msg.id ? msg.id.split('-').pop() : msg.getAttribute('data-id');
messages.push({ id, page: lastPage, username, userRank, avatarUrl, content: msgEl.innerHTML, creationDate: dateEl.textContent.trim() });
}
const connectedEl = doc.querySelector('#forums-info-app .sideCardForum__headerExtra .sideCardForum__Link');
const connectedCount = connectedEl ? parseInt(connectedEl.textContent.trim()) || 0 : 0;
return { title, lastPage, messages, connectedCount };
}
function getPayload(doc) {
for (const sc of doc.getElementsByTagName('script')) {
const txt = sc.textContent || '';
const m = txt.match(/(?:window\.)?jvc\.forumsAppPayload\s*=\s*['"]([^'"]+)['"]/);
if (m) { try { return JSON.parse(atob(m[1])); } catch (e) { err('payload:', e); } }
}
return null;
}
// ─────────────────────────────────────────────────────────────
// API
// ─────────────────────────────────────────────────────────────
class JVCAPI {
constructor(viewId, forumId, topicId, topicTitle) {
Object.assign(this, { viewId, forumId, topicId, topicTitle, payload: null });
}
_url(page) {
return `https://www.jeuxvideo.com/forums/${this.viewId}-${this.forumId}-${this.topicId}-${page}-0-1-0-${this.topicTitle}.htm`;
}
async getPageDocument(page) {
const res = await fetch(this._url(page));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return new DOMParser().parseFromString(await res.text(), 'text/html');
}
async getMessage(id) {
try {
const res = await fetch(`https://www.jeuxvideo.com/forums/message/${id}`);
return res.ok ? await res.text() : null;
} catch { return null; }
}
async postMessage(text) {
const fd = new FormData();
fd.set('text', text); fd.set('topicId', this.topicId); fd.set('forumId', this.forumId);
fd.set('group', 1); fd.set('messageId', 'undefined'); fd.set('ajax_hash', this.payload.ajaxToken);
for (const k in this.payload.formSession) fd.append(k, this.payload.formSession[k]);
const res = await fetch('https://www.jeuxvideo.com/forums/message/add', {
credentials: 'include', method: 'POST', mode: 'cors', body: fd
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
if (data.errors) {
const msgs = Object.values(data.errors);
if (msgs.length) throw new Error(msgs.join(' | '));
}
}
async deleteMessage(msgId) {
const url = `https://www.jeuxvideo.com${this.payload.topicActions.deleteMessageUrl}&ids=${msgId}`;
const res = await fetch(url, { credentials: 'include', method: 'POST', mode: 'cors' });
const data = await res.json();
if (data.errors?.length) throw new Error(data.errors.join(' | '));
}
async getEditForm(msgId) {
const url = `https://www.jeuxvideo.com/forums/message/edit/form-values?id_message=${msgId}&ajax_hash=${this.payload.ajaxToken}`;
const res = await fetch(url, { credentials: 'include', headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json' } });
if (!res.ok) throw new Error('Echec récupération');
const data = await res.json();
if (data.errors) {
const msgs = Array.isArray(data.errors) ? data.errors : Object.values(data.errors);
if (msgs.length) throw new Error(msgs.join(' | '));
}
const jvcode = data.jvcode || data.text || '';
if (!jvcode) throw new Error("Délai d'édition dépassé");
const fs = {};
for (const k in data) if (k.startsWith('fs_')) fs[k] = data[k];
if (data.edit_form_session) Object.assign(fs, data.edit_form_session);
return { jvcode, formSession: fs };
}
async updateMessage(msgId, text, formSession) {
const fd = new FormData();
fd.set('text', text); fd.set('topicId', this.topicId); fd.set('forumId', this.forumId);
fd.set('group', 1); fd.set('messageId', msgId); fd.set('ajax_hash', this.payload.ajaxToken);
fd.set('resetFormAfterSuccess', 'false');
for (const k in formSession) fd.append(k, formSession[k]);
const res = await fetch('https://www.jeuxvideo.com/forums/message/edit', {
method: 'POST', credentials: 'include',
headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json' }, body: fd
});
const data = await res.json();
if (data.errors) {
const msgs = Object.values(data.errors);
if (msgs.length) throw new Error(msgs.join(' | '));
}
return data.html;
}
}
// ─────────────────────────────────────────────────────────────
// LONG PRESS
// ─────────────────────────────────────────────────────────────
function attachLongPress(el, onLongPress, delay = 420) {
let timer = null, fired = false, active = false, startX, startY;
const cancel = () => { clearTimeout(timer); timer = null; active = false; };
el.addEventListener('touchstart', (e) => {
fired = false; active = true;
startX = e.touches[0].clientX; startY = e.touches[0].clientY;
timer = setTimeout(() => { fired = true; onLongPress(e); }, delay);
}, { passive: true });
el.addEventListener('touchmove', (e) => {
if (!active) return;
if (Math.abs(e.touches[0].clientX - startX) > 8 || Math.abs(e.touches[0].clientY - startY) > 8) cancel();
}, { passive: true });
el.addEventListener('touchend', cancel);
el.addEventListener('touchcancel', cancel);
el.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
fired = false; active = true;
startX = e.clientX; startY = e.clientY;
timer = setTimeout(() => { fired = true; onLongPress(e); }, delay);
const onMove = (ev) => {
if (Math.abs(ev.clientX - startX) > 8 || Math.abs(ev.clientY - startY) > 8) { cancel(); cleanup(); }
};
const onUp = () => { cancel(); cleanup(); };
const cleanup = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
el.addEventListener('contextmenu', (e) => { if (fired) { e.preventDefault(); fired = false; } });
}
// ─────────────────────────────────────────────────────────────
// MATCHS ESPORT
// ─────────────────────────────────────────────────────────────
const LOLESPORTS_API = 'https://esports-api.lolesports.com/persisted/gw';
const LOLESPORTS_KEY = '0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z';
const MATCHES_TTL = 60_000;
const BO3GG_API = 'https://api.bo3.gg/api/v1';
const BO3GG_CACHE_TTL = 12 * 3600 * 1000;
const BO3GG_BLOCK_KEY = 'jvcm:bo3_blocked_until';
const BO3GG_BLOCK_MS = 30 * 60_000;
// Toutes les ligues disponibles avec leur label affiché
const ALL_LEAGUES = [
// Tier 1
{ slug: 'lec', label: 'LEC', group: 'Tier 1' },
{ slug: 'lck', label: 'LCK', group: 'Tier 1' },
{ slug: 'lpl', label: 'LPL', group: 'Tier 1' },
{ slug: 'lcp', label: 'LCP', group: 'Tier 1' },
{ slug: 'lcs', label: 'LCS', group: 'Tier 1' },
{ slug: 'cblol', label: 'CBLOL', group: 'Tier 1' },
{ slug: 'worlds', label: 'Worlds', group: 'Tier 1' },
{ slug: 'msi', label: 'MSI', group: 'Tier 1' },
{ slug: 'first_stand', label: 'First Stand', group: 'Tier 1' },
{ slug: 'ewc', label: 'EWC', group: 'Tier 1' },
// Tier 2
{ slug: 'emea_masters', label: 'EMEA Masters',group: 'Tier 2' },
// ERL
{ slug: 'lfl', label: 'LFL', group: 'Tier 2' },
{ slug: 'primeleague', label: 'Prime League',group: 'Tier 2' },
{ slug: 'superliga', label: 'Superliga', group: 'Tier 2' },
{ slug: 'nlc', label: 'NLC', group: 'Tier 2' },
{ slug: 'pg_nationals', label: 'PG Nationals',group: 'Tier 2' },
{ slug: 'ultraliga', label: 'Ultraliga', group: 'Tier 2' },
{ slug: 'tal', label: 'TAL', group: 'Tier 2' },
];
const BO3GG_TOURNAMENTS = {
ewc: [
'esports-world-cup-2026-emea-qualifier',
'esports-world-cup-2026-asia-pacific-qualifier',
'esports-world-cup-2026-korea-qualifier',
'esports-world-cup-2026',
],
};
const LEAGUES_STORAGE_KEY = 'jvcm:selected-leagues';
const LEAGUES_TTL = 7 * 24 * 3600_000; // 7 jours
function getSelectedLeagues() {
try {
const raw = localStorage.getItem(LEAGUES_STORAGE_KEY);
if (raw) {
const { slugs, ts } = JSON.parse(raw);
if (Date.now() - ts < LEAGUES_TTL) return slugs;
// Expiré : supprimer et retourner le défaut
try { localStorage.removeItem(LEAGUES_STORAGE_KEY); } catch {}
}
} catch {}
return ['lec', 'lck', 'lpl', 'lfl'];
}
function setSelectedLeagues(slugs) {
try {
localStorage.setItem(LEAGUES_STORAGE_KEY, JSON.stringify({ slugs, ts: Date.now() }));
} catch {}
}
function gmFetch(url, headers = {}) {
return new Promise((resolve, reject) => {
const fn = (typeof GM_xmlhttpRequest !== 'undefined') ? GM_xmlhttpRequest
: (typeof GM !== 'undefined' && GM.xmlHttpRequest) ? GM.xmlHttpRequest
: null;
// Fallback fetch natif (iOS Safari, contextes sans GM_xmlhttpRequest)
if (!fn) {
fetch(url, { headers })
.then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
.then(resolve).catch(reject);
return;
}
fn({
method: 'GET', url, headers, timeout: 10_000,
onload: r => {
if (r.status >= 200 && r.status < 300) {
try { resolve(JSON.parse(r.responseText)); }
catch (e) { reject(new Error('JSON parse error')); }
} else { reject(new Error(`HTTP ${r.status}`)); }
},
onerror: () => reject(new Error('network error')),
ontimeout: () => reject(new Error('timeout')),
});
});
}
function _matchEsc(s) {
return String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
}
function _matchFormatStart(iso) {
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const diff = d.getTime() - Date.now();
const hh = String(d.getHours()).padStart(2,'0');
const mm = String(d.getMinutes()).padStart(2,'0');
if (diff < 0) return `Imminent (${hh}:${mm})`;
const mins = Math.round(diff / 60_000);
if (mins < 60) return `Dans ${mins} min`;
const h = Math.floor(mins / 60);
const r = mins % 60;
if (h < 24) return `Dans ${h}h${r > 0 ? String(r).padStart(2,'0') : ''}`;
const today = new Date(); today.setHours(0,0,0,0);
const tomorrow = new Date(today.getTime() + 86_400_000);
if (d >= today && d < tomorrow) return `Aujourd'hui ${hh}:${mm}`;
if (d >= tomorrow) return `Demain ${hh}:${mm}`;
return d.toLocaleDateString('fr-FR',{weekday:'short',day:'numeric',month:'short'}) + ` ${hh}:${mm}`;
}
function _matchFormatPast(iso) {
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const hh = String(d.getHours()).padStart(2,'0');
const mm = String(d.getMinutes()).padStart(2,'0');
const today = new Date(); today.setHours(0,0,0,0);
const yesterday = new Date(today.getTime() - 86_400_000);
if (d >= today) return `Aujourd'hui ${hh}h${mm}`;
if (d >= yesterday) return `Hier ${hh}h${mm}`;
return d.toLocaleDateString('fr-FR',{weekday:'short',day:'numeric',month:'short'}) + ` ${hh}h${mm}`;
}
async function fetchLolesportsMatches(allowedSlugs, pastDays = 1, futureDays = 7) {
const allowed = new Set(allowedSlugs.map(s => s.toLowerCase()));
const headers = { 'x-api-key': LOLESPORTS_KEY };
const now = Date.now();
// L'API getSchedule retourne max ~48h dans le passé — on plafonne à 2j
const agoMs = now - Math.min(pastDays, 2) * 24 * 3600_000;
const futureMs = now + Math.min(futureDays, 7) * 24 * 3600_000;
const isOk = e => allowed.has((e.league?.slug || '').toLowerCase());
const [liveData, schedData] = await Promise.all([
gmFetch(`${LOLESPORTS_API}/getLive?hl=fr-FR`, headers).catch(() => null),
gmFetch(`${LOLESPORTS_API}/getSchedule?hl=fr-FR`, headers).catch(() => null),
]);
const liveEvents = liveData?.data?.schedule?.events || [];
const schedEvents = schedData?.data?.schedule?.events || [];
const filtered = schedEvents.filter(e => {
if (e.type !== 'match' || !isOk(e)) return false;
const t = new Date(e.startTime).getTime();
if (e.state === 'completed') return t >= agoMs;
if (e.state === 'inProgress') return true;
if (e.state === 'unstarted') return t <= futureMs;
return false;
});
const seen = new Set(); const matches = [];
for (const ev of [...liveEvents, ...filtered]) {
const id = ev.match?.id;
if (!id || seen.has(id) || !isOk(ev)) continue;
seen.add(id);
matches.push({
matchId: id, startTime: ev.startTime, state: ev.state,
blockName: ev.blockName || '',
league: { name: ev.league?.name || '', slug: ev.league?.slug || '' },
teams: (ev.match?.teams || []).map(t => ({
name: t.name, code: t.code,
wins: t.result?.gameWins || 0, outcome: t.result?.outcome || null,
})),
strategy: ev.match?.strategy || {},
games: (ev.match?.games || []).map(g => ({
number: g.number,
state: g.state,
teams: (g.teams || []).map(t => ({ id: t.id, side: t.side })),
})),
matchUrl: `https://lolesports.com/match/${id}`,
streams: (ev.streams || [])
.filter(s => s.provider === 'twitch')
.map(s => s.parameter)
.filter(Boolean),
source: 'lolesports',
});
}
return matches;
}
function _bo3CollectMatches(tournament) {
const out = [];
for (const stage of (tournament?.stages || [])) {
for (const round of (stage?.rounds || [])) {
for (const m of (round?.matches || [])) out.push({ ...m, _stage: stage, _round: round });
}
}
return out;
}
function _bo3NormalizeMatch(m) {
if (!m?.start_date) return null;
const ts = new Date(m.start_date).getTime();
if (!ts || isNaN(ts)) return null;
const t1 = { name: m.team1?.name || 'TBD', code: (m.team1?.acronym?.trim()) || m.team1?.name || 'TBD' };
const t2 = { name: m.team2?.name || 'TBD', code: (m.team2?.acronym?.trim()) || m.team2?.name || 'TBD' };
const s1 = parseInt(m.team1_score, 10) || 0;
const s2 = parseInt(m.team2_score, 10) || 0;
let state, o1 = null, o2 = null;
const status = (m.status || '').toLowerCase();
if (status === 'finished' || status === 'defwin' || m.winner_team_id) {
state = 'completed';
if (m.winner_team_id === m.team1_id) { o1='win'; o2='loss'; }
else if (m.winner_team_id === m.team2_id) { o1='loss'; o2='win'; }
else if (s1 > s2) { o1='win'; o2='loss'; }
else if (s2 > s1) { o1='loss'; o2='win'; }
} else if (['started','live','in_progress'].includes(status)) {
state = 'inProgress';
} else {
state = 'unstarted';
}
return {
matchId: `bo3-${m.id || ts}`, startTime: new Date(ts).toISOString(),
state, blockName: m._stage?.title || m._round?.name || '',
league: { name: 'EWC 2026', slug: 'ewc' },
teams: [
{ name: t1.name, code: t1.code, wins: s1, outcome: o1 },
{ name: t2.name, code: t2.code, wins: s2, outcome: o2 },
],
strategy: { count: parseInt(m.bo_type, 10) || 3 },
source: 'bo3gg',
};
}
async function fetchBo3ggTournament(slug) {
const cacheKey = `jvcm:bo3:${slug}`;
try {
const raw = localStorage.getItem(cacheKey);
if (raw) {
const c = JSON.parse(raw);
if (Date.now() - c.ts < BO3GG_CACHE_TTL) return c.matches;
}
} catch {}
const data = await gmFetch(`${BO3GG_API}/tournaments/${encodeURIComponent(slug)}`);
if (!data || data.error) throw new Error(data?.error?.message || 'bo3.gg error');
const matches = _bo3CollectMatches(data).map(_bo3NormalizeMatch).filter(Boolean);
try { localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), matches })); } catch {}
return matches;
}
async function fetchBo3ggMatches(allowedSlugs) {
const blockedUntil = parseInt(localStorage.getItem(BO3GG_BLOCK_KEY) || '0', 10);
if (Date.now() < blockedUntil) return [];
const slugs = new Set();
for (const league of allowedSlugs) {
if (BO3GG_TOURNAMENTS[league]) for (const s of BO3GG_TOURNAMENTS[league]) slugs.add(s);
}
if (!slugs.size) return [];
const all = []; let hadError = false;
for (const slug of slugs) {
try { all.push(...await fetchBo3ggTournament(slug)); }
catch (e) { err('bo3.gg:', e.message); hadError = true; }
}
if (hadError && !all.length) localStorage.setItem(BO3GG_BLOCK_KEY, String(Date.now() + BO3GG_BLOCK_MS));
const now = Date.now();
const min = now - 48 * 3600_000;
const max = now + 7 * 24 * 3600_000;
const seen = new Set();
return all.filter(m => {
const t = new Date(m.startTime).getTime();
if (t < min || t > max) return false;
const k = `${m.teams[0]?.code}-${m.teams[1]?.code}-${m.startTime}`;
if (seen.has(k)) return false;
seen.add(k); return true;
});
}
async function fetchMatches(allowedSlugs, pastDays = 1, futureDays = 7) {
const cacheKey = `jvcm:matches:${[...allowedSlugs].sort().join(',')}_p${pastDays}_f${futureDays}`;
try {
const raw = localStorage.getItem(cacheKey);
if (raw) {
const c = JSON.parse(raw);
if (Date.now() - c.ts < MATCHES_TTL) return c.data;
}
} catch {}
let matches = await fetchLolesportsMatches(allowedSlugs, pastDays, futureDays).catch(e => { err('lolesports:', e.message); return []; });
const wantsExtra = allowedSlugs.some(l => BO3GG_TOURNAMENTS[l]);
if (wantsExtra) {
const extra = await fetchBo3ggMatches(allowedSlugs).catch(() => []);
if (extra.length) {
const keyOf = m => `${m.teams[0]?.code}-${m.teams[1]?.code}-${m.startTime}`;
const seen = new Set(matches.map(keyOf));
for (const m of extra) { const k = keyOf(m); if (!seen.has(k)) { matches.push(m); seen.add(k); } }
}
}
const order = { inProgress: 0, unstarted: 1, completed: 2 };
matches.sort((a, b) => {
const d = (order[a.state] ?? 9) - (order[b.state] ?? 9);
return d !== 0 ? d : new Date(a.startTime) - new Date(b.startTime);
});
try { localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), data: matches })); } catch {}
return matches;
}
// Streams Twitch de fallback par ligue slug (quand l'API ne retourne rien)
const LEAGUE_STREAMS = {
lec: ['lec', 'otplol_', 'caedrel'],
lck: ['lck'],
lpl: ['lpl'],
lcp: ['lcp'],
lfl: ['otplol_', 'solary'],
lcs: ['lcsofficial'],
cblol: ['cblol'],
emea_masters: ['emeamasters'],
primeleague: ['primeleague'],
superliga: ['lvpes'],
nlc: ['nlcofficial'],
pg_nationals: ['pgnationals'],
ultraliga: ['ultraliga'],
tal: ['talofficial'],
worlds: ['lec', 'riotgames'],
msi: ['lec', 'riotgames'],
first_stand: ['lec'],
ewc: ['lec'],
};
function getStreamsForMatch(m) {
// Priorité : streams déclarés par l'API, sinon fallback par ligue
const declared = (m.streams || []).filter(Boolean);
if (declared.length) return declared;
return LEAGUE_STREAMS[m.league?.slug?.toLowerCase()] || [];
}
function openTwitch(channel) {
// Tente d'ouvrir l'app Twitch native, fallback sur le navigateur
const appUrl = `twitch://stream/${channel}`;
const browserUrl = `https://www.twitch.tv/${channel}`;
const start = Date.now();
window.location.href = appUrl;
setTimeout(() => {
if (Date.now() - start < 1500) window.open(browserUrl, '_blank');
}, 800);
}
function _attachDotHandlers(matchRow, matchId) {
// Les dots sont purement visuels (gagnant de chaque game)
// Les stats des games terminées ne sont pas disponibles via l'API publique
}
function renderMatchRow(m) {
const [a, b] = m.teams?.length >= 2 ? m.teams : [m.teams?.[0] || {}, {}];
const isLive = m.state === 'inProgress';
const isDone = m.state === 'completed';
const bo = m.strategy?.count ? `BO${m.strategy.count}` : '';
const leagueLabel = (m.league?.slug || '').toUpperCase();
const stageParts = [leagueLabel, m.blockName, bo, isDone ? _matchFormatPast(m.startTime) : ''].filter(Boolean).map(_matchEsc).join(' · ');
let badge;
if (isLive) {
badge = `<span class="jvcm-match-badge-live"><span class="jvcm-match-dot"></span>LIVE</span>`;
} else if (isDone) {
badge = `<span class="jvcm-match-badge-done">Terminé</span>`;
} else {
badge = `<span class="jvcm-match-badge-upcoming">${_matchEsc(_matchFormatStart(m.startTime))}</span>`;
}
const winA = a.outcome === 'win', winB = b.outcome === 'win';
const clA = isDone ? (winA ? 'winner' : winB ? 'loser' : '') : '';
const clB = isDone ? (winB ? 'winner' : winA ? 'loser' : '') : '';
const middle = (isLive || isDone)
? `<span class="jvcm-match-score">${a.wins ?? 0} – ${b.wins ?? 0}</span>`
: `<span class="jvcm-match-vs">vs</span>`;
// Streams
const streams = getStreamsForMatch(m);
let streamsHtml = '';
if (streams.length === 1) {
streamsHtml = `<div class="jvcm-match-streams"><button class="jvcm-stream-btn" data-channel="${_matchEsc(streams[0])}">▶ ${_matchEsc(streams[0])}</button></div>`;
} else if (streams.length > 1) {
const opts = streams.map(s => `<option value="${_matchEsc(s)}">${_matchEsc(s)}</option>`).join('');
streamsHtml = `<div class="jvcm-match-streams"><select class="jvcm-stream-select" data-stream-select><option value="">▶ Regarder sur Twitch</option>${opts}</select></div>`;
}
// Dots games (LIVE + terminés)
let gamesHtml = '';
if ((isLive || isDone) && m.games?.length) {
let blueWins = 0, redWins = 0;
const dots = m.games.map(g => {
const st = (g.state || '').toLowerCase();
const completed = st === 'completed' || st === 'finished';
const live = st === 'inprogress' || st === 'in_progress';
let dotClass = 'jvcm-game-dot';
if (live) dotClass += ' live';
if (completed) {
dotClass += ' clickable';
if (g.teams?.[0]?.result?.gameWins > blueWins) { dotClass += ' win-blue'; blueWins = g.teams[0].result.gameWins; }
else if (g.teams?.[1]?.result?.gameWins > redWins) { dotClass += ' win-red'; redWins = g.teams[1].result.gameWins; }
}
const label = live ? '▶' : `G${g.number}`;
return `<span class="${dotClass}" data-game-num="${g.number}" title="Game ${g.number}">${label}</span>`;
}).join('');
gamesHtml = `<div class="jvcm-match-games">${dots}</div>`;
}
// Lien lolesports
const leagueSlug = m.league?.slug || '';
const lolLink = leagueSlug
? `<a href="https://lolesports.com/schedule?leagues=${_matchEsc(leagueSlug)}" target="_blank" style="font-size:0.62rem;color:var(--jvc-text-muted);text-decoration:none;margin-left:auto;flex-shrink:0;padding:0.1rem 0.3rem;border:1px solid var(--jvc-border);border-radius:3px;">lolesports ↗</a>`
: '';
return `
<div class="jvcm-match-row" data-state="${m.state}" data-match-id="${_matchEsc(m.matchId)}">
<div class="jvcm-match-row-head">
${badge}
<span class="jvcm-match-stage">${stageParts}</span>
${lolLink}
</div>
<div class="jvcm-match-row-body">
<span class="jvcm-match-team ${clA}">${_matchEsc(a.code || a.name || 'TBD')}</span>
${middle}
<span class="jvcm-match-team ${clB}">${_matchEsc(b.code || b.name || 'TBD')}</span>
</div>
${gamesHtml}
${streamsHtml}
</div>
`;
}
// HTML partagé de la modale matchs — identique partout
function _matchesModalHTML() {
return `
<div id="jvcm-matches-overlay" class="hidden">
<div id="jvcm-matches-panel">
<div id="jvcm-matches-handle"></div>
<div id="jvcm-matches-header">
<span id="jvcm-matches-title">🏆 Matchs esport</span>
<button id="jvcm-matches-refresh" title="Actualiser">⟳</button>
<button id="jvcm-matches-close">×</button>
</div>
<div id="jvcm-leagues-wrap"></div>
<div id="jvcm-time-range">
<div class="jvcm-range-group">
<div class="jvcm-range-label">Passé <span id="jvcm-past-val">1j</span></div>
<input type="range" class="jvcm-range-input" id="jvcm-past-range" min="0" max="2" value="1" step="1">
</div>
<div class="jvcm-range-group">
<div class="jvcm-range-label">Futur <span id="jvcm-future-val">7j</span></div>
<input type="range" class="jvcm-range-input" id="jvcm-future-range" min="0" max="7" value="7" step="1">
</div>
</div>
<div id="jvcm-matches-loading" class="hidden">
<div class="jvcm-spinner"></div>
<span>Chargement des matchs…</span>
</div>
<div id="jvcm-matches-empty" class="hidden">Aucun match trouvé pour les ligues sélectionnées.</div>
<div id="jvcm-matches-list"></div>
</div>
</div>`;
}
// ─────────────────────────────────────────────────────────────
// MATCHES UI — logique partagée entre JVChat et standalone
// ─────────────────────────────────────────────────────────────
function setupMatchesUI() {
const overlay = document.getElementById('jvcm-matches-overlay');
const list = document.getElementById('jvcm-matches-list');
const loading = document.getElementById('jvcm-matches-loading');
const empty = document.getElementById('jvcm-matches-empty');
const leaguesWrap = document.getElementById('jvcm-leagues-wrap');
const refreshBtn = document.getElementById('jvcm-matches-refresh');
loading.style.display = 'none';
empty.style.display = 'none';
list.style.display = 'block';
const showLoading = () => { loading.style.display='flex'; empty.style.display='none'; list.style.display='none'; };
const showEmpty = (msg) => { loading.style.display='none'; empty.style.display='block'; list.style.display='none'; if (msg) empty.textContent = msg; };
const showList = () => { loading.style.display='none'; empty.style.display='none'; list.style.display='block'; };
let selected = getSelectedLeagues();
const renderLeagueBtns = () => {
leaguesWrap.innerHTML = '';
const groups = [...new Set(ALL_LEAGUES.map(l => l.group))];
for (const group of groups) {
const label = document.createElement('span');
label.style.cssText = 'font-size:0.58rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--jvc-text-muted);width:100%;margin-top:0.3rem;font-family:Inter,system-ui,sans-serif;';
label.textContent = group;
leaguesWrap.appendChild(label);
for (const league of ALL_LEAGUES.filter(l => l.group === group)) {
const btn = document.createElement('button');
btn.className = 'jvcm-league-btn' + (selected.includes(league.slug) ? ' active' : '');
btn.textContent = league.label;
btn.addEventListener('click', () => {
if (selected.includes(league.slug)) {
if (selected.length === 1) return;
selected = selected.filter(s => s !== league.slug);
} else {
selected = [...selected, league.slug];
}
setSelectedLeagues(selected);
renderLeagueBtns();
loadMatches();
});
leaguesWrap.appendChild(btn);
}
}
};
let _lastSelectedGame = {};
let _pastDays = 1;
let _futureDays = 7;
// Sliders fenêtre temporelle
const pastRange = document.getElementById('jvcm-past-range');
const futureRange = document.getElementById('jvcm-future-range');
const pastVal = document.getElementById('jvcm-past-val');
const futureVal = document.getElementById('jvcm-future-val');
const onRangeChange = () => {
_pastDays = parseInt(pastRange?.value || '1');
_futureDays = parseInt(futureRange?.value || '7');
if (pastVal) pastVal.textContent = _pastDays === 0 ? 'auj.' : `${_pastDays}j`;
if (futureVal) futureVal.textContent = _futureDays === 0 ? 'auj.' : `${_futureDays}j`;
// Invalider le cache pour forcer un rechargement avec les nouveaux paramètres
const cacheKey = `jvcm:matches:${[...selected].sort().join(',')}_p${_pastDays}_f${_futureDays}`;
try { localStorage.removeItem(cacheKey); } catch {}
loadMatches();
};
let rangeTimer = null;
const debouncedChange = () => { clearTimeout(rangeTimer); rangeTimer = setTimeout(onRangeChange, 400); };
pastRange?.addEventListener('input', () => {
_pastDays = parseInt(pastRange.value, 10) || 1;
if (pastVal) pastVal.textContent = _pastDays === 0 ? 'auj.' : `${_pastDays}j`;
debouncedChange();
});
futureRange?.addEventListener('input', () => {
_futureDays = parseInt(futureRange.value, 10) || 7;
if (futureVal) futureVal.textContent = _futureDays === 0 ? 'auj.' : `${_futureDays}j`;
debouncedChange();
}); // matchId → gameNum mémorisé
const _renderSection = (label, matchesArr) => {
if (!matchesArr.length) return '';
const header = `<div style="padding:0.3rem 1rem;font-size:0.58rem;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--jvc-text-muted);background:var(--jvc-surface-2);border-bottom:1px solid var(--jvc-divider);">${label}</div>`;
const html = matchesArr.map(m => {
const div = document.createElement('div');
div.innerHTML = renderMatchRow(m);
return div.firstElementChild?.outerHTML || '';
}).join('');
return header + html;
};
const loadMatches = async () => {
list.innerHTML = '';
showLoading();
refreshBtn.classList.add('spinning');
try {
const matches = await fetchMatches(selected, _pastDays, _futureDays);
const now = Date.now();
const agoMs = now - _pastDays * 24 * 3600_000;
const futMs = now + _futureDays * 24 * 3600_000;
const today = new Date(); today.setHours(0,0,0,0);
const tomorrow = new Date(today.getTime() + 86_400_000);
const nextWeek = new Date(today.getTime() + 7 * 86_400_000);
const live = matches.filter(m => m.state === 'inProgress');
const todayUp = matches.filter(m => m.state === 'unstarted' && new Date(m.startTime) >= today && new Date(m.startTime) < tomorrow);
const weekUp = matches.filter(m => m.state === 'unstarted' && new Date(m.startTime) >= tomorrow && new Date(m.startTime) < nextWeek);
const completed = matches.filter(m => m.state === 'completed' && new Date(m.startTime).getTime() >= agoMs).slice(0, 20);
// Mettre à jour le badge 🏆 dans le header JVChat
const matchesBtn = document.getElementById('jvcm-matches-btn');
if (matchesBtn) {
const existing = matchesBtn.querySelector('.jvcm-live-badge');
if (live.length > 0) {
if (!existing) {
const badge = document.createElement('span');
badge.className = 'jvcm-live-badge';
badge.textContent = live.length;
matchesBtn.appendChild(badge);
} else {
existing.textContent = live.length;
}
} else if (existing) {
existing.remove();
}
}
refreshBtn.classList.remove('spinning');
const total = live.length + todayUp.length + weekUp.length + completed.length;
if (!total) { showEmpty('Aucun match trouvé pour les ligues sélectionnées.'); return; }
// Injecter par sections
let html = '';
if (live.length) html += _renderSection('🔴 En direct', live);
if (todayUp.length) html += _renderSection("📅 Aujourd'hui", todayUp);
if (weekUp.length) html += _renderSection('📆 Cette semaine', weekUp);
if (completed.length) html += _renderSection('✅ Récemment terminés', completed);
list.innerHTML = html;
// Brancher streams
for (const btn of list.querySelectorAll('.jvcm-stream-btn[data-channel]')) {
btn.addEventListener('click', () => openTwitch(btn.dataset.channel));
}
for (const sel of list.querySelectorAll('.jvcm-stream-select[data-stream-select]')) {
sel.addEventListener('change', () => { if (sel.value) { openTwitch(sel.value); sel.value = ''; } });
}
showList();
// Dots async pour les matchs terminés sans games dans le payload
for (const row of list.querySelectorAll('.jvcm-match-row[data-state="completed"]')) {
const matchId = row.dataset.matchId;
if (!matchId) continue;
// Si les dots sont déjà présents (payload avait m.games), skip
if (row.querySelector('.jvcm-match-games')) continue;
// Charger les games via getEventDetails
gmFetch(`${LOLESPORTS_API}/getEventDetails?hl=fr-FR&id=${matchId}`, { 'x-api-key': LOLESPORTS_KEY })
.then(details => {
const games = details?.data?.event?.match?.games || [];
if (!games.length) return;
const teams = details?.data?.event?.match?.teams || [];
const t1Wins = teams[0]?.result?.gameWins ?? 0;
const t2Wins = teams[1]?.result?.gameWins ?? 0;
let blueW = 0, redW = 0;
const dots = games.map(g => {
const st = (g.state || '').toLowerCase();
const completed = st === 'completed' || st === 'finished';
let cls = 'jvcm-game-dot' + (completed ? ' clickable' : '');
if (completed) {
// Déduire le gagnant game par game depuis les wins cumulés
const newBlue = teams[0]?.result?.gameWins ?? 0;
const newRed = teams[1]?.result?.gameWins ?? 0;
if (g.number <= t1Wins) cls += ' win-blue';
else if (g.number <= t1Wins + t2Wins) cls += ' win-red';
}
return `<span class="${cls}" data-game-num="${g.number}" title="Game ${g.number}">G${g.number}</span>`;
}).join('');
const gamesDiv = document.createElement('div');
gamesDiv.className = 'jvcm-match-games';
gamesDiv.innerHTML = dots;
// Insérer avant le bloc streams
const streamsEl = row.querySelector('.jvcm-match-streams');
if (streamsEl) row.insertBefore(gamesDiv, streamsEl);
else row.appendChild(gamesDiv);
// Brancher les dots
_attachDotHandlers(row, matchId);
}).catch(() => {});
}
// Refresh auto toutes les 30s si des matchs LIVE
} catch (e) {
refreshBtn.classList.remove('spinning');
showEmpty('Erreur de chargement. Réessayez.');
err('fetchMatches:', e);
}
};
const open = () => {
overlay.style.display = 'flex';
overlay.classList.remove('hidden');
renderLeagueBtns();
loadMatches();
};
const close = () => {
overlay.style.display = 'none';
overlay.classList.add('hidden');
};
document.getElementById('jvcm-matches-close').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
refreshBtn.addEventListener('click', () => {
if (refreshBtn.classList.contains('spinning')) return;
const cacheKey = `jvcm:matches:${[...selected].sort().join(',')}_p${_pastDays}_f${_futureDays}`;
try { localStorage.removeItem(cacheKey); } catch {}
loadMatches();
});
// Swipe bas pour fermer
const handle = document.getElementById('jvcm-matches-handle');
if (handle) {
let startY = 0;
handle.addEventListener('touchstart', (e) => { startY = e.touches[0].clientY; }, { passive: true });
handle.addEventListener('touchend', (e) => { if (e.changedTouches[0].clientY - startY > 60) close(); }, { passive: true });
}
return { open, close };
}
// ─────────────────────────────────────────────────────────────
class JVChatMobile {
constructor() {
this.messages = [];
this.lastPage = 1;
this.payload = null;
this.connectedUser = null;
this.autoScroll = true;
this.unreadOnScroll = 0;
this.unreadTotal = 0;
this.refreshRate = 6000;
this.isTabActive = true;
this._cycle = 0;
this._retryDelay = 6000;
this._baseTitle = '';
this._newSepInserted = false;
this._relTimerID = null;
this.settings = new Settings();
// Confirmation suppression
this._pendingDelete = null;
}
async start() {
addStyle();
const info = this._parseURL(location.href);
this.viewId = info.viewId; this.forumId = info.forumId;
this.topicId = info.topicId; this.topicTitle = info.title;
this.api = new JVCAPI(this.viewId, this.forumId, this.topicId, this.topicTitle);
this._buildUI();
await this._loadInitial();
let lastRefresh = performance.now();
this._resetPollTimer = () => { lastRefresh = 0; }; // remet le timer à 0 pour forcer un poll immédiat
setInterval(() => {
if (performance.now() - lastRefresh >= this.refreshRate) {
lastRefresh = performance.now();
this._poll();
}
}, 50);
this._relTimerID = setInterval(() => this._updateRelativeTimes(), 60_000);
document.addEventListener('visibilitychange', () => {
this.isTabActive = document.visibilityState === 'visible';
if (this.isTabActive) {
this.unreadTotal = 0;
updateFavicon(0);
if (this._baseTitle) document.title = this._baseTitle;
this.autoScroll = true;
this.unreadOnScroll = 0;
this._newSepInserted = false;
this._scrollToBottom();
}
});
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => this._onViewportResize());
}
}
_parseURL(url) {
const m = url.match(/^https:\/\/www\.jeuxvideo\.com\/(?:recherche\/)?forums\/(\d+)-(\d+)-(\d+)-(\d+)-(\d+)-(\d+)-(\d+)-(.*?)\.htm/);
if (!m) throw new Error('URL invalide');
return { viewId: +m[1], forumId: +m[2], topicId: +m[3], title: m[8] };
}
_buildUI() {
for (const el of document.body.children) el.style.display = 'none';
document.documentElement.style.overflow = 'hidden';
document.body.style.cssText = 'margin:0;padding:0;overflow:hidden;background:var(--jvc-bg,#18181b)';
document.body.insertAdjacentHTML('beforeend', `
<div id="jvcm-app">
<header id="jvcm-header">
<button id="jvcm-home-btn" title="Retour à la liste des topics">⌂</button>
<span id="jvcm-topic-title" title="Fermer JVChat">Chargement…</span>
<div id="jvcm-meta">
<span class="jvcm-chip jvcm-chip--live" id="jvcm-connected">–</span>
</div>
<button id="jvcm-matches-btn" title="Matchs esport">🏆</button>
<button id="jvcm-settings-btn" title="Paramètres">⚙</button>
</header>
<div id="jvcm-loading">
<div class="jvcm-spinner"></div>
<span class="jvcm-loading-label">Chargement des messages…</span>
</div>
<div id="jvcm-messages"></div>
<div id="jvcm-input-wrap">
<div id="jvcm-input-bar">
<textarea id="jvcm-textarea" rows="1" placeholder="Votre message…"></textarea>
<button id="jvcm-clear-btn" aria-label="Vider le message" title="Vider">×</button>
<button id="jvcm-send-btn" aria-label="Envoyer">
<svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
</div>
</div>
<!-- Modale matchs esport -->
${_matchesModalHTML()}
<div id="jvcm-settings">
<div id="jvcm-settings-header">
⚙ Paramètres
<button id="jvcm-settings-close">×</button>
</div>
<div id="jvcm-settings-body"></div>
</div>
<!-- Modal générique (report + confirm delete) -->
<div id="jvcm-modal-overlay" class="jvcm-modal-overlay hidden">
<div class="jvcm-modal" id="jvcm-modal">
<div class="jvcm-modal-header">
<span id="jvcm-modal-title"></span>
<button class="jvcm-modal-close" id="jvcm-modal-close">×</button>
</div>
<div class="jvcm-modal-body" id="jvcm-modal-body"></div>
<div class="jvcm-modal-footer">
<button class="jvcm-modal-cancel" id="jvcm-modal-cancel">Annuler</button>
<button class="jvcm-modal-confirm" id="jvcm-modal-confirm">Confirmer</button>
</div>
</div>
</div>
`);
this.$messages = document.getElementById('jvcm-messages');
this.$textarea = document.getElementById('jvcm-textarea');
this.$sendBtn = document.getElementById('jvcm-send-btn');
this.$loading = document.getElementById('jvcm-loading');
// Bouton home → retour liste des topics
const forumLink = document.querySelector('.spreadContainer nav.breadcrumb > a[href^="/forums/0-"]')
|| document.querySelector('nav.breadcrumb a[href*="/forums/0-"]')
|| document.querySelector('a[href*="/forums/0-19163"]')
|| document.querySelector('a[href*="/forums/0-42"]');
const forumUrl = forumLink?.href || null;
const homeBtn = document.getElementById('jvcm-home-btn');
if (forumUrl) {
homeBtn.addEventListener('click', () => { window.location.href = forumUrl; });
} else {
homeBtn.style.display = 'none';
}
// Fermer JVChat
document.getElementById('jvcm-topic-title').addEventListener('click', () => {
clearInterval(this._relTimerID);
document.getElementById('jvcm-app')?.remove();
document.getElementById('jvcm-settings')?.remove();
document.getElementById('jvcm-modal-overlay')?.remove();
document.documentElement.style.overflow = '';
document.body.style.cssText = '';
for (const el of document.body.children) el.style.display = '';
location.reload();
});
// Fermer actions si clic ailleurs
document.addEventListener('click', (e) => {
if (!e.target.closest('.jvcm-message')) {
for (const m of document.querySelectorAll('.jvcm-message.is-active')) m.classList.remove('is-active');
}
});
// Textarea + compteur de caractères
const clearBtn = document.getElementById('jvcm-clear-btn');
this.$clearBtn = clearBtn;
clearBtn.addEventListener('click', () => {
this.$textarea.value = '';
this.$textarea.style.height = '';
clearBtn.classList.remove('visible');
this.$textarea.focus();
});
this.$textarea.addEventListener('input', () => {
this.$textarea.style.height = 'auto';
this.$textarea.style.height = Math.min(this.$textarea.scrollHeight, 120) + 'px';
clearBtn.classList.toggle('visible', this.$textarea.value.length > 0);
});
this.$sendBtn.addEventListener('click', () => this._send());
this.$textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && this.settings.get('send_on_enter')) {
e.preventDefault(); this._send();
}
});
// Scroll
this.$messages.addEventListener('scroll', () => {
const atBottom = this.$messages.scrollHeight - this.$messages.scrollTop <= this.$messages.clientHeight + 2;
this.autoScroll = atBottom;
if (atBottom) {
this.unreadOnScroll = 0;
// Retirer le séparateur "nouveaux messages" quand on arrive en bas
document.getElementById('jvcm-new-sep')?.remove();
this._newSepInserted = false;
}
}, { passive: true });
// Settings panel + swipe bas pour fermer
this._buildSettings();
document.getElementById('jvcm-settings-btn').addEventListener('click', () => {
document.getElementById('jvcm-settings').classList.add('open');
});
document.getElementById('jvcm-settings-close').addEventListener('click', () => {
document.getElementById('jvcm-settings').classList.remove('open');
});
this._setupSettingsSwipe();
// Modale matchs
this._setupMatchesModal();
// Modal générique
this._setupModal();
}
_setupMatchesModal() {
const { open } = setupMatchesUI();
document.getElementById('jvcm-matches-btn').addEventListener('click', open);
}
_setupSettingsSwipe() {
const panel = document.getElementById('jvcm-settings');
const header = document.getElementById('jvcm-settings-header');
let startY = 0, dragging = false;
header.addEventListener('touchstart', (e) => {
startY = e.touches[0].clientY;
dragging = true;
panel.style.transition = 'none';
}, { passive: true });
header.addEventListener('touchmove', (e) => {
if (!dragging) return;
const dy = e.touches[0].clientY - startY;
if (dy > 0) panel.style.transform = `translateY(${dy}px)`;
}, { passive: true });
header.addEventListener('touchend', (e) => {
if (!dragging) return;
dragging = false;
panel.style.transition = '';
const dy = e.changedTouches[0].clientY - startY;
if (dy > 80) {
panel.classList.remove('open');
panel.style.transform = '';
} else {
panel.style.transform = '';
}
});
}
_setupModal() {
const overlay = document.getElementById('jvcm-modal-overlay');
const close = () => {
overlay.classList.add('hidden');
this._pendingDelete = null;
document.getElementById('jvcm-modal-confirm').onclick = null;
};
document.getElementById('jvcm-modal-close').addEventListener('click', close);
document.getElementById('jvcm-modal-cancel').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
}
_showModal({ title, body, confirmLabel, confirmClass = '', onConfirm }) {
document.getElementById('jvcm-modal-title').textContent = title;
document.getElementById('jvcm-modal-body').innerHTML = body;
const btn = document.getElementById('jvcm-modal-confirm');
btn.textContent = confirmLabel;
btn.className = 'jvcm-modal-confirm ' + confirmClass;
btn.onclick = () => {
document.getElementById('jvcm-modal-overlay').classList.add('hidden');
onConfirm();
};
document.getElementById('jvcm-modal-overlay').classList.remove('hidden');
}
_buildSettings() {
const body = document.getElementById('jvcm-settings-body');
for (const def of this.settings.defs) {
const id = `jvcm-s-${def.key}`;
const checked = this.settings.get(def.key) ? 'checked' : '';
body.insertAdjacentHTML('beforeend', `
<div class="jvcm-setting-row">
<div>
<div class="jvcm-setting-label">${def.label}</div>
${def.desc ? `<div class="jvcm-setting-desc">${def.desc}</div>` : ''}
</div>
<label class="jvcm-toggle">
<input type="checkbox" id="${id}" ${checked}>
<span class="jvcm-toggle-track"></span>
</label>
</div>
`);
document.getElementById(id).addEventListener('change', async (e) => {
const val = e.target.checked;
if (def.key === 'notifications' && val) {
const ok = await requestNotifPermission();
if (!ok) { e.target.checked = false; this._toast('Permission refusée', 'error'); return; }
}
this.settings.set(def.key, val);
});
}
}
_onViewportResize() {
const app = document.getElementById('jvcm-app');
if (!app) return;
app.style.height = window.visualViewport.height + 'px';
if (this.autoScroll) this._scrollToBottom();
}
// ── Chargement initial ──
async _loadInitial() {
this.$loading.classList.remove('hidden');
try {
const currentPage = parsePage(document);
this.lastPage = currentPage.lastPage;
const lastDoc = await this.api.getPageDocument(this.lastPage);
const lastParsed = parsePage(lastDoc);
const loadPages = async (finalPage, finalDoc, finalParsed) => {
this.payload = getPayload(finalDoc);
this.api.payload = this.payload;
this.connectedUser = this._getConnectedUser(finalDoc);
this._updateHeader(finalParsed);
if (finalPage >= 2) {
const prevDoc = await this.api.getPageDocument(finalPage - 1);
for (const m of parsePage(prevDoc).messages) this._addMessage(m, false);
}
for (const m of finalParsed.messages) this._addMessage(m, false);
};
if (lastParsed.lastPage > this.lastPage) {
this.lastPage = lastParsed.lastPage;
const realDoc = await this.api.getPageDocument(this.lastPage);
await loadPages(this.lastPage, realDoc, parsePage(realDoc));
} else {
await loadPages(this.lastPage, lastDoc, lastParsed);
}
this._recheckMentions();
this._baseTitle = document.title;
this.$loading.classList.add('hidden');
this._scrollToBottom(true);
} catch (e) {
err('loadInitial:', e);
this.$loading.classList.add('hidden');
this._toast('Erreur de chargement — nouvelle tentative dans 10s', 'error');
setTimeout(() => this._loadInitial(), 10000);
}
}
// ── Polling ──
async _poll() {
const c = (this._cycle = (this._cycle || 0) + 1);
try {
const doc = await this.api.getPageDocument(this.lastPage);
const page = parsePage(doc);
this.payload = getPayload(doc);
this.api.payload = this.payload;
this.lastPage = page.lastPage;
this.connectedUser = this._getConnectedUser(doc);
this._updateHeader(page);
for (const m of page.messages) {
if (!this.messages.find(x => x.id === m.id)) this._addMessage(m, true);
}
if (page.messages.length >= 20) {
try {
const nextDoc = await this.api.getPageDocument(this.lastPage + 1);
const next = parsePage(nextDoc);
if (next.lastPage > this.lastPage) {
this.lastPage = next.lastPage;
for (const m of next.messages) {
if (!this.messages.find(x => x.id === m.id)) this._addMessage(m, true);
}
}
} catch {}
}
const recent = this.messages.filter(m => m.page >= this.lastPage - 1).slice(-20);
for (const m of recent) {
if (m.isDeleted) continue;
if (!page.messages.find(x => x.id === m.id)) {
const raw = await this.api.getMessage(m.id);
if (!raw) { m.element?.classList.add('is-deleted'); m.isDeleted = true; }
}
}
if (this.autoScroll) this._scrollToBottom();
if (this._retryDelay > 6000) { this._retryDelay = 6000; this.refreshRate = 6000; }
} catch (e) {
err(`Cycle #${c}:`, e);
this._retryDelay = Math.min(this._retryDelay * 2, 48000);
this.refreshRate = this._retryDelay;
this._toast(`Reconnexion dans ${this._retryDelay / 1000}s…`, 'info');
setTimeout(() => { this.refreshRate = 6000; }, this._retryDelay + 500);
}
}
_updateHeader(page) {
const t = document.getElementById('jvcm-topic-title');
const c = document.getElementById('jvcm-connected');
if (t) t.textContent = page.title || '';
if (c) {
const n = this.payload?.forumInfo?.header?.btnVal ?? page.connectedCount;
c.textContent = `${n} connecté${n !== 1 ? 's' : ''}`;
}
}
_getConnectedUser(doc) {
if (!doc.querySelector('.headerAccount__pm')) return null;
const username = doc.querySelector('.headerAccount__pseudo')?.textContent.trim();
const avatarUrl = doc.querySelector('.headerAccount__avatar')?.style.backgroundImage
.slice(5, -2).replace('/avatar-md/', '/avatar/');
return { username, avatarUrl };
}
// ── Ajout d'un message ──
_addMessage(message, isNew) {
if (this.messages.find(m => m.id === message.id)) return;
// Séparateur "nouveaux messages"
if (isNew && !this._newSepInserted && !this.autoScroll) {
this._newSepInserted = true;
this._pendingNewCount = 1;
const sep = document.createElement('button');
sep.className = 'jvcm-new-sep'; sep.id = 'jvcm-new-sep';
sep.textContent = '↓ Nouveaux messages';
sep.addEventListener('click', () => {
this._scrollToBottom();
this.autoScroll = true;
sep.remove();
this._newSepInserted = false;
});
// Insérer entre $messages et la zone de saisie, pas dans le scroll
const inputWrap = document.getElementById('jvcm-input-wrap');
if (inputWrap) inputWrap.insertAdjacentElement('beforebegin', sep);
else this.$messages.appendChild(sep);
} else if (isNew && this._newSepInserted) {
this._pendingNewCount = (this._pendingNewCount || 1) + 1;
const sep = document.getElementById('jvcm-new-sep');
if (sep) sep.textContent = `${this._pendingNewCount} nouveaux messages`;
}
if (isNew && !this.autoScroll) { this.unreadOnScroll++; this._updateScrollBtn(); }
if (isNew && !this.isTabActive) {
this.unreadTotal++;
updateFavicon(this.unreadTotal);
document.title = `(${this.unreadTotal}) ${this._baseTitle || document.title.replace(/^\(\d+\)\s*/, '')}`;
if (this.settings.get('notifications')) {
sendNotif(`${message.username} — JVChat`, message.content.replace(/<[^>]+>/g, '').slice(0, 80));
}
}
const prev = this.messages[this.messages.length - 1];
const grouped = prev && prev.username === message.username;
const hue = pseudoHue(message.username);
const ts = parseJVCDate(message.creationDate);
const relTxt = ts ? relativeTime(ts) : message.creationDate.slice(-8);
const absTxt = message.creationDate.slice(-8);
const isOwn = this.connectedUser && this.connectedUser.username === message.username;
// Avatar : afficher l'URL originale immédiatement, remplacer par le blob caché en arrière-plan
const avatarSrc = _avatarCache.get(message.avatarUrl) || message.avatarUrl;
const el = document.createElement('article');
el.className = 'jvcm-message' + (grouped ? ' is-grouped' : '');
el.dataset.id = message.id;
if (ts) el.dataset.ts = ts;
el.innerHTML = `
<span class="jvcm-swipe-hint">💬</span>
<img class="jvcm-avatar" src="${avatarSrc}" alt="" loading="lazy">
<div class="jvcm-msg-body">
<div class="jvcm-msg-header">
<span class="jvcm-username" style="--pseudo-h:${hue}">${message.username}</span>
${message.userRank ? `<span class="jvcm-userrank">${message.userRank}</span>` : ''}
<span class="jvcm-date" title="${message.creationDate}">
<span class="jvcm-date-rel">${relTxt}</span>
<span class="jvcm-date-abs">${absTxt}</span>
</span>
<div class="jvcm-actions">
<div class="jvcm-actions-row">
<button class="jvcm-action-btn jvcm-btn-quote">💬 Citer</button>
<button class="jvcm-action-btn jvcm-btn-copy">🔗</button>
</div>
<div class="jvcm-actions-row">
${isOwn ? `<button class="jvcm-action-btn jvcm-btn-edit">✏️ Éditer</button>` : ''}
${isOwn ? `<button class="jvcm-action-btn danger jvcm-btn-delete">🗑️</button>` : ''}
${!isOwn ? `<button class="jvcm-action-btn danger jvcm-btn-report">🚩</button>` : ''}
</div>
</div>
</div>
<div class="jvcm-content txt-msg text-enrichi-forum">${message.content}</div>
</div>
`;
// Toggle date abs/rel
el.querySelector('.jvcm-date')?.addEventListener('click', (e) => {
e.stopPropagation();
el.querySelector('.jvcm-date').classList.toggle('show-abs');
});
// Fix JvCare
for (const jvc of el.querySelectorAll('.JvCare')) {
const a = document.createElement('a');
a.href = jvCake(jvc.getAttribute('class')); a.target = '_blank';
a.innerHTML = jvc.innerHTML; jvc.replaceWith(a);
}
// Fix img-shack
for (const img of el.querySelectorAll('.img-shack')) {
const parent = img.parentElement;
const a = document.createElement('a');
a.href = jvCake(parent.getAttribute('class')); a.target = '_blank';
a.appendChild(img.cloneNode(true)); parent.replaceWith(a);
}
// Citations imbriquées
for (const bq of el.querySelectorAll('.text-enrichi-forum > blockquote > blockquote')) {
const btn = document.createElement('button');
btn.className = 'jvcm-collapse-btn'; btn.textContent = '⟲ Voir la citation';
bq.prepend(btn);
btn.addEventListener('click', (e) => {
e.stopPropagation();
btn.closest('.message__blockquote, blockquote').classList.toggle('is-open');
});
}
// Scroll vers citation
for (const bq of el.querySelectorAll('.jvcm-content > blockquote, .jvcm-content > .message__blockquote')) {
bq.addEventListener('click', (e) => {
if (e.target.closest('.jvcm-collapse-btn')) return;
e.stopPropagation();
this._scrollToQuoteOrigin(bq);
});
}
this._checkMention(el, message);
// Tap → toggle actions
el.addEventListener('click', (e) => {
if (e.target.closest('a, button, input, textarea, .jvcm-date, blockquote')) return;
if (window.getSelection?.().toString().length > 0) return;
const wasActive = el.classList.contains('is-active');
for (const other of document.querySelectorAll('.jvcm-message.is-active')) other.classList.remove('is-active');
if (!wasActive) el.classList.add('is-active');
});
attachLongPress(el, () => {
el.classList.add('is-pressing');
setTimeout(() => el.classList.remove('is-pressing'), 300);
if (!el.classList.contains('is-active')) {
for (const other of document.querySelectorAll('.jvcm-message.is-active')) other.classList.remove('is-active');
el.classList.add('is-active');
}
});
if (this.settings.get('swipe_to_quote')) this._attachSwipeQuote(el, message);
// Bouton copier lien du message
el.querySelector('.jvcm-btn-copy')?.addEventListener('click', (e) => {
e.stopPropagation();
el.classList.remove('is-active');
const url = `https://www.jeuxvideo.com/forums/message/${message.id}`;
const doCopy = () => {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(url);
}
// Fallback iOS Safari / anciens navigateurs
const ta = document.createElement('textarea');
ta.value = url;
ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;';
document.body.appendChild(ta);
ta.focus(); ta.select();
try { document.execCommand('copy'); } catch {}
ta.remove();
return Promise.resolve();
};
doCopy().then(() => this._toast('Lien copié', 'success')).catch(() => this._toast(url, 'info'));
});
// Bouton citer
el.querySelector('.jvcm-btn-quote')?.addEventListener('click', (e) => {
e.stopPropagation();
this._quoteMessage(message);
el.classList.remove('is-active');
});
// Bouton éditer
el.querySelector('.jvcm-btn-edit')?.addEventListener('click', async (e) => {
e.stopPropagation();
const btn = el.querySelector('.jvcm-btn-edit');
btn.disabled = true; btn.textContent = '…';
try {
await this._editMessage(message);
el.classList.remove('is-active');
} catch {
btn.style.display = 'none';
this._toast("Édition non disponible", 'error');
}
});
// Bouton supprimer — confirmation avant suppression
el.querySelector('.jvcm-btn-delete')?.addEventListener('click', (e) => {
e.stopPropagation();
el.classList.remove('is-active');
this._showModal({
title: '🗑️ Supprimer le message',
body: `Supprimer définitivement ce message de <strong>${message.username}</strong> ? Cette action est irréversible.`,
confirmLabel: 'Supprimer',
confirmClass: 'danger',
onConfirm: () => this._deleteMessage(message),
});
});
// Bouton signaler
el.querySelector('.jvcm-btn-report')?.addEventListener('click', (e) => {
e.stopPropagation();
el.classList.remove('is-active');
this._showModal({
title: '🚩 Faire un signalement',
body: `Signaler le message de <strong>${message.username}</strong> ?<br><br>
Le formulaire JVC (avec captcha) s'ouvrira dans un nouvel onglet.`,
confirmLabel: 'Signaler',
confirmClass: '',
onConfirm: () => {
window.open(`https://www.jeuxvideo.com/forums/message/${message.id}`, '_blank');
this._toast('Cliquez sur ⚠ dans le message pour signaler', 'info');
},
});
});
this.$messages.appendChild(el);
message.element = el;
this.messages.push(message);
// Animation d'entrée pour les nouveaux messages
if (isNew) {
requestAnimationFrame(() => el.classList.add('is-new'));
}
// Forcer le chargement des images lazy et des backgrounds spoil JVC
for (const img of el.querySelectorAll('img[loading="lazy"]')) {
img.removeAttribute('loading'); // désactiver le lazy loading natif
}
for (const img of el.querySelectorAll('img[data-src], img[data-original], img[data-lazy]')) {
img.src = img.dataset.src || img.dataset.original || img.dataset.lazy || img.src;
}
// Images spoil : data-src-background sur <span> → transformer en <img>
for (const span of el.querySelectorAll('[data-src-background]')) {
const src = span.dataset.srcBackground;
if (!src) continue;
const img = document.createElement('img');
img.src = src;
img.style.cssText = 'max-width:100%;display:block;border-radius:4px;';
span.replaceWith(img);
}
// Mettre en cache l'avatar en arrière-plan et remplacer si l'URL change
if (!_avatarCache.has(message.avatarUrl)) {
cachedAvatar(message.avatarUrl).then(cached => {
if (cached !== message.avatarUrl) {
const img = el.querySelector('.jvcm-avatar');
if (img) img.src = cached;
}
}).catch(() => {}); // CORS safe - pas de fetch
}
}
_scrollToQuoteOrigin(bqNode) {
const raw = bqNode.textContent.replace(/\s+/g, ' ').trim();
const timeStr = raw.match(/\d{2}:\d{2}:\d{2}/)?.[0] ?? null;
const bodyMatch = raw.match(/\d{2}:\d{2}:\d{2}\s*:\s*(.+)/);
const fragment = bodyMatch ? bodyMatch[1].trim().slice(0, 60).toLowerCase() : null;
if (!fragment) { this._toast('Impossible d\'identifier le message cité', 'info'); return; }
let bestMatch = null;
for (const m of this.messages) {
if (!m.element) continue;
const msgText = (m.element.querySelector('.jvcm-content')?.textContent || '')
.replace(/\s+/g, ' ').trim().toLowerCase();
if (msgText.includes(fragment)) {
if (timeStr && m.creationDate && !m.creationDate.includes(timeStr)) continue;
bestMatch = m.element; break;
}
}
if (!bestMatch) {
for (const m of this.messages) {
if (!m.element) continue;
const msgText = (m.element.querySelector('.jvcm-content')?.textContent || '')
.replace(/\s+/g, ' ').trim().toLowerCase();
if (msgText.includes(fragment)) { bestMatch = m.element; break; }
}
}
if (!bestMatch) { this._toast('Message original introuvable dans le chat chargé', 'info'); return; }
this.autoScroll = false;
const current = this.$messages.scrollTop;
const cRect = this.$messages.getBoundingClientRect();
const tRect = bestMatch.getBoundingClientRect();
const center = current + tRect.top - cRect.top
- (this.$messages.clientHeight / 2)
+ (bestMatch.offsetHeight / 2);
this.$messages.scrollTo({ top: Math.max(0, center), behavior: 'smooth' });
bestMatch.classList.add('quote-flash');
setTimeout(() => bestMatch.classList.remove('quote-flash'), 900);
}
_checkMention(el, message) {
if (!this.connectedUser) return;
const bq = el.querySelector('.jvcm-content > blockquote, .jvcm-content > .message__blockquote');
if (bq && bq.textContent.toLowerCase().includes(this.connectedUser.username.toLowerCase())) {
el.classList.add('mentions-me');
if (navigator.vibrate && this.settings.get('vibration')) navigator.vibrate([80, 40, 80]);
}
}
_recheckMentions() {
for (const m of this.messages) {
if (m.element && !m.element.classList.contains('mentions-me')) this._checkMention(m.element, m);
}
}
_updateRelativeTimes() {
for (const el of this.$messages.querySelectorAll('[data-ts]')) {
const ts = parseInt(el.dataset.ts);
if (!ts) continue;
const rel = el.querySelector('.jvcm-date-rel');
if (rel) rel.textContent = relativeTime(ts);
}
}
_attachSwipeQuote(el, message) {
let startX = 0, startY = 0, swiping = false;
const THRESHOLD = 70;
el.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX; startY = e.touches[0].clientY; swiping = false;
}, { passive: true });
el.addEventListener('touchmove', (e) => {
const dx = e.touches[0].clientX - startX;
const dy = Math.abs(e.touches[0].clientY - startY);
if (!swiping && Math.abs(dx) > 10 && dy < 20) swiping = true;
if (!swiping || dx <= 0) return;
el.style.transform = `translateX(${Math.min(dx, THRESHOLD * 1.4) * 0.45}px)`;
el.classList.toggle('swipe-ready', dx >= THRESHOLD);
}, { passive: true });
const endSwipe = () => {
if (swiping && el.classList.contains('swipe-ready')) {
this._quoteMessage(message);
this._toast('Citation ajoutée', 'info');
}
el.style.transform = ''; el.classList.remove('swipe-ready'); swiping = false;
};
el.addEventListener('touchend', endSwipe, { passive: true });
el.addEventListener('touchcancel', endSwipe, { passive: true });
}
// ── Envoi optimiste ──
async _send() {
const text = this.$textarea.value.trim();
if (!text || !this.connectedUser) return;
this.$textarea.value = '';
this.$textarea.style.height = '';
this.$clearBtn?.classList.remove('visible');
this.$sendBtn.disabled = true;
try {
await this.api.postMessage(text);
await this._poll();
this._resetPollTimer?.();
} catch (e) {
// En cas d'erreur, remettre le texte dans le textarea
this.$textarea.value = text;
this.$textarea.dispatchEvent(new Event('input'));
this._toast(e.message, 'error');
} finally {
this.$sendBtn.disabled = false;
}
}
_quoteMessage(message) {
const contentEl = message.element?.querySelector('.jvcm-content');
if (!contentEl) return;
const txt = reverseMessage(contentEl, true);
const quote = `> Le ${message.creationDate} ${message.username} a écrit :\n${txt}\n\n`;
const ta = this.$textarea;
const before = ta.value.slice(0, ta.selectionStart ?? ta.value.length);
const prefix = (before.length > 0 && !before.endsWith('\n\n')) ? '\n\n' : '';
this._insertAtCursor(prefix + quote);
this.$textarea.focus();
}
_insertAtCursor(text) {
const ta = this.$textarea;
const start = ta.selectionStart ?? ta.value.length;
const end = ta.selectionEnd ?? ta.value.length;
ta.value = ta.value.slice(0, start) + text + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + text.length;
ta.dispatchEvent(new Event('input', { bubbles: true }));
}
async _deleteMessage(message) {
try {
await this.api.deleteMessage(message.id);
message.element?.classList.add('is-deleted');
message.isDeleted = true;
} catch (e) {
this._toast(e.message, 'error');
}
}
async _editMessage(message) {
const { jvcode, formSession } = await this.api.getEditForm(message.id);
const contentEl = message.element?.querySelector('.jvcm-content');
if (!contentEl) return;
const backup = contentEl.innerHTML;
contentEl.innerHTML = `
<textarea class="jvcm-edit-ta" style="width:100%;min-height:80px;background:var(--jvc-surface-2);color:var(--jvc-text);border:1px solid var(--jvc-border);border-radius:6px;padding:0.4rem 0.6rem;font-family:inherit;font-size:0.85rem;resize:vertical"></textarea>
<div style="display:flex;gap:0.4rem;margin-top:0.4rem">
<button class="jvcm-edit-cancel" style="flex:1;padding:0.35rem;background:var(--jvc-surface-3);border:1px solid var(--jvc-border);border-radius:5px;color:var(--jvc-text-dim);font-family:inherit;font-size:0.8rem;cursor:pointer">Annuler</button>
<button class="jvcm-edit-submit" style="flex:1;padding:0.35rem;background:var(--jvc-orange);border:none;border-radius:5px;color:#fff;font-family:inherit;font-size:0.8rem;font-weight:600;cursor:pointer">Modifier</button>
</div>
`;
const ta = contentEl.querySelector('.jvcm-edit-ta');
ta.value = jvcode; ta.focus();
contentEl.querySelector('.jvcm-edit-cancel').addEventListener('click', () => { contentEl.innerHTML = backup; });
contentEl.querySelector('.jvcm-edit-submit').addEventListener('click', async () => {
try {
contentEl.innerHTML = await this.api.updateMessage(message.id, ta.value, formSession);
this._toast('Message modifié', 'success');
} catch (e) {
this._toast(e.message, 'error');
contentEl.innerHTML = backup;
}
});
}
_scrollToBottom(force = false) {
if (!force) {
requestAnimationFrame(() => {
this.$messages.scrollTo({ top: this.$messages.scrollHeight, behavior: 'smooth' });
});
return;
}
const imgs = [...this.$messages.querySelectorAll('img')].filter(i => !i.complete);
if (imgs.length === 0) {
requestAnimationFrame(() => this.$messages.scrollTo({ top: this.$messages.scrollHeight, behavior: 'instant' }));
return;
}
let settled = false;
const doScroll = () => {
if (settled) return; settled = true;
this.$messages.scrollTo({ top: this.$messages.scrollHeight, behavior: 'instant' });
};
let loaded = 0;
for (const img of imgs) {
const done = () => { if (++loaded >= imgs.length) doScroll(); };
img.addEventListener('load', done, { once: true });
img.addEventListener('error', done, { once: true });
}
setTimeout(doScroll, 1200);
}
_toast(msg, type = 'info') {
const el = document.createElement('div');
el.className = `jvcm-toast ${type}`;
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), 4000);
}
}
// ─────────────────────────────────────────────────────────────
// DÉTECTION FORUM LOL
// ─────────────────────────────────────────────────────────────
const LOL_KEYWORDS = /league\s*of\s*legends|\blol\b|lec|lck|lpl|lfl|esport/i;
const LOL_FORUM_IDS = new Set(['19163', '42']); // IDs des forums LoL sur JVC
function isLolContext() {
// Priorité 1 : ID du forum dans l'URL
const forumIdMatch = location.href.match(/\/forums\/(?:0-)?(\d+)-/);
if (forumIdMatch && LOL_FORUM_IDS.has(forumIdMatch[1])) return true;
// Priorité 2 : mots-clés dans le fil d'Ariane / titre / nom du forum
const breadcrumb = document.querySelector('.spreadContainer nav.breadcrumb, .breadcrumb')?.textContent || '';
const title = document.title || '';
const forumName = document.querySelector('.spreadContainer nav.breadcrumb > a[href^="/forums/0-"]')?.textContent || '';
return LOL_KEYWORDS.test(breadcrumb + title + forumName);
}
// ─────────────────────────────────────────────────────────────
// MODALE MATCHS STANDALONE (utilisable hors JVChat)
// ─────────────────────────────────────────────────────────────
function injectStandaloneMatchesModal() {
if (document.getElementById('jvcm-matches-overlay')) return setupMatchesUI(); // déjà injecté, rebrancher
// Injecter les variables CSS + styles de base si JVChat n'est pas actif
if (!document.getElementById('jvcm-style')) {
const style = document.createElement('style');
style.id = 'jvcm-standalone-style';
style.textContent = `
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
:root {
--jvc-bg:#18181b; --jvc-surface-1:#1f1f23; --jvc-surface-2:#27272a;
--jvc-surface-3:#303035; --jvc-border:#2e2e33; --jvc-divider:#242428;
--jvc-text:#e4e4e7; --jvc-text-dim:#a1a1aa; --jvc-text-muted:#71717a;
--jvc-orange:#e84d1a; --jvc-green:#4ade80; --jvc-red:#f87171; --jvc-blue:#7fb4ff;
}
@keyframes jvcm-spin { to { transform: rotate(360deg); } }
@keyframes jvcm-breathe { 0%,100%{opacity:1} 50%{opacity:0.3} }
.jvcm-spinner { width:28px;height:28px;border:3px solid var(--jvc-surface-3);border-top-color:var(--jvc-orange);border-radius:50%;animation:jvcm-spin 0.7s linear infinite; }
/* Modale structure */
#jvcm-matches-overlay { position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9001;display:flex;align-items:flex-end;justify-content:center; }
#jvcm-matches-overlay.hidden { display:none; }
#jvcm-matches-panel { background:var(--jvc-bg);border:1px solid var(--jvc-border);border-radius:12px 12px 0 0;width:100%;max-width:520px;max-height:85dvh;display:flex;flex-direction:column;overflow:hidden;font-family:Inter,system-ui,sans-serif; }
#jvcm-matches-handle { width:36px;height:4px;background:var(--jvc-border);border-radius:2px;margin:10px auto 0;flex-shrink:0; }
#jvcm-matches-header { display:flex;align-items:center;padding:0.75rem 1rem 0.5rem;gap:0.5rem;flex-shrink:0; }
#jvcm-matches-title { font-size:0.9rem;font-weight:700;color:var(--jvc-text);flex:1; }
#jvcm-matches-refresh { background:none;border:none;color:var(--jvc-text-muted);font-size:1rem;cursor:pointer;padding:0.2rem 0.4rem;border-radius:4px; }
#jvcm-matches-close { background:none;border:none;color:var(--jvc-text-muted);font-size:1.3rem;cursor:pointer;line-height:1;padding:0.2rem; }
#jvcm-leagues-wrap { padding:0 1rem 0.6rem;display:flex;flex-wrap:wrap;gap:0.35rem;flex-shrink:0;border-bottom:1px solid var(--jvc-border); }
#jvcm-matches-loading { display:none;text-align:center;padding:2rem 1rem;color:var(--jvc-text-muted);font-size:0.82rem;flex-direction:column;align-items:center;gap:0.75rem; }
#jvcm-matches-loading.hidden { display:none; }
#jvcm-matches-empty { display:none;text-align:center;padding:2rem 1rem;color:var(--jvc-text-muted);font-size:0.82rem; }
#jvcm-matches-list { flex:1;overflow-y:auto;padding:0.5rem 0; }
/* Sliders */
#jvcm-time-range { padding:0.5rem 1rem 0.4rem;border-bottom:1px solid var(--jvc-border);display:flex;gap:1rem;flex-shrink:0; }
.jvcm-range-group { flex:1;display:flex;flex-direction:column;gap:0.25rem; }
.jvcm-range-label { font-size:0.6rem;font-weight:600;color:var(--jvc-text-muted);text-transform:uppercase;letter-spacing:0.08em;display:flex;justify-content:space-between; }
.jvcm-range-label span { color:var(--jvc-text);font-variant-numeric:tabular-nums; }
.jvcm-range-input { -webkit-appearance:none;appearance:none;width:100%;height:4px;background:var(--jvc-surface-3);border-radius:2px;outline:none; }
.jvcm-range-input::-webkit-slider-thumb { -webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:var(--jvc-orange);cursor:pointer; }
#jvcm-matches-refresh.spinning { animation:jvcm-spin 0.7s linear infinite;color:var(--jvc-orange); }
.jvcm-league-btn { padding:0.25rem 0.6rem;border-radius:999px;font-size:0.68rem;font-weight:600;font-family:Inter,system-ui,sans-serif;cursor:pointer;background:var(--jvc-surface-2);border:1px solid var(--jvc-border);color:var(--jvc-text-muted);transition:all 150ms ease; }
.jvcm-league-btn.active { background:var(--jvc-orange);border-color:var(--jvc-orange);color:#fff; }
.jvcm-match-row { padding:0.55rem 1rem;border-bottom:1px solid var(--jvc-divider);display:flex;flex-direction:column;gap:0.25rem;font-family:Inter,system-ui,sans-serif; }
.jvcm-match-row:last-child { border-bottom:none; }
.jvcm-match-row[data-state="inProgress"] { background:rgba(239,68,68,0.04);border-left:2px solid rgba(239,68,68,0.5); }
.jvcm-match-row-head { display:flex;align-items:center;gap:0.4rem;font-size:0.58rem;font-weight:700;letter-spacing:0.08em;text-transform:uppercase; }
.jvcm-match-badge-live { color:#ef4444;display:flex;align-items:center;gap:0.3rem; }
.jvcm-match-badge-upcoming { color:#fbbf24;text-transform:none;letter-spacing:0;font-weight:500;font-size:0.65rem; }
.jvcm-match-badge-done { color:var(--jvc-text-muted); }
.jvcm-match-dot { width:6px;height:6px;border-radius:50%;background:currentColor;animation:jvcm-breathe 2.4s ease-in-out infinite; }
.jvcm-match-stage { color:var(--jvc-text-muted);font-weight:500;font-size:0.6rem;text-transform:none;letter-spacing:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.jvcm-match-row-body { display:flex;align-items:baseline;gap:0.3rem;font-size:0.82rem;font-weight:700;color:var(--jvc-text); }
.jvcm-match-team { flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.jvcm-match-team:last-child { text-align:right; }
.jvcm-match-team.winner { color:var(--jvc-green); }
.jvcm-match-team.loser { color:var(--jvc-text-muted); }
.jvcm-match-score { flex-shrink:0;font-size:0.78rem;font-weight:800;padding:0 0.4rem;font-variant-numeric:tabular-nums; }
.jvcm-match-vs { flex-shrink:0;font-size:0.65rem;color:var(--jvc-text-muted);padding:0 0.4rem; }
.jvcm-match-games { display:flex;align-items:center;gap:0.25rem;margin-top:0.15rem;flex-wrap:wrap; }
.jvcm-game-dot { width:22px;height:22px;border-radius:4px;font-size:0.62rem;font-weight:700;display:grid;place-items:center;background:var(--jvc-surface-3);color:var(--jvc-text-muted);cursor:default;transition:opacity 150ms ease; }
.jvcm-game-dot.clickable { cursor:pointer;border:1px solid var(--jvc-border); }
.jvcm-game-dot.clickable:active { opacity:0.7; }
.jvcm-game-dot.selected { border-color:var(--jvc-orange);box-shadow:0 0 0 1px var(--jvc-orange); }
.jvcm-game-dot.live { background:rgba(239,68,68,0.2);color:#ef4444;animation:jvcm-breathe 2.4s ease-in-out infinite; }
.jvcm-game-dot.win { background:rgba(74,222,128,0.2);color:var(--jvc-green); }
.jvcm-game-dot.loss { background:rgba(248,113,113,0.15);color:var(--jvc-red); }
.jvcm-game-dot.win-blue { background:rgba(100,149,237,0.25);color:#7fb4ff;border-color:rgba(100,149,237,0.4); }
.jvcm-game-dot.win-red { background:rgba(248,113,113,0.2);color:var(--jvc-red);border-color:rgba(248,113,113,0.4); }
.jvcm-match-streams { display:flex;align-items:center;gap:0.35rem;margin-top:0.4rem;flex-wrap:wrap; }
.jvcm-stream-btn { display:inline-flex;align-items:center;gap:0.3rem;padding:0.28rem 0.65rem;background:#9147ff;border:none;border-radius:4px;color:#fff;font-size:0.7rem;font-weight:600;font-family:inherit;cursor:pointer;text-decoration:none; }
.jvcm-stream-btn:active { background:#7b35d9; }
.jvcm-stream-select { padding:0.28rem 0.5rem;background:#9147ff;border:none;border-radius:4px;color:#fff;font-size:0.7rem;font-weight:600;font-family:inherit;cursor:pointer;-webkit-appearance:none;outline:none; }
/* Stats game */
.jvcm-game-stats { display:none;flex-direction:column;margin-top:0.35rem;background:rgba(255,255,255,0.03);border:1px solid var(--jvc-border);border-radius:6px;overflow:hidden; }
.jvcm-game-stats.open { display:flex; }
.jvcm-game-stats-header { padding:0.3rem 0.5rem;font-size:0.6rem;font-weight:700;color:var(--jvc-text-muted);text-transform:uppercase;letter-spacing:0.08em;border-bottom:1px solid var(--jvc-border); }
.jvcm-game-stats-loading { padding:0.6rem;text-align:center;font-size:0.65rem;color:var(--jvc-text-muted); }
/* Lane detail */
.jvcm-lane-detail { display:none;flex-direction:column;border-top:1px solid rgba(239,68,68,0.15); }
.jvcm-lane-detail.open { display:flex; }
.jvcm-lane-row { display:flex;align-items:center;padding:0.35rem 0.5rem;gap:0.3rem;border-bottom:1px solid var(--jvc-divider);font-size:0.62rem;font-variant-numeric:tabular-nums; }
.jvcm-lane-row:last-child { border-bottom:none; }
.jvcm-lane-badge { width:20px;text-align:center;flex-shrink:0;font-size:0.6rem;color:var(--jvc-text-muted);font-weight:600; }
.jvcm-lane-player { flex:1;min-width:0;display:flex;flex-direction:column;gap:0.05rem; }
.jvcm-lane-player.right { align-items:flex-end;text-align:right; }
.jvcm-lane-name { font-weight:600;color:var(--jvc-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:80px; }
.jvcm-lane-champ { color:var(--jvc-text-muted);font-size:0.58rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:80px; }
.jvcm-lane-kda { color:var(--jvc-text-dim);font-size:0.6rem;white-space:nowrap; }
.jvcm-lane-gold { color:#fbbf24;font-size:0.6rem;font-weight:600; }
.jvcm-lane-cs { color:var(--jvc-text-muted);font-size:0.58rem;font-style:italic; }
.jvcm-lane-mid-col { display:flex;flex-direction:column;align-items:center;gap:0.1rem;flex-shrink:0;padding:0 0.2rem; }
.jvcm-lane-level { font-size:0.6rem;color:var(--jvc-text-muted);background:var(--jvc-surface-3);border-radius:3px;padding:0.05rem 0.25rem; }
`;
document.head.appendChild(style);
}
document.body.insertAdjacentHTML('beforeend', _matchesModalHTML());
return setupMatchesUI();
}
// ─────────────────────────────────────────────────────────────
// BOOT
// ─────────────────────────────────────────────────────────────
function boot() {
// Injecter Material Symbols pour l'icône JVChat
if (!document.querySelector('link[href*="material-symbols"]')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,[email protected],100..700,0..1,-50..200&icon_names=chat';
document.head.appendChild(link);
}
// Liste des topics : /forums/0-FORUMID-...
// Page topic : /forums/42-FORUMID-TOPICID-... ou /forums/1-...
const isTopicPage = !!location.href.match(/\/forums\/(?:42|1)-\d+/);
const lol = isLolContext();
if (isTopicPage) {
// ── Page topic : bouton JVChat + bouton 🏆 dans la navbar ──
// Chercher le bouton Actualiser par classe OU par texte OU par icône
let refreshIcon = document.querySelector('.buttonsNavbar .buttonsNavbar__icon.icon-refresh')
|| document.querySelector('[class*="icon-refresh"]');
if (!refreshIcon) {
const allBtns = document.querySelectorAll('button, [class*="buttonsNavbar"] > *');
for (const b of allBtns) {
if (b.textContent.trim().includes('Actualiser') || b.querySelector('[class*="refresh"]')) {
refreshIcon = b; break;
}
}
}
if (!refreshIcon) {
// Dernier fallback : chercher la navbar et prendre le dernier bouton
const navbar2 = document.querySelector('.buttonsNavbar, [class*="buttonsNavbar"]');
if (navbar2) refreshIcon = navbar2.lastElementChild;
}
if (!refreshIcon) return;
// Bouton JVChat
let matchesModal = null;
// Créer une deuxième ligne sous la navbar JVC
const navbar = refreshIcon.closest('.buttonsNavbar, [class*="navbar"], nav')
|| refreshIcon.closest('div[class]')?.parentElement
|| refreshIcon.parentElement;
if (!navbar) return;
const row2 = document.createElement('div');
row2.id = 'jvcm-navbar-row2';
row2.style.cssText = `
display: flex; align-items: center; justify-content: center;
background: ${getComputedStyle(navbar).backgroundColor || '#1a1a1f'};
border-top: 1px solid rgba(255,255,255,0.08);
padding: 4px 0;
gap: 0;
`;
navbar.insertAdjacentElement('afterend', row2);
const makeBtn = (icon, label, onClick) => {
const btn = document.createElement('button');
btn.style.cssText = `
flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 2px; padding: 6px 4px;
background: none; border: none; cursor: pointer;
color: inherit; font-family: inherit;
-webkit-tap-highlight-color: transparent;
`;
btn.innerHTML = `<span style="font-size:1.1rem;line-height:1;">${icon}</span><span style="font-size:0.6rem;">${label}</span>`;
btn.addEventListener('click', onClick);
return btn;
};
const jvcBtn = makeBtn(
'<span class="material-symbols-outlined" style="font-size:1.1rem;line-height:1;">chat</span>',
'JVChat',
() => {
row2.remove();
if (matchesModal) document.getElementById('jvcm-matches-overlay')?.remove();
new JVChatMobile().start().catch(e => console.error('[JVChat Mobile]', e));
}
);
row2.appendChild(jvcBtn);
if (lol) {
const sep = document.createElement('span');
sep.style.cssText = 'width:1px;background:rgba(255,255,255,0.1);align-self:stretch;margin:4px 0;';
row2.appendChild(sep);
const matchBtn = makeBtn('🏆', 'Matchs', () => {
if (!matchesModal) matchesModal = injectStandaloneMatchesModal();
matchesModal.open();
});
row2.appendChild(matchBtn);
}
} else {
// ── Liste des topics : bouton 🏆 dans la navbar du bas ──
if (!lol) return;
const injectIntoBottomNav = () => {
const allBtns = document.querySelectorAll('.buttonsNavbar__button, [class*="navbar"] button, .forum-actions button');
let refreshBtn = null;
for (const b of allBtns) {
if (b.textContent.includes('Actualiser') || b.querySelector('[class*="refresh"], [class*="icon-refresh"]')) {
refreshBtn = b; break;
}
}
if (!refreshBtn) {
if (document.getElementById('jvcm-float-btn')) return;
injectStandaloneMatchesModal();
const floatBtn = document.createElement('button');
floatBtn.id = 'jvcm-float-btn';
floatBtn.title = 'Matchs esport';
floatBtn.textContent = '🏆';
document.body.appendChild(floatBtn);
let mm = null;
floatBtn.addEventListener('click', () => {
if (!mm) mm = injectStandaloneMatchesModal();
mm.open();
});
return;
}
if (document.getElementById('jvcm-nav-matches-btn')) return;
const matchBtn = document.createElement('button');
matchBtn.id = 'jvcm-nav-matches-btn';
matchBtn.type = 'button';
matchBtn.className = refreshBtn.className;
matchBtn.removeAttribute('style');
const labelClass = refreshBtn.querySelector('div')?.className || '';
matchBtn.innerHTML = `<i style="font-style:normal;animation:none;transform:none;">🏆</i><div class="${labelClass}">Matchs</div>`;
// Insérer : Actualiser [spacer] [Matchs]
const spBefore = document.createElement('span');
spBefore.className = 'buttonsNavbar__space';
refreshBtn.insertAdjacentElement('afterend', spBefore);
spBefore.insertAdjacentElement('afterend', matchBtn);
let mm = null;
matchBtn.addEventListener('click', () => {
if (!mm) mm = injectStandaloneMatchesModal();
mm.open();
});
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', injectIntoBottomNav, { once: true });
else injectIntoBottomNav();
}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();