Files
farm-tracker-sao/renderer/index.html
s cf43ada948 feat: système de profils — historiques et drop stats séparés par profil
- Profils stockés dans profiles.json (userData), profil "Défaut" rétro-compatible
- Chaque profil a son propre historique de sessions et app state (fichiers suffixés par id)
- Drop stats : mode Privé (fichier propre) ou Partagé (pool dropstats-shared.json commun)
- Bouton 👤 dans la topbar ouvre une modal de gestion des profils
- Créer, renommer (double-clic), supprimer, activer un profil
- Basculer le mode de drop stats (Privé ↔ Partagé) par profil
- timers et alertes isolés par profil via clés localStorage préfixées par profil id
- Switch de profil = rechargement de page (données propres sans état résiduel)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 18:15:42 +02:00

3005 lines
133 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Farm Tracker</title>
<style>
/* Règle imposée : aucun coin arrondi nulle part dans l'application. */
* {
border-radius: 0px !important;
}
:root{
--bg:#0B0F0C;
--bg-panel:#121810;
--bg-panel-2:#171F15;
--border:#26301F;
--text:#EDEAE0;
--text-dim:#8C9A8A;
--corrupt:#9B5DE5;
--corrupt-glow:rgba(155,93,229,.28);
--moss:#6B8F5C;
--boss:#E0556B;
--gold:#F2C94C;
--copper:#C97B4A;
--paused:#C9A227;
--font-display:'IBM Plex Mono', ui-monospace, monospace;
--font-body:-apple-system, system-ui, 'Segoe UI', sans-serif;
--font-mono:'IBM Plex Mono', ui-monospace, 'SF Mono', monospace;
}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
[hidden]{display:none !important;}
body{
background:
radial-gradient(900px 500px at 12% -10%, var(--corrupt-glow), transparent 60%),
var(--bg);
color:var(--text);
font-family:var(--font-body);
min-height:100vh;
-webkit-font-smoothing:antialiased;
user-select:none;
}
input, .feed, .bars, .table-scroll, .drop-list{ user-select:text; }
:focus-visible{outline:2px solid var(--gold); outline-offset:2px;}
.app{max-width:1100px;margin:0 auto;padding:20px 18px 60px;}
/* ---------- Barre du shell (toujours visible une fois passé l'écran de dossier) ---------- */
.shell-topbar{display:flex;align-items:center;gap:10px;padding:6px 2px 22px;flex-wrap:wrap;
-webkit-app-region:drag;}
.shell-topbar button{-webkit-app-region:no-drag;}
.brand{display:flex;align-items:center;gap:10px;font-weight:700;letter-spacing:.06em;cursor:pointer;}
.brand-mark{font-size:18px;}
.brand-text{font-size:13px;color:var(--text-dim);letter-spacing:.18em;font-family:var(--font-mono);}
.spacer{flex:1;}
.btn-ghost{background:transparent;border:1px solid var(--border);color:var(--text-dim);
font-family:var(--font-body);font-size:12px;padding:7px 12px;cursor:pointer;
white-space:nowrap;text-decoration:none;display:inline-flex;align-items:center;}
.btn-ghost:hover{color:var(--text);border-color:#3a4632;}
.btn-ghost.small{font-size:11.5px;padding:5px 10px;}
.btn-ghost.danger:hover{color:var(--boss);border-color:var(--boss);}
.btn-ghost.accent{color:var(--paused);border-color:#4a4322;}
/* ---------- Écran de choix du dossier (une seule fois, global) ---------- */
.connect-screen{display:flex;justify-content:center;padding:8vh 0;}
.connect-card{max-width:480px;background:var(--bg-panel);border:1px solid var(--border);
padding:36px 32px;text-align:left;}
.eyebrow{font-family:var(--font-mono);font-size:11px;letter-spacing:.16em;color:var(--corrupt);
margin:0 0 10px;text-transform:uppercase;}
.connect-card h1{font-size:22px;line-height:1.3;margin:0 0 14px;}
.lead{color:var(--text-dim);font-size:14px;line-height:1.6;margin:0 0 22px;}
.lead code{background:var(--bg-panel-2);border:1px solid var(--border);
padding:1px 6px;font-family:var(--font-mono);font-size:12.5px;color:var(--text);}
.btn-primary{background:var(--corrupt);color:#fff;border:none;
font-family:var(--font-body);font-weight:600;font-size:14px;padding:12px 20px;cursor:pointer;
width:100%;transition:transform .12s ease, background .12s ease;}
.btn-primary:hover{background:#a86cf0;}
.btn-primary:active{transform:scale(.98);}
.hint{font-size:12px;color:var(--text-dim);margin-top:16px;line-height:1.6;}
.error-box{margin-top:16px;font-size:13px;color:var(--boss);background:rgba(224,85,107,.08);
border:1px solid rgba(224,85,107,.3);padding:10px 12px;line-height:1.5;}
/* ---------- Écran d'accueil (hub) ---------- */
.hub-screen{padding:4vh 0;}
.hub-screen h1{font-size:24px;margin:0 0 6px;}
.hub-lead{color:var(--text-dim);font-size:14px;margin:0 0 28px;}
.app-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px;}
.app-tile{background:var(--bg-panel);border:1px solid var(--border);padding:22px 20px;
text-align:left;cursor:pointer;color:var(--text);font-family:var(--font-body);
display:flex;flex-direction:column;gap:10px;transition:border-color .15s ease, transform .1s ease;}
.app-tile:hover{border-color:var(--corrupt);transform:translateY(-2px);}
.app-tile:active{transform:translateY(0);}
.app-tile-icon{font-size:26px;}
.app-tile-title{font-size:15px;font-weight:700;}
.app-tile-desc{font-size:12.5px;color:var(--text-dim);line-height:1.5;}
.app-tile-status{font-family:var(--font-mono);font-size:10.5px;color:var(--moss);
text-transform:uppercase;letter-spacing:.08em;}
.app-tile-placeholder{border-style:dashed;color:var(--text-dim);align-items:center;
justify-content:center;text-align:center;opacity:.5;cursor:default;}
.app-tile-placeholder:hover{border-color:var(--border);transform:none;}
/* ---------- Vue d'un outil (Farm Tracker) ---------- */
.app-screen{display:flex;flex-direction:column;gap:18px;}
.app-subbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
.status{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-dim);
border:1px solid var(--border);padding:6px 12px;background:var(--bg-panel);}
.dot{width:8px;height:8px;background:#555;flex:none;}
.dot.live{background:#5BD17C;animation:pulse 1.8s infinite;}
.dot.error{background:var(--boss);}
.dot.paused{background:var(--paused);}
@keyframes pulse{
0%{box-shadow:0 0 0 0 rgba(91,209,124,.55);}
70%{box-shadow:0 0 0 7px rgba(91,209,124,0);}
100%{box-shadow:0 0 0 0 rgba(91,209,124,0);}
}
.global-msg{font-family:var(--font-mono);font-size:12px;color:var(--gold);
background:rgba(242,201,76,.08);border:1px solid rgba(242,201,76,.25);
padding:8px 12px;display:none;}
.global-msg.error{color:var(--boss);background:rgba(224,85,107,.08);border-color:rgba(224,85,107,.3);}
.global-msg.show{display:block;}
.hero{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;}
.hero.hero-economy{grid-template-columns:repeat(2,1fr);}
.hero-stat{background:var(--bg-panel);border:1px solid var(--border);padding:18px 18px 16px;}
.hero-stat.estimate{border-style:dashed;border-color:#3a4225;}
.hero-number{font-family:var(--font-display);font-size:24px;margin:6px 0 4px;letter-spacing:.02em;}
.hero-sub{font-family:var(--font-mono);font-size:11.5px;color:var(--text-dim);margin:0;}
.muted{color:var(--text-dim);font-weight:400;text-transform:none;letter-spacing:0;font-family:var(--font-body);font-size:11px;}
.economy-note,.session-note{font-size:12px;color:var(--text-dim);line-height:1.5;margin:-4px 0 0;}
.section-toolbar{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin-bottom:2px;}
.section-toolbar h2{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--text-dim);
margin:0;font-weight:600;}
.grid{display:grid;grid-template-columns:1fr 1.3fr;gap:14px;}
.panel{background:var(--bg-panel);border:1px solid var(--border);padding:18px 18px 14px;}
.panel-head-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
.panel-head-row h2{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--text-dim);
margin:0;font-weight:600;}
.panel-head-row .spacer{flex:1;}
.panel h2{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--text-dim);
margin:0 0 14px;font-weight:600;}
.filter-input{background:var(--bg-panel-2);border:1px solid var(--border);color:var(--text);
font-family:var(--font-body);font-size:12px;padding:6px 10px;min-width:140px;}
.filter-input::placeholder{color:var(--text-dim);}
select.filter-input{cursor:pointer;max-width:260px;}
.bars{display:flex;flex-direction:column;gap:14px;max-height:340px;overflow-y:auto;padding-right:4px;}
.bar-row .bar-label{display:flex;justify-content:space-between;align-items:baseline;
font-size:13.5px;margin-bottom:6px;}
.bar-row .bar-count{font-family:var(--font-mono);font-size:13px;color:var(--text-dim);}
.bar-track{height:7px;background:var(--bg-panel-2);overflow:hidden;}
.bar-fill{height:100%;transition:width .4s ease;}
.table-scroll{max-height:340px;overflow-y:auto;border-bottom:1px solid var(--border);margin-bottom:10px;}
.loot-table{width:100%;border-collapse:collapse;}
.loot-table thead th{position:sticky;top:0;background:var(--bg-panel);z-index:1;
font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;
color:var(--text-dim);text-align:left;padding-bottom:8px;border-bottom:1px solid var(--border);
font-weight:600;}
.loot-table th.qty-cell,.loot-table th.rate-cell,.loot-table th.price-cell,.loot-table th.value-cell{text-align:right;}
.loot-table td{padding:8px 6px 8px 0;font-size:13.5px;border-bottom:1px solid var(--border);vertical-align:middle;}
.loot-table tr:last-child td{border-bottom:none;}
.name-cell{display:flex;align-items:center;gap:8px;}
.swatch{width:8px;height:8px;flex:none;}
.qty-cell{font-family:var(--font-mono);text-align:right;white-space:nowrap;}
.rate-cell{font-family:var(--font-mono);text-align:right;color:var(--text-dim);font-size:11.5px;
white-space:nowrap;padding-left:10px;}
.price-cell{text-align:right;padding-left:10px;}
.value-cell{font-family:var(--font-mono);text-align:right;padding-left:10px;white-space:nowrap;color:var(--copper);}
.price-input{width:64px;background:var(--bg-panel-2);border:1px solid var(--border);
color:var(--text);font-family:var(--font-mono);font-size:12.5px;
padding:5px 7px;text-align:right;}
.price-input::-webkit-outer-spin-button,.price-input::-webkit-inner-spin-button{margin:0;}
.loot-total-row{display:flex;justify-content:space-between;align-items:baseline;
font-family:var(--font-mono);font-size:13px;padding-top:2px;}
.loot-total-row .value-cell{color:var(--copper);font-weight:600;padding-left:0;}
.feed-panel{min-height:120px;}
.feed{display:flex;flex-direction:column;gap:6px;max-height:280px;overflow-y:auto;}
.feed-empty{color:var(--text-dim);font-size:13px;padding:6px 0;}
.feed-item{display:flex;align-items:center;gap:10px;font-size:13px;padding:8px 10px;
background:var(--bg-panel-2);border-left:3px solid var(--moss);
animation:feedIn .35s ease;}
.feed-item .feed-time{font-family:var(--font-mono);font-size:11px;color:var(--text-dim);
flex:none;width:54px;}
.feed-item .feed-mob{color:var(--text-dim);font-size:11.5px;white-space:nowrap;}
.feed-item .feed-items{flex:1;display:flex;flex-wrap:wrap;gap:4px 10px;}
@keyframes feedIn{
from{opacity:0; transform:translateY(-6px);}
to{opacity:1; transform:translateY(0);}
}
.droprow{margin-bottom:12px;}
.droprow:last-child{margin-bottom:0;}
.droprow-label{display:flex;justify-content:space-between;align-items:baseline;font-size:13px;margin-bottom:5px;}
.droprow-pct{font-family:var(--font-mono);font-size:12.5px;}
.droprow-sub{font-family:var(--font-mono);font-size:10.5px;color:var(--text-dim);margin-top:3px;}
.drop-list{max-height:340px;overflow-y:auto;padding-right:4px;}
.drop-share-note{font-size:11.5px;color:var(--text-dim);line-height:1.5;margin:-2px 0 12px;}
/* ---------- Drop Board ---------- */
.drop-board{display:flex;flex-direction:column;gap:32px;}
.drop-board-mob-header{display:flex;align-items:baseline;gap:14px;
border-bottom:1px solid var(--border);padding-bottom:10px;margin-bottom:14px;}
.drop-board-mob-name{font-family:var(--font-mono);font-size:11px;font-weight:700;
letter-spacing:.18em;text-transform:uppercase;color:var(--text);}
.drop-board-mob-kills{font-family:var(--font-mono);font-size:11px;color:var(--text-dim);}
.drop-board-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;}
.drop-card{background:var(--bg-panel-2);border:1px solid var(--border);border-left:3px solid;
padding:14px 14px 12px;display:flex;flex-direction:column;gap:5px;cursor:context-menu;}
.drop-card:hover{border-color:var(--border) var(--border) var(--border);}
.drop-card-pct{font-family:var(--font-display);font-size:28px;line-height:1;letter-spacing:.01em;}
.drop-card-name{font-size:12.5px;line-height:1.4;}
.drop-card-sub{font-family:var(--font-mono);font-size:10px;color:var(--text-dim);
margin-top:auto;padding-top:6px;}
/* ---------- Badge communauté + sync ---------- */
.community-badge{font-family:var(--font-mono);font-size:10px;color:var(--moss);
background:rgba(107,143,92,.12);border:1px solid rgba(107,143,92,.28);
padding:2px 8px;text-transform:uppercase;letter-spacing:.08em;}
.sync-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:6px;}
.sync-url-input{flex:1;min-width:180px;background:var(--bg-panel-2);border:1px solid var(--border);
color:var(--text);font-family:var(--font-body);font-size:12px;padding:6px 10px;}
.sync-url-input::placeholder{color:var(--text-dim);}
/* ---------- Modals sync ---------- */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:900;display:none;
align-items:center;justify-content:center;}
.modal-overlay.visible{display:flex;}
.modal-overlay-2{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:910;display:none;
align-items:center;justify-content:center;}
.modal-overlay-2.visible{display:flex;}
.modal-box{background:var(--bg-panel);border:1px solid var(--border);padding:28px 28px 22px;
max-width:480px;width:90%;box-shadow:0 8px 40px rgba(0,0,0,.7);}
.modal-box h3{font-size:15px;margin:0 0 12px;font-weight:700;}
.modal-desc{font-size:13px;color:var(--text-dim);line-height:1.65;margin:0 0 16px;}
.modal-desc strong{color:var(--text);}
.modal-url-label{font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;
text-transform:uppercase;color:var(--text-dim);margin-bottom:5px;}
.modal-url-input{width:100%;background:var(--bg-panel-2);border:1px solid var(--border);
color:var(--text);font-family:var(--font-mono);font-size:13px;padding:8px 10px;margin-bottom:16px;}
.modal-url-input::placeholder{color:var(--text-dim);}
.modal-files{background:var(--bg-panel-2);border:1px solid var(--border);
padding:10px 12px;margin-bottom:18px;display:flex;flex-direction:column;gap:6px;}
.modal-file-row{display:flex;align-items:baseline;gap:8px;font-size:11.5px;}
.modal-file-arrow{font-size:11px;flex:none;}
.modal-file-path{font-family:var(--font-mono);font-size:10.5px;color:var(--text-dim);
word-break:break-all;}
.modal-actions{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap;}
.modal-warn{background:rgba(242,201,76,.07);border:1px solid rgba(242,201,76,.2);
padding:12px 14px;margin-bottom:18px;font-size:13px;color:var(--gold);line-height:1.6;}
.modal-warn strong{display:block;margin-bottom:4px;font-size:13.5px;}
/* ---------- Menu contextuel "qui drop cet item ?" ---------- */
.item-ctx-menu{position:fixed;z-index:999;background:var(--bg-panel);
border:1px solid var(--border);padding:12px 14px;
min-width:220px;max-width:320px;box-shadow:0 4px 24px rgba(0,0,0,.65);
display:none;pointer-events:none;}
.item-ctx-menu.visible{display:block;pointer-events:auto;}
.item-ctx-title{font-family:var(--font-mono);font-size:10px;letter-spacing:.12em;
color:var(--text-dim);text-transform:uppercase;margin-bottom:10px;}
.item-ctx-row{padding:5px 0;border-bottom:1px solid var(--border);}
.item-ctx-row:last-child{border-bottom:none;}
.item-ctx-mob{display:flex;justify-content:space-between;align-items:baseline;gap:8px;}
.item-ctx-mob-name{font-size:13px;}
.item-ctx-mob-pct{font-family:var(--font-mono);font-size:12px;}
.item-ctx-mob-sub{font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:2px;}
.item-ctx-empty{font-size:12px;color:var(--text-dim);}
@media (max-width:760px){
.grid{grid-template-columns:1fr;}
.hero{grid-template-columns:1fr;}
.hero.hero-economy{grid-template-columns:1fr;}
}
/* ---------- Historique des sessions ---------- */
.chart-wrap{position:relative;margin:0 0 2px;}
.chart-empty{font-size:13px;color:var(--text-dim);padding:18px 0 6px;}
.chart-label-row{display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:3px;}
.charts-2col{display:grid;grid-template-columns:1fr 1fr;gap:14px;}
.sessions-table{width:100%;border-collapse:collapse;}
.sessions-table thead th{position:sticky;top:0;background:var(--bg-panel);z-index:1;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--text-dim);text-align:left;padding:0 8px 8px 0;border-bottom:1px solid var(--border);font-weight:600;white-space:nowrap;}
.sessions-table th.r,.sessions-table td.r{text-align:right;padding-left:12px;}
.sessions-table td{padding:9px 8px 9px 0;font-size:13px;border-bottom:1px solid var(--border);vertical-align:middle;}
.sessions-table tr:last-child td{border-bottom:none;}
.sessions-table td.mono{font-family:var(--font-mono);color:var(--text-dim);}
.sessions-table td.hl{font-family:var(--font-mono);color:var(--text);}
.sessions-table td.dim{font-size:12px;color:var(--text-dim);}
.hist-empty{padding:40px 0;text-align:center;color:var(--text-dim);font-size:14px;line-height:1.8;}
.sessions-table tbody tr:hover td{background:rgba(255,255,255,.03);}
@media(max-width:760px){.charts-2col{grid-template-columns:1fr;}}
/* ---------- Graphes en direct ---------- */
.charts-3col{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;}
@media(max-width:760px){.charts-3col{grid-template-columns:1fr;}}
.live-chart-header{display:flex;justify-content:space-between;align-items:baseline;
font-family:var(--font-mono);font-size:11px;margin-bottom:5px;}
.live-chart-header .chart-label{color:var(--text-dim);}
.live-chart-header .chart-val{font-size:12px;font-weight:600;}
/* ---------- Timers ---------- */
.timer-add-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:4px;}
.timer-add-row input{background:var(--bg-panel-2);border:1px solid var(--border);color:var(--text);
font-family:var(--font-body);font-size:13px;padding:7px 10px;min-width:0;}
.timer-add-row input:focus{outline:2px solid var(--gold);outline-offset:0;}
.timer-add-row input::placeholder{color:var(--text-dim);}
.timer-config-list{display:flex;flex-direction:column;gap:6px;margin-top:12px;}
.timer-config-row{display:flex;align-items:center;gap:10px;padding:8px 10px;
background:var(--bg-panel-2);border:1px solid var(--border);}
.timer-config-mob{flex:1;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.timer-config-dur{font-family:var(--font-mono);font-size:12px;color:var(--text-dim);white-space:nowrap;}
.timer-active-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:10px;margin-top:12px;}
.timer-card{background:var(--bg-panel-2);border:2px solid var(--border);padding:14px 16px;}
.timer-card.timer-ok{border-color:var(--moss);}
.timer-card.timer-warn{border-color:var(--paused);}
.timer-card.timer-done{border-color:var(--boss);background:rgba(224,85,107,.07);}
.timer-card-mob{font-size:13px;font-weight:600;margin-bottom:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.timer-card-countdown{font-family:var(--font-mono);font-size:32px;font-weight:700;line-height:1;letter-spacing:.04em;}
.timer-card-countdown.timer-ok{color:var(--moss);}
.timer-card-countdown.timer-warn{color:var(--paused);}
.timer-card-countdown.timer-done{color:var(--boss);}
.timer-card-sub{font-size:11px;color:var(--text-dim);margin-top:5px;}
.timer-card-bar{height:3px;background:var(--border);margin-top:10px;overflow:hidden;}
.timer-card-bar-fill{height:100%;transition:width .9s linear;}
.timer-empty-msg{color:var(--text-dim);font-size:13px;padding:20px 0;}
/* --- Volume slider --- */
input[type=range]{-webkit-appearance:none;height:3px;background:var(--border);outline:none;cursor:pointer;flex:1;}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:11px;height:11px;background:var(--moss);cursor:pointer;}
input[type=range]::-moz-range-thumb{width:11px;height:11px;background:var(--moss);cursor:pointer;border:none;}
/* --- Alert config rows --- */
.alert-cfg-row{display:flex;align-items:center;gap:8px;padding:7px 0;border-bottom:1px solid var(--border);}
.alert-cfg-row:last-child{border-bottom:none;}
.alert-cfg-name{flex:1;font-size:12px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.alert-cfg-sound{font-size:11px;background:var(--bg);border:1px solid var(--border);color:var(--text);
padding:2px 5px;cursor:pointer;max-width:140px;}
/* --- Sound library --- */
.sound-lib-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:8px;margin-top:10px;}
.sound-card{background:var(--bg);border:1px solid var(--border);padding:10px 12px;
display:flex;flex-direction:column;gap:5px;}
.sound-card-name{font-size:12px;font-weight:600;}
.sound-card-desc{font-size:11px;color:var(--text-dim);}
.sound-card-actions{display:flex;gap:6px;align-items:center;margin-top:2px;}
.sound-card-status{font-size:10px;font-family:var(--font-mono);color:var(--moss);margin-left:auto;}
/* --- Profils --- */
#profileBtn{font-size:11px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.profile-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:900;display:flex;align-items:center;justify-content:center;}
.profile-modal-overlay[hidden]{display:none;}
.profile-modal-box{background:var(--bg-panel);border:1px solid var(--border);padding:22px 24px;width:460px;max-width:95vw;max-height:80vh;overflow-y:auto;display:flex;flex-direction:column;gap:14px;}
.profile-modal-box h3{font-size:14px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;margin:0;}
.profile-list{display:flex;flex-direction:column;gap:6px;}
.profile-row{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid var(--border);background:var(--bg);}
.profile-row.active{border-color:var(--moss);}
.profile-row-name{flex:1;font-size:13px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.profile-row-mode{font-size:10px;font-family:var(--font-mono);padding:2px 6px;border:1px solid var(--border);color:var(--text-dim);cursor:pointer;white-space:nowrap;}
.profile-row-mode.shared{color:var(--corrupt);border-color:var(--corrupt);}
.profile-create-form{display:flex;flex-direction:column;gap:8px;padding:10px;border:1px solid var(--border);background:var(--bg);}
.profile-create-form input[type=text]{width:100%;box-sizing:border-box;}
.profile-mode-row{display:flex;gap:16px;font-size:12px;color:var(--text-dim);}
.profile-mode-row label{display:flex;align-items:center;gap:5px;cursor:pointer;}
.profile-mode-desc{font-size:11px;color:var(--text-dim);line-height:1.5;}
#chartTip{position:fixed;z-index:1001;background:var(--bg-panel);border:1px solid var(--border);
padding:4px 10px;font-family:var(--font-mono);font-size:11px;color:var(--text);
pointer-events:none;display:none;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,.5);}
</style>
</head>
<body>
<div class="app">
<div class="shell-topbar">
<div class="brand" id="brandHome"><span class="brand-mark"></span><span class="brand-text">FARM TRACKER</span></div>
<div class="spacer"></div>
<button class="btn-ghost small" id="profileBtn">👤 …</button>
<button class="btn-ghost" id="homeBtn" hidden>🏠 Accueil</button>
<button class="btn-ghost" id="changeFolderBtn" hidden>Changer de dossier</button>
<button class="btn-ghost small" id="checkUpdateBtn" title="Vérifier les mises à jour">Mises à jour</button>
</div>
<!-- Modal profils -->
<div class="profile-modal-overlay" id="profileModal" hidden>
<div class="profile-modal-box">
<div style="display:flex;justify-content:space-between;align-items:center;">
<h3>Profils</h3>
<button class="btn-ghost small" id="profileModalClose"></button>
</div>
<div class="profile-list" id="profileList"></div>
<div class="profile-create-form" id="profileCreateForm" hidden>
<input type="text" id="profileNewName" placeholder="Nom du profil" maxlength="40">
<div class="profile-mode-row">
<label><input type="radio" name="newProfileMode" value="private" checked> Stats de drop privées</label>
<label><input type="radio" name="newProfileMode" value="shared"> Stats de drop partagées</label>
</div>
<p class="profile-mode-desc" id="profileModeDesc">
<strong>Privé :</strong> ce profil a ses propres chances de drop, indépendantes des autres.<br>
<strong>Partagé :</strong> ce profil partage le même pool de drop stats que tous les autres profils en mode partagé.
</p>
<div style="display:flex;gap:8px;">
<button class="btn-ghost small" id="profileCreateCancel">Annuler</button>
<button class="btn-ghost accent small" id="profileCreateConfirm">Créer le profil</button>
</div>
</div>
<button class="btn-ghost small" id="profileNewBtn" style="align-self:flex-start;">+ Nouveau profil</button>
</div>
</div>
<!-- ============ ÉCRAN 1 : choix du dossier (une seule fois, global) ============ -->
<section class="connect-screen" id="folderScreen">
<div class="connect-card">
<p class="eyebrow">Étape 1</p>
<h1>Connecte ton dossier Minecraft</h1>
<p class="lead">
Choisis ton dossier <code>.minecraft</code> (généralement <code>~/.minecraft</code>
sur Linux), ou directement le dossier <code>logs</code>. Cette étape ne se fait
qu'une fois : tous les outils de l'appli s'appuieront sur ce même dossier.
</p>
<button class="btn-primary" id="pickFolderBtn">Choisir le dossier</button>
<p class="hint">
Tout est sauvegardé automatiquement sur ton disque — rien à exporter ni à
recharger toi-même. Au prochain lancement, l'appli retrouve directement ton
dossier et reprend exactement où tu en étais.
</p>
<div class="error-box" id="folderErrorBox" hidden></div>
</div>
</section>
<!-- ============ ÉCRAN 2 : accueil / choix d'outil ============ -->
<section class="hub-screen" id="hubScreen" hidden>
<p class="eyebrow">Accueil</p>
<h1>Tes outils</h1>
<p class="hub-lead">Choisis un outil. Les autres continuent de tourner en arrière-plan, même quand tu n'es pas sur leur écran.</p>
<div class="app-grid">
<button class="app-tile" id="tileFarmTracker">
<span class="app-tile-icon"></span>
<span class="app-tile-title">Farm Tracker</span>
<span class="app-tile-desc">Suivi de loot Minecraft en direct : mobs, items, cols, chances de drop.</span>
<span class="app-tile-status" id="tileFarmTrackerStatus">&nbsp;en attente</span>
</button>
<button class="app-tile" id="tileDropBoard">
<span class="app-tile-icon">🎲</span>
<span class="app-tile-title">Drop Board</span>
<span class="app-tile-desc">Vue visuelle des chances de drop par mob, sous forme de cartes.</span>
<span class="app-tile-status" id="tileDropBoardStatus">&nbsp;en attente</span>
</button>
<button class="app-tile" id="tileHistory">
<span class="app-tile-icon">📊</span>
<span class="app-tile-title">Historique</span>
<span class="app-tile-desc">Statistiques des sessions passées et évolution du farm au fil du temps.</span>
<span class="app-tile-status" id="tileHistoryStatus">&nbsp;0 session</span>
</button>
<button class="app-tile" id="tileTimers">
<span class="app-tile-icon"></span>
<span class="app-tile-title">Timers</span>
<span class="app-tile-desc">Compte à rebours automatique au kill — idéal pour les respawns de boss.</span>
<span class="app-tile-status" id="tileTimersStatus">&nbsp;0 timer</span>
</button>
</div>
</section>
<!-- ============ ÉCRAN 3 : outil Farm Tracker ============ -->
<section class="app-screen" id="appFarmTracker" hidden>
<div class="app-subbar">
<div class="status" id="statusPill"><span class="dot" id="statusDot"></span><span id="statusText"></span></div>
<button class="btn-ghost accent" id="pauseBtn">⏸ Pause</button>
<button class="btn-ghost" id="resetBtn">Réinitialiser la session</button>
</div>
<p class="global-msg" id="globalMsg"></p>
<div class="hero">
<div class="hero-stat">
<p class="eyebrow">Session</p>
<p class="hero-number" id="timer">00:00:00</p>
<p class="hero-sub" id="lastEvent">en attente de loot…</p>
</div>
<div class="hero-stat">
<p class="eyebrow">Mobs lootés</p>
<p class="hero-number" id="totalKills">0</p>
<p class="hero-sub" id="killsRate">0,0 / min</p>
</div>
<div class="hero-stat">
<p class="eyebrow">Exp cumulée</p>
<p class="hero-number" id="expTotal">0</p>
<p class="hero-sub" id="expRate">0,0 / min</p>
</div>
</div>
<div class="hero hero-economy">
<div class="hero-stat">
<p class="eyebrow">Cols gagnés en tuant</p>
<p class="hero-number" id="colsKill">0</p>
<p class="hero-sub" id="colsKillRate">0,0 / min</p>
</div>
<div class="hero-stat estimate">
<p class="eyebrow">Cols gagnés en vendant <span class="muted">(estimé)</span></p>
<p class="hero-number" id="colsSell">≈ 0</p>
<p class="hero-sub" id="colsSellRate">0,0 / min</p>
</div>
</div>
<p class="economy-note">Le jeu ne transmet pas les ventes : fixe un prix par item dans le tableau « Loot » ci-dessous. Tes prix sont sauvegardés automatiquement.</p>
<div class="panel" id="liveChartsPanel" hidden>
<div class="panel-head-row">
<h2>Progression dans le temps <span class="muted">— snapshot toutes les 30s</span></h2>
</div>
<div class="charts-3col">
<div>
<div class="live-chart-header">
<span class="chart-label">Kills / min</span>
<span class="chart-val" id="liveChartKillsVal" style="color:var(--moss)"></span>
</div>
<div class="chart-wrap" id="liveChartKills"></div>
<div class="chart-label-row" id="liveChartKillsTime"><span></span><span></span></div>
</div>
<div>
<div class="live-chart-header">
<span class="chart-label">XP / min</span>
<span class="chart-val" id="liveChartExpVal" style="color:var(--gold)"></span>
</div>
<div class="chart-wrap" id="liveChartExp"></div>
<div class="chart-label-row" id="liveChartExpTime"><span></span><span></span></div>
</div>
<div>
<div class="live-chart-header">
<span class="chart-label">Cols / min</span>
<span class="chart-val" id="liveChartColsVal" style="color:var(--copper)"></span>
</div>
<div class="chart-wrap" id="liveChartCols"></div>
<div class="chart-label-row" id="liveChartColsTime"><span></span><span></span></div>
</div>
</div>
</div>
<div class="section-toolbar">
<h2>Stats de farm — cette session</h2>
</div>
<p class="session-note">Sauvegardées en continu sur le disque. "Réinitialiser la session" remet juste les compteurs à zéro — les chances de drop, elles, ne sont jamais perdues.</p>
<div class="grid">
<div class="panel">
<div class="panel-head-row">
<h2>Mobs</h2>
<div class="spacer"></div>
<input type="text" id="mobFilterInput" class="filter-input" placeholder="Filtrer…">
</div>
<div id="mobsList" class="bars"></div>
</div>
<div class="panel">
<div class="panel-head-row">
<h2>Loot</h2>
<div class="spacer"></div>
<input type="text" id="itemFilterInput" class="filter-input" placeholder="Filtrer les items…">
</div>
<p style="font-size:11px;color:var(--text-dim);margin:-10px 0 8px;">Clic droit sur un item pour voir qui le drop.</p>
<div class="table-scroll">
<table class="loot-table">
<thead>
<tr>
<th>Item</th>
<th class="qty-cell">Qté</th>
<th class="rate-cell">/min</th>
<th class="price-cell">Prix</th>
<th class="value-cell">Valeur</th>
</tr>
</thead>
<tbody id="itemsList"></tbody>
</table>
</div>
<div class="loot-total-row">
<span>Total estimé</span>
<span class="value-cell" id="sellTotalCell">0</span>
</div>
</div>
</div>
<div class="panel">
<div class="panel-head-row">
<h2>Chance de drop <span class="muted">— cumulée, fichier séparé et partageable</span></h2>
<span class="community-badge" id="communityBadge" hidden>🌐 Communauté</span>
<div class="spacer"></div>
<button class="btn-ghost small" id="toggleCommunityBtn" hidden>👤 Mes stats perso</button>
<select id="dropMobSelect" class="filter-input"></select>
</div>
<p class="drop-share-note">
Stockées dans un fichier à part (lisible, noms d'items décodés). Exporte / importe manuellement,
ou utilise le <strong>sync communautaire</strong> pour envoyer tes stats au serveur et recevoir
celles de tout le monde en une fois. Clic droit sur un item pour voir quel mob le drop.
</p>
<div class="panel-head-row" style="margin-top:-6px;">
<button class="btn-ghost small" id="exportDropBtn">📤 Exporter mes stats</button>
<button class="btn-ghost small" id="importDropBtn">📥 Importer / fusionner…</button>
<div class="spacer"></div>
<button class="btn-ghost small danger" id="resetDropBtn">Réinitialiser</button>
</div>
<div class="sync-row">
<input type="text" id="syncServerUrl" class="sync-url-input filter-input" placeholder="URL serveur sync (ex: http://monvps.com:3742)">
<button class="btn-ghost small" id="syncDropBtn">🔄 Sync communauté</button>
</div>
<div id="dropStatsBody" class="drop-list" style="margin-top:14px;"></div>
</div>
<div class="panel feed-panel">
<h2>Flux en direct</h2>
<div id="feed" class="feed"><p class="feed-empty">Les nouveaux drops apparaîtront ici…</p></div>
</div>
</section>
<!-- ============ ÉCRAN 5 : Historique des sessions ============ -->
<section class="app-screen" id="appHistory" hidden>
<div class="hero" id="histHero" hidden>
<div class="hero-stat">
<p class="eyebrow">Sessions</p>
<p class="hero-number" id="histTotalSessions">0</p>
<p class="hero-sub">enregistrées</p>
</div>
<div class="hero-stat">
<p class="eyebrow">Kills totaux</p>
<p class="hero-number" id="histTotalKills">0</p>
<p class="hero-sub" id="histAvgKills">moy. 0 / session</p>
</div>
<div class="hero-stat">
<p class="eyebrow">Temps total</p>
<p class="hero-number" id="histTotalTime">0h 00m</p>
<p class="hero-sub" id="histAvgTime">moy. 0h / session</p>
</div>
<div class="hero-stat">
<p class="eyebrow">Kills/min moyen</p>
<p class="hero-number" id="histAvgRate">0</p>
<p class="hero-sub" id="histBestRate">meilleur 0/min</p>
</div>
<div class="hero-stat">
<p class="eyebrow">Exp totale</p>
<p class="hero-number" id="histTotalExp">0</p>
<p class="hero-sub" id="histAvgExp">moy. 0 / session</p>
</div>
<div class="hero-stat">
<p class="eyebrow">Kamas totaux</p>
<p class="hero-number" id="histTotalKamas">0</p>
<p class="hero-sub" id="histAvgKamas">moy. 0 / session</p>
</div>
</div>
<div id="histEmptyState">
<div class="hist-empty">
<div style="font-size:36px;margin-bottom:12px;">📊</div>
<strong>Pas encore d'historique</strong><br>
Les sessions sont sauvegardées automatiquement quand tu cliques sur<br>
<strong>"Réinitialiser la session"</strong> dans Farm Tracker.
</div>
</div>
<div id="histChartsArea" hidden>
<div class="panel" style="margin-bottom:14px;">
<div class="panel-head-row">
<h2>Kills par session</h2>
<div class="spacer"></div>
<span style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);" id="histKillsAvgLabel"></span>
</div>
<div class="chart-wrap" id="chartKills"></div>
<div class="chart-label-row" id="chartKillsLabels"></div>
</div>
<div class="charts-2col" style="margin-bottom:14px;">
<div class="panel">
<h2>Kills / min</h2>
<div class="chart-wrap" id="chartRate"></div>
<div class="chart-label-row" id="chartRateLabels"></div>
</div>
<div class="panel">
<h2>Exp par session</h2>
<div class="chart-wrap" id="chartExp"></div>
<div class="chart-label-row" id="chartExpLabels"></div>
</div>
</div>
</div>
<div class="panel" id="histSessionsPanel" hidden>
<div class="panel-head-row">
<h2>Détail des sessions</h2>
<div class="spacer"></div>
<button class="btn-ghost small danger" id="clearHistoryBtn">Effacer l'historique</button>
</div>
<div class="table-scroll">
<table class="sessions-table">
<thead>
<tr>
<th>Date</th>
<th class="r">Durée</th>
<th class="r">Kills</th>
<th class="r">K/min</th>
<th class="r">Exp</th>
<th class="r">Exp/min</th>
<th class="r">Kamas</th>
<th class="r">Cols/min</th>
<th>Mob principal</th>
<th>Top item</th>
</tr>
</thead>
<tbody id="histSessionsBody"></tbody>
</table>
</div>
</div>
</section>
<!-- ============ ÉCRAN 6 : Détail session ============ -->
<section class="app-screen" id="appSessionDetail" hidden>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:4px;">
<button class="btn-ghost small" id="sessionDetailBackBtn">← Historique</button>
<span id="sessionDetailTitle" style="font-size:13px;color:var(--text-dim);"></span>
</div>
<div class="hero" id="sessionDetailHero">
<div class="hero-stat">
<p class="eyebrow">Durée</p>
<p class="hero-number" id="sdDur"></p>
</div>
<div class="hero-stat">
<p class="eyebrow">Kills</p>
<p class="hero-number" id="sdKills"></p>
<p class="hero-sub" id="sdKillsRate"></p>
</div>
<div class="hero-stat">
<p class="eyebrow">Exp</p>
<p class="hero-number" id="sdExp"></p>
<p class="hero-sub" id="sdExpRate"></p>
</div>
<div class="hero-stat">
<p class="eyebrow">Kamas</p>
<p class="hero-number" id="sdKamas"></p>
<p class="hero-sub" id="sdCols"></p>
</div>
</div>
<div class="charts-2col" id="sessionDetailPanels">
<div class="panel">
<h2>Mobs tués</h2>
<div id="sdMobList" class="bars" style="max-height:none;"></div>
</div>
<div class="panel">
<h2>Top items lootés</h2>
<div id="sdItemList" class="bars" style="max-height:none;"></div>
</div>
</div>
</section>
<!-- ============ ÉCRAN 4 : Drop Board ============ -->
<section class="app-screen" id="appDropBoard" hidden>
<div class="app-subbar">
<input type="text" id="dropBoardFilter" class="filter-input" placeholder="Filtrer les items…" style="min-width:180px;">
<select id="dropBoardMobSelect" class="filter-input"></select>
<span class="community-badge" id="dropBoardCommunityBadge" hidden>🌐 Communauté</span>
</div>
<div id="dropBoardContent"></div>
</section>
<!-- ============ ÉCRAN 5 : Timers ============ -->
<section class="app-screen" id="appTimers" hidden>
<div class="panel" style="margin-bottom:14px;">
<div class="panel-head-row">
<h2>Ajouter un timer</h2>
</div>
<div class="timer-add-row">
<input type="text" id="timerMobInput" placeholder="Nom du mob (ex: Skeleton)" style="flex:2;" list="timerMobSuggestions" autocomplete="off">
<datalist id="timerMobSuggestions"></datalist>
<input type="text" id="timerDurInput" placeholder="Durée (ex: 5:00 ou 90)" style="flex:1;max-width:130px;">
<button class="btn-ghost" id="timerAddBtn">Ajouter</button>
</div>
<div id="timerConfigList" class="timer-config-list"></div>
</div>
<div class="panel">
<div class="panel-head-row">
<h2>Comptes à rebours actifs</h2>
<div class="spacer"></div>
<span style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);">se déclenchent automatiquement au kill</span>
</div>
<div id="timerActiveGrid" class="timer-active-grid"></div>
</div>
<div class="panel">
<div class="panel-head-row">
<h2>Alertes de drop</h2>
</div>
<div class="timer-add-row" style="flex-wrap:wrap;gap:6px;">
<input type="text" id="alertItemInput" placeholder="Nom de l'item (ex: Diamond)" style="flex:2;min-width:140px;" list="alertItemSuggestions" autocomplete="off">
<datalist id="alertItemSuggestions"></datalist>
<select id="alertSoundSelect" class="alert-cfg-sound"></select>
<div style="display:flex;align-items:center;gap:5px;">
<span style="font-size:10px;color:var(--text-dim);">Vol</span>
<input type="range" id="alertVolumeInput" min="0" max="100" step="1" value="10" style="width:64px;">
<span id="alertVolumeVal" style="font-size:10px;font-family:var(--font-mono);color:var(--text-dim);min-width:26px;">10%</span>
</div>
<button class="btn-ghost" id="alertAddBtn">Ajouter</button>
</div>
<p style="font-size:11.5px;color:var(--text-dim);margin:6px 0 4px;">Chaque alerte a son propre son et volume.</p>
<div id="alertConfigList"></div>
</div>
<div class="panel">
<div class="panel-head-row" id="soundLibToggle" style="cursor:pointer;user-select:none;">
<h2>Bibliothèque de sons Minecraft</h2>
<span id="soundLibArrow" style="margin-left:auto;font-size:11px;color:var(--text-dim);">▶ ouvrir</span>
</div>
<div id="soundLibContent" hidden>
<p style="font-size:11.5px;color:var(--text-dim);margin:0 0 8px;">
Sons téléchargés depuis le CDN officiel Mojang. Clic ▶ pour prévisualiser, ⬇ pour télécharger.
</p>
<div class="sound-lib-grid" id="soundLibGrid"></div>
</div>
</div>
</section>
</div>
<!-- Modal 1 : confirmation sync -->
<div class="modal-overlay" id="syncModal">
<div class="modal-box">
<h3>🔄 Sync communauté</h3>
<p class="modal-desc">
Tes stats personnelles sont <strong>envoyées</strong> au serveur, et tu reçois en échange
l'<strong>agrégat de tous les joueurs</strong>.<br>
Tes stats locales ne sont <strong>pas écrasées</strong> — un bouton te permet de basculer
entre ta vue perso et la vue communauté.
</p>
<p class="modal-url-label">URL du serveur</p>
<input type="text" id="syncModalUrl" class="modal-url-input" placeholder="http://51.77.195.139:3742">
<div class="modal-files">
<div class="modal-file-row">
<span class="modal-file-arrow">📤</span>
<span class="modal-file-path" id="syncFileSent"></span>
</div>
<div class="modal-file-row">
<span class="modal-file-arrow">📥</span>
<span class="modal-file-path" id="syncFileReceived"></span>
</div>
</div>
<div class="modal-actions">
<button class="btn-ghost" id="syncModalCancel">Annuler</button>
<button class="btn-ghost" id="syncModalReceiveOnly">📥 Recevoir seulement</button>
<button class="btn-ghost accent" id="syncModalConfirm">📤📥 Envoyer + Recevoir</button>
</div>
</div>
</div>
<!-- Modal 2 : avertissement "recevoir seulement" (apparaît par-dessus modal 1) -->
<div class="modal-overlay-2" id="syncReceiveOnlyModal">
<div class="modal-box">
<h3>📥 Recevoir sans envoyer</h3>
<div class="modal-warn">
<strong>La communauté se nourrit des contributions de chacun.</strong>
Si tout le monde reçoit sans envoyer, les stats communauté ne s'améliorent jamais.
Tes données ne sont pas sensibles — c'est juste des chances de drop Minecraft.
</div>
<p class="modal-desc">
Tu peux quand même continuer sans envoyer si tu veux juste consulter les stats
existantes. Mais pense à envoyer les tiennes la prochaine fois !
</p>
<div class="modal-actions">
<button class="btn-ghost" id="syncReceiveOnlyBack">← Revenir et envoyer</button>
<button class="btn-ghost danger" id="syncReceiveOnlyConfirm">Recevoir quand même</button>
</div>
</div>
</div>
<div id="chartTip"></div>
<!-- Menu contextuel "qui drop cet item ?" -->
<div class="item-ctx-menu" id="itemCtxMenu">
<div class="item-ctx-title">Qui drop cet item ?</div>
<div id="itemCtxBody"></div>
</div>
<script>
(function(){
"use strict";
// ---------- Décodage des codes couleur Minecraft ----------
const LEGACY = {0:'#000000',1:'#0000AA',2:'#00AA00',3:'#00AAAA',4:'#AA0000',5:'#AA00AA',
6:'#FFAA00',7:'#AAAAAA',8:'#555555',9:'#5555FF',a:'#55FF55',b:'#55FFFF',
c:'#FF5555',d:'#FF55FF',e:'#FFFF55',f:'#FFFFFF'};
function escHtml(c){ return c.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function escAttr(s){ return escHtml(s).replace(/"/g,'&quot;'); }
function mcPlain(raw){
if (!raw) return '';
return raw.replace(/§x(§[0-9A-Fa-f]){6}/g,'').replace(/§./g,'');
}
function mcColorToHtml(raw){
if (!raw) return '';
let i=0, color=null, bold=false, italic=false, html='';
while (i < raw.length){
if (raw[i] === '§' && i+1 < raw.length){
const code = raw[i+1].toLowerCase();
if (code === 'x'){
let hex='', j=i+2;
for (let k=0;k<6 && raw[j]==='§';k++){ hex += raw[j+1]; j+=2; }
if (hex.length === 6) color = '#'+hex;
i = j; continue;
}
if (LEGACY[code]){ color = LEGACY[code]; bold=false; italic=false; i+=2; continue; }
if (code === 'l'){ bold = true; i+=2; continue; }
if (code === 'o'){ italic = true; i+=2; continue; }
if (code === 'r'){ color=null; bold=false; italic=false; i+=2; continue; }
i+=2; continue;
}
let style = '';
if (color) style += 'color:'+color+';';
if (bold) style += 'font-weight:700;';
if (italic) style += 'font-style:italic;';
html += style ? '<span style="'+style+'">'+escHtml(raw[i])+'</span>' : escHtml(raw[i]);
i++;
}
return html;
}
const PALETTE = ['#E0A949','#6B8F5C','#9B5DE5','#5DB7E5','#E0556B','#F2C94C','#5DE5C0','#E58FD8'];
function colorForKey(key){
let h = 0;
for (let i=0;i<key.length;i++) h = (h*31 + key.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length];
}
function fmt(n, maxDec){
if (n === undefined || n === null || isNaN(n)) n = 0;
return n.toLocaleString('fr-FR', {maximumFractionDigits: maxDec === undefined ? 0 : maxDec});
}
function generateUUID(){
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
const $ = (id) => document.getElementById(id);
// ======================================================================
// PROFILS
// ======================================================================
let _profiles = null; // { activeProfileId, profiles: [...] }
let _activeProfile = null; // profil actif
async function loadProfiles() {
_profiles = await window.api.getProfiles();
_activeProfile = _profiles.profiles.find(p => p.id === _profiles.activeProfileId) || _profiles.profiles[0];
renderProfileBtn();
}
function renderProfileBtn() {
const btn = $('profileBtn');
if (btn && _activeProfile) btn.textContent = '👤 ' + _activeProfile.name;
}
// Clé localStorage préfixée par profil (compat : profil "default" = clé sans préfixe)
function lsKey(key) {
if (!_activeProfile || _activeProfile.id === 'default') return key;
return _activeProfile.id + ':' + key;
}
function renderProfileModal() {
const list = $('profileList');
if (!list || !_profiles) return;
list.innerHTML = _profiles.profiles.map(p => {
const isActive = p.id === _profiles.activeProfileId;
const modeLabel = p.dropStatsMode === 'shared' ? 'Partagé' : 'Privé';
const modeClass = p.dropStatsMode === 'shared' ? 'shared' : '';
return '<div class="profile-row' + (isActive ? ' active' : '') + '" data-profile-id="' + escHtml(p.id) + '">' +
'<span class="profile-row-name">' + (isActive ? '● ' : '') + escHtml(p.name) + '</span>' +
'<button class="profile-row-mode ' + modeClass + '" data-toggle-mode="' + escHtml(p.id) + '" title="Cliquer pour changer le mode de drop stats">' + modeLabel + '</button>' +
(isActive ? '' : '<button class="btn-ghost small" data-switch-profile="' + escHtml(p.id) + '">Activer</button>') +
(p.id !== 'default' && !isActive ? '<button class="btn-ghost small danger" data-delete-profile="' + escHtml(p.id) + '">✕</button>' : '') +
'</div>';
}).join('');
}
$('profileBtn').addEventListener('click', () => {
renderProfileModal();
$('profileModal').hidden = false;
$('profileCreateForm').hidden = true;
});
$('profileModalClose').addEventListener('click', () => { $('profileModal').hidden = true; });
$('profileModal').addEventListener('click', e => { if (e.target === $('profileModal')) $('profileModal').hidden = true; });
$('profileNewBtn').addEventListener('click', () => {
$('profileCreateForm').hidden = false;
$('profileNewBtn').hidden = true;
$('profileNewName').value = '';
$('profileNewName').focus();
});
$('profileCreateCancel').addEventListener('click', () => {
$('profileCreateForm').hidden = true;
$('profileNewBtn').hidden = false;
});
$('profileCreateConfirm').addEventListener('click', async () => {
const name = $('profileNewName').value.trim();
if (!name) return;
const mode = document.querySelector('input[name="newProfileMode"]:checked')?.value || 'private';
_profiles = await window.api.createProfile(name, mode);
_activeProfile = _profiles.profiles.find(p => p.id === _profiles.activeProfileId) || _profiles.profiles[0];
$('profileCreateForm').hidden = true;
$('profileNewBtn').hidden = false;
renderProfileModal();
});
$('profileList').addEventListener('click', async e => {
const switchId = e.target.dataset.switchProfile;
if (switchId) {
await window.api.switchProfile(switchId);
location.reload();
return;
}
const deleteId = e.target.dataset.deleteProfile;
if (deleteId) {
if (!confirm('Supprimer ce profil ? L\'historique et les stats de drop privées seront effacés définitivement.')) return;
const res = await window.api.deleteProfile(deleteId);
if (res.ok) {
_profiles = await window.api.getProfiles();
renderProfileModal();
}
return;
}
const toggleId = e.target.dataset.toggleMode;
if (toggleId) {
const p = _profiles.profiles.find(pr => pr.id === toggleId);
if (!p) return;
const newMode = p.dropStatsMode === 'shared' ? 'private' : 'shared';
await window.api.setDropStatsMode(toggleId, newMode);
_profiles = await window.api.getProfiles();
_activeProfile = _profiles.profiles.find(pr => pr.id === _profiles.activeProfileId) || _profiles.profiles[0];
renderProfileModal();
// si c'est le profil actif, reload pour recharger les drop stats
if (toggleId === _profiles.activeProfileId) {
if (confirm('Mode de drop stats changé. Recharger pour appliquer ?')) location.reload();
}
}
});
// Renommage double-clic sur le nom
$('profileList').addEventListener('dblclick', async e => {
const row = e.target.closest('.profile-row');
if (!row) return;
const id = row.dataset.profileId;
const nameEl = row.querySelector('.profile-row-name');
if (!nameEl) return;
const current = _profiles.profiles.find(p => p.id === id);
if (!current) return;
const newName = prompt('Renommer le profil :', current.name);
if (!newName || !newName.trim()) return;
await window.api.renameProfile(id, newName.trim());
_profiles = await window.api.getProfiles();
_activeProfile = _profiles.profiles.find(p => p.id === _profiles.activeProfileId) || _profiles.profiles[0];
renderProfileBtn();
renderProfileModal();
});
// ---------- Tooltip pour les graphes ----------
const chartTip = $('chartTip');
function showChartTip(text, cx, cy){
chartTip.textContent = text;
chartTip.style.display = 'block';
chartTip.style.left = Math.min(cx + 14, window.innerWidth - 170) + 'px';
chartTip.style.top = Math.min(cy - 30, window.innerHeight - 44) + 'px';
}
function hideChartTip(){ chartTip.style.display = 'none'; }
function attachChartTip(svg){
if (!svg) return;
svg.addEventListener('mouseover', e => { if (e.target.dataset.tip) showChartTip(e.target.dataset.tip, e.clientX, e.clientY); });
svg.addEventListener('mouseout', e => { if (e.target.dataset.tip) hideChartTip(); });
svg.addEventListener('mousemove', e => {
if (e.target.dataset.tip){
chartTip.style.left = Math.min(e.clientX + 14, window.innerWidth - 170) + 'px';
chartTip.style.top = Math.min(e.clientY - 30, window.innerHeight - 44) + 'px';
}
});
svg.addEventListener('mouseleave', hideChartTip);
}
// ======================================================================
// SHELL : navigation entre écrans (dossier -> accueil -> outils)
// ======================================================================
const screens = {
folder: $('folderScreen'),
hub: $('hubScreen'),
farmTracker: $('appFarmTracker'),
dropBoard: $('appDropBoard'),
history: $('appHistory'),
timers: $('appTimers'),
sessionDetail: $('appSessionDetail')
};
const homeBtn = $('homeBtn');
const changeFolderBtn = $('changeFolderBtn');
function showScreen(name){
for (const key in screens) screens[key].hidden = (key !== name);
homeBtn.hidden = (name === 'hub' || name === 'folder');
changeFolderBtn.hidden = (name === 'folder');
}
$('brandHome').addEventListener('click', () => {
if (!screens.folder.hidden) return;
showScreen('hub');
});
homeBtn.addEventListener('click', () => showScreen('hub'));
// ---------- Bouton "Mises à jour" ----------
const checkUpdateBtn = $('checkUpdateBtn');
checkUpdateBtn.addEventListener('click', async () => {
checkUpdateBtn.disabled = true;
checkUpdateBtn.textContent = 'Vérification...';
const result = await window.api.checkUpdateManual();
checkUpdateBtn.disabled = false;
checkUpdateBtn.textContent = 'Mises à jour';
if (!result.checked) return;
if (!result.hasUpdate) {
checkUpdateBtn.textContent = 'A jour ✓';
setTimeout(() => { checkUpdateBtn.textContent = 'Mises à jour'; }, 3000);
}
// Si hasUpdate: la dialog native s'est affichée côté main.js
});
$('tileFarmTracker').addEventListener('click', () => showScreen('farmTracker'));
$('tileDropBoard').addEventListener('click', () => { showScreen('dropBoard'); renderDropBoard(); });
$('tileHistory').addEventListener('click', () => { showScreen('history'); renderHistoryScreen(); });
$('tileTimers').addEventListener('click', () => { showScreen('timers'); renderTimerScreen(); });
const globalState = { logPath: null, serverUrl: '', clientId: '' };
let sessionHistory = { type: 'farm-tracker-session-history', version: 1, sessions: [] };
function showFolderError(msg){
$('folderErrorBox').hidden = false;
$('folderErrorBox').textContent = msg;
}
function clearFolderError(){
$('folderErrorBox').hidden = true;
$('folderErrorBox').textContent = '';
}
async function pickFolderFlow(isReconfigure){
clearFolderError();
let result;
try{ result = await window.api.pickFolder(); }
catch(err){ showFolderError('Erreur inattendue : ' + err.message); return false; }
if (result.canceled) return false;
if (result.error){ showFolderError(result.error); return false; }
globalState.logPath = result.logPath;
await persistAppState(true);
if (isReconfigure){
await reconfigureFarmTracker(result.logPath);
} else {
await initFarmTracker(result.logPath, true);
}
return true;
}
$('pickFolderBtn').addEventListener('click', async () => {
const ok = await pickFolderFlow(false);
if (ok) showScreen('hub');
});
changeFolderBtn.addEventListener('click', () => pickFolderFlow(true));
// ======================================================================
// Persistance : état général (app-state.json) + stats de drop (dropstats.json)
// ======================================================================
function dropStatsToPlain(dropStatsMap){
const mobs = {};
for (const [mobName, entry] of dropStatsMap.entries()){
const items = {};
for (const [key, it] of entry.items.entries()){
items[key] = {name: mcPlain(it.rawName) || it.name || '', rawName: it.rawName, occurrences: it.occurrences, totalQty: it.totalQty};
}
mobs[mobName] = {kills: entry.kills, totalExp: entry.totalExp || 0, totalCols: entry.totalCols || 0, items};
}
return {type: 'farm-tracker-dropstats', version: 1, updatedAt: new Date().toISOString(), mobs};
}
function plainToDropStats(plainMobs){
const map = new Map();
for (const [mobName, mobData] of Object.entries(plainMobs || {})){
const items = new Map();
for (const [key, it] of Object.entries(mobData.items || {})){
items.set(key, {rawName: it.rawName || it.name || '', occurrences: it.occurrences || 0, totalQty: it.totalQty || 0});
}
map.set(mobName, {kills: mobData.kills || 0, totalExp: mobData.totalExp || 0, totalCols: mobData.totalCols || 0, items});
}
return map;
}
let appStateSaveTimer = null;
function persistAppState(immediate){
if (appStateSaveTimer) clearTimeout(appStateSaveTimer);
const run = async () => {
appStateSaveTimer = null;
if (!globalState.clientId) globalState.clientId = generateUUID();
const obj = {
version: 2,
logPath: globalState.logPath,
serverUrl: globalState.serverUrl,
clientId: globalState.clientId,
farmTracker: ft ? {
mobCounts: Object.fromEntries(ft.state.mobCounts),
itemCounts: Array.from(ft.state.itemCounts.values()),
expTotal: ft.state.expTotal,
killCols: ft.state.killCols,
sellPrices: Object.fromEntries(ft.state.sellPrices),
sessionStart: ft.state.sessionStart,
pausedAccum: ft.state.pausedAccum,
feedEntries: ft.state.feedEntries.slice(0, 50),
savedAt: Date.now()
} : null
};
try{ await window.api.saveAppState(obj); }catch(e){ console.warn('Sauvegarde auto échouée', e); }
};
if (immediate) run(); else appStateSaveTimer = setTimeout(run, 400);
}
let dropStatsSaveTimer = null;
function persistDropStats(immediate){
if (dropStatsSaveTimer) clearTimeout(dropStatsSaveTimer);
const run = async () => {
dropStatsSaveTimer = null;
try{ await window.api.saveDropStats(dropStatsToPlain(ft.state.dropStats)); }
catch(e){ console.warn('Sauvegarde des stats de drop échouée', e); }
};
if (immediate) run(); else dropStatsSaveTimer = setTimeout(run, 400);
}
let communityDropStatsSaveTimer = null;
function persistCommunityDropStats(immediate){
if (communityDropStatsSaveTimer) clearTimeout(communityDropStatsSaveTimer);
const run = async () => {
communityDropStatsSaveTimer = null;
if (!ft || !ft.state.communityDropStats) return;
try{ await window.api.saveCommunityDropStats(dropStatsToPlain(ft.state.communityDropStats)); }
catch(e){ console.warn('Sauvegarde community échouée', e); }
};
if (immediate) run(); else communityDropStatsSaveTimer = setTimeout(run, 400);
}
// ======================================================================
// OUTIL : Farm Tracker
// ======================================================================
let ft = null;
function createFarmTracker(){
const state = {
logPath: null,
lastSize: 0,
carry: '',
pollTimer: null,
clockTimer: null,
sessionStart: null,
lastEventAt: null,
paused: false,
pauseStartedAt: null,
pausedAccum: 0,
mobCounts: new Map(),
itemCounts: new Map(),
expTotal: 0,
killCols: 0,
sellPrices: new Map(),
sellTotal: 0,
dropStats: new Map(), // stats perso (uniquement ton farming)
communityDropStats: null, // agrégat reçu du serveur (null = pas encore syncé)
showCommunity: true, // bascule perso / communauté dans l'affichage
feedEntries: [],
itemRows: new Map(),
mobFilter: '',
itemFilter: '',
selectedDropMob: null,
timeline: [],
timelineTimer: null
};
// Retourne les stats à afficher : communauté si dispo et activé, sinon perso
function getDisplayDropStats(){
if (state.communityDropStats && state.showCommunity) return state.communityDropStats;
return state.dropStats;
}
const pauseBtn = $('pauseBtn');
const resetBtn = $('resetBtn');
const statusDot = $('statusDot');
const statusText = $('statusText');
const itemsList = $('itemsList');
const mobsList = $('mobsList');
const globalMsg = $('globalMsg');
function setStatus(kind, text){
statusDot.className = 'dot' + (kind ? ' '+kind : '');
statusText.textContent = text;
$('tileFarmTrackerStatus').textContent = (kind === 'live' ? '● en direct' : kind === 'paused' ? '● en pause' : kind === 'error' ? '● erreur' : '● en attente');
}
let msgTimer = null;
function flashMessage(text, isError){
globalMsg.textContent = text;
globalMsg.className = 'global-msg show' + (isError ? ' error' : '');
if (msgTimer) clearTimeout(msgTimer);
msgTimer = setTimeout(() => { globalMsg.className = 'global-msg'; }, 5000);
}
function getElapsedMs(){
let accum = state.pausedAccum;
if (state.paused && state.pauseStartedAt) accum += (Date.now() - state.pauseStartedAt);
return Math.max(Date.now() - state.sessionStart - accum, 0);
}
async function start(logPath, resetCounters){
state.logPath = logPath;
if (resetCounters){
state.mobCounts.clear();
state.itemCounts.clear();
state.itemRows.clear();
itemsList.innerHTML = '';
state.feedEntries = [];
state.expTotal = 0;
state.killCols = 0;
state.sellTotal = 0;
state.sessionStart = Date.now();
state.pausedAccum = 0;
}
if (!state.sessionStart) state.sessionStart = Date.now();
const sizeRes = await window.api.getFileSize(state.logPath);
state.lastSize = sizeRes.size || 0;
state.carry = '';
state.paused = false;
state.pauseStartedAt = null;
setStatus('live', 'En direct — ' + state.logPath);
renderAll();
startPolling();
startClock();
if (resetCounters) state.timeline = [];
startTimelineTimer();
persistAppState(true);
}
pauseBtn.addEventListener('click', async () => {
if (!state.logPath) return;
if (!state.paused){
state.paused = true;
state.pauseStartedAt = Date.now();
stopPolling();
stopTimelineTimer();
setStatus('paused', 'En pause');
pauseBtn.textContent = '▶ Reprendre';
flashMessage('Suivi en pause — le chrono et les cadences sont gelés.');
} else {
state.pausedAccum += Date.now() - state.pauseStartedAt;
state.paused = false;
state.pauseStartedAt = null;
const sizeRes = await window.api.getFileSize(state.logPath);
state.lastSize = sizeRes.size || state.lastSize;
state.carry = '';
setStatus('live', 'En direct — ' + state.logPath);
pauseBtn.textContent = '⏸ Pause';
startPolling();
startTimelineTimer();
flashMessage('Suivi repris.');
}
});
resetBtn.addEventListener('click', async () => {
if (!state.logPath) return;
await saveCurrentSessionToHistory();
state.mobCounts.clear();
state.itemCounts.clear();
state.itemRows.clear();
itemsList.innerHTML = '';
state.feedEntries = [];
state.expTotal = 0;
state.killCols = 0;
state.sellTotal = 0;
state.sessionStart = Date.now();
state.pausedAccum = 0;
if (state.paused) state.pauseStartedAt = Date.now();
state.lastEventAt = null;
state.timeline = [];
const sizeRes = await window.api.getFileSize(state.logPath);
state.lastSize = sizeRes.size || 0;
state.carry = '';
$('liveChartsPanel').hidden = true;
stopTimelineTimer();
if (!state.paused) startTimelineTimer();
renderAll();
persistAppState(true);
flashMessage('Session réinitialisée (les chances de drop ne sont pas affectées).');
});
$('resetDropBtn').addEventListener('click', () => {
if (!state.dropStats.size) return;
if (!window.confirm('Réinitialiser tes stats de chance de drop perso ? (les stats communauté ne sont pas affectées)')) return;
state.dropStats.clear();
state.selectedDropMob = null;
renderDropStats();
persistDropStats(true);
flashMessage('Stats de chance de drop perso réinitialisées.');
});
$('exportDropBtn').addEventListener('click', async () => {
const result = await window.api.exportDropStats();
if (result.canceled) return;
flashMessage('Stats exportées : ' + result.filePath);
});
$('importDropBtn').addEventListener('click', async () => {
const result = await window.api.importMergeDropStats();
if (result.canceled) return;
state.dropStats = plainToDropStats(result.dropStats.mobs);
renderDropStats();
const okCount = result.details.filter(d => d.ok).length;
const failCount = result.details.length - okCount;
flashMessage(
'Fusion : ' + okCount + ' fichier(s) ajoutés (+' + fmt(result.totalAdded) + ' kills)' +
(failCount ? ', ' + failCount + ' en erreur' : '') + '.',
failCount > 0 && okCount === 0
);
});
// ---------- Toggle communauté / perso ----------
$('toggleCommunityBtn').addEventListener('click', () => {
state.showCommunity = !state.showCommunity;
renderDropStats();
if (!$('appDropBoard').hidden) renderDropBoard();
});
// ---------- Sync communautaire ----------
$('syncServerUrl').addEventListener('input', (e) => {
globalState.serverUrl = e.target.value.trim();
});
// Ouvre le modal de confirmation
$('syncDropBtn').addEventListener('click', async () => {
const modalUrl = $('syncModalUrl');
const current = $('syncServerUrl').value.trim() || globalState.serverUrl;
modalUrl.value = current;
// Charge les chemins de fichiers
try {
const paths = await window.api.getFilePaths();
$('syncFileSent').textContent = paths.dropStats;
$('syncFileReceived').textContent = paths.communityDropStats;
} catch(e) {}
$('syncModal').classList.add('visible');
modalUrl.focus();
});
$('syncModalCancel').addEventListener('click', () => {
$('syncModal').classList.remove('visible');
});
$('syncModal').addEventListener('click', (e) => {
if (e.target === $('syncModal')) $('syncModal').classList.remove('visible');
});
// "Recevoir seulement" → ouvre modal 2 par-dessus
$('syncModalReceiveOnly').addEventListener('click', () => {
$('syncReceiveOnlyModal').classList.add('visible');
});
// Modal 2 : revenir au modal 1
$('syncReceiveOnlyBack').addEventListener('click', () => {
$('syncReceiveOnlyModal').classList.remove('visible');
});
// Modal 2 : confirmer recevoir sans envoyer
$('syncReceiveOnlyConfirm').addEventListener('click', async () => {
const serverUrl = $('syncModalUrl').value.trim();
if (!serverUrl) return;
$('syncReceiveOnlyModal').classList.remove('visible');
$('syncModal').classList.remove('visible');
globalState.serverUrl = serverUrl;
$('syncServerUrl').value = serverUrl;
const btn = $('syncDropBtn');
btn.disabled = true;
btn.textContent = '⏳ Récupération…';
const result = await window.api.fetchCommunityStats(serverUrl);
btn.disabled = false;
btn.textContent = '🔄 Sync communauté';
if (!result.ok){
flashMessage('Récupération échouée : ' + (result.error || 'erreur inconnue'), true);
return;
}
state.communityDropStats = plainToDropStats(result.dropStats.mobs);
state.showCommunity = true;
state.selectedDropMob = null;
renderDropStats();
if (!$('appDropBoard').hidden) renderDropBoard();
persistCommunityDropStats(true);
persistAppState(false);
const n = result.clientCount || '?';
flashMessage('Stats communauté récupérées (' + n + ' joueur' + (n > 1 ? 's' : '') + ').');
});
// Modal 1 : envoyer + recevoir
$('syncModalConfirm').addEventListener('click', async () => {
const serverUrl = $('syncModalUrl').value.trim();
if (!serverUrl) return;
$('syncModal').classList.remove('visible');
globalState.serverUrl = serverUrl;
$('syncServerUrl').value = serverUrl;
if (!globalState.clientId) globalState.clientId = generateUUID();
const btn = $('syncDropBtn');
btn.disabled = true;
btn.textContent = '⏳ Sync…';
const plain = dropStatsToPlain(state.dropStats);
const result = await window.api.syncDropStats(serverUrl, globalState.clientId, plain);
btn.disabled = false;
btn.textContent = '🔄 Sync communauté';
if (!result.ok){
flashMessage('Sync échoué : ' + (result.error || 'erreur inconnue'), true);
return;
}
state.communityDropStats = plainToDropStats(result.dropStats.mobs);
state.showCommunity = true;
state.selectedDropMob = null;
renderDropStats();
if (!$('appDropBoard').hidden) renderDropBoard();
persistCommunityDropStats(true);
persistAppState(false);
const n = result.clientCount || '?';
flashMessage('Sync OK — ' + n + ' joueur' + (n > 1 ? 's' : '') + ' dans la communauté.');
});
// ---------- Lecture / parsing du log ----------
function tryParseLootLine(line){
const idx = line.indexOf('{"op":"PLAYER_LOOT"');
if (idx === -1) return null;
const jsonStr = line.slice(idx).trim();
try{ return JSON.parse(jsonStr); }catch(e){ return null; }
}
function processEvent(evt){
const data = evt.data || {};
const mobRaw = data.mob || 'Mob inconnu';
const mobPlain = mcPlain(mobRaw) || 'Mob inconnu';
const col = data.col || 0;
const exp = data.exp || 0;
state.mobCounts.set(mobPlain, (state.mobCounts.get(mobPlain) || 0) + 1);
state.expTotal += exp;
state.killCols += col;
triggerTimer(mobPlain);
if (!state.dropStats.has(mobPlain)) state.dropStats.set(mobPlain, {kills: 0, totalExp: 0, totalCols: 0, items: new Map()});
const dropEntry = state.dropStats.get(mobPlain);
dropEntry.kills += 1;
dropEntry.totalExp = (dropEntry.totalExp || 0) + exp;
dropEntry.totalCols = (dropEntry.totalCols || 0) + col;
const items = Array.isArray(data.items) ? data.items : [];
const feedItems = [];
for (const it of items){
const material = it.material || '?';
const modeldata = it.modeldata !== undefined ? it.modeldata : '?';
const key = material + ':' + modeldata;
const amt = it.amount || 1;
const existing = state.itemCounts.get(key);
if (existing){ existing.qty += amt; existing.rawName = it.name || existing.rawName; }
else { state.itemCounts.set(key, {rawName: it.name || '', qty: amt, key}); }
const dropItem = dropEntry.items.get(key) || {rawName: '', occurrences: 0, totalQty: 0};
dropItem.occurrences += 1;
dropItem.totalQty += amt;
if (it.name) dropItem.rawName = it.name;
dropEntry.items.set(key, dropItem);
feedItems.push({rawName: it.name || '', qty: amt, key});
if (alertConfigs.length){
const pn = mcPlain(it.name || '').toLowerCase();
for (const alert of alertConfigs){
if (pn && alert.nameLower && pn.includes(alert.nameLower)){ playAlert(alert); break; }
}
}
}
state.feedEntries.unshift({time: Date.now(), mobRaw, mobPlain, exp, col, items: feedItems});
if (state.feedEntries.length > 50) state.feedEntries.length = 50;
state.lastEventAt = Date.now();
}
async function poll(){
if (!state.logPath || state.paused) return;
const sizeRes = await window.api.getFileSize(state.logPath);
if (sizeRes.error){
setStatus('error', 'Fichier introuvable — change de dossier');
stopPolling();
return;
}
const newSize = sizeRes.size;
if (newSize === state.lastSize) return;
if (newSize < state.lastSize){ state.lastSize = 0; state.carry = ''; }
const chunkRes = await window.api.readChunk(state.logPath, state.lastSize, newSize);
state.lastSize = newSize;
const combined = state.carry + (chunkRes.text || '');
const lines = combined.split('\n');
state.carry = lines.pop();
let changed = false;
for (const line of lines){
if (!line) continue;
const evt = tryParseLootLine(line);
if (evt){ processEvent(evt); changed = true; }
}
if (changed){
renderAll();
persistAppState(false);
persistDropStats(false);
}
}
function startPolling(){ stopPolling(); state.pollTimer = setInterval(poll, 1200); }
function stopPolling(){ if (state.pollTimer){ clearInterval(state.pollTimer); state.pollTimer = null; } }
function startClock(){
if (state.clockTimer) clearInterval(state.clockTimer);
state.clockTimer = setInterval(updateClock, 1000);
updateClock();
}
function pad(n){ return String(n).padStart(2,'0'); }
// ---------- Timeline / graphes en direct ----------
function snapshotTimeline(){
if (!state.sessionStart || state.paused) return;
const totalKills = sumValues(state.mobCounts);
state.timeline.push({ t: Date.now(), kills: totalKills, exp: state.expTotal, cols: state.killCols });
if (state.timeline.length > 180) state.timeline.shift();
renderLiveCharts();
}
function startTimelineTimer(){
stopTimelineTimer();
snapshotTimeline();
state.timelineTimer = setInterval(snapshotTimeline, 30000);
}
function stopTimelineTimer(){
if (state.timelineTimer){ clearInterval(state.timelineTimer); state.timelineTimer = null; }
}
function drawLiveChart(container, rates, colorHex, unit){
if (rates.length < 2){
container.innerHTML = '<p class="chart-empty" style="font-size:11px;padding:6px 0;">En attente…</p>';
return;
}
const W=400,H=55,padL=4,padR=4,padT=6,padB=4;
const plotW=W-padL-padR,plotH=H-padT-padB,n=rates.length;
const maxV=Math.max(...rates,0.01);
const xs=rates.map((_,i)=>padL+(n===1?plotW/2:(i/(n-1))*plotW));
const ys=rates.map(v=>padT+plotH-(Math.max(0,v)/maxV)*plotH);
const linePath=xs.map((x,i)=>(i===0?'M':'L')+x.toFixed(1)+','+ys[i].toFixed(1)).join(' ');
const areaPath=linePath+' L'+xs[n-1].toFixed(1)+','+(padT+plotH)+' L'+xs[0].toFixed(1)+','+(padT+plotH)+' Z';
const grid=[0.5,1.0].map(pct=>{
const y=(padT+plotH*(1-pct)).toFixed(1);
return '<line x1="'+padL+'" y1="'+y+'" x2="'+(W-padR)+'" y2="'+y+'" stroke="#26301F" stroke-width="1"/>';
}).join('');
const dots=xs.map((x,i)=>{
const label=fmt(rates[i],1)+(unit?' '+unit:'');
return '<circle cx="'+x.toFixed(1)+'" cy="'+ys[i].toFixed(1)+'" r="2" fill="'+colorHex+'" opacity="0.8"/>'
+'<circle cx="'+x.toFixed(1)+'" cy="'+ys[i].toFixed(1)+'" r="12" fill="transparent" data-tip="'+escAttr(label)+'"/>';
}).join('');
container.innerHTML=
'<svg viewBox="0 0 '+W+' '+H+'" width="100%" height="'+H+'" xmlns="http://www.w3.org/2000/svg">'
+grid
+'<path d="'+areaPath+'" fill="'+colorHex+'" fill-opacity="0.13"/>'
+'<path d="'+linePath+'" stroke="'+colorHex+'" fill="none" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>'
+dots
+'</svg>';
attachChartTip(container.querySelector('svg'));
}
function fmtHHMM(ts){ const d=new Date(ts); return pad(d.getHours())+':'+pad(d.getMinutes()); }
function renderLiveCharts(){
const snap = state.timeline;
const panel = $('liveChartsPanel');
if (snap.length < 1){ panel.hidden = true; return; }
panel.hidden = false;
const killRates=[], expRates=[], colRates=[];
for (let i=1; i<snap.length; i++){
const dt=(snap[i].t-snap[i-1].t)/60000;
if (dt<=0) continue;
killRates.push((snap[i].kills-snap[i-1].kills)/dt);
expRates.push((snap[i].exp-snap[i-1].exp)/dt);
colRates.push((snap[i].cols-snap[i-1].cols)/dt);
}
drawLiveChart($('liveChartKills'), killRates, '#6B8F5C', 'kills/min');
drawLiveChart($('liveChartExp'), expRates, '#F2C94C', 'xp/min');
drawLiveChart($('liveChartCols'), colRates, '#C97B4A', 'cols/min');
const last = arr => arr.length ? arr[arr.length-1] : 0;
$('liveChartKillsVal').textContent = fmt(last(killRates),1)+'/min';
$('liveChartExpVal').textContent = fmt(last(expRates),1)+'/min';
$('liveChartColsVal').textContent = fmt(last(colRates),1)+'/min';
if (snap.length >= 2){
const t1=fmtHHMM(snap[0].t), t2=fmtHHMM(snap[snap.length-1].t);
['KillsTime','ExpTime','ColsTime'].forEach(id=>{
const el=$('liveChart'+id);
if (el) el.innerHTML='<span>'+t1+'</span><span>'+t2+'</span>';
});
}
}
function sumValues(map){ let t=0; for (const v of map.values()) t+=v; return t; }
async function saveCurrentSessionToHistory(){
const totalKills=sumValues(state.mobCounts);
if (!state.sessionStart||totalKills===0) return;
const durationMs=getElapsedMs();
const topItems=Array.from(state.itemCounts.values())
.sort((a,b)=>b.qty-a.qty).slice(0,5)
.map(it=>({key:it.key,name:mcPlain(it.rawName)||'Item inconnu',qty:it.qty}));
let topMob='',topMobCount=0;
for (const [name,count] of state.mobCounts){
if (count>topMobCount){topMob=name;topMobCount=count;}
}
sessionHistory.sessions.push({
id:generateUUID(),
startedAt:state.sessionStart,
endedAt:Date.now(),
durationMs,
totalKills,
totalExp:Math.round(state.expTotal),
killCols:Math.round(state.killCols),
sellTotal:Math.round(state.sellTotal*100)/100,
topMob,
mobCounts:Object.fromEntries(state.mobCounts),
topItems
});
try{
await window.api.saveSessionHistory(sessionHistory);
renderHistoryTileStatus();
}catch(e){console.warn('Sauvegarde historique échouée',e);}
}
function updateClock(){
if (!state.sessionStart) return;
const elapsedMs = getElapsedMs();
const elapsedSec = Math.floor(elapsedMs/1000);
const h = Math.floor(elapsedSec/3600), m = Math.floor((elapsedSec%3600)/60), s = elapsedSec%60;
$('timer').textContent = pad(h)+':'+pad(m)+':'+pad(s);
const elapsedMin = Math.max(elapsedSec/60, 1/60);
const totalKills = sumValues(state.mobCounts);
$('killsRate').textContent = fmt(totalKills/elapsedMin,1) + ' / min';
$('expRate').textContent = fmt(state.expTotal/elapsedMin,1) + ' / min';
$('colsKillRate').textContent = fmt(state.killCols/elapsedMin,1) + ' / min';
$('colsSellRate').textContent = fmt(state.sellTotal/elapsedMin,1) + ' / min';
if (state.lastEventAt){
const secAgo = Math.floor((Date.now()-state.lastEventAt)/1000);
$('lastEvent').textContent = secAgo < 3 ? 'loot à l\'instant' : 'dernier loot il y a ' + secAgo + 's';
}
}
function renderAll(){
renderHeroCounts();
renderMobs();
renderItemsTable();
renderDropStats();
renderFeed();
if (!$('appDropBoard').hidden) renderDropBoard();
}
function renderHeroCounts(){
const totalKills = sumValues(state.mobCounts);
$('totalKills').textContent = fmt(totalKills);
$('expTotal').textContent = fmt(Math.round(state.expTotal));
$('colsKill').textContent = fmt(Math.round(state.killCols));
$('colsSell').textContent = '≈ ' + fmt(state.sellTotal,2);
}
function renderMobs(){
mobsList.innerHTML = '';
const allEntries = Array.from(state.mobCounts.entries()).sort((a,b) => b[1]-a[1]);
if (!allEntries.length){ mobsList.innerHTML = '<p class="feed-empty">Aucun mob encore looté.</p>'; return; }
const maxMob = allEntries[0][1];
const filter = state.mobFilter;
const filtered = filter ? allEntries.filter(([name]) => name.toLowerCase().includes(filter)) : allEntries;
if (!filtered.length){ mobsList.innerHTML = '<p class="feed-empty">Aucun mob ne correspond au filtre.</p>'; return; }
for (const [name, count] of filtered){
const color = colorForKey(name);
const row = document.createElement('div');
row.className = 'bar-row';
row.innerHTML =
'<div class="bar-label"><span>'+escHtml(name)+'</span><span class="bar-count">'+fmt(count)+'</span></div>'+
'<div class="bar-track"><div class="bar-fill" style="width:'+(count/maxMob*100)+'%;background:'+color+'"></div></div>';
mobsList.appendChild(row);
}
}
$('mobFilterInput').addEventListener('input', (e) => { state.mobFilter = e.target.value.toLowerCase(); renderMobs(); });
function renderItemsTable(){
const itemEntries = Array.from(state.itemCounts.values()).sort((a,b) => b.qty-a.qty);
if (!itemEntries.length){
itemsList.innerHTML = '<tr><td colspan="5" class="feed-empty">Aucun item encore looté.</td></tr>';
state.itemRows.clear();
updateSellValues();
return;
}
if (itemsList.querySelector('.feed-empty')){ itemsList.innerHTML = ''; state.itemRows.clear(); }
const elapsedMin = Math.max(getElapsedMs()/60000, 1/60);
const seenKeys = new Set();
for (const item of itemEntries){
seenKeys.add(item.key);
const color = colorForKey(item.key);
const nameHtml = mcColorToHtml(item.rawName) || '<span>Item inconnu</span>';
let refs = state.itemRows.get(item.key);
if (!refs){
const tr = document.createElement('tr');
tr.dataset.key = item.key;
tr.title = 'Clic droit pour voir qui drop cet item';
tr.innerHTML =
'<td class="name-cell"><span class="swatch" style="background:'+color+'"></span><span class="name-html"></span></td>'+
'<td class="qty-cell"></td>'+
'<td class="rate-cell"></td>'+
'<td class="price-cell"><input type="number" class="price-input" min="0" step="0.01" placeholder="0"></td>'+
'<td class="value-cell"></td>';
itemsList.appendChild(tr);
refs = {
tr,
nameEl: tr.querySelector('.name-html'),
qtyEl: tr.querySelector('.qty-cell'),
rateEl: tr.querySelector('.rate-cell'),
priceInput: tr.querySelector('.price-input'),
valueEl: tr.querySelector('.value-cell'),
plainName: ''
};
const existingPrice = state.sellPrices.get(item.key);
if (existingPrice !== undefined) refs.priceInput.value = existingPrice;
refs.priceInput.addEventListener('input', () => {
const val = parseFloat(refs.priceInput.value);
state.sellPrices.set(item.key, isNaN(val) ? 0 : val);
updateSellValues();
persistAppState(false);
});
state.itemRows.set(item.key, refs);
}
refs.nameEl.innerHTML = nameHtml;
refs.qtyEl.textContent = '×' + fmt(item.qty);
refs.rateEl.textContent = fmt(item.qty/elapsedMin,1) + '/min';
refs.plainName = mcPlain(item.rawName).toLowerCase();
refs.tr.dataset.qty = item.qty;
itemsList.appendChild(refs.tr);
}
for (const key of Array.from(state.itemRows.keys())){
if (!seenKeys.has(key)){
const refs = state.itemRows.get(key);
refs.tr.remove();
state.itemRows.delete(key);
}
}
applyItemFilter();
updateSellValues();
}
function applyItemFilter(){
const filter = state.itemFilter;
for (const refs of state.itemRows.values()){
const match = !filter || refs.plainName.includes(filter);
refs.tr.style.display = match ? '' : 'none';
}
}
$('itemFilterInput').addEventListener('input', (e) => { state.itemFilter = e.target.value.toLowerCase(); applyItemFilter(); });
function updateSellValues(){
let total = 0;
for (const [key, refs] of state.itemRows.entries()){
const qty = parseFloat(refs.tr.dataset.qty) || 0;
const price = state.sellPrices.get(key) || 0;
const value = qty * price;
total += value;
refs.valueEl.textContent = fmt(value,2);
}
state.sellTotal = total;
$('sellTotalCell').textContent = fmt(total,2);
$('colsSell').textContent = '≈ ' + fmt(total,2);
}
function renderDropStats(){
const select = $('dropMobSelect');
const body = $('dropStatsBody');
const badge = $('communityBadge');
const toggleBtn = $('toggleCommunityBtn');
const hasCommunity = !!state.communityDropStats;
const usingCommunity = hasCommunity && state.showCommunity;
if (badge) badge.hidden = !usingCommunity;
if (toggleBtn){
toggleBtn.hidden = !hasCommunity;
toggleBtn.textContent = usingCommunity ? '👤 Mes stats perso' : '🌐 Stats communauté';
}
const displayStats = getDisplayDropStats();
if (!displayStats.size){
select.innerHTML = '';
select.disabled = true;
body.innerHTML = '<p class="feed-empty">Pas encore de données. Continue à farmer, ou importe des fichiers d\'autres joueurs.</p>';
return;
}
select.disabled = false;
const mobs = Array.from(displayStats.entries()).sort((a,b) => b[1].kills - a[1].kills);
select.innerHTML = mobs.map(([name, entry]) =>
'<option value="'+escAttr(name)+'">'+escHtml(name)+' (n='+fmt(entry.kills)+')</option>'
).join('');
const stillValid = state.selectedDropMob && mobs.some(([n]) => n === state.selectedDropMob);
const selection = stillValid ? state.selectedDropMob : mobs[0][0];
state.selectedDropMob = selection;
select.value = selection;
const entry = displayStats.get(selection);
const kills = entry.kills;
body.innerHTML = '';
if (!entry.items.size){
body.innerHTML = '<p class="feed-empty">Pas encore de drop enregistré pour ce mob.</p>';
return;
}
const totalExp = entry.totalExp || 0;
const totalCols = entry.totalCols || 0;
const statDiv = document.createElement('div');
statDiv.style.cssText = 'font-family:var(--font-mono);font-size:11px;color:var(--text-dim);margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border);display:flex;gap:18px;flex-wrap:wrap;';
if (totalExp > 0){
statDiv.innerHTML += '<span><span style="color:var(--gold)">XP</span> ~'+fmt(totalExp/kills,1)+' / kill · '+fmt(Math.round(totalExp))+' total</span>';
} else {
statDiv.innerHTML += '<span><span style="color:var(--gold)">XP</span> <span style="opacity:.5">— non disponible</span></span>';
}
if (totalCols > 0){
statDiv.innerHTML += '<span><span style="color:var(--copper)">Cols</span> ~'+fmt(totalCols/kills,2)+' / kill · '+fmt(Math.round(totalCols))+' total</span>';
}
body.appendChild(statDiv);
const itemEntries = Array.from(entry.items.entries()).sort((a,b) => (b[1].occurrences/kills) - (a[1].occurrences/kills));
for (const [key, it] of itemEntries){
const pct = kills > 0 ? (it.occurrences/kills*100) : 0;
const avgQty = it.occurrences > 0 ? it.totalQty/it.occurrences : 0;
const color = colorForKey(key);
const nameHtml = mcColorToHtml(it.rawName) || '<span>Item inconnu</span>';
const row = document.createElement('div');
row.className = 'droprow';
row.dataset.itemKey = key;
row.title = 'Clic droit pour voir tous les mobs qui droppent cet item';
row.innerHTML =
'<div class="droprow-label"><span>'+nameHtml+'</span><span class="droprow-pct">'+fmt(pct,1)+' %</span></div>'+
'<div class="bar-track"><div class="bar-fill" style="width:'+Math.min(pct,100)+'%;background:'+color+'"></div></div>'+
'<div class="droprow-sub">'+fmt(it.occurrences)+'/'+fmt(kills)+' kills · ~'+fmt(avgQty,1)+' par drop</div>';
body.appendChild(row);
}
}
$('dropMobSelect').addEventListener('change', (e) => { state.selectedDropMob = e.target.value; renderDropStats(); });
function renderFeed(){
const feed = $('feed');
if (!state.feedEntries.length){
feed.innerHTML = '<p class="feed-empty">Les nouveaux drops apparaîtront ici…</p>';
return;
}
feed.innerHTML = '';
for (const entry of state.feedEntries.slice(0, 40)){
const d = new Date(entry.time);
const timeStr = pad(d.getHours())+':'+pad(d.getMinutes())+':'+pad(d.getSeconds());
const itemsHtml = entry.items.map(it =>
'<span style="border-left:2px solid '+colorForKey(it.key)+';padding-left:6px;">×'+it.qty+' '+(mcColorToHtml(it.rawName)||'?')+'</span>'
).join('');
const tagsParts = [];
if (entry.exp) tagsParts.push('+'+entry.exp+' exp');
if (entry.col) tagsParts.push('+'+entry.col+' col');
const tags = tagsParts.length ? ' · '+tagsParts.join(' · ') : '';
const div = document.createElement('div');
div.className = 'feed-item';
div.style.borderLeftColor = colorForKey(entry.mobPlain);
div.innerHTML =
'<span class="feed-time">'+timeStr+'</span>'+
'<span class="feed-items">'+(itemsHtml || '<span>—</span>')+'</span>'+
'<span class="feed-mob">'+mcColorToHtml(entry.mobRaw)+tags+'</span>';
feed.appendChild(div);
}
}
function restoreFrom(saved, dropStatsPlain){
if (saved){
state.mobCounts = new Map(Object.entries(saved.mobCounts || {}));
state.itemCounts = new Map((saved.itemCounts || []).map(it => [it.key, it]));
state.expTotal = saved.expTotal || 0;
state.killCols = saved.killCols || 0;
state.sellPrices = new Map(Object.entries(saved.sellPrices || {}));
state.sessionStart = saved.sessionStart || null;
state.pausedAccum = saved.pausedAccum || 0;
state.feedEntries = saved.feedEntries || [];
if (saved.savedAt && state.sessionStart){
const gap = Date.now() - saved.savedAt;
if (gap > 0) state.pausedAccum += gap;
}
}
if (dropStatsPlain) state.dropStats = plainToDropStats(dropStatsPlain.mobs);
}
return { state, start, restoreFrom, getElapsedMs, getDisplayDropStats };
}
// ======================================================================
// DROP BOARD
// ======================================================================
function getDropBoardStats(){
if (!ft) return null;
if (ft.state.communityDropStats && ft.state.showCommunity) return ft.state.communityDropStats;
return ft.state.dropStats;
}
function updateDropBoardMobSelect(){
const select = $('dropBoardMobSelect');
if (!select) return;
const currentVal = select.value;
const allOpt = '<option value="__all__">Tous les mobs</option>';
const stats = getDropBoardStats();
if (!stats || !stats.size){
select.innerHTML = allOpt;
select.disabled = true;
return;
}
select.disabled = false;
const mobs = Array.from(stats.entries()).sort((a,b) => b[1].kills - a[1].kills);
select.innerHTML = allOpt + mobs.map(([name, entry]) =>
'<option value="'+escAttr(name)+'">'+escHtml(name)+' ('+fmt(entry.kills)+')</option>'
).join('');
if ([...select.options].some(o => o.value === currentVal)) select.value = currentVal;
}
function renderDropBoard(){
const content = $('dropBoardContent');
if (!content) return;
const stats = getDropBoardStats();
const usingCommunity = ft && !!ft.state.communityDropStats && ft.state.showCommunity;
const badge = $('dropBoardCommunityBadge');
if (badge) badge.hidden = !usingCommunity;
if (stats && stats.size){
let totalKills = 0;
for (const [,e] of stats) totalKills += e.kills;
const tile = $('tileDropBoardStatus');
if (tile) tile.textContent = '● ' + stats.size + ' mob' + (stats.size > 1 ? 's' : '') + ' · ' + fmt(totalKills) + ' kills';
}
if (!stats || !stats.size){
updateDropBoardMobSelect();
content.innerHTML = '<p class="feed-empty" style="padding:24px 0;">Pas encore de données. Lance Farm Tracker et commence à farmer, ou importe des fichiers.</p>';
return;
}
updateDropBoardMobSelect();
const filterVal = ($('dropBoardFilter') ? $('dropBoardFilter').value : '').toLowerCase();
const mobVal = $('dropBoardMobSelect') ? $('dropBoardMobSelect').value : '__all__';
const board = document.createElement('div');
board.className = 'drop-board';
let mobs = Array.from(stats.entries()).sort((a,b) => b[1].kills - a[1].kills);
if (mobVal !== '__all__') mobs = mobs.filter(([name]) => name === mobVal);
for (const [mobName, entry] of mobs){
if (!entry.items.size) continue;
let items = Array.from(entry.items.entries());
if (filterVal) items = items.filter(([, it]) => mcPlain(it.rawName).toLowerCase().includes(filterVal));
if (!items.length) continue;
items.sort((a,b) => (b[1].occurrences / entry.kills) - (a[1].occurrences / entry.kills));
const section = document.createElement('div');
section.className = 'drop-board-mob';
const header = document.createElement('div');
header.className = 'drop-board-mob-header';
const xpPerKill = entry.kills > 0 && (entry.totalExp || 0) > 0 ? fmt((entry.totalExp||0)/entry.kills,1)+' xp/kill' : null;
const colPerKill = entry.kills > 0 && (entry.totalCols || 0) > 0 ? fmt((entry.totalCols||0)/entry.kills,2)+' col/kill' : null;
const xpColParts = [xpPerKill, colPerKill].filter(Boolean).join(' · ');
header.innerHTML =
'<span class="drop-board-mob-name">'+escHtml(mobName)+'</span>'+
'<span class="drop-board-mob-kills">'+fmt(entry.kills)+' kills'+(xpColParts ? ' · '+xpColParts : '')+'</span>';
section.appendChild(header);
const cardsGrid = document.createElement('div');
cardsGrid.className = 'drop-board-cards';
for (const [key, it] of items){
const pct = entry.kills > 0 ? (it.occurrences / entry.kills * 100) : 0;
const avgQty = it.occurrences > 0 ? it.totalQty / it.occurrences : 0;
const color = colorForKey(key);
const nameHtml = mcColorToHtml(it.rawName) || '<span>Item inconnu</span>';
let pctColor = 'var(--text-dim)';
if (pct >= 50) pctColor = 'var(--gold)';
else if (pct >= 20) pctColor = 'var(--copper)';
else if (pct >= 5) pctColor = 'var(--moss)';
const card = document.createElement('div');
card.className = 'drop-card';
card.style.borderLeftColor = color;
card.dataset.itemKey = key;
card.title = 'Clic droit pour voir tous les mobs qui droppent cet item';
card.innerHTML =
'<div class="drop-card-pct" style="color:'+pctColor+'">'+fmt(pct,1)+' %</div>'+
'<div class="drop-card-name">'+nameHtml+'</div>'+
'<div class="drop-card-sub">'+fmt(it.occurrences)+'/'+fmt(entry.kills)+' kills · ~'+fmt(avgQty,1)+'/drop</div>';
cardsGrid.appendChild(card);
}
section.appendChild(cardsGrid);
board.appendChild(section);
}
if (!board.children.length){
content.innerHTML = '<p class="feed-empty" style="padding:24px 0;">Aucun résultat pour ce filtre.</p>';
} else {
content.innerHTML = '';
content.appendChild(board);
}
}
$('dropBoardFilter').addEventListener('input', renderDropBoard);
$('dropBoardMobSelect').addEventListener('change', renderDropBoard);
// ======================================================================
// HISTORIQUE DES SESSIONS
// ======================================================================
function fmtShort(v){
if (v>=1000000) return (v/1000000).toFixed(v>=10000000?0:1).replace('.',',')+'M';
if (v>=1000) return (v/1000).toFixed(v>=10000?0:1).replace('.',',')+'k';
return Math.round(v).toLocaleString('fr-FR');
}
function renderSvgChart(container, sessions, getValue, colorHex, labelFn, labelContainer){
const data=sessions.map(getValue);
if (data.length<2){
container.innerHTML='<p class="chart-empty">Pas assez de données (min. 2 sessions).</p>';
if (labelContainer) labelContainer.innerHTML='';
return;
}
const W=600,H=80,padL=8,padR=8,padT=10,padB=8;
const plotW=W-padL-padR,plotH=H-padT-padB,n=data.length;
const maxV=Math.max(...data,1);
const xs=data.map((_,i)=>padL+(n===1?plotW/2:(i/(n-1))*plotW));
const ys=data.map(v=>padT+plotH-(Math.max(0,v)/maxV)*plotH);
const linePath=xs.map((x,i)=>(i===0?'M':'L')+x.toFixed(1)+','+ys[i].toFixed(1)).join(' ');
const areaPath=linePath+' L'+xs[n-1].toFixed(1)+','+(padT+plotH)+' L'+xs[0].toFixed(1)+','+(padT+plotH)+' Z';
const gridLines=[0.25,0.5,0.75,1.0].map(pct=>{
const y=(padT+plotH*(1-pct)).toFixed(1);
return '<line x1="'+padL+'" y1="'+y+'" x2="'+(W-padR)+'" y2="'+y+'" stroke="#26301F" stroke-width="1"/>';
}).join('');
const r=n>30?2:3;
const dots=xs.map((x,i)=>{
const val=data[i];
const d=new Date(sessions[i].startedAt);
const dateStr=d.toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit'});
const valStr=labelFn?labelFn(val):Math.round(val).toLocaleString('fr-FR');
const tip=escAttr('Session '+(i+1)+' ('+dateStr+') : '+valStr);
return '<circle cx="'+x.toFixed(1)+'" cy="'+ys[i].toFixed(1)+'" r="'+r+'" fill="'+colorHex+'" stroke="#0B0F0C" stroke-width="1.5"/>'
+'<circle cx="'+x.toFixed(1)+'" cy="'+ys[i].toFixed(1)+'" r="10" fill="transparent" data-tip="'+tip+'"/>';
}).join('');
container.innerHTML=
'<svg viewBox="0 0 '+W+' '+H+'" width="100%" height="'+H+'" xmlns="http://www.w3.org/2000/svg">'
+gridLines
+'<path d="'+areaPath+'" fill="'+colorHex+'" fill-opacity="0.10"/>'
+'<path d="'+linePath+'" stroke="'+colorHex+'" fill="none" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>'
+dots
+'</svg>';
attachChartTip(container.querySelector('svg'));
if (labelContainer&&sessions.length>=2){
const d1=new Date(sessions[0].startedAt);
const d2=new Date(sessions[sessions.length-1].startedAt);
const s1=d1.toLocaleDateString('fr-FR',{day:'2-digit',month:'short'});
const s2=d2.toLocaleDateString('fr-FR',{day:'2-digit',month:'short'});
labelContainer.innerHTML='<span>'+escHtml(s1)+'</span><span>'+escHtml(s2)+'</span>';
}
}
function renderHistoryTileStatus(){
const n=sessionHistory.sessions.length;
const el=$('tileHistoryStatus');
if (el) el.textContent='● '+n+' session'+(n>1?'s':'');
}
function renderHistoryScreen(){
const sessions=sessionHistory.sessions;
renderHistoryTileStatus();
const histHero=$('histHero');
const histEmpty=$('histEmptyState');
const histCharts=$('histChartsArea');
const histPanel=$('histSessionsPanel');
if (!sessions.length){
histHero.hidden=true; histEmpty.hidden=false; histCharts.hidden=true; histPanel.hidden=true;
return;
}
histEmpty.hidden=true; histHero.hidden=false; histCharts.hidden=false; histPanel.hidden=false;
const totalKills=sessions.reduce((s,x)=>s+x.totalKills,0);
const totalMs=sessions.reduce((s,x)=>s+x.durationMs,0);
const totalExp=sessions.reduce((s,x)=>s+(x.totalExp||0),0);
const totalKamas=sessions.reduce((s,x)=>s+(x.sellTotal||0),0);
const avgKills=Math.round(totalKills/sessions.length);
const avgMs=totalMs/sessions.length;
const avgExp=Math.round(totalExp/sessions.length);
const avgKamas=Math.round(totalKamas/sessions.length);
const ratesPerMin=sessions.filter(s=>s.durationMs>0).map(s=>s.totalKills/(s.durationMs/60000));
const avgRate=ratesPerMin.length?ratesPerMin.reduce((a,b)=>a+b,0)/ratesPerMin.length:0;
const bestRate=ratesPerMin.length?Math.max(...ratesPerMin):0;
$('histTotalSessions').textContent=fmt(sessions.length);
$('histTotalKills').textContent=fmt(totalKills);
$('histAvgKills').textContent='moy. '+fmt(avgKills)+' / session';
const tH=Math.floor(totalMs/3600000),tM=Math.floor((totalMs%3600000)/60000);
$('histTotalTime').textContent=tH+'h '+String(tM).padStart(2,'0')+'m';
const aH=Math.floor(avgMs/3600000),aM=Math.floor((avgMs%3600000)/60000);
$('histAvgTime').textContent='moy. '+(aH>0?aH+'h ':'')+String(aM).padStart(2,'0')+'m / session';
$('histAvgRate').textContent=fmt(avgRate,1)+'/min';
$('histBestRate').textContent='meilleur '+fmt(bestRate,1)+'/min';
$('histTotalExp').textContent=fmtShort(totalExp);
$('histAvgExp').textContent='moy. '+fmtShort(avgExp)+' / session';
$('histTotalKamas').textContent=fmtShort(totalKamas);
$('histAvgKamas').textContent='moy. '+fmtShort(avgKamas)+' / session';
const kAvgEl=$('histKillsAvgLabel');
if (kAvgEl) kAvgEl.textContent='moy. '+fmt(avgKills)+' kills';
renderSvgChart($('chartKills'),sessions,s=>s.totalKills,'#9B5DE5',null,$('chartKillsLabels'));
renderSvgChart($('chartRate'),sessions,s=>{
const min=s.durationMs/60000; return min>0?Math.round(s.totalKills/min*10)/10:0;
},'#5DB7E5',v=>fmt(v,1)+'/min',$('chartRateLabels'));
renderSvgChart($('chartExp'),sessions,s=>s.totalExp,'#F2C94C',v=>fmtShort(v),$('chartExpLabels'));
const tbody=$('histSessionsBody');
tbody.innerHTML='';
const sorted=[...sessions].reverse();
for (const s of sorted){
const d=new Date(s.startedAt);
const dateStr=d.toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'2-digit'})
+' '+d.toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'});
const dH=Math.floor(s.durationMs/3600000),dM=Math.floor((s.durationMs%3600000)/60000);
const durStr=dH>0?dH+'h'+String(dM).padStart(2,'0')+'m':dM+'min';
const minElapsed=s.durationMs/60000;
const killsPerMin=minElapsed>0?Math.round(s.totalKills/minElapsed*10)/10:0;
const expPerMin=minElapsed>0?Math.round(s.totalExp/minElapsed):0;
const colsPerMin=minElapsed>0?Math.round((s.killCols||0)/minElapsed*10)/10:0;
const topItemName=s.topItems&&s.topItems.length?s.topItems[0].name+' ×'+s.topItems[0].qty:'—';
const tr=document.createElement('tr');
tr.style.cursor='pointer';
tr.title='Clic pour voir le détail';
tr.dataset.sessionId=s.id;
tr.innerHTML=
'<td>'+escHtml(dateStr)+'</td>'+
'<td class="r mono">'+escHtml(durStr)+'</td>'+
'<td class="r hl">'+fmt(s.totalKills)+'</td>'+
'<td class="r mono">'+fmt(killsPerMin,1)+'/min</td>'+
'<td class="r mono">'+fmtShort(s.totalExp)+'</td>'+
'<td class="r mono">'+fmtShort(expPerMin)+'/min</td>'+
'<td class="r mono">'+(s.sellTotal?fmtShort(Math.round(s.sellTotal)):'—')+'</td>'+
'<td class="r mono">'+fmt(colsPerMin,1)+'/min</td>'+
'<td class="dim">'+escHtml(s.topMob||'—')+'</td>'+
'<td class="dim" title="'+escHtml(topItemName)+'">'+escHtml(topItemName.length>22?topItemName.slice(0,20)+'…':topItemName)+'</td>'+
'<td style="color:var(--text-dim);font-size:11px;padding-left:8px;">→</td>';
tbody.appendChild(tr);
}
}
function renderSessionDetail(s){
const d = new Date(s.startedAt);
const dateStr = d.toLocaleDateString('fr-FR',{weekday:'long',day:'2-digit',month:'long',year:'numeric'})
+ ' à ' + d.toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'});
$('sessionDetailTitle').textContent = dateStr;
const minElapsed = s.durationMs / 60000;
const dH = Math.floor(s.durationMs/3600000), dM = Math.floor((s.durationMs%3600000)/60000), dS = Math.floor((s.durationMs%60000)/1000);
$('sdDur').textContent = dH > 0 ? dH+'h'+String(dM).padStart(2,'0')+'m' : dM+'min '+String(dS).padStart(2,'0')+'s';
$('sdKills').textContent = fmt(s.totalKills);
$('sdKillsRate').textContent = minElapsed > 0 ? fmt(s.totalKills/minElapsed,1)+' kills/min' : '—';
$('sdExp').textContent = fmtShort(s.totalExp||0);
$('sdExpRate').textContent = minElapsed > 0 ? fmtShort(Math.round((s.totalExp||0)/minElapsed))+'/min' : '—';
$('sdKamas').textContent = s.sellTotal ? fmtShort(Math.round(s.sellTotal)) : '—';
$('sdCols').textContent = minElapsed > 0 ? fmt((s.killCols||0)/minElapsed,1)+' cols/min' : '—';
// Mobs
const mobEl = $('sdMobList');
const mobEntries = Object.entries(s.mobCounts||{}).sort((a,b)=>b[1]-a[1]);
if (!mobEntries.length){
mobEl.innerHTML = '<p style="color:var(--text-dim);font-size:13px;">Aucun mob enregistré.</p>';
} else {
const maxCount = mobEntries[0][1];
const colors = ['#9B5DE5','#5DB7E5','#F2C94C','#E05563','#5DE5A0','#E5955D'];
mobEl.innerHTML = mobEntries.map(([name, count], i) => {
const pct = (count / maxCount * 100).toFixed(1);
const color = colors[i % colors.length];
const perMin = minElapsed > 0 ? (count/minElapsed).toFixed(1) : '—';
return '<div class="bar-row">' +
'<div class="bar-label"><span>'+escHtml(name)+'</span>' +
'<span class="bar-count">'+fmt(count)+' <span style="color:var(--text-dim);font-size:11px;">('+perMin+'/min)</span></span></div>' +
'<div class="bar-track"><div class="bar-fill" style="width:'+pct+'%;background:'+color+'"></div></div>' +
'</div>';
}).join('');
}
// Items
const itemEl = $('sdItemList');
const items = s.topItems||[];
if (!items.length){
itemEl.innerHTML = '<p style="color:var(--text-dim);font-size:13px;">Aucun item enregistré.</p>';
} else {
const maxQty = items[0].qty;
const colors = ['#F2C94C','#9B5DE5','#5DB7E5','#E05563','#5DE5A0'];
itemEl.innerHTML = items.map(({name, qty}, i) => {
const pct = (qty / maxQty * 100).toFixed(1);
const color = colors[i % colors.length];
const perMin = minElapsed > 0 ? (qty/minElapsed).toFixed(2) : '—';
return '<div class="bar-row">' +
'<div class="bar-label"><span>'+escHtml(name)+'</span>' +
'<span class="bar-count">×'+fmt(qty)+' <span style="color:var(--text-dim);font-size:11px;">('+perMin+'/min)</span></span></div>' +
'<div class="bar-track"><div class="bar-fill" style="width:'+pct+'%;background:'+color+'"></div></div>' +
'</div>';
}).join('');
}
}
$('sessionDetailBackBtn').addEventListener('click', () => {
showScreen('history');
renderHistoryScreen();
});
$('histSessionsBody').addEventListener('click', e => {
const tr = e.target.closest('tr[data-session-id]');
if (!tr) return;
const session = sessionHistory.sessions.find(s => s.id === tr.dataset.sessionId);
if (!session) return;
renderSessionDetail(session);
showScreen('sessionDetail');
});
$('clearHistoryBtn').addEventListener('click', async () => {
if (!sessionHistory.sessions.length) return;
if (!window.confirm('Effacer tout l\'historique des sessions ? Cette action est irréversible.')) return;
sessionHistory.sessions=[];
try{ await window.api.saveSessionHistory(sessionHistory); }catch(e){}
renderHistoryScreen();
});
// ======================================================================
// TIMERS
// ======================================================================
let timerConfigs = JSON.parse(localStorage.getItem(lsKey('timerConfigs')) || '[]');
// ---- Sound catalog ----
const SOUND_CATALOG = [
{ id:'levelup', label:'Level Up XP', desc:'Montée de niveau', hash:'19034765ba8ba5389b35804ab213537ab5cf706f' },
{ id:'orb', label:'XP Orb', desc:'Ramassage d\'orbe XP', hash:'8a04a60d5c28fc60df472a877ca57f37eabc78d7' },
{ id:'pop', label:'Item Pop', desc:'Ramassage d\'item', hash:'d6ae1c04d0a7376a33d1df12e1b8057cfbab6bc2' },
{ id:'pling', label:'Pling', desc:'Note de bloc Pling', hash:'774ae41e86f0b62a5cc961d1bc2e3d0aef9d229c' },
{ id:'bell', label:'Bell', desc:'Note de bloc Bell', hash:'a1e833dec61595dc79d0c672fcd7838579ca4b14' },
{ id:'toast', label:'Toast Avancement', desc:'Notification avancement', hash:'506f2fdb1b7530df66134aa04c71e66513df0c93' },
{ id:'challenge', label:'Défi Terminé', desc:'Challenge complété', hash:'bd01dff39a7bd1e0e7f6847f7aee981f157e4f94' },
{ id:'chestopen', label:'Coffre', desc:'Ouverture de coffre', hash:'186d5d9481d59cc99bc4be1b5fbb98d0ef877b8e' },
{ id:'hit', label:'Hit', desc:'Coup réussi', hash:'57f50076e7b91b12595a17cf0d38303f979f862b' },
{ id:'click', label:'Click', desc:'Clic de bouton', hash:'3455ca942556c7d5eac7dd5e458e7fb3bad564c9' },
];
// ---- IndexedDB cache pour sons téléchargés ----
let _soundDb = null;
function openSoundDb(){
if (_soundDb) return Promise.resolve(_soundDb);
return new Promise((res, rej) => {
const req = indexedDB.open('FarmTrackerSounds', 1);
req.onupgradeneeded = e => e.target.result.createObjectStore('sounds', { keyPath: 'id' });
req.onsuccess = e => { _soundDb = e.target.result; res(_soundDb); };
req.onerror = e => rej(e.target.error);
});
}
async function isSoundCached(soundId){
try {
const db = await openSoundDb();
return new Promise(res => {
const req = db.transaction('sounds','readonly').objectStore('sounds').get(soundId);
req.onsuccess = () => res(!!req.result);
req.onerror = () => res(false);
});
} catch { return false; }
}
async function getSoundBytes(soundId){
const db = await openSoundDb();
return new Promise((res, rej) => {
const req = db.transaction('sounds','readonly').objectStore('sounds').get(soundId);
req.onsuccess = () => res(req.result ? req.result.buffer : null);
req.onerror = () => rej(req.error);
});
}
async function saveSoundBytes(soundId, buffer){
const db = await openSoundDb();
return new Promise((res, rej) => {
const tx = db.transaction('sounds','readwrite');
tx.objectStore('sounds').put({ id: soundId, buffer });
tx.oncomplete = res;
tx.onerror = () => rej(tx.error);
});
}
async function downloadSound(soundId, onProgress){
const entry = SOUND_CATALOG.find(s => s.id === soundId);
if (!entry) throw new Error('Unknown sound: ' + soundId);
const prefix = entry.hash.slice(0, 2);
const url = `https://resources.download.minecraft.net/${prefix}/${entry.hash}`;
const resp = await fetch(url);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const buffer = await resp.arrayBuffer();
await saveSoundBytes(soundId, buffer);
_audioBufferCache.delete(soundId);
return buffer;
}
// ---- Web Audio playback ----
const _audioBufferCache = new Map();
let _audioCtx = null;
function getAudioCtx(){
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
return _audioCtx;
}
async function _getDecodedBuffer(soundId){
if (_audioBufferCache.has(soundId)) return _audioBufferCache.get(soundId);
let bytes = await getSoundBytes(soundId);
if (!bytes && soundId === 'levelup'){
// fallback : fichier bundlé dans renderer/
const r = await fetch('levelup.ogg');
bytes = await r.arrayBuffer();
await saveSoundBytes('levelup', bytes);
}
if (!bytes) return null;
const ctx = getAudioCtx();
const decoded = await ctx.decodeAudioData(bytes.slice(0));
_audioBufferCache.set(soundId, decoded);
return decoded;
}
async function playAlert(cfg){
try {
const soundId = cfg ? (cfg.soundId || 'levelup') : 'levelup';
const vol = Math.max(0, Math.min(1, ((cfg && cfg.volume != null) ? cfg.volume : 10) / 100));
const buf = await _getDecodedBuffer(soundId);
if (!buf){ console.warn('Sound not cached:', soundId); return; }
const ctx = getAudioCtx();
const src = ctx.createBufferSource();
const gain = ctx.createGain();
src.buffer = buf;
src.connect(gain);
gain.connect(ctx.destination);
gain.gain.value = vol;
src.start();
} catch(e){ console.warn('playAlert failed', e); }
}
// migration : ajoute soundId et volume si absents
let alertConfigs = JSON.parse(localStorage.getItem(lsKey('alertConfigs')) || '[]').map(c => ({
soundId: 'levelup', volume: 10, ...c
}));
function saveAlertConfigs(){
localStorage.setItem(lsKey('alertConfigs'), JSON.stringify(alertConfigs));
}
// activeTimers : Map<configId, { endsAt: number, durationSec: number, mobName: string }>
const activeTimers = new Map();
let timerClockId = null;
function saveTimerConfigs(){
localStorage.setItem(lsKey('timerConfigs'), JSON.stringify(timerConfigs));
updateTimerTileStatus();
}
function parseDuration(str){
str = str.trim();
if (str.includes(':')) {
const parts = str.split(':');
const m = parseInt(parts[0], 10) || 0;
const s = parseInt(parts[1], 10) || 0;
return m * 60 + s;
}
return parseInt(str, 10) || 0;
}
function fmtCountdown(sec){
if (sec <= 0) return 'PRÊT';
const m = Math.floor(sec / 60), s = sec % 60;
return String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0');
}
function updateTimerTileStatus(){
const el = $('tileTimersStatus');
if (el) el.textContent = '● ' + timerConfigs.length + ' timer' + (timerConfigs.length !== 1 ? 's' : '');
}
function triggerTimer(mobPlain){
for (const cfg of timerConfigs){
if (cfg.mobName.toLowerCase() === mobPlain.toLowerCase()){
const endsAt = Date.now() + cfg.durationSec * 1000;
activeTimers.set(cfg.id, { endsAt, durationSec: cfg.durationSec, mobName: cfg.mobName });
renderTimerActiveGrid();
}
}
}
function startTimerClock(){
if (timerClockId) return;
timerClockId = setInterval(() => {
if (activeTimers.size > 0) renderTimerActiveGrid();
}, 1000);
}
function renderTimerActiveGrid(){
const grid = $('timerActiveGrid');
if (!grid) return;
if (activeTimers.size === 0){
grid.innerHTML = '<p class="timer-empty-msg">Aucun timer actif — tue un mob configuré pour démarrer un compte à rebours.</p>';
return;
}
const now = Date.now();
let html = '';
for (const [id, timer] of activeTimers){
const remaining = Math.max(0, Math.ceil((timer.endsAt - now) / 1000));
const pct = Math.max(0, remaining / timer.durationSec * 100);
const cls = remaining <= 0 ? 'timer-done' : pct < 25 ? 'timer-warn' : 'timer-ok';
const barColor = remaining <= 0 ? 'var(--boss)' : pct < 25 ? 'var(--paused)' : 'var(--moss)';
html +=
'<div class="timer-card '+cls+'" data-timer-id="'+escHtml(id)+'">' +
'<div class="timer-card-mob">'+escHtml(timer.mobName)+'</div>' +
'<div class="timer-card-countdown '+cls+'">'+fmtCountdown(remaining)+'</div>' +
'<div class="timer-card-sub">'+(remaining<=0?'Respawn disponible !':'reste '+fmtCountdown(remaining))+'</div>'+
'<div class="timer-card-bar"><div class="timer-card-bar-fill" style="width:'+pct.toFixed(1)+'%;background:'+barColor+';"></div></div>'+
'</div>';
}
grid.innerHTML = html;
}
function renderTimerConfigList(){
const list = $('timerConfigList');
if (!list) return;
if (!timerConfigs.length){ list.innerHTML = ''; return; }
list.innerHTML = timerConfigs.map(cfg => {
const m = Math.floor(cfg.durationSec/60), s = cfg.durationSec%60;
const durStr = m > 0 ? m+'min'+(s>0?' '+s+'s':'') : s+'s';
return '<div class="timer-config-row">' +
'<span class="timer-config-mob">'+escHtml(cfg.mobName)+'</span>' +
'<span class="timer-config-dur">'+escHtml(durStr)+'</span>' +
'<button class="btn-ghost small danger" data-del-timer="'+escHtml(cfg.id)+'">✕</button>' +
'</div>';
}).join('');
}
function populateTimerMobSuggestions(){
const dl = $('timerMobSuggestions');
if (!dl) return;
const seen = new Set();
// mobs de la session courante
if (ft && ft.state && ft.state.mobCounts){
for (const name of ft.state.mobCounts.keys()) seen.add(name);
}
// mobs de l'historique
for (const sess of sessionHistory.sessions){
if (sess.mobCounts) for (const name of Object.keys(sess.mobCounts)) seen.add(name);
}
// exclure ceux déjà configurés
const configured = new Set(timerConfigs.map(c => c.mobName.toLowerCase()));
dl.innerHTML = Array.from(seen)
.filter(n => !configured.has(n.toLowerCase()))
.sort((a,b) => a.localeCompare(b))
.map(n => '<option value="'+escHtml(n)+'"></option>')
.join('');
}
function renderTimerScreen(){
renderTimerConfigList();
renderTimerActiveGrid();
populateTimerMobSuggestions();
renderAlertConfigList();
populateAlertItemSuggestions();
populateAddSoundSelect();
startTimerClock();
}
$('timerAddBtn').addEventListener('click', () => {
const mobName = $('timerMobInput').value.trim();
const durationSec = parseDuration($('timerDurInput').value);
if (!mobName || durationSec <= 0) return;
timerConfigs.push({ id: generateUUID(), mobName, durationSec });
saveTimerConfigs();
$('timerMobInput').value = '';
$('timerDurInput').value = '';
renderTimerConfigList();
populateTimerMobSuggestions();
});
$('timerMobInput').addEventListener('keydown', e => { if (e.key === 'Enter') $('timerAddBtn').click(); });
$('timerDurInput').addEventListener('keydown', e => { if (e.key === 'Enter') $('timerAddBtn').click(); });
$('timerConfigList').addEventListener('click', e => {
const id = e.target.dataset.delTimer;
if (!id) return;
timerConfigs = timerConfigs.filter(c => c.id !== id);
activeTimers.delete(id);
saveTimerConfigs();
renderTimerConfigList();
renderTimerActiveGrid();
populateTimerMobSuggestions();
});
// ---- Alert config list ----
function buildSoundOptions(selectedId){
return SOUND_CATALOG.map(s =>
'<option value="'+escHtml(s.id)+'"'+(s.id === selectedId ? ' selected' : '')+'>'+escHtml(s.label)+'</option>'
).join('');
}
async function renderAlertConfigList(){
const list = $('alertConfigList');
if (!list) return;
if (!alertConfigs.length){ list.innerHTML = ''; return; }
const cachedIds = new Set();
for (const s of SOUND_CATALOG){
if (await isSoundCached(s.id)) cachedIds.add(s.id);
}
list.innerHTML = alertConfigs.map(cfg => {
const sid = cfg.soundId || 'levelup';
const opts = SOUND_CATALOG.map(s => {
const notCached = !cachedIds.has(s.id) && s.id !== 'levelup';
const label = s.label + (notCached ? ' (⬇)' : '');
return '<option value="'+escHtml(s.id)+'"'+(s.id === sid ? ' selected' : '')+'>'+escHtml(label)+'</option>';
}).join('');
const vol = cfg.volume != null ? cfg.volume : 10;
return '<div class="alert-cfg-row">'+
'<span class="alert-cfg-name">'+escHtml(cfg.itemName)+'</span>'+
'<select class="alert-cfg-sound" data-alert-sound="'+escHtml(cfg.id)+'">'+opts+'</select>'+
'<input type="range" min="0" max="100" step="1" value="'+vol+'" data-alert-vol="'+escHtml(cfg.id)+'" style="width:64px;" title="Volume: '+vol+'%">'+
'<span data-alert-vol-label="'+escHtml(cfg.id)+'" style="font-size:10px;font-family:var(--font-mono);color:var(--text-dim);min-width:26px;">'+vol+'%</span>'+
'<button class="btn-ghost small" data-preview-alert="'+escHtml(cfg.id)+'" title="Prévisualiser">▶</button>'+
'<button class="btn-ghost small danger" data-del-alert="'+escHtml(cfg.id)+'">✕</button>'+
'</div>';
}).join('');
}
function populateAlertItemSuggestions(){
const dl = $('alertItemSuggestions');
if (!dl) return;
const seen = new Set();
if (ft && ft.state && ft.state.itemCounts){
for (const it of ft.state.itemCounts.values()){
const n = mcPlain(it.rawName); if (n) seen.add(n);
}
}
if (ft && ft.state && ft.state.dropStats){
for (const [, entry] of ft.state.dropStats){
for (const [, it] of entry.items){ const n = mcPlain(it.rawName); if (n) seen.add(n); }
}
}
const configured = new Set(alertConfigs.map(c => c.itemName.toLowerCase()));
dl.innerHTML = Array.from(seen)
.filter(n => !configured.has(n.toLowerCase()))
.sort((a,b) => a.localeCompare(b))
.map(n => '<option value="'+escHtml(n)+'"></option>')
.join('');
}
function populateAddSoundSelect(){
const sel = $('alertSoundSelect');
if (!sel) return;
sel.innerHTML = buildSoundOptions('levelup');
}
// slider volume dans le formulaire d'ajout
$('alertVolumeInput').addEventListener('input', () => {
$('alertVolumeVal').textContent = $('alertVolumeInput').value + '%';
});
$('alertAddBtn').addEventListener('click', () => {
const itemName = $('alertItemInput').value.trim();
if (!itemName) return;
const soundId = $('alertSoundSelect').value || 'levelup';
const volume = parseInt($('alertVolumeInput').value, 10);
alertConfigs.push({ id: generateUUID(), itemName, nameLower: itemName.toLowerCase(), soundId, volume });
saveAlertConfigs();
$('alertItemInput').value = '';
renderAlertConfigList();
populateAlertItemSuggestions();
if (soundId !== 'levelup') isSoundCached(soundId).then(ok => { if (!ok) downloadSound(soundId).catch(()=>{}); });
});
$('alertItemInput').addEventListener('keydown', e => { if (e.key === 'Enter') $('alertAddBtn').click(); });
$('alertConfigList').addEventListener('click', async e => {
const delId = e.target.dataset.delAlert;
if (delId){
alertConfigs = alertConfigs.filter(c => c.id !== delId);
saveAlertConfigs();
await renderAlertConfigList();
populateAlertItemSuggestions();
return;
}
const prevId = e.target.dataset.previewAlert;
if (prevId){
const cfg = alertConfigs.find(c => c.id === prevId);
if (cfg){
const soundId = cfg.soundId || 'levelup';
const cached = await isSoundCached(soundId) || soundId === 'levelup';
if (!cached){
e.target.textContent = '⬇';
await downloadSound(soundId).catch(err => console.warn('Download failed', err));
await renderAlertConfigList();
renderSoundLibrary();
}
playAlert(cfg);
}
}
});
$('alertConfigList').addEventListener('change', e => {
const soundId = e.target.dataset.alertSound;
if (soundId){
const cfg = alertConfigs.find(c => c.id === soundId);
if (!cfg) return;
cfg.soundId = e.target.value;
saveAlertConfigs();
if (cfg.soundId !== 'levelup') isSoundCached(cfg.soundId).then(ok => { if (!ok) downloadSound(cfg.soundId).catch(()=>{}); });
return;
}
});
$('alertConfigList').addEventListener('input', e => {
const id = e.target.dataset.alertVol;
if (!id) return;
const cfg = alertConfigs.find(c => c.id === id);
if (!cfg) return;
cfg.volume = parseInt(e.target.value, 10);
saveAlertConfigs();
const label = document.querySelector('[data-alert-vol-label="'+id+'"]');
if (label) label.textContent = cfg.volume + '%';
});
// ---- Sound library ----
$('soundLibToggle').addEventListener('click', () => {
const content = $('soundLibContent');
const arrow = $('soundLibArrow');
const open = content.hidden;
content.hidden = !open;
arrow.textContent = open ? '▼ fermer' : '▶ ouvrir';
if (open) renderSoundLibrary();
});
async function renderSoundLibrary(){
const grid = $('soundLibGrid');
if (!grid) return;
const cards = await Promise.all(SOUND_CATALOG.map(async s => {
const cached = await isSoundCached(s.id) || s.id === 'levelup';
return '<div class="sound-card" data-sound-id="'+escHtml(s.id)+'">'+
'<span class="sound-card-name">'+escHtml(s.label)+'</span>'+
'<span class="sound-card-desc">'+escHtml(s.desc)+'</span>'+
'<div class="sound-card-actions">'+
'<button class="btn-ghost small" data-preview-sound="'+escHtml(s.id)+'" title="Prévisualiser">▶ Preview</button>'+
(cached
? '<span class="sound-card-status">✓ Téléchargé</span>'
: '<button class="btn-ghost small" data-dl-sound="'+escHtml(s.id)+'">⬇ Télécharger</button>')+
'</div>'+
'</div>';
}));
grid.innerHTML = cards.join('');
}
$('soundLibGrid').addEventListener('click', async e => {
const dlId = e.target.dataset.dlSound;
if (dlId){
e.target.textContent = '…';
e.target.disabled = true;
try {
await downloadSound(dlId);
await renderSoundLibrary();
await renderAlertConfigList();
populateAddSoundSelect();
} catch(err){
e.target.textContent = '✗ Erreur';
console.warn('Sound download failed', err);
}
return;
}
const prevId = e.target.dataset.previewSound;
if (prevId){
const cached = await isSoundCached(prevId) || prevId === 'levelup';
if (!cached){
e.target.textContent = '⬇…';
await downloadSound(prevId).catch(err => console.warn('dl failed', err));
await renderSoundLibrary();
await renderAlertConfigList();
}
playAlert({ soundId: prevId });
}
});
updateTimerTileStatus();
startTimerClock();
async function initFarmTracker(logPath, resetCounters){
if (!ft) ft = createFarmTracker();
await ft.start(logPath, resetCounters);
}
async function reconfigureFarmTracker(newLogPath){
if (!ft) { await initFarmTracker(newLogPath, true); return; }
await ft.start(newLogPath, true);
}
// ======================================================================
// Menu contextuel : "qui drop cet item ?"
// ======================================================================
const itemCtxMenu = $('itemCtxMenu');
const itemCtxBody = $('itemCtxBody');
function hideCtxMenu(){
itemCtxMenu.classList.remove('visible');
}
function showItemDroppers(itemKey, clientX, clientY){
const stats = ft ? (ft.state.communityDropStats && ft.state.showCommunity ? ft.state.communityDropStats : ft.state.dropStats) : null;
if (!stats || !stats.size){ hideCtxMenu(); return; }
const droppers = [];
for (const [mobName, entry] of stats){
const it = entry.items.get(itemKey);
if (!it) continue;
const pct = entry.kills > 0 ? it.occurrences / entry.kills * 100 : 0;
droppers.push({
mobName,
pct,
avgQty: it.occurrences > 0 ? it.totalQty / it.occurrences : 0,
occ: it.occurrences,
kills: entry.kills
});
}
if (!droppers.length){
itemCtxBody.innerHTML = '<p class="item-ctx-empty">Aucun mob connu pour cet item.</p>';
} else {
droppers.sort((a, b) => b.pct - a.pct);
itemCtxBody.innerHTML = droppers.map(d => {
let pctColor = 'var(--text-dim)';
if (d.pct >= 50) pctColor = 'var(--gold)';
else if (d.pct >= 20) pctColor = 'var(--copper)';
else if (d.pct >= 5) pctColor = 'var(--moss)';
return '<div class="item-ctx-row">'+
'<div class="item-ctx-mob">'+
'<span class="item-ctx-mob-name">'+escHtml(d.mobName)+'</span>'+
'<span class="item-ctx-mob-pct" style="color:'+pctColor+'">'+fmt(d.pct,1)+' %</span>'+
'</div>'+
'<div class="item-ctx-mob-sub">'+fmt(d.occ)+'/'+fmt(d.kills)+' kills · ~'+fmt(d.avgQty,1)+'/drop</div>'+
'</div>';
}).join('');
}
itemCtxMenu.classList.add('visible');
// Positionne sans déborder de l'écran
const mW = 280, mH = Math.min(droppers.length * 58 + 38, 420);
itemCtxMenu.style.left = Math.min(clientX, window.innerWidth - mW - 8) + 'px';
itemCtxMenu.style.top = Math.min(clientY, window.innerHeight - mH - 8) + 'px';
}
document.addEventListener('contextmenu', e => {
// Ne pas intercepter le clic droit sur les inputs (paste, etc.)
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA'){
hideCtxMenu(); return;
}
const target = e.target.closest('[data-item-key],[data-key]');
if (!target){ hideCtxMenu(); return; }
const key = target.dataset.itemKey || target.dataset.key;
if (!key){ hideCtxMenu(); return; }
e.preventDefault();
showItemDroppers(key, e.clientX, e.clientY);
});
document.addEventListener('click', hideCtxMenu);
document.addEventListener('keydown', e => { if (e.key === 'Escape') hideCtxMenu(); });
// ======================================================================
// Démarrage : restauration automatique + reconnexion silencieuse
// ======================================================================
(async () => {
// Charger les profils en premier (nécessaire avant lsKey et les données)
await loadProfiles();
// Re-lire timerConfigs et alertConfigs maintenant que lsKey est disponible
timerConfigs = JSON.parse(localStorage.getItem(lsKey('timerConfigs')) || '[]');
alertConfigs = JSON.parse(localStorage.getItem(lsKey('alertConfigs')) || '[]').map(c => ({ soundId: 'levelup', volume: 10, ...c }));
let appState = null, dropStatsPlain = null, communityDropStatsPlain = null;
try{ appState = await window.api.loadAppState(); }catch(e){}
try{ dropStatsPlain = await window.api.loadDropStats(); }catch(e){}
try{ communityDropStatsPlain = await window.api.loadCommunityDropStats(); }catch(e){}
try{ const h=await window.api.loadSessionHistory(); if (h&&h.sessions) sessionHistory=h; }catch(e){}
renderHistoryTileStatus();
if (appState && appState.logPath){
let check = {ok: false};
try{ check = await window.api.checkLogPath(appState.logPath); }catch(e){}
if (check.ok){
globalState.logPath = appState.logPath;
globalState.serverUrl = appState.serverUrl || '';
globalState.clientId = appState.clientId || generateUUID();
ft = createFarmTracker();
ft.restoreFrom(appState.farmTracker, dropStatsPlain);
if (communityDropStatsPlain && communityDropStatsPlain.mobs){
ft.state.communityDropStats = plainToDropStats(communityDropStatsPlain.mobs);
}
await ft.start(appState.logPath, false);
// Restore sync URL input
const urlInput = $('syncServerUrl');
if (urlInput && globalState.serverUrl) urlInput.value = globalState.serverUrl;
showScreen('hub');
return;
}
}
showScreen('folder');
})();
})();
// ---------- Progression de mise à jour ----------
if (window.api && window.api.onUpdateProgress) {
const overlay = document.createElement('div');
overlay.id = 'updateOverlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(11,15,12,0.94);z-index:9999;display:none;align-items:center;justify-content:center;flex-direction:column;gap:18px;';
overlay.innerHTML =
'<div style="font-family:var(--font-mono);color:var(--text);font-size:14px;letter-spacing:.04em;" id="updateOverlayText">Téléchargement de la mise à jour…</div>' +
'<div style="width:320px;height:5px;background:var(--bg-panel-2);border:1px solid var(--border);">' +
'<div id="updateProgressBar" style="height:100%;background:var(--corrupt);width:0%;transition:width .25s;"></div>' +
'</div>' +
'<div style="font-family:var(--font-mono);color:var(--text-dim);font-size:12px;" id="updateOverlayPct">0%</div>';
document.body.appendChild(overlay);
window.api.onUpdateProgress(({ percent, status, error }) => {
const bar = document.getElementById('updateProgressBar');
const pct = document.getElementById('updateOverlayPct');
const txt = document.getElementById('updateOverlayText');
if (status === 'error') {
overlay.style.display = 'none';
return;
}
overlay.style.display = 'flex';
bar.style.width = percent + '%';
pct.textContent = percent + '%';
txt.textContent = status === 'installing' ? 'Installation… l\'application va redémarrer.' : 'Téléchargement de la mise à jour…';
});
}
</script>
</body>
</html>