using portfolio api

This commit is contained in:
zipfriis
2026-03-29 15:46:33 +02:00
parent 760bc46fb0
commit f13eac36fc
6 changed files with 732 additions and 677 deletions
-2
View File
@@ -25,7 +25,6 @@ func Home(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./s
func Infra(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/infra.html") }
func Finance(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/finance.html") }
func Cinema(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/cinema.html") }
func Cyber(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/cyber.html") }
func Engine(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "./static/engine.html") }
func Portfolio(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./static/portfolio.html")
@@ -54,7 +53,6 @@ func main() {
http.HandleFunc("/infra", Infra)
http.HandleFunc("/finance", Finance)
http.HandleFunc("/cinema", Cinema)
http.HandleFunc("/cyber", Cyber)
http.HandleFunc("/engine", Engine)
http.HandleFunc("/portfolio", Portfolio)
BIN
View File
Binary file not shown.
+4 -6
View File
@@ -15,9 +15,8 @@
<div class="nav-links">
<a href="/engine">engine</a>
<a href="/portfolio">portfolio</a>
<a href="/cyber">cyber</a>
<a href="/cinema">cinema</a>
<a href="/cinema">finance</a>
<a href="/finance">finance</a>
<a href="/infra">infrastructure</a>
<a href="/git">git</a>
<a class="cta" href="mailto:me@samantha42.xyz">contact</a>
@@ -108,10 +107,9 @@
<section id="opinion">
<div class="section-head"><h2>opinion</h2></div>
<div class="opinion-grid">
<a class="opinion-link" href="https://samantha42.xyz/cinema">cinema</a>
<a class="opinion-link" href="https://samantha42.xyz/finance">finance</a>
<a class="opinion-link" href="https://samantha42.xyz/cyber">cyber</a>
<a class="opinion-link" href="https://samantha42.xyz/infra">infra</a>
<a class="opinion-link" href="/cinema">cinema</a>
<a class="opinion-link" href="/finance">finance</a>
<a class="opinion-link" href="/infra">infra</a>
</div>
</section>
+1 -1
View File
@@ -51,7 +51,7 @@
<!-- ── HEADER ── -->
<header>
<div class="container">
<a class="back" href="https://samantha42.xyz/">← back</a>
<a class="back" href="/">← back</a>
<div class="tag">project</div>
+245 -445
View File
@@ -5,202 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Portfolio — Samantha Friis</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@400;700;800&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="styles.css">
<style>
/* ── Add Trade Modal ── */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.75);
backdrop-filter: blur(4px);
z-index: 100;
align-items: center;
justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--bg, #0d0d0d);
border: 1px solid var(--border, #2a2a2a);
width: min(520px, 94vw);
padding: 2rem;
position: relative;
animation: modalIn .2s ease;
}
@keyframes modalIn {
from { opacity:0; transform: translateY(12px); }
to { opacity:1; transform: translateY(0); }
}
.modal-title {
font-family: 'DM Mono', monospace;
font-size: .75rem;
color: var(--muted, #555);
letter-spacing: .08em;
margin-bottom: 1.4rem;
}
.modal-title span { color: var(--fg, #e8e8e8); }
.modal-close {
position: absolute;
top: 1rem; right: 1rem;
background: none;
border: none;
color: var(--muted, #555);
font-size: 1.1rem;
cursor: pointer;
line-height: 1;
padding: .2rem .4rem;
transition: color .15s;
}
.modal-close:hover { color: var(--fg, #e8e8e8); }
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: .8rem 1.2rem;
}
.form-grid .full { grid-column: 1 / -1; }
.form-field label {
display: block;
font-family: 'DM Mono', monospace;
font-size: .68rem;
color: var(--muted, #555);
letter-spacing: .06em;
margin-bottom: .3rem;
}
.form-field input,
.form-field select {
width: 100%;
background: #141414;
border: 1px solid #2a2a2a;
color: var(--fg, #e8e8e8);
font-family: 'DM Mono', monospace;
font-size: .82rem;
padding: .5rem .7rem;
outline: none;
transition: border-color .15s;
box-sizing: border-box;
-webkit-appearance: none;
appearance: none;
}
.form-field input:focus,
.form-field select:focus { border-color: #555; }
.form-field select option { background: #1a1a1a; }
.form-actions {
display: flex;
justify-content: flex-end;
gap: .7rem;
margin-top: 1.4rem;
}
.btn-cancel {
background: none;
border: 1px solid #2a2a2a;
color: var(--muted, #555);
font-family: 'DM Mono', monospace;
font-size: .78rem;
padding: .5rem 1.1rem;
cursor: pointer;
transition: border-color .15s, color .15s;
}
.btn-cancel:hover { border-color: #555; color: var(--fg, #e8e8e8); }
.btn-submit {
background: var(--fg, #e8e8e8);
border: none;
color: #0d0d0d;
font-family: 'DM Mono', monospace;
font-size: .78rem;
font-weight: 500;
padding: .5rem 1.3rem;
cursor: pointer;
transition: opacity .15s;
}
.btn-submit:hover { opacity: .85; }
.btn-submit:disabled { opacity: .4; cursor: not-allowed; }
.form-error {
font-family: 'DM Mono', monospace;
font-size: .72rem;
color: #e05555;
margin-top: .9rem;
min-height: 1rem;
}
/* ── Add Trade trigger button ── */
.add-trade-btn {
background: none;
border: 1px solid #2a2a2a;
color: var(--muted, #555);
font-family: 'DM Mono', monospace;
font-size: .72rem;
letter-spacing: .06em;
padding: .35rem .8rem;
cursor: pointer;
transition: border-color .15s, color .15s;
margin-left: auto;
}
.add-trade-btn:hover { border-color: #888; color: var(--fg, #e8e8e8); }
/* ── Loading skeleton rows ── */
.skeleton-row td {
padding: .6rem 0;
}
.skeleton-cell {
height: .7rem;
background: linear-gradient(90deg, #1c1c1c 25%, #252525 50%, #1c1c1c 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
border-radius: 2px;
width: 70%;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.trades-empty {
font-family: 'DM Mono', monospace;
font-size: .78rem;
color: var(--muted, #555);
padding: 1.4rem 0;
text-align: center;
}
/* trades section header row flex */
.trades-header-row {
display: flex;
align-items: center;
gap: .7rem;
padding: .9rem 1rem .6rem;
border-bottom: 1px solid #1e1e1e;
}
/* toast */
.toast {
position: fixed;
bottom: 1.8rem;
right: 1.8rem;
background: #1c1c1c;
border: 1px solid #2e2e2e;
color: var(--fg, #e8e8e8);
font-family: 'DM Mono', monospace;
font-size: .78rem;
padding: .7rem 1.1rem;
z-index: 200;
opacity: 0;
transform: translateY(8px);
transition: opacity .2s, transform .2s;
pointer-events: none;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.success { border-color: #3a6e3a; color: #7ecf7e; }
.toast.error { border-color: #6e2e2e; color: #cf7e7e; }
</style>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="container">
@@ -210,98 +15,71 @@
<p class="tag">// portfolio</p>
<h1>Investment<br/><span>Portfolio</span></h1>
<p class="header-sub">Equity positions &nbsp;·&nbsp; Personal research</p>
<div class="health-badge" id="healthBadge" title="API server status">
<span class="health-dot loading" id="healthDot"></span>
<span id="healthText">connecting to api…</span>
</div>
<p>
Test of API:
Source at
<a href="https://git.samantha42.xyz/samantha/FPandA-Engine"
style="color:var(--accent);text-decoration:none"
target="_blank">git.samantha42.xyz/samantha/FPandA-Engine</a>
</p>
</header>
<!-- Summary cards -->
<!-- Summary cards (populated dynamically from /positions/list) -->
<div class="cards">
<div class="card">
<p class="card-label">Total Weight</p>
<p class="card-value neutral">100,10%</p>
</div>
<div class="card">
<p class="card-label">Weighted Return</p>
<p class="card-value up">+7,84%</p>
</div>
<div class="card">
<p class="card-label">Price Return (w)</p>
<p class="card-value up">+7,73%</p>
<p class="card-value neutral" id="sumWeight"></p>1
</div>
<div class="card">
<p class="card-label">Positions</p>
<p class="card-value neutral">5</p>
<p class="card-value neutral" id="sumPositions"></p>
</div>
<div class="card">
<p class="card-label">Total Cost Basis</p>
<p class="card-value neutral" id="sumCostBasis"></p>
</div>
<div class="card">
<p class="card-label">Total Shares</p>
<p class="card-value neutral" id="sumShares"></p>
</div>
</div>
<!-- Invested table -->
<!-- Positions table -->
<div class="section-header-row">
<p class="section-label" style="margin:0">// invested positions</p>
<p class="section-label" style="margin:0">// positions</p>
<span class="price-status" id="priceStatus">
<span class="price-dot loading" id="priceDot"></span>
<span id="priceStatusText">fetching prices…</span>
</span>
<button class="refresh-btn" onclick="fetchHoldingPrices()">↺ refresh</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Ticker</th>
<th>Market Price</th>
<th>Avg Price</th>
<th>Shares Out.</th>
<th>Mkt Cap (M)</th>
<th>Symbol</th>
<th>Currency</th>
<th>Shares</th>
<th>Cost Basis</th>
<th>Weight</th>
<th>Return Rate</th>
<th>Price Return</th>
<th>Rate × Weight</th>
<th>P.Return × Weight</th>
<th>Market Price</th>
</tr>
</thead>
<tbody>
<tr data-ticker="MAERSK-B.CO">
<td><span class="ticker">MEARSK</span><span class="currency-badge">DKK</span></td>
<td class="price-cell"><span class="price-val" id="price-MAERSK-B.CO">15,270.00</span></td><td>12,414</td><td>15,224,309</td><td>232,475</td>
<td><div class="weight-cell">45.95%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:46%"></div></div></div></td>
<td class="neutral">9%</td><td class="up">23.01%</td><td>4.17%</td><td class="up">10.57%</td>
</tr>
<tr data-ticker="DFDS.CO">
<td><span class="ticker">DFDS</span><span class="currency-badge">DKK</span></td>
<td class="price-cell"><span class="price-val" id="price-DFDS.CO">98.80</span></td><td>89.82</td><td>53,892,216</td><td>5,325</td>
<td><div class="weight-cell">10.43%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:10%"></div></div></div></td>
<td class="neutral">8%</td><td class="up">10.00%</td><td>0.83%</td><td class="up">1.04%</td>
</tr>
<tr data-ticker="ORSTED.CO">
<td><span class="ticker">ORSTED</span><span class="currency-badge">DKK</span></td>
<td class="price-cell"><span class="price-val" id="price-ORSTED.CO">152.00</span></td><td>128.80</td><td>756,088,069</td><td>114,925</td>
<td><div class="weight-cell">9.17%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:9%"></div></div></div></td>
<td class="neutral">3.25%</td><td class="up">18.01%</td><td>0.30%</td><td class="up">1.65%</td>
</tr>
<tr data-ticker="CSIQ">
<td><span class="ticker">CSIQ</span><span class="currency-badge">USD</span></td>
<td class="price-cell"><span class="price-val" id="price-CSIQ">20.74</span></td><td>20.53</td><td>67,620,463</td><td>1,402</td>
<td><div class="weight-cell">3.09%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:3%"></div></div></div></td>
<td class="neutral">4.95%</td><td class="up">1.02%</td><td>0.15%</td><td class="up">0.03%</td>
</tr>
<tr data-ticker="NOVO-B.CO">
<td><span class="ticker">NOVO NORDISK</span><span class="currency-badge">DKK</span></td>
<td class="price-cell"><span class="price-val" id="price-NOVO-B.CO">251.00</span></td><td>305.00</td><td>4,442,064,180</td><td>1,114,958</td>
<td><div class="weight-cell">31.46%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:31%"></div></div></div></td>
<td class="neutral">8%</td><td class="down">17.70%</td><td>2.38%</td><td class="down">5.57%</td>
</tr>
</tbody>
<tbody>
<tr><td colspan="10" class="watchlist-label">// watchlist — not invested</td></tr>
<tr class="watchlist-row">
<td><span class="ticker" style="color:var(--muted)">COST</span><span class="currency-badge">USD</span></td>
<td>978.00</td><td></td><td></td><td></td>
<td><div class="weight-cell"></div></td>
<td></td><td></td><td></td><td></td>
</tr>
<tbody id="positionsBody">
<tr class="skeleton-row"><td><div class="skeleton-cell" style="width:55%"></div></td><td><div class="skeleton-cell" style="width:40%"></div></td><td><div class="skeleton-cell" style="width:40%"></div></td><td><div class="skeleton-cell" style="width:60%"></div></td><td><div class="skeleton-cell" style="width:50%"></div></td><td><div class="skeleton-cell" style="width:50%"></div></td></tr>
<tr class="skeleton-row"><td><div class="skeleton-cell" style="width:65%"></div></td><td><div class="skeleton-cell" style="width:35%"></div></td><td><div class="skeleton-cell" style="width:45%"></div></td><td><div class="skeleton-cell" style="width:55%"></div></td><td><div class="skeleton-cell" style="width:48%"></div></td><td><div class="skeleton-cell" style="width:52%"></div></td></tr>
<tr class="skeleton-row"><td><div class="skeleton-cell" style="width:60%"></div></td><td><div class="skeleton-cell" style="width:42%"></div></td><td><div class="skeleton-cell" style="width:38%"></div></td><td><div class="skeleton-cell" style="width:58%"></div></td><td><div class="skeleton-cell" style="width:44%"></div></td><td><div class="skeleton-cell" style="width:56%"></div></td></tr>
</tbody>
</table>
</div>
<!-- ── Trades accordion ── -->
<!-- Trades accordion -->
<div class="trades-section">
<button class="trades-toggle" aria-expanded="false" onclick="toggleTrades(this)">
<span class="toggle-label">// <span>trade history</span> — all executed orders</span>
@@ -309,11 +87,9 @@
</button>
<div class="trades-body" id="tradesBody">
<div class="trades-inner">
<!-- Filter + Add row -->
<div>
<div class="trades-header-row">
<div class="trades-filter" style="margin:0;border:none;padding:0">
<div class="trades-filter">
<button class="filter-btn active" onclick="filterTrades('all', this)">All</button>
<button class="filter-btn" onclick="filterTrades('stock', this)">Stocks</button>
<button class="filter-btn" onclick="filterTrades('option', this)">Options</button>
@@ -323,13 +99,13 @@
<button class="add-trade-btn" onclick="openAddTrade()">+ add trade</button>
</div>
<p class="trades-count" id="tradesCount">loading…</p>
<p class="trades-count" id="tradesCount"></p>
<div class="trades-table-wrap">
<table class="trades-table">
<thead>
<tr>
<th>Ticker</th>
<th>Symbol</th>
<th>Asset</th>
<th>Ccy</th>
<th>Date</th>
@@ -339,16 +115,7 @@
</tr>
</thead>
<tbody id="tradesBody_rows">
<!-- skeleton -->
<tr class="skeleton-row">
<td><div class="skeleton-cell" style="width:60%"></div></td>
<td><div class="skeleton-cell" style="width:50%"></div></td>
<td><div class="skeleton-cell" style="width:40%"></div></td>
<td><div class="skeleton-cell" style="width:80%"></div></td>
<td><div class="skeleton-cell" style="width:35%"></div></td>
<td><div class="skeleton-cell" style="width:30%"></div></td>
<td><div class="skeleton-cell" style="width:55%"></div></td>
</tr>
<tr><td colspan="7" class="trades-empty">open to load trades</td></tr>
</tbody>
</table>
</div>
@@ -357,22 +124,23 @@
</div>
<footer>
<a href="mailto:me@samantha42.xyz">me@samantha42.xyz</a>
<a href="mailto:samantha@samantha42.xyz">samantha@samantha42.xyz</a>
<p class="footer-copy">&copy; 2026 — All rights reserved</p>
</footer>
</div>
<!-- ── Add Trade Modal ── -->
<!-- Add Trade Modal -->
<div class="modal-overlay" id="addTradeModal" onclick="handleOverlayClick(event)">
<div class="modal">
<p class="modal-title">// <span>add trade</span> — new executed order</p>
<button class="modal-close" onclick="closeAddTrade()"></button>
<div class="form-grid">
<div class="form-field">
<label>TICKER ID</label>
<input type="number" id="f-tickerId" min="1" placeholder="1" />
<!-- API uses "symbol" + "currency_code", not tickerId -->
<div class="form-field full">
<label>SYMBOL</label>
<input type="text" id="f-symbol" placeholder="NOVO-B.CO" style="text-transform:uppercase"/>
</div>
<div class="form-field">
<label>SHARES</label>
@@ -383,7 +151,7 @@
<input type="number" id="f-price" step="0.01" placeholder="251.00" />
</div>
<div class="form-field">
<label>CURRENCY</label>
<label>CURRENCY CODE</label>
<input type="text" id="f-currency" placeholder="DKK" maxlength="8" style="text-transform:uppercase"/>
</div>
<div class="form-field">
@@ -393,6 +161,7 @@
<option value="1">1 — Option Call</option>
<option value="2">2 — Option Put</option>
<option value="3">3 — Currency</option>
<option value="4">4 — Bond</option>
</select>
</div>
<div class="form-field">
@@ -417,32 +186,173 @@
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
<script>
// ─────────────────────────────────────────
// Product + Type label helpers
// ─────────────────────────────────────────
const PRODUCT_LABELS = ['Stock', 'Opt Call', 'Opt Put', 'Currency'];
const API = 'http://127.0.0.1:8080';
// ── Helpers ──────────────────────────────
const PRODUCT_LABELS = ['Stock', 'Opt Call', 'Opt Put', 'Currency', 'Bond'];
function productLabel(n) { return PRODUCT_LABELS[n] ?? '—'; }
function typeLabel(v) { return v ? 'SELL' : 'BUY'; }
// ─────────────────────────────────────────
// Trade list — GET /trade/list
// ─────────────────────────────────────────
// ── GET /health ──────────────────────────
async function checkHealth() {
const dot = document.getElementById('healthDot');
const text = document.getElementById('healthText');
try {
const res = await fetch(`${API}/health`);
const data = await res.json();
if (data.status === 'ok') {
dot.className = 'health-dot ok';
text.textContent = `api ok — uptime ${data.uptime ?? ''}`;
} else {
dot.className = 'health-dot error';
text.textContent = 'api degraded';
}
} catch {
dot.className = 'health-dot error';
text.textContent = 'api unreachable';
}
}
// ── GET /positions/list ──────────────────
let positions = [];
async function loadPositions() {
try {
const res = await fetch(`${API}/positions/list`);
if (!res.ok) throw new Error('status ' + res.status);
const data = await res.json();
positions = Array.isArray(data) ? data : (data ? [data] : []);
renderPositions();
updateSummaryCards();
} catch (err) {
console.warn('positions/list failed:', err);
document.getElementById('positionsBody').innerHTML =
`<tr><td colspan="6" class="trades-empty">could not load positions — ${err.message}</td></tr>`;
}
}
function renderPositions() {
const tbody = document.getElementById('positionsBody');
if (!positions.length) {
tbody.innerHTML = `<tr><td colspan="6" class="trades-empty">no positions found</td></tr>`;
return;
}
tbody.innerHTML = positions.map(p => {
const sym = p.Symbol ?? '—';
const ccy = p.CurrencyCode ?? '—';
const sh = p.Shares ?? '—';
const cb = typeof p.CostBasis === 'number'
? p.CostBasis.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '—';
const wt = typeof p.Weight === 'number'
? (p.Weight * 100).toFixed(2) + '%'
: '—';
const pct = typeof p.Weight === 'number' ? Math.min(p.Weight * 100, 100) : 0;
return `<tr data-symbol="${sym}">
<td><span class="ticker">${sym}</span><span class="currency-badge">${ccy}</span></td>
<td>${ccy}</td>
<td>${sh}</td>
<td>${cb}</td>
<td>
<div class="weight-cell">${wt}
<div class="weight-bar-track"><div class="weight-bar-fill" style="width:${pct}%"></div></div>
</div>
</td>
<td><span class="price-val" id="price-${sym}">—</span></td>
</tr>`;
}).join('');
}
function updateSummaryCards() {
const totalWeight = positions.reduce((s, p) => s + (p.Weight ?? 0), 0);
const totalCB = positions.reduce((s, p) => s + (p.CostBasis ?? 0), 0);
const totalShares = positions.reduce((s, p) => s + (p.Shares ?? 0), 0);
document.getElementById('sumWeight').textContent =
(totalWeight * 100).toFixed(2) + '%';
document.getElementById('sumPositions').textContent = positions.length;
document.getElementById('sumCostBasis').textContent =
totalCB.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
document.getElementById('sumShares').textContent = totalShares;
}
// ── Price fetching ────────────────────────
function setStatus(state, text) {
document.getElementById('priceDot').className = 'price-dot ' + state;
document.getElementById('priceStatusText').textContent = text;
}
function flashPrice(sym, newVal, oldVal) {
const el = document.getElementById('price-' + sym);
if (!el) return;
const cls = newVal > oldVal ? 'flash-up' : newVal < oldVal ? 'flash-down' : '';
el.textContent = newVal.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
if (cls) { el.classList.add(cls); setTimeout(() => el.classList.remove(cls), 1200); }
}
async function fetchHoldingPrices() {
if (!positions.length) return;
setStatus('loading', 'fetching prices…');
const prev = {};
positions.forEach(p => {
const el = document.getElementById('price-' + p.Symbol);
if (el) prev[p.Symbol] = parseFloat(el.textContent.replace(/\./g,'').replace(',','.')) || 0;
});
// Try backend proxy
try {
const res = await fetch('/api/prices', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tickers: positions.map(p => p.Symbol) }),
});
if (!res.ok) throw new Error('backend ' + res.status);
const data = await res.json();
const map = {};
data.forEach(d => { map[d.ticker] = d.close; });
positions.forEach(p => { if (map[p.Symbol] != null) flashPrice(p.Symbol, map[p.Symbol], prev[p.Symbol] || map[p.Symbol]); });
setStatus('live', 'updated ' + new Date().toLocaleTimeString('en-GB', { hour:'2-digit', minute:'2-digit' }));
return;
} catch { /* fallback */ }
// Fallback: direct Yahoo Finance
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
while (yesterday.getDay() === 0 || yesterday.getDay() === 6) yesterday.setDate(yesterday.getDate() - 1);
const start = Math.floor(yesterday.setHours(0,0,0,0) / 1000);
const end = start + 86400;
let ok = 0, fail = 0;
await Promise.allSettled(positions.map(async p => {
try {
const url = `https://corsproxy.io/?${encodeURIComponent(
`https://query1.finance.yahoo.com/v8/finance/chart/${p.Symbol}?period1=${start}&period2=${end}&interval=1d`
)}`;
const json = await (await fetch(url)).json();
const close = json?.chart?.result?.[0]?.indicators?.quote?.[0]?.close?.[0];
if (close != null) { flashPrice(p.Symbol, close, prev[p.Symbol] || close); ok++; } else fail++;
} catch { fail++; }
}));
const now = new Date().toLocaleTimeString('en-GB', { hour:'2-digit', minute:'2-digit' });
if (fail === 0) setStatus('live', 'updated ' + now + ' (direct)');
else if (ok > 0) setStatus('live', `${ok} ok, ${fail} failed — ${now}`);
else setStatus('error', 'price fetch failed');
}
// ── GET /trade/list ───────────────────────
let allTrades = [];
let activeFilter = 'all';
async function loadTrades() {
document.getElementById('tradesCount').textContent = 'loading…';
document.getElementById('tradesBody_rows').innerHTML =
`<tr><td colspan="7" class="trades-empty">loading…</td></tr>`;
try {
const res = 'http://localhost:8080/trade/list';
const res = await fetch(`${API}/trade/list`);
if (!res.ok) throw new Error('status ' + res.status);
const data = await res.json();
allTrades = Array.isArray(data) ? data : [];
} catch (err) {
console.warn('Could not load trades:', err);
console.warn('trade/list failed:', err);
allTrades = [];
}
renderTrades();
@@ -451,7 +361,6 @@
function renderTrades() {
const tbody = document.getElementById('tradesBody_rows');
const countEl = document.getElementById('tradesCount');
const filtered = allTrades.filter(t => {
if (activeFilter === 'all') return true;
if (activeFilter === 'stock') return t.Product === 0;
@@ -460,34 +369,28 @@
if (activeFilter === 'sell') return t.Type === true;
return true;
});
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" class="trades-empty">${allTrades.length === 0 ? 'no trades found' : 'no trades match filter'}</td></tr>`;
if (!filtered.length) {
tbody.innerHTML = `<tr><td colspan="7" class="trades-empty">${
allTrades.length === 0 ? 'no trades found' : 'no trades match filter'
}</td></tr>`;
countEl.textContent = '0 trades';
return;
}
countEl.textContent = filtered.length + ' trade' + (filtered.length !== 1 ? 's' : '')
+ (activeFilter !== 'all' ? ' — ' + activeFilter : '');
tbody.innerHTML = filtered.map(t => {
const ticker = t.Ticker?.Name ?? '—';
const product = productLabel(t.Product);
const ccy = t.Currency?.Code ?? '—';
const sym = t.Symbol ?? '—';
const ccy = t.CurrencyCode ?? '—';
const date = t.Date ? new Date(t.Date).toLocaleDateString('sv-SE') : '—';
const dir = t.Type;
const dirCls = dir ? 'dir-sell' : 'dir-buy';
const dirLbl = dir ? 'SELL' : 'BUY';
const price = typeof t.Price === 'number'
? t.Price.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '—';
return `<tr>
<td><span class="trade-ticker">${ticker}</span></td>
<td>${product}</td>
<td><span class="trade-ticker">${sym}</span></td>
<td>${productLabel(t.Product)}</td>
<td>${ccy}</td>
<td>${date}</td>
<td class="${dirCls}">${dirLbl}</td>
<td class="${t.Type ? 'dir-sell' : 'dir-buy'}">${t.Type ? 'SELL' : 'BUY'}</td>
<td>${t.Shares ?? '—'}</td>
<td>${price}</td>
</tr>`;
@@ -501,32 +404,31 @@
renderTrades();
}
// ─────────────────────────────────────────
// Add Trade Modal
// ─────────────────────────────────────────
function toggleTrades(btn) {
const body = document.getElementById('tradesBody');
const expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!expanded));
body.classList.toggle('open', !expanded);
if (!expanded && allTrades.length === 0) loadTrades();
}
// ── POST /trade/add ───────────────────────
function openAddTrade() {
// Default date to now
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
document.getElementById('f-date').value = now.toISOString().slice(0, 16);
document.getElementById('formError').textContent = '';
document.getElementById('addTradeModal').classList.add('open');
}
function closeAddTrade() {
document.getElementById('addTradeModal').classList.remove('open');
}
function handleOverlayClick(e) {
if (e.target === document.getElementById('addTradeModal')) closeAddTrade();
}
function closeAddTrade() { document.getElementById('addTradeModal').classList.remove('open'); }
function handleOverlayClick(e) { if (e.target === document.getElementById('addTradeModal')) closeAddTrade(); }
async function submitTrade() {
const errEl = document.getElementById('formError');
const btn = document.getElementById('submitBtn');
errEl.textContent = '';
const tickerId = parseInt(document.getElementById('f-tickerId').value);
const symbol = document.getElementById('f-symbol').value.trim().toUpperCase();
const shares = parseInt(document.getElementById('f-shares').value);
const price = parseFloat(document.getElementById('f-price').value);
const currency = document.getElementById('f-currency').value.trim().toUpperCase();
@@ -534,45 +436,40 @@
const type = document.getElementById('f-type').value === 'true';
const dateVal = document.getElementById('f-date').value;
// Client-side validation
if (!tickerId || tickerId < 1) return errEl.textContent = 'ticker id must be a positive integer';
if (!shares || shares < 1) return errEl.textContent = 'shares must be a positive integer';
if (!price || price <= 0) return errEl.textContent = 'price must be a positive number';
if (!currency) return errEl.textContent = 'currency is required';
if (!dateVal) return errEl.textContent = 'trade date is required';
if (!symbol) return (errEl.textContent = 'symbol is required');
if (!shares || shares < 1) return (errEl.textContent = 'shares must be a positive integer');
if (!price || price <= 0) return (errEl.textContent = 'price must be a positive number');
if (!currency) return (errEl.textContent = 'currency code is required');
if (!dateVal) return (errEl.textContent = 'trade date is required');
const date = new Date(dateVal);
if (date > new Date()) return errEl.textContent = 'date cannot be in the future';
if (date > new Date()) return (errEl.textContent = 'date cannot be in the future');
// Payload matches POST /trade/add spec from wiki
const payload = {
TickerId: tickerId,
Shares: shares,
Product: product,
Type: type,
Price: price,
Currency: currency,
Date: date.toISOString(),
symbol,
currency_code: currency,
shares,
product,
type,
price,
date: date.toISOString(),
};
btn.disabled = true;
btn.textContent = 'submitting…';
try {
const res = await fetch('http://localhost:8080/trade/add', {
const res = await fetch(`${API}/trade/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const msg = await res.text().catch(() => 'unknown error');
throw new Error(msg || 'server returned ' + res.status);
}
closeAddTrade();
showToast('trade added successfully', 'success');
await loadTrades(); // refresh list
await loadTrades();
} catch (err) {
errEl.textContent = err.message || 'failed to submit trade';
} finally {
@@ -581,117 +478,20 @@
}
}
// ─────────────────────────────────────────
// Toast
// ─────────────────────────────────────────
// ── Toast ─────────────────────────────────
function showToast(msg, type = '') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast ' + type + ' show';
clearTimeout(el._t);
el._t = setTimeout(() => { el.classList.remove('show'); }, 3000);
el._t = setTimeout(() => el.classList.remove('show'), 3000);
}
// ─────────────────────────────────────────
// Trades accordion
// ─────────────────────────────────────────
function toggleTrades(btn) {
const body = document.getElementById('tradesBody');
const expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!expanded));
body.classList.toggle('open', !expanded);
// Load on first open
if (!expanded && allTrades.length === 0) loadTrades();
}
// ─────────────────────────────────────────
// Holdings price fetch (unchanged)
// ─────────────────────────────────────────
const HOLDINGS = [
{ ticker: 'MAERSK-B.CO', label: 'MEARSK' },
{ ticker: 'DFDS.CO', label: 'DFDS' },
{ ticker: 'ORSTED.CO', label: 'ORSTED' },
{ ticker: 'CSIQ', label: 'CSIQ' },
{ ticker: 'NOVO-B.CO', label: 'NOVO NORDISK' },
];
function setStatus(state, text) {
document.getElementById('priceDot').className = 'price-dot ' + state;
document.getElementById('priceStatusText').textContent = text;
}
function flashPrice(id, newVal, oldVal) {
const el = document.getElementById('price-' + id);
if (!el) return;
const direction = newVal > oldVal ? 'flash-up' : newVal < oldVal ? 'flash-down' : '';
el.textContent = newVal.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
if (direction) {
el.classList.add(direction);
setTimeout(() => el.classList.remove(direction), 1200);
}
}
async function fetchHoldingPrices() {
setStatus('loading', 'fetching prices…');
const prev = {};
HOLDINGS.forEach(h => {
const el = document.getElementById('price-' + h.ticker);
if (el) prev[h.ticker] = parseFloat(el.textContent.replace(/\./g, '').replace(',', '.')) || 0;
});
try {
const res = await fetch('/api/prices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tickers: HOLDINGS.map(h => h.ticker) }),
});
if (!res.ok) throw new Error('backend returned ' + res.status);
const data = await res.json();
const map = {};
data.forEach(d => { map[d.ticker] = d.close; });
HOLDINGS.forEach(h => {
const close = map[h.ticker];
if (close != null) flashPrice(h.ticker, close, prev[h.ticker] || close);
});
const now = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
setStatus('live', 'updated ' + now);
} catch (err) {
console.warn('Backend unavailable, trying Yahoo Finance directly…', err);
await fetchDirectYahoo(prev);
}
}
async function fetchDirectYahoo(prev) {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
while (yesterday.getDay() === 0 || yesterday.getDay() === 6) {
yesterday.setDate(yesterday.getDate() - 1);
}
const start = Math.floor(yesterday.setHours(0,0,0,0) / 1000);
const end = start + 86400;
let ok = 0, fail = 0;
await Promise.allSettled(HOLDINGS.map(async h => {
try {
const url = `https://corsproxy.io/?${encodeURIComponent(
`https://query1.finance.yahoo.com/v8/finance/chart/${h.ticker}?period1=${start}&period2=${end}&interval=1d`
)}`;
const res = await fetch(url);
const json = await res.json();
const close = json?.chart?.result?.[0]?.indicators?.quote?.[0]?.close?.[0];
if (close) { flashPrice(h.ticker, close, prev[h.ticker] || close); ok++; }
else fail++;
} catch { fail++; }
}));
const now = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
if (fail === 0) setStatus('live', 'updated ' + now + ' (direct)');
else if (ok > 0) setStatus('live', `${ok} ok, ${fail} failed — ${now}`);
else setStatus('error', 'price fetch failed');
}
document.addEventListener('DOMContentLoaded', () => {
// ── Init ──────────────────────────────────
(async () => {
await Promise.all([checkHealth(), loadPositions()]);
fetchHoldingPrices();
// Eagerly load trades so accordion is instant on first open
loadTrades();
});
})();
</script>
</body>
</html>
+435 -176
View File
@@ -1151,168 +1151,50 @@ nav .section-label {
grid-template-columns: 1fr;
}
}
/* ── Trades accordion ── */
.trades-section {
margin-top: 2.5rem;
/* ─── Portfolio page ─── */
/* Health / API status badge */
.health-badge {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-family: var(--ff-mono);
font-size: 0.68rem;
color: var(--muted);
letter-spacing: 0.05em;
margin-top: 0.8rem;
cursor: default;
}
.health-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--dim);
flex-shrink: 0;
transition: background 0.3s;
}
.health-dot.ok {
background: var(--green);
box-shadow: 0 0 6px rgba(10, 124, 82, 0.5);
}
.health-dot.error {
background: var(--red);
}
.health-dot.loading {
background: var(--yellow);
animation: pulse 1s ease-in-out infinite;
}
.trades-toggle {
/* Section header row (above table) */
.section-header-row {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
background: none;
border: none;
border-top: 1px solid var(--border, #2a2a3a);
border-bottom: 1px solid var(--border, #2a2a3a);
width: 100%;
padding: 0.85rem 0;
color: inherit;
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
letter-spacing: 0.07em;
text-align: left;
user-select: none;
}
.trades-toggle:hover .toggle-label {
color: var(--accent, #f59e0b);
}
.toggle-label {
color: var(--muted, #475569);
transition: color 0.15s;
}
.toggle-label span {
color: var(--accent, #f59e0b);
}
.toggle-icon {
color: var(--muted, #475569);
font-size: 1rem;
transition: transform 0.25s ease;
line-height: 1;
}
.trades-toggle[aria-expanded="true"] .toggle-icon {
transform: rotate(180deg);
}
.trades-body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease;
}
.trades-body.open {
grid-template-rows: 1fr;
}
.trades-inner {
overflow: hidden;
}
.trades-filter {
display: flex;
gap: 0.5rem;
gap: 0.75rem;
margin-bottom: 0.6rem;
flex-wrap: wrap;
padding: 1rem 0 0.75rem;
}
.filter-btn {
background: none;
border: 1px solid var(--border, #2a2a3a);
border-radius: 4px;
color: var(--muted, #475569);
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
letter-spacing: 0.06em;
padding: 3px 10px;
cursor: pointer;
transition: all 0.15s;
}
.filter-btn:hover,
.filter-btn.active {
border-color: var(--accent, #f59e0b);
color: var(--accent, #f59e0b);
background: rgba(245,158,11,0.06);
}
.trades-table-wrap {
overflow-x: auto;
padding-bottom: 1.5rem;
}
.trades-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.trades-table thead tr {
border-bottom: 1px solid var(--border, #2a2a3a);
}
.trades-table th {
color: var(--muted, #475569);
font-weight: 400;
letter-spacing: 0.07em;
font-size: 0.68rem;
padding: 0.5rem 0.75rem;
text-align: left;
white-space: nowrap;
}
.trades-table td {
padding: 0.65rem 0.75rem;
border-bottom: 1px solid rgba(42,42,58,0.5);
white-space: nowrap;
color: var(--text, #e2e8f0);
}
.trades-table tbody tr:last-child td {
border-bottom: none;
}
.trades-table tbody tr:hover td {
background: rgba(255,255,255,0.02);
}
.trades-table tr.hidden-row {
display: none;
}
.dir-buy { color: #4ade80; }
.dir-sell { color: #f87171; }
.pnl-pos { color: #4ade80; }
.pnl-neg { color: #f87171; }
.pnl-zero{ color: var(--muted, #475569); }
.trade-code {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 0.65rem;
background: rgba(42,42,58,0.8);
color: var(--muted, #475569);
letter-spacing: 0.05em;
}
.trade-ticker {
font-weight: 500;
color: #f1f5f9;
}
.trades-count {
color: var(--muted, #475569);
font-size: 0.68rem;
padding: 0 0 0.5rem;
}
/* ── Live price styles ── */
/* Live price status indicator */
.price-cell {
position: relative;
}
@@ -1320,59 +1202,436 @@ nav .section-label {
transition: color 0.4s;
}
.price-val.loading {
color: var(--muted, #475569);
color: var(--dim);
}
.price-val.flash-up {
color: #4ade80;
color: var(--green);
}
.price-val.flash-down {
color: #f87171;
color: var(--red);
}
.price-status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: var(--ff-mono);
font-size: 0.68rem;
color: var(--muted, #475569);
margin-left: 0.5rem;
color: var(--muted);
margin-left: auto;
vertical-align: middle;
}
.price-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--muted, #475569);
background: var(--dim);
display: inline-block;
flex-shrink: 0;
}
.price-dot.live {
background: var(--green);
box-shadow: 0 0 4px rgba(10, 124, 82, 0.6);
}
.price-dot.error {
background: var(--red);
}
.price-dot.live { background: #4ade80; box-shadow: 0 0 4px #4ade80; }
.price-dot.error { background: #f87171; }
.price-dot.loading {
background: #f59e0b;
background: var(--yellow);
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.refresh-btn {
background: none;
border: 1px solid var(--border, #2a2a3a);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--muted, #475569);
font-family: 'DM Mono', monospace;
color: var(--muted);
font-family: var(--ff-mono);
font-size: 0.68rem;
padding: 2px 8px;
cursor: pointer;
letter-spacing: 0.05em;
transition: all 0.15s;
transition: border-color 0.15s, color 0.15s;
}
.refresh-btn:hover {
border-color: var(--accent, #f59e0b);
color: var(--accent, #f59e0b);
border-color: var(--accent2);
color: var(--accent);
}
.section-header-row {
/* Trades accordion */
.trades-section {
margin-top: 2.5rem;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.trades-toggle {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
background: var(--surface2);
border: none;
border-bottom: 1px solid var(--border);
width: 100%;
padding: 0.85rem 1rem;
color: inherit;
font-family: var(--ff-mono);
font-size: 0.75rem;
letter-spacing: 0.07em;
text-align: left;
user-select: none;
transition: background 0.15s;
}
.trades-toggle:hover {
background: var(--surface);
}
.trades-toggle:hover .toggle-label {
color: var(--accent);
}
.toggle-label {
color: var(--muted);
transition: color 0.15s;
}
.toggle-label span {
color: var(--accent);
}
.toggle-icon {
color: var(--dim);
font-size: 1rem;
transition: transform 0.25s ease;
line-height: 1;
}
.trades-toggle[aria-expanded="true"] .toggle-icon {
transform: rotate(180deg);
}
.trades-body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease;
}
.trades-body.open {
grid-template-rows: 1fr;
}
.trades-inner {
overflow: hidden;
}
/* Trades header row (filters + add button) */
.trades-header-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.6rem;
padding: 0.9rem 1rem 0.6rem;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.trades-filter {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-btn {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--muted);
font-family: var(--ff-mono);
font-size: 0.68rem;
letter-spacing: 0.06em;
padding: 3px 10px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.filter-btn:hover,
.filter-btn.active {
border-color: var(--accent2);
color: var(--accent);
background: rgba(26, 86, 219, 0.05);
}
.add-trade-btn {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--muted);
font-family: var(--ff-mono);
font-size: 0.68rem;
letter-spacing: 0.05em;
padding: 0.3rem 0.8rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
margin-left: auto;
}
.add-trade-btn:hover {
border-color: var(--accent2);
color: var(--accent);
}
/* Trades table */
.trades-count {
font-family: var(--ff-mono);
color: var(--muted);
font-size: 0.68rem;
padding: 0.5rem 1rem 0.3rem;
letter-spacing: 0.04em;
}
.trades-table-wrap {
overflow-x: auto;
padding-bottom: 1.5rem;
}
.trades-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
font-family: var(--ff-mono);
}
.trades-table thead tr {
border-bottom: 1px solid var(--border);
background: var(--surface2);
}
.trades-table th {
color: var(--dim);
font-weight: 400;
letter-spacing: 0.07em;
font-size: 0.62rem;
text-transform: uppercase;
padding: 0.5rem 0.75rem;
text-align: left;
white-space: nowrap;
}
.trades-table td {
padding: 0.65rem 0.75rem;
border-bottom: 1px solid var(--border);
white-space: nowrap;
color: var(--text);
}
.trades-table tbody tr {
background: var(--surface);
transition: background 0.15s;
}
.trades-table tbody tr:last-child td {
border-bottom: none;
}
.trades-table tbody tr:hover {
background: var(--surface2);
}
.trades-table tr.hidden-row {
display: none;
}
.trades-empty {
font-family: var(--ff-mono);
font-size: 0.75rem;
color: var(--dim);
padding: 1.4rem 0;
text-align: center;
}
/* Trade cell types */
.dir-buy { color: var(--green); }
.dir-sell { color: var(--red); }
.pnl-pos { color: var(--green); }
.pnl-neg { color: var(--red); }
.pnl-zero { color: var(--muted); }
.trade-ticker {
font-weight: 500;
color: var(--text);
}
.trade-code {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 0.65rem;
border: 1px solid var(--border);
color: var(--muted);
letter-spacing: 0.05em;
}
/* Add Trade modal */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(3px);
z-index: 100;
align-items: center;
justify-content: center;
}
.modal-overlay.open {
display: flex;
}
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
width: min(520px, 94vw);
padding: 2rem;
position: relative;
animation: modalIn 0.2s ease;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.12);
}
@keyframes modalIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.modal-title {
font-family: var(--ff-mono);
font-size: 0.72rem;
color: var(--muted);
letter-spacing: 0.08em;
margin-bottom: 1.4rem;
}
.modal-title span {
color: var(--text);
}
.modal-close {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
color: var(--dim);
font-size: 1.1rem;
cursor: pointer;
padding: 0.2rem 0.4rem;
transition: color 0.15s;
line-height: 1;
}
.modal-close:hover {
color: var(--text);
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.8rem 1.2rem;
}
.form-grid .full {
grid-column: 1 / -1;
}
.form-field label {
display: block;
font-family: var(--ff-mono);
font-size: 0.62rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 0.3rem;
}
.form-field input,
.form-field select {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: var(--ff-mono);
font-size: 0.82rem;
padding: 0.5rem 0.7rem;
outline: none;
border-radius: 4px;
transition: border-color 0.15s, box-shadow 0.15s;
-webkit-appearance: none;
appearance: none;
}
.form-field input:focus,
.form-field select:focus {
border-color: var(--accent2);
box-shadow: 0 0 0 3px rgba(26, 71, 184, 0.08);
}
.form-field select option {
background: var(--surface);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.7rem;
margin-top: 1.4rem;
}
.btn-cancel {
background: none;
border: 1px solid var(--border);
color: var(--muted);
font-family: var(--ff-mono);
font-size: 0.78rem;
padding: 0.5rem 1.1rem;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.btn-cancel:hover {
border-color: var(--border-focus);
color: var(--text);
}
.btn-submit {
background: var(--accent2);
border: none;
color: #ffffff;
font-family: var(--ff-mono);
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.06em;
padding: 0.5rem 1.3rem;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.btn-submit:hover {
background: var(--accent);
}
.btn-submit:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.form-error {
font-family: var(--ff-mono);
font-size: 0.72rem;
color: var(--red);
margin-top: 0.9rem;
min-height: 1rem;
}
/* Toast notification */
.toast {
position: fixed;
bottom: 1.8rem;
right: 1.8rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text);
font-family: var(--ff-mono);
font-size: 0.78rem;
padding: 0.7rem 1.1rem;
z-index: 200;
opacity: 0;
transform: translateY(8px);
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast.success {
border-color: rgba(10, 124, 82, 0.4);
color: var(--green);
}
.toast.error {
border-color: rgba(185, 28, 28, 0.4);
color: var(--red);
}
/* Loading skeleton rows */
.skeleton-row td {
padding: 0.75rem 1rem;
}
.skeleton-cell {
height: 0.65rem;
background: linear-gradient(90deg, var(--surface2) 25%, var(--surface) 50%, var(--surface2) 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
border-radius: 2px;
width: 70%;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}