feat(ui): Dialog with render, drag, mode toggle, milestone dropdown

This commit is contained in:
dev
2026-06-01 16:52:25 -05:00
parent cd6fb7cf91
commit 3053a6d713
+238
View File
@@ -0,0 +1,238 @@
import { computeEstimate } from './pure.js';
const STYLE = `
.tat-root {
position: fixed; z-index: 99999; min-width: 320px; max-width: 420px;
background: #2b2b2b; color: #ddd; border: 1px solid #444; border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
font: 13px/1.4 Tahoma, Verdana, sans-serif;
padding: 12px 14px;
}
.tat-header {
display: flex; justify-content: space-between; align-items: center;
padding-bottom: 8px; margin-bottom: 10px;
border-bottom: 1px solid #444;
cursor: move; user-select: none;
}
.tat-header strong { color: #fff; }
.tat-close { cursor: pointer; opacity: 0.6; padding: 0 4px; }
.tat-close:hover { opacity: 1; }
.tat-row { display: flex; justify-content: space-between; padding: 2px 0; }
.tat-row.tat-target input, .tat-row.tat-target select {
background: #1a1a1a; color: #ddd; border: 1px solid #555; padding: 2px 4px;
font: inherit; font-size: 12px;
}
.tat-hr { border: none; border-top: 1px solid #444; margin: 8px 0; }
.tat-modes { display: flex; gap: 6px; margin-top: 12px; }
.tat-modes button {
flex: 1; padding: 4px; background: #2b2b2b; color: #ddd;
border: 1px solid #555; font: inherit; font-size: 11px; cursor: pointer;
}
.tat-modes button.active { background: #444; border-color: #888; }
.tat-warn { color: #c90; margin-top: 6px; font-size: 11px; }
.tat-error { padding: 8px 0; color: #f88; }
.tat-error button { margin-left: 8px; }
`;
const MILESTONES = [
{ label: 'Custom', value: null },
{ label: '1M', value: 1_000_000 },
{ label: '5M', value: 5_000_000 },
{ label: '10M', value: 10_000_000 },
{ label: '25M', value: 25_000_000 },
{ label: '50M', value: 50_000_000 },
{ label: '100M', value: 100_000_000 },
{ label: '250M', value: 250_000_000 },
{ label: '500M', value: 500_000_000 },
{ label: '1B', value: 1_000_000_000 },
];
function fmt(n) {
if (n == null) return '—';
if (n >= 1e9) return (n / 1e9).toFixed(2).replace(/\.?0+$/, '') + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.?0+$/, '') + 'K';
return String(n);
}
function fmtFull(n) {
if (n == null) return '—';
return Math.round(n).toLocaleString('en-US');
}
function fmtDate(d) {
if (!d) return '—';
return d.toLocaleDateString('en-US', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' });
}
export class Dialog {
constructor({ onTargetChange, onModeChange, onPosChange, onClose } = {}) {
this.onTargetChange = onTargetChange;
this.onModeChange = onModeChange;
this.onPosChange = onPosChange;
this.onClose = onClose;
this.root = null;
this.dragState = null;
this.mode = 'free';
this.warn = null;
}
mount({ initialMode = 'free', initialPos = { x: 0, y: 0 } } = {}) {
if (this.root) return;
injectStyles();
const root = document.createElement('div');
root.className = 'tat-root';
root.dataset.tat = '1';
document.body.appendChild(root);
this.root = root;
this.mode = initialMode;
if (initialMode === 'free') {
root.style.bottom = '20px';
root.style.right = '20px';
if (initialPos.x || initialPos.y) {
root.style.transform = `translate(${initialPos.x}px, ${initialPos.y}px)`;
}
}
this._wireHeaderDrag();
}
destroy() {
if (this.root && this.root.parentNode) this.root.parentNode.removeChild(this.root);
this.root = null;
}
setMode(mode, anchorInfo) {
this.mode = mode;
if (!this.root) return;
this.root.style.transform = '';
this.root.style.top = '';
this.root.style.bottom = '';
this.root.style.left = '';
this.root.style.right = '';
if (mode === 'free') {
this.root.style.bottom = '20px';
this.root.style.right = '20px';
} else if (anchorInfo && anchorInfo.canAnchor) {
this._positionAnchored(anchorInfo.rect);
} else {
this.root.style.top = '20px';
this.root.style.left = '50%';
this.root.style.transform = 'translateX(-50%)';
}
}
_positionAnchored(rect) {
const dialogRect = this.root.getBoundingClientRect();
let top = rect.top - dialogRect.height - 8;
if (top < 8) top = 20;
let left = rect.left + (rect.width - dialogRect.width) / 2;
if (left < 8) left = 8;
if (left + dialogRect.width > window.innerWidth - 8) left = window.innerWidth - dialogRect.width - 8;
this.root.style.top = `${top}px`;
this.root.style.left = `${left}px`;
}
_wireHeaderDrag() {
const onDown = (e) => {
if (this.mode !== 'free') return;
if (e.target.classList.contains('tat-close')) return;
const rect = this.root.getBoundingClientRect();
this.dragState = { dx: e.clientX - rect.left, dy: e.clientY - rect.top };
e.preventDefault();
};
const onMove = (e) => {
if (!this.dragState) return;
const x = e.clientX - this.dragState.dx;
const y = e.clientY - this.dragState.dy;
this.root.style.left = `${x}px`;
this.root.style.top = `${y}px`;
this.root.style.right = 'auto';
this.root.style.bottom = 'auto';
};
const onUp = () => {
if (!this.dragState) return;
const rect = this.root.getBoundingClientRect();
this.dragState = null;
this.onPosChange && this.onPosChange({ x: rect.left, y: rect.top });
};
this.root.addEventListener('mousedown', onDown);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
render(state) {
if (!this.root) return;
const { attr, gym, current, target, perTrain, summary, error, warn } = state;
if (error) {
this.root.innerHTML = `
<div class="tat-header"><strong>⚙ Attribute Tracker</strong><span class="tat-close">✕</span></div>
<div class="tat-error">${error}<button data-action="reload">Reload</button></div>
`;
this.root.querySelector('[data-action="reload"]').onclick = () => location.reload();
this.root.querySelector('.tat-close').onclick = () => this.onClose && this.onClose();
return;
}
const est = computeEstimate(current, target || 0, perTrain || 0, summary?.perDay || 0);
const modes = ['free', 'anchored'].map((m) =>
`<button data-mode="${m}" class="${this.mode === m ? 'active' : ''}">${m === 'free' ? 'Float free' : 'Above training UI'}</button>`
).join('');
const milestoneOptions = MILESTONES.map((m) => {
const sel = m.value === target ? 'selected' : '';
return `<option value="${m.value ?? ''}" ${sel}>${m.label}</option>`;
}).join('');
this.root.innerHTML = `
<div class="tat-header">
<strong>⚙ Attribute Tracker</strong>
<span class="tat-close" title="Hide for this session">✕</span>
</div>
<div class="tat-row"><span>Attribute</span><span><strong>${attr || '—'}</strong> · <em>${gym || '—'}</em></span></div>
<div class="tat-row"><span>Current</span><span>${fmtFull(current)}</span></div>
<div class="tat-row tat-target">
<span>Target</span>
<span>
<input data-role="target" value="${target ?? ''}" placeholder="e.g. 25M" style="width:110px">
<select data-role="milestone">${milestoneOptions}</select>
</span>
</div>
<hr class="tat-hr">
<div class="tat-row"><span>Per train</span><span>${perTrain ? '+ ' + fmtFull(perTrain) : '—'}</span></div>
<div class="tat-row"><span>Trains today</span><span>${fmtFull(summary?.trainsToday ?? 0)}</span></div>
<div class="tat-row"><span>7-day avg</span><span>${summary ? summary.sevenDayAvgPerDay.toFixed(1) : '0.0'} / day</span></div>
<div class="tat-row"><span>Per day</span><span>${summary && summary.perDay > 0 ? '+ ' + fmtFull(summary.perDay) : '—'}</span></div>
<hr class="tat-hr">
<div class="tat-row"><span>Remaining</span><span>${fmtFull(est.remaining)}</span></div>
<div class="tat-row"><span>Trains to go</span><span>≈ ${fmtFull(est.trainsToGo)}</span></div>
<div class="tat-row"><span>ETA</span><span>${est.days > 0 ? `~ ${fmtFull(est.days)} days (${fmtDate(est.eta)})` : '—'}</span></div>
<div class="tat-modes">${modes}</div>
${warn ? `<div class="tat-warn">⚠ ${warn}</div>` : ''}
`;
this.root.querySelector('.tat-close').onclick = () => this.onClose && this.onClose();
this.root.querySelector('[data-role="target"]').onchange = (e) => {
this.onTargetChange && this.onTargetChange(e.target.value);
};
this.root.querySelector('[data-role="milestone"]').onchange = (e) => {
const v = e.target.value;
if (v !== '') this.onTargetChange && this.onTargetChange(Number(v));
};
this.root.querySelectorAll('[data-mode]').forEach((btn) => {
btn.onclick = () => this.onModeChange && this.onModeChange(btn.dataset.mode);
});
}
}
let styleInjected = false;
function injectStyles() {
if (styleInjected) return;
const s = document.createElement('style');
s.textContent = STYLE;
document.head.appendChild(s);
styleInjected = true;
}