2037 lines
88 KiB
HTML
2037 lines
88 KiB
HTML
<!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;}
|
||
@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;}
|
||
</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" 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>
|
||
|
||
<!-- ============ É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">● 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">● 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">● 0 session</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>
|
||
|
||
<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 / heure</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">Kills/h</th>
|
||
<th class="r">Exp</th>
|
||
<th class="r">Cols kill</th>
|
||
<th>Mob principal</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="histSessionsBody"></tbody>
|
||
</table>
|
||
</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>
|
||
|
||
</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>
|
||
|
||
<!-- 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||
function escAttr(s){ return escHtml(s).replace(/"/g,'"'); }
|
||
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);
|
||
|
||
// ======================================================================
|
||
// SHELL : navigation entre écrans (dossier -> accueil -> outils)
|
||
// ======================================================================
|
||
|
||
const screens = {
|
||
folder: $('folderScreen'),
|
||
hub: $('hubScreen'),
|
||
farmTracker: $('appFarmTracker'),
|
||
dropBoard: $('appDropBoard'),
|
||
history: $('appHistory')
|
||
};
|
||
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(); });
|
||
|
||
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, 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, 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;
|
||
|
||
if (!state.dropStats.has(mobPlain)) state.dropStats.set(mobPlain, {kills: 0, items: new Map()});
|
||
const dropEntry = state.dropStats.get(mobPlain);
|
||
dropEntry.kills += 1;
|
||
|
||
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});
|
||
}
|
||
|
||
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){
|
||
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('');
|
||
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"/>'
|
||
+'</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');
|
||
drawLiveChart($('liveChartExp'), expRates, '#F2C94C');
|
||
drawLiveChart($('liveChartCols'), colRates, '#C97B4A');
|
||
|
||
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 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';
|
||
header.innerHTML =
|
||
'<span class="drop-board-mob-name">'+escHtml(mobName)+'</span>'+
|
||
'<span class="drop-board-mob-kills">'+fmt(entry.kills)+' kills</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 title=escHtml('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"><title>'+title+'</title></circle>';
|
||
}).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>';
|
||
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 avgKills=Math.round(totalKills/sessions.length);
|
||
const avgMs=totalMs/sessions.length;
|
||
$('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';
|
||
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 h=s.durationMs/3600000; return h>0?Math.round(s.totalKills/h):0;
|
||
},'#5DB7E5',v=>fmtShort(v)+'/h',$('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 killsPerH=s.durationMs>0?Math.round(s.totalKills/(s.durationMs/3600000)):0;
|
||
const tr=document.createElement('tr');
|
||
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(killsPerH)+'/h</td>'+
|
||
'<td class="r mono">'+fmt(s.totalExp)+'</td>'+
|
||
'<td class="r mono">'+fmt(s.killCols)+'</td>'+
|
||
'<td class="dim">'+escHtml(s.topMob||'—')+'</td>';
|
||
tbody.appendChild(tr);
|
||
}
|
||
}
|
||
|
||
$('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();
|
||
});
|
||
|
||
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 () => {
|
||
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>
|