feat: add neon color picker for wallet management
- Replace muted Tailwind palette with 16-color neon palette - Add clickable color dot in sidebar that opens a popup swatch grid - Allow color override for all wallets (including embedded) - Fix getColorForWallet to respect localStorage override - Fix duplicate renameWallet in wallets.js - Update Dockerfile to serve ESM modules via nginx
This commit is contained in:
66
AGENTS.md
66
AGENTS.md
@ -10,11 +10,24 @@ docker build -t cbBTC-dashboard . && docker run -p 8080:80 cbBTC-dashboard
|
||||
|
||||
Or just open `index.html` in a browser (without the `/api/` proxy, only the Kraken BTC price chart will work).
|
||||
|
||||
## Architecture
|
||||
|
||||
The codebase is split into three vanilla ESM modules plus the single-page dashboard:
|
||||
|
||||
| Module | Purpose |
|
||||
|---|---|
|
||||
| `wallets.js` | **`WalletManager`** — localStorage state, chain-specific address validation, verification lifecycle |
|
||||
| `verifier.js` | **`WalletVerifier`** — EIP-712 typed-data signing via `window.ethereum`, user rejection handling |
|
||||
| `stream.js` | **`WalletStreamManager`** — single WebSocket with BroadcastChannel leader election, exponential backoff + jitter |
|
||||
|
||||
## File layout
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `index.html` | Everything. Frontend, embedded chart data, inline JS logic. Edit here. |
|
||||
| `index.html` | Dashboard UI. Embedded chart data, ApexCharts, TailwindCSS. Loads modules via `<script type="module">`. |
|
||||
| `wallets.js` | `WalletManager` — anonymous localStorage wallet state, add/remove/verify/revoke |
|
||||
| `verifier.js` | `WalletVerifier` — `window.ethereum` connection + EIP-712 signature flow |
|
||||
| `stream.js` | `WalletStreamManager` — single WS, cross-tab leader election, event routing |
|
||||
| `Dockerfile` | 4 lines. Copies `index.html` and `default.conf` into `nginx:alpine`. |
|
||||
| `default.conf` | Reverse proxy: `/api/` → `http://192.168.1.102:8000/api/`. |
|
||||
| `aave_portfolio.md` / `wallet_portfolio.md` | Reference data dumps. NOT consumed by the app. |
|
||||
@ -34,11 +47,62 @@ Or just open `index.html` in a browser (without the `/api/` proxy, only the Krak
|
||||
- **Tracked wallet:** `0x0c1a4a060e119f981412e323104d1c134d413dba` ("penguin", Base)
|
||||
- **Token decimals:** cbBTC=8, WETH=18, USDC=6
|
||||
|
||||
## Module API
|
||||
|
||||
Each module is loaded via `<script type="module">` in `index.html`:
|
||||
|
||||
```js
|
||||
import { WalletManager } from './wallets.js';
|
||||
import { WalletVerifier } from './verifier.js';
|
||||
import { WalletStreamManager } from './stream.js';
|
||||
|
||||
const wm = new WalletManager();
|
||||
wm.loadWallets();
|
||||
|
||||
const verifier = new WalletVerifier(wm);
|
||||
const stream = new WalletStreamManager(wm);
|
||||
stream.connect();
|
||||
```
|
||||
|
||||
### WalletManager
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `loadWallets()` | Reads from localStorage, sanitizes (always `isVerified: false`) |
|
||||
| `addWallet(address, chain, nickname)` | Validates format, appends, persists |
|
||||
| `removeWallet(address, chain)` | Drops from state, persists |
|
||||
| `verifyWallet(address, chain, signature, messageData)` | Sets `isVerified: true` |
|
||||
| `revokeVerification(address, chain)` | Resets to unverified |
|
||||
| `findWallet(address, chain)` | Lookup helper |
|
||||
| `getWallets()` | Returns deep copy of state |
|
||||
|
||||
### WalletVerifier
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `triggerWalletVerification(chain, nickname)` | Full flow: eth_requestAccounts → EIP-712 sign → add + verify |
|
||||
| `connect()` | Quick connect check (no signature) |
|
||||
|
||||
EIP-712 domain: `{ name: "Anonymous Wallet Tracker", version: "1", chainId: 1 }`
|
||||
|
||||
### WalletStreamManager
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `connect()` | Triggers BroadcastChannel leader election → opens single WS |
|
||||
| `disconnect()` | Closes WS + BC, cancels timers |
|
||||
| `on(event, callback)` | Register callback per event type |
|
||||
| `subscribeToNewWallet(wallet)` | Dynamic subscribe (leader → WS, follower → BC) |
|
||||
| `unsubscribeFromWallet(address, chain)` | Dynamic unsubscribe |
|
||||
|
||||
Cross-tab leader election uses `BroadcastChannel("dione_shared_stream")` with a 200ms timeout. Leader-death broadcast triggers failover election. Reconnect uses exponential backoff (1s → 30s) with ±25% jitter.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `apiBase = window.location.origin` — the dashboard relies on nginx proxy to reach the backend at `192.168.1.102:8000`. Running without Docker/proxy means the Aave table and price charts fail silently.
|
||||
- No linting, no type-checking, no CI. Validate changes by opening the HTML in a browser.
|
||||
- Adding chart data? Update the embedded arrays (`walletCumulData`, etc.) — they are plain JS arrays of `[timestampMs, value]` pairs.
|
||||
- `wallets.js` always forces `isVerified: false` on `loadWallets()` — persisted signatures are discarded. Verification must go through `WalletVerifier`.
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
FROM nginx:alpine
|
||||
COPY index.html /usr/share/nginx/html/index.html
|
||||
COPY wallets.js /usr/share/nginx/html/wallets.js
|
||||
COPY verifier.js /usr/share/nginx/html/verifier.js
|
||||
COPY stream.js /usr/share/nginx/html/stream.js
|
||||
COPY default.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
|
||||
93
index.html
93
index.html
@ -47,6 +47,37 @@ window.WalletManager = WalletManager;
|
||||
.wallet-filter-toggle { appearance: none; width: 12px; height: 12px; border-radius: 2px; border: 1px solid rgba(255,255,255,0.2); cursor: pointer; position: relative; transition: all 0.2s; }
|
||||
.wallet-filter-toggle:checked { background: var(--wallet-color); border-color: transparent; box-shadow: 0 0 8px color-mix(in srgb, var(--wallet-color) 40%, transparent); }
|
||||
|
||||
.wallet-color-dot {
|
||||
width: 16px; height: 16px; border-radius: 50%; cursor: pointer;
|
||||
flex-shrink: 0; transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.wallet-color-dot:hover {
|
||||
transform: scale(1.25); box-shadow: 0 0 10px var(--wallet-color);
|
||||
}
|
||||
|
||||
.color-picker-overlay {
|
||||
position: fixed; inset: 0; z-index: 70; display: none; background: rgba(0,0,0,0.5);
|
||||
}
|
||||
.color-picker-overlay.open { display: block; }
|
||||
.color-picker-popup {
|
||||
position: absolute; background: #090D14; border: 1px solid #1A1F2C;
|
||||
border-radius: 12px; padding: 12px; box-shadow: 0 16px 40px rgba(0,0,0,0.7);
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;
|
||||
z-index: 80; width: 210px;
|
||||
}
|
||||
.color-picker-swatch {
|
||||
width: 36px; height: 36px; border-radius: 50%; cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.color-picker-swatch:hover {
|
||||
transform: scale(1.2); border-color: rgba(255,255,255,0.3);
|
||||
box-shadow: 0 0 12px var(--swatch-color);
|
||||
}
|
||||
.color-picker-swatch.selected {
|
||||
border-color: #fff; box-shadow: 0 0 14px var(--swatch-color);
|
||||
}
|
||||
|
||||
.ledger-header { display: flex; align-items: center; }
|
||||
.ledger-title-col { width: 260px; flex-shrink: 0; }
|
||||
.col-timestamp { width: 140px; flex-shrink: 0; }
|
||||
@ -117,6 +148,9 @@ window.WalletManager = WalletManager;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color Picker Popup -->
|
||||
<div id="color-picker-overlay" class="color-picker-overlay" onclick="closeColorPicker()"></div>
|
||||
|
||||
<!-- Main Dashboard -->
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- Top Nav Bar -->
|
||||
@ -338,7 +372,7 @@ window.WalletManager = WalletManager;
|
||||
const orangeBrandColor = '#FF7A00';
|
||||
const blueBrandColor = '#3b82f6';
|
||||
const API_BASE = window.location.origin;
|
||||
const WALLET_COLORS = ['#f7931a','#58a6ff','#22c55e','#f472b6','#a78bfa','#fb923c','#14b8a6','#8b5cf6','#facc15','#ec4899'];
|
||||
const WALLET_COLORS = ['#F7931A','#FF007F','#39FF14','#00FFFF','#CCFF00','#9D00FF','#FF0033','#00FFCC','#FF00FF','#007FFF','#DEFF0A','#FF5E00','#8A2BE2','#00FF66','#FF1493','#7B00FF'];
|
||||
const TOKENS = {"cbBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WETH": {"decimals": 18, "priceSymbol": "WETH"}, "USDC": {"decimals": 6, "priceSymbol": "USDC"}};
|
||||
|
||||
/* Embedded data for hardcoded wallets */
|
||||
@ -388,10 +422,10 @@ function findEmbeddedId(address) {
|
||||
/* Assigned color index for dynamic wallets */
|
||||
let _colorIdx = 0;
|
||||
function getColorForWallet(address) {
|
||||
const embedded = findEmbeddedId(address);
|
||||
if (embedded && walletsMetadata[embedded].color) return walletsMetadata[embedded].color;
|
||||
const c = localStorage.getItem('cbbtc_color_' + address);
|
||||
if (c) return c;
|
||||
const embedded = findEmbeddedId(address);
|
||||
if (embedded && walletsMetadata[embedded].color) return walletsMetadata[embedded].color;
|
||||
const color = WALLET_COLORS[_colorIdx % WALLET_COLORS.length];
|
||||
_colorIdx++;
|
||||
localStorage.setItem('cbbtc_color_' + address, color);
|
||||
@ -399,8 +433,6 @@ function getColorForWallet(address) {
|
||||
}
|
||||
|
||||
function setColorForWallet(address, color) {
|
||||
const embedded = findEmbeddedId(address);
|
||||
if (embedded) return; /* embedded wallets have fixed colors */
|
||||
localStorage.setItem('cbbtc_color_' + address, color);
|
||||
renderAll();
|
||||
}
|
||||
@ -417,6 +449,55 @@ function getNextColor(address) {
|
||||
return WALLET_COLORS[nextIdx];
|
||||
}
|
||||
|
||||
/* Color Picker */
|
||||
let _colorPickerTargetAddr = null;
|
||||
|
||||
function openColorPicker(event, address) {
|
||||
event.stopPropagation();
|
||||
_colorPickerTargetAddr = address;
|
||||
const overlay = document.getElementById('color-picker-overlay');
|
||||
const popup = document.getElementById('color-picker-popup');
|
||||
|
||||
if (popup) popup.remove();
|
||||
|
||||
const newPopup = document.createElement('div');
|
||||
newPopup.id = 'color-picker-popup';
|
||||
newPopup.className = 'color-picker-popup';
|
||||
newPopup.style.left = event.clientX + 'px';
|
||||
newPopup.style.top = event.clientY + 'px';
|
||||
const currentColor = getColorForWallet(address);
|
||||
WALLET_COLORS.forEach((c, i) => {
|
||||
const swatch = document.createElement('div');
|
||||
swatch.className = 'color-picker-swatch' + (c.toLowerCase() === currentColor.toLowerCase() ? ' selected' : '');
|
||||
swatch.style.setProperty('--swatch-color', c);
|
||||
swatch.style.background = c;
|
||||
swatch.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectColor(c);
|
||||
closeColorPicker();
|
||||
});
|
||||
newPopup.appendChild(swatch);
|
||||
});
|
||||
document.body.appendChild(newPopup);
|
||||
const rect = newPopup.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth) newPopup.style.left = (event.clientX - rect.width) + 'px';
|
||||
if (rect.bottom > window.innerHeight) newPopup.style.top = (event.clientY - rect.height) + 'px';
|
||||
overlay.classList.add('open');
|
||||
}
|
||||
|
||||
function closeColorPicker() {
|
||||
document.getElementById('color-picker-overlay').classList.remove('open');
|
||||
const popup = document.getElementById('color-picker-popup');
|
||||
if (popup) popup.remove();
|
||||
_colorPickerTargetAddr = null;
|
||||
}
|
||||
|
||||
function selectColor(color) {
|
||||
if (_colorPickerTargetAddr) {
|
||||
setColorForWallet(_colorPickerTargetAddr, color);
|
||||
}
|
||||
}
|
||||
|
||||
/* Wallet filter state — stores UNCHECKED addresses. Empty = all checked. */
|
||||
function getWalletFilter() {
|
||||
return JSON.parse(localStorage.getItem('cbbtc_ledger_wallets') || '[]');
|
||||
@ -503,7 +584,7 @@ function renderSidebar() {
|
||||
const nick = w.nickname || shortAddr;
|
||||
html += '<div class="flex items-center justify-between bg-[#05070B] border border-[#1A1F2C]/40 rounded-lg px-3 py-2.5">' +
|
||||
'<div class="flex items-center gap-2.5 min-w-0 flex-1">' +
|
||||
'<div onclick="cycleWalletColor(\'' + w.address + '\')" class="wallet-filter-toggle cursor-pointer flex-shrink-0" style="--wallet-color:' + color + ';" title="Click to change color"></div>' +
|
||||
'<div onclick="openColorPicker(event, \'' + w.address + '\')" class="wallet-color-dot" style="--wallet-color:' + color + ';background:' + color + ';" title="Click to change color"></div>' +
|
||||
'<div class="min-w-0 flex-1">' +
|
||||
'<input class="rename-nick-input bg-transparent text-sm font-medium text-white truncate border border-transparent focus:border-[#FF7A00]/50 rounded px-1 outline-none transition block w-full" value="' + nick + '" data-addr="' + w.address + '" data-chain="' + w.chain + '" onblur="handleRenameNickname(this)" onkeydown="if(event.key===\'Enter\')this.blur()">' +
|
||||
'<div class="text-[10px] text-gray-500 font-mono truncate">' + shortAddr + '</div>' +
|
||||
|
||||
18
wallets.js
18
wallets.js
@ -266,6 +266,24 @@ export class WalletManager {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a wallet's nickname.
|
||||
*
|
||||
* @param {string} address
|
||||
* @param {ChainId} chain
|
||||
* @param {string} nickname
|
||||
* @returns {{ success: true } | { success: false, error: string }}
|
||||
*/
|
||||
renameWallet(address, chain, nickname) {
|
||||
const w = this.findWallet(address, chain);
|
||||
if (!w) {
|
||||
return { success: false, error: 'Wallet not found' };
|
||||
}
|
||||
w.nickname = String(nickname).trim();
|
||||
this._persist();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Verification */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
Reference in New Issue
Block a user