first commit

This commit is contained in:
2026-08-25 06:37:14 +01:00
commit 5c3e72b40e
26 changed files with 6301 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
{{define "content"}}
<section class="panel">
<h2>Account</h2>
{{if .MustChangePassword}}
<p class="notice">You're signed in with the default admin/admin login — set your own username and password below to continue.</p>
{{end}}
<p class="status">Signed in as <strong>{{.Username}}</strong>.</p>
{{if .Error}}<p class="notice error">{{.Error}}</p>{{end}}
<form method="post" action="/account/credentials" class="form">
<label>Current Password <input type="password" name="current_password" autocomplete="current-password" required></label>
<label>New Username <input type="text" name="new_username" value="{{.Username}}" maxlength="32" required></label>
<label>New Password <input type="password" name="new_password" autocomplete="new-password" minlength="8" required></label>
<label>Confirm New Password <input type="password" name="confirm_password" autocomplete="new-password" minlength="8" required></label>
<button type="submit">Save</button>
</form>
<p class="hint">Username: 3-32 characters (letters, numbers, dot, underscore, hyphen). Password: at least 8 characters.</p>
</section>
<section class="panel">
<h2>Two-Factor Authentication (MFA)</h2>
{{if .MFAError}}<p class="notice error">{{.MFAError}}</p>{{end}}
{{if .MFAEnabled}}
<p class="status ok">MFA is enabled — an authenticator code is required at sign-in.</p>
<form method="post" action="/account/mfa/disable">
<button type="submit" class="toggle is-hidden">Disable MFA</button>
</form>
{{else if .MFAPendingSecret}}
<p class="status">Scan this with your authenticator app (Google Authenticator, Authy, 1Password, etc.), or enter the key manually, then enter the 6-digit code it shows to confirm:</p>
{{if .MFAQRDataURI}}<img src="{{.MFAQRDataURI}}" alt="MFA setup QR code" class="qr-code">{{end}}
<p class="mono secret-key">{{.MFAPendingSecret}}</p>
<p class="hint">otpauth URI: <span class="mono">{{.MFAOtpauthURI}}</span></p>
<form method="post" action="/account/mfa/confirm" class="form">
<label>6-digit code <input type="text" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required></label>
<button type="submit">Confirm</button>
</form>
<form method="post" action="/account/mfa/disable">
<button type="submit" class="toggle">Cancel setup</button>
</form>
{{else}}
<p class="status">MFA is not enabled. 1 user account is currently supported.</p>
<form method="post" action="/account/mfa/enable">
<button type="submit">Enable MFA</button>
</form>
{{end}}
</section>
{{end}}
+40
View File
@@ -0,0 +1,40 @@
{{define "base"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Basis — Crypto Portfolio</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body data-page="{{.Page}}" data-current="{{.CurrentTab}}" data-poll="{{.PollSeconds}}" data-currency-symbol="{{curSymbol}}">
<header class="topbar">
<a href="/" class="topbar-total tab-link" data-topbar data-open-tab="portfolio">
<span class="topbar-value" data-field="topbar-value">{{money .TotalValue}}</span>
<span class="topbar-pl {{signClass .TotalPL}}" data-field="topbar-pl">{{money .TotalPL}} ({{printf "%+.2f" .TotalPLPercent}}%)</span>
</a>
<nav>
<a href="/" class="{{if or (eq .Page "settings") (eq .Page "account")}}{{else}}active{{end}}">Dashboard</a>
<a href="/settings" class="{{if eq .Page "settings"}}active{{end}}">Settings</a>
<a href="/account" class="{{if eq .Page "account"}}active{{end}}">Account</a>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-btn">Sign out</button></form>
</nav>
</header>
<main>
{{if or (eq .Page "settings") (eq .Page "account")}}
{{template "content" .}}
{{else}}
<div class="tabbar" id="tabbar" role="tablist"></div>
<div id="tab-panels">
<div class="tab-panel active" data-panel="{{if eq .Page "position"}}pos:{{.CurrentTab}}{{else if eq .Page "transfers"}}transfers{{else}}portfolio{{end}}">
{{template "content" .}}
</div>
</div>
{{end}}
</main>
<script src="/static/app.js"></script>
</body>
</html>
{{end}}
+44
View File
@@ -0,0 +1,44 @@
{{define "content"}}
{{if not .HasCredentials}}
<p class="notice">No Kraken account connected yet — <a href="/settings">add your API key</a> to auto-import trades, or add purchases manually.</p>
{{end}}
<div class="chart-card">
<div class="chart-controls" data-chart="portfolio">
<button type="button" data-range="24h">24H</button>
<button type="button" data-range="7d">7D</button>
<button type="button" data-range="30d">30D</button>
<button type="button" data-range="1y">1Y</button>
<button type="button" data-range="all" class="active">All</button>
</div>
<svg class="chart" data-chart-target="portfolio" data-symbol="{{curSymbol}}" viewBox="0 0 600 220" aria-hidden="true"></svg>
<p class="chart-empty" data-chart-empty="portfolio" hidden>Not enough history yet — check back after a few polling cycles.</p>
<p class="chart-caption" data-chart-caption="portfolio"></p>
</div>
{{if .Rows}}
<div class="table-scroll">
<table class="ledger sortable" data-poll="{{.PollSeconds}}" data-sort-key="dashboard">
<thead>
<tr>
<th class="star-cell"></th>
<th data-sort="currency">Currency</th>
<th data-sort="holdings">Holdings</th>
<th data-sort="avgcost">Avg Cost</th>
<th data-sort="price">Price</th>
<th data-sort="value">Value</th>
<th data-sort="4h">4H</th>
<th data-sort="24h">24H</th>
<th data-sort="7d">7D</th>
<th data-sort="30d">30D</th>
<th data-sort="all">All-time</th>
<th data-sort="pl">P/L</th>
</tr>
</thead>
<tbody id="rows">{{template "rows" .}}</tbody>
</table>
</div>
{{else}}
<p class="empty">No purchases tracked yet. <a href="/settings">Add one</a> or connect Kraken.</p>
{{end}}
{{end}}
@@ -0,0 +1,23 @@
{{define "rows"}}{{range .Rows}}<tr class="{{if .Favourite}}is-favourite{{end}}"
data-currency="{{.Currency}}"
data-ws-symbol="{{if .Pair}}{{wsSymbol .Currency .Quote}}{{end}}"
data-holdings="{{sortVal .TotalAmount}}"
data-cost="{{sortVal .TotalCost}}"
data-fx-rate="{{sortVal .FXRate}}"
data-value="{{sortVal .CurrentValue}}">
<td class="star-cell" data-label="">
<button type="button" class="star-btn {{if .Favourite}}active{{end}}" data-favourite-toggle data-currency="{{.Currency}}" data-favourite="{{if .Favourite}}0{{else}}1{{end}}" aria-label="Toggle favourite" aria-pressed="{{if .Favourite}}true{{else}}false{{end}}">&#9733;</button>
</td>
<td data-label="Currency"><a href="/position/{{.Currency}}" class="tab-link" data-open-tab="{{.Currency}}">{{.Currency}}</a></td>
<!-- {{if .Staked}} <span class="staked-note">({{amt .Staked}} staked)</span>{{end}} -->
<td class="mono" data-label="Holdings" data-sort-value="{{sortVal .TotalAmount}}">{{amt .TotalAmount}}</td>
<td class="mono" data-label="Avg Cost" data-sort-value="{{sortVal .AvgCost}}">{{money .AvgCost}}</td>
<td class="mono" data-label="Price" data-field="price" data-sort-value="{{sortVal .CurrentPrice}}">{{money .CurrentPrice}}</td>
<td class="mono" data-label="Value" data-field="value" data-sort-value="{{sortVal .CurrentValue}}">{{money .CurrentValue}}</td>
<td data-label="4H" data-sort-value="{{sortVal .Change4h}}"><span class="chip {{pctClass .Change4h}}">{{pctStr .Change4h}}</span></td>
<td data-label="24H" data-sort-value="{{sortVal .Change1d}}"><span class="chip {{pctClass .Change1d}}">{{pctStr .Change1d}}</span></td>
<td data-label="7D" data-sort-value="{{sortVal .Change7d}}"><span class="chip {{pctClass .Change7d}}">{{pctStr .Change7d}}</span></td>
<td data-label="30D" data-sort-value="{{sortVal .Change30d}}"><span class="chip {{pctClass .Change30d}}">{{pctStr .Change30d}}</span></td>
<td data-label="All-time" data-sort-value="{{sortVal .ChangeAll}}"><span class="chip {{pctClass .ChangeAll}}">{{pctStr .ChangeAll}}</span></td>
<td class="mono {{signClass .PLDollar}}" data-label="P/L" data-field="pl" data-sort-value="{{sortVal .PLDollar}}">{{money .PLDollar}}</td>
</tr>{{end}}{{end}}
+111
View File
@@ -0,0 +1,111 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in — Basis</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<main class="login-main">
<section class="panel login-panel">
<h1 class="login-title">Basis</h1>
<p class="notice error" id="login-error" hidden></p>
<form id="login-form" class="form">
<label>Username <input type="text" name="username" autocomplete="username" required autofocus></label>
<label>Password <input type="password" name="password" autocomplete="current-password" required></label>
<button type="submit">Sign in</button>
</form>
</section>
</main>
<div class="modal-overlay" id="mfa-overlay" hidden>
<div class="modal">
<h2>Enter authenticator code</h2>
<p class="hint">Your account has MFA enabled — enter the 6-digit code from your authenticator app.</p>
<p class="notice error" id="mfa-error" hidden></p>
<form id="mfa-form" class="form">
<label>Code <input type="text" name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required></label>
<button type="submit">Verify</button>
</form>
</div>
</div>
<script>
(function () {
"use strict";
var loginForm = document.getElementById("login-form");
var loginError = document.getElementById("login-error");
var overlay = document.getElementById("mfa-overlay");
var mfaForm = document.getElementById("mfa-form");
var mfaError = document.getElementById("mfa-error");
var pendingToken = "";
function postForm(url, fields) {
var body = Object.keys(fields).map(function (k) {
return encodeURIComponent(k) + "=" + encodeURIComponent(fields[k]);
}).join("&");
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body,
}).then(function (r) { return r.json(); });
}
function showError(el, msg) {
el.textContent = msg;
el.hidden = false;
}
loginForm.addEventListener("submit", function (e) {
e.preventDefault();
loginError.hidden = true;
var fd = new FormData(loginForm);
postForm("/login", { username: fd.get("username"), password: fd.get("password") }).then(function (res) {
if (res.error) {
showError(loginError, res.error);
return;
}
if (res.mfa_required) {
pendingToken = res.pending_token;
overlay.hidden = false;
mfaForm.querySelector('input[name="code"]').focus();
return;
}
window.location.href = res.redirect || "/";
}).catch(function () {
showError(loginError, "Something went wrong — try again.");
});
});
mfaForm.addEventListener("submit", function (e) {
e.preventDefault();
mfaError.hidden = true;
var fd = new FormData(mfaForm);
postForm("/login/mfa", { pending_token: pendingToken, code: fd.get("code") }).then(function (res) {
if (res.error) {
showError(mfaError, res.error);
return;
}
window.location.href = res.redirect || "/";
}).catch(function () {
showError(mfaError, "Something went wrong — try again.");
});
});
overlay.addEventListener("click", function (e) {
if (e.target === overlay) {
overlay.hidden = true;
pendingToken = "";
mfaForm.reset();
mfaError.hidden = true;
}
});
})();
</script>
</body>
</html>
+74
View File
@@ -0,0 +1,74 @@
{{define "content"}}
<section class="hero" data-ws-hero="{{if .Pair}}{{wsSymbol .Currency .Quote}}{{end}}" data-holdings="{{sortVal .Amount}}" data-cost="{{sortVal .Cost}}" data-fx-rate="{{sortVal .FXRate}}">
<div class="hero-label"><a href="/" class="back tab-link" data-open-tab="portfolio">&larr; Dashboard</a></div>
<div class="hero-value">{{.Currency}}</div>
<div class="hero-sub">
<span class="mono">{{amt .Amount}} held{{if .Staked}} ({{amt .Staked}} staked){{end}}</span> &middot;
<span class="mono" data-field="hero-value">{{money .Value}} value</span> &middot;
<span class="mono {{signClass .PLDollar}}" data-field="hero-pl">{{money .PLDollar}} ({{printf "%+.2f" .PLPercent}}%)</span>
</div>
</section>
<div class="chart-card" data-advanced-chart data-currency="{{.Currency}}">
<div class="chart-controls" data-chart="currency" data-currency="{{.Currency}}">
<button type="button" data-range="24h">24H</button>
<button type="button" data-range="7d">7D</button>
<button type="button" data-range="30d">30D</button>
<button type="button" data-range="1y">1Y</button>
<button type="button" data-range="all" class="active">All</button>
<button type="button" class="mode-toggle" data-chart-mode-toggle>Advanced view</button>
</div>
<div data-chart-simple>
<svg class="chart" data-chart-target="currency:{{.Currency}}" data-symbol="{{curSymbol}}" viewBox="0 0 600 220" aria-hidden="true"></svg>
<p class="chart-empty" data-chart-empty="currency:{{.Currency}}" hidden>Not enough price history yet for this range.</p>
<p class="chart-caption" data-chart-caption="currency:{{.Currency}}"></p>
</div>
<div data-chart-advanced hidden>
<svg class="chart candle-chart" data-candle-price viewBox="0 0 600 260" aria-hidden="true"></svg>
<p class="chart-empty" data-candle-empty hidden></p>
<p class="chart-sublabel">RSI (14)</p>
<svg class="chart rsi-chart" data-candle-rsi viewBox="0 0 600 90" aria-hidden="true"></svg>
<p class="hint">Bollinger Bands (20, 2) and RSI computed from Kraken's own OHLC candles, in the pair's native price.</p>
</div>
</div>
{{if .Entries}}
<div class="table-scroll">
<table class="ledger sortable" data-sort-key="position:{{.Currency}}">
<thead>
<tr>
<th data-sort="date">Date</th>
<th data-sort="type">Type</th>
<th data-sort="source">Source</th>
<th data-sort="amount">Amount</th>
<th data-sort="price">Price</th>
<th data-sort="fee">Fee</th>
<th data-sort="cost">Cost / Proceeds</th>
<th data-sort="value">Current Value</th>
<th data-sort="pl">P/L</th>
</tr>
</thead>
<tbody>
{{range .Entries}}
<tr>
<td data-label="Date" data-sort-value="{{.OccurredAt.Unix}}">{{dateStr .OccurredAt}}</td>
<td data-label="Type"><span class="badge {{.EntryType}}">{{capitalize .EntryType}}</span></td>
<td class="source" data-label="Source">{{.Source}}</td>
<td class="mono" data-label="Amount" data-sort-value="{{sortVal .Amount}}">{{amt .Amount}}</td>
<td class="mono" data-label="Price" data-sort-value="{{sortVal .Price}}">{{if .Price}}{{money .Price}}{{else}}&mdash;{{end}}</td>
<td class="mono" data-label="Fee" data-sort-value="{{sortVal .Fee}}">{{money .Fee}}</td>
<td class="mono" data-label="Cost / Proceeds" data-sort-value="{{sortVal .Cost}}">{{if .Cost}}{{money .Cost}}{{else}}&mdash;{{end}}</td>
<td class="mono" data-label="Current Value" data-sort-value="{{sortVal .CurrentValue}}">{{money .CurrentValue}}</td>
<td class="mono {{if eq .EntryType "buy"}}{{signClass .PLDollar}}{{end}}" data-label="P/L" data-sort-value="{{if eq .EntryType "buy"}}{{sortVal .PLDollar}}{{end}}">
{{if eq .EntryType "buy"}}{{money .PLDollar}} ({{printf "%+.1f" .PLPercent}}%){{else}}&mdash;{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<p class="hint">A buy row's P/L assumes that specific purchase is still fully held — the total above (which matches your live Kraken balance when connected) is the accurate figure once you've sold or moved coins around.</p>
{{else}}
<p class="empty">No activity recorded for {{.Currency}} yet.</p>
{{end}}
{{end}}
+74
View File
@@ -0,0 +1,74 @@
{{define "content"}}
<section class="panel">
<h2>Base Currency</h2>
<p class="status">All values are shown in <strong>{{.BaseCurrency}}</strong>.</p>
<form method="post" action="/settings/base-currency" class="form">
<label>Base Currency
<input type="text" name="base_currency" list="currency-options" value="{{.BaseCurrency}}" maxlength="3" required>
</label>
<datalist id="currency-options">
<option value="GBP"><option value="USD"><option value="EUR"><option value="JPY">
<option value="CHF"><option value="CAD"><option value="AUD">
</datalist>
<button type="submit">Save</button>
</form>
<p class="hint">Prices convert to this currency using Kraken's own FX crosses, refreshed on the same cycle as prices.</p>
</section>
<section class="panel">
<h2>Kraken Connection</h2>
{{if .HasCredentials}}
<p class="status ok">Connected &mdash; credentials encrypted at rest.</p>
{{else}}
<p class="status">Not connected.</p>
{{end}}
<form method="post" action="/settings/credentials" class="form">
<label>API Key <input type="text" name="api_key" autocomplete="off" placeholder="{{if .HasCredentials}}leave blank to keep current{{else}}Kraken API key{{end}}"></label>
<label>API Secret <input type="password" name="api_secret" autocomplete="off" placeholder="{{if .HasCredentials}}leave blank to keep current{{else}}Kraken API secret{{end}}"></label>
<button type="submit">Save</button>
</form>
<p class="hint">Use a Kraken API key with only "Query Funds" and "Query Closed Orders &amp; Trades" permissions &mdash; no withdrawal or trading rights are needed for read-only monitoring.</p>
</section>
<section class="panel">
<h2>Add a Ledger Entry</h2>
<form method="post" action="/settings/purchase" class="form grid">
<label>Type
<select name="entry_type">
<option value="buy">Buy</option>
<option value="sell">Sell</option>
<option value="deposit">Deposit</option>
<option value="withdrawal">Withdrawal</option>
</select>
</label>
<label>Currency <input type="text" name="currency" placeholder="BTC" required></label>
<label>Kraken Pair <input type="text" name="pair" placeholder="XBTUSD" required></label>
<label>Amount <input type="text" name="amount" placeholder="0.5" required></label>
<label>Price (buy/sell only) <input type="text" name="price" placeholder="42000"></label>
<label>Fee <input type="text" name="fee" placeholder="0"></label>
<label>Date <input type="date" name="occurred_at" required></label>
<button type="submit">Add Entry</button>
</form>
<p class="hint">The pair must be a market Kraken lists (used to fetch price data), even for a crypto deposit/withdrawal or a coin bought elsewhere. Price is only needed for buy/sell. Leave Pair blank for a real-money (GBP/USD/EUR) deposit or withdrawal — that shows on the Transfers tab instead.</p>
</section>
<section class="panel">
<h2>Visible Currencies</h2>
{{if .Currencies}}
<ul class="toggle-list">
{{range .Currencies}}
<li>
<span>{{.Name}}</span>
<form method="post" action="/settings/hidden">
<input type="hidden" name="currency" value="{{.Name}}">
<input type="hidden" name="hidden" value="{{if .Hidden}}0{{else}}1{{end}}">
<button type="submit" class="toggle {{if .Hidden}}is-hidden{{end}}">{{if .Hidden}}Hidden &mdash; show{{else}}Visible &mdash; hide{{end}}</button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="empty">No currencies tracked yet.</p>
{{end}}
</section>
{{end}}
+37
View File
@@ -0,0 +1,37 @@
{{define "content"}}
<section class="hero">
<div class="hero-label">Account Funding</div>
<div class="hero-sub">Real-money deposits and withdrawals to/from Kraken (GBP, USD, EUR, ...) — how much cash you've put in or taken out, separate from what your crypto is worth. Crypto deposits/withdrawals show on that coin's own page.</div>
</section>
{{if .Transfers}}
<div class="table-scroll">
<table class="ledger sortable" data-sort-key="transfers">
<thead>
<tr>
<th data-sort="date">Date</th>
<th data-sort="currency">Currency</th>
<th data-sort="type">Type</th>
<th data-sort="source">Source</th>
<th data-sort="amount">Amount</th>
<th data-sort="value">Value</th>
</tr>
</thead>
<tbody>
{{range .Transfers}}
<tr>
<td data-label="Date" data-sort-value="{{.OccurredAt.Unix}}">{{dateStr .OccurredAt}}</td>
<td data-label="Currency">{{.Currency}}</td>
<td data-label="Type"><span class="badge {{.EntryType}}">{{capitalize .EntryType}}</span></td>
<td class="source" data-label="Source">{{.Source}}</td>
<td class="mono" data-label="Amount" data-sort-value="{{sortVal .Amount}}">{{amt .Amount}}</td>
<td class="mono" data-label="Value" data-sort-value="{{sortVal .CurrentValue}}">{{money .CurrentValue}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="empty">No account funding recorded yet.</p>
{{end}}
{{end}}