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>
This commit is contained in:
s
2026-07-02 18:15:42 +02:00
parent 4ce8b41a4c
commit cf43ada948
3 changed files with 373 additions and 137 deletions

View File

@@ -348,6 +348,24 @@
.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);}
@@ -359,11 +377,39 @@
<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">
@@ -901,6 +947,126 @@
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){
@@ -2217,7 +2383,7 @@
// TIMERS
// ======================================================================
let timerConfigs = JSON.parse(localStorage.getItem('timerConfigs') || '[]');
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' },
@@ -2331,12 +2497,12 @@
}
// migration : ajoute soundId et volume si absents
let alertConfigs = JSON.parse(localStorage.getItem('alertConfigs') || '[]').map(c => ({
let alertConfigs = JSON.parse(localStorage.getItem(lsKey('alertConfigs')) || '[]').map(c => ({
soundId: 'levelup', volume: 10, ...c
}));
function saveAlertConfigs(){
localStorage.setItem('alertConfigs', JSON.stringify(alertConfigs));
localStorage.setItem(lsKey('alertConfigs'), JSON.stringify(alertConfigs));
}
// activeTimers : Map<configId, { endsAt: number, durationSec: number, mobName: string }>
@@ -2344,7 +2510,7 @@
let timerClockId = null;
function saveTimerConfigs(){
localStorage.setItem('timerConfigs', JSON.stringify(timerConfigs));
localStorage.setItem(lsKey('timerConfigs'), JSON.stringify(timerConfigs));
updateTimerTileStatus();
}
@@ -2761,6 +2927,13 @@
// ======================================================================
(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){}