feat(ui): Dialog with render, drag, mode toggle, milestone dropdown
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user