moving handlers into service. adding website
This commit is contained in:
137
internal/website/static/index.html
Normal file
137
internal/website/static/index.html
Normal file
@@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Portfolio — Samantha Friis</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Fraunces:ital,wght@0,300;0,400;0,600;1,300;1,400&display=swap" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="/style">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<header>
|
||||
<a href="/" class="back">← back</a>
|
||||
<p class="tag">// portfolio</p>
|
||||
<h1>Investment<br/><span>Portfolio</span></h1>
|
||||
<p class="header-sub">Equity positions · Personal research</p>
|
||||
</header>
|
||||
|
||||
<!-- Health bar: polls every 60s, shows error on failure -->
|
||||
<div class="status-bar"
|
||||
hx-get="/health/fragment"
|
||||
hx-trigger="load, every 60s"
|
||||
hx-target="this"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::response-error="this.innerHTML = '<span class=\'status-dot error\'></span><span>backend unreachable</span><span class=\'status-spacer\'></span><button class=\'refresh-btn\' hx-get=\'/health/fragment\' hx-target=\'closest .status-bar\' hx-swap=\'innerHTML\' onclick=\'htmx.process(this)\'>↺ retry</button>'"
|
||||
hx-on::send-error="this.innerHTML = '<span class=\'status-dot error\'></span><span>failed to connect</span><span class=\'status-spacer\'></span><button class=\'refresh-btn\' hx-get=\'/health/fragment\' hx-target=\'closest .status-bar\' hx-swap=\'innerHTML\' onclick=\'htmx.process(this)\'>↺ retry</button>'">
|
||||
<span class="status-dot loading"></span>
|
||||
<span>connecting to backend…</span>
|
||||
<span class="status-spacer"></span>
|
||||
<button class="refresh-btn"
|
||||
hx-get="/health/fragment"
|
||||
hx-target="closest .status-bar"
|
||||
hx-swap="innerHTML">↺ refresh</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<div class="cards"
|
||||
hx-get="/summary/fragment"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML">
|
||||
<div class="card"><p class="card-label">Positions</p><p class="card-value dim">…</p></div>
|
||||
<div class="card"><p class="card-label">Total Shares</p><p class="card-value dim">…</p></div>
|
||||
<div class="card"><p class="card-label">Total Trades</p><p class="card-value dim">…</p></div>
|
||||
<div class="card"><p class="card-label">Currencies</p><p class="card-value dim">…</p></div>
|
||||
</div>
|
||||
|
||||
<!-- Positions table -->
|
||||
<p class="section-label">// <span>positions</span></p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th><th>Currency</th><th>Shares</th>
|
||||
<th>Cost Basis</th><th>Weight</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody hx-get="/positions/fragment"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<tr class="state-row"><td colspan="5">loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Companies table -->
|
||||
<p class="section-label">// <span>companies</span> — tracked universe</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th><th>Currency</th><th>Price</th>
|
||||
<th>Shares Outstanding</th><th>Market Cap</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody hx-get="/company/fragment"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<tr class="state-row"><td colspan="5">loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Trades accordion: lazy-loads on open -->
|
||||
<div class="trades-section">
|
||||
<button class="trades-toggle"
|
||||
aria-expanded="false"
|
||||
onclick="this.setAttribute('aria-expanded', this.getAttribute('aria-expanded')==='true'?'false':'true'); this.nextElementSibling.classList.toggle('open')">
|
||||
<span class="toggle-label">// <span>trade history</span> — all executed orders</span>
|
||||
<span class="toggle-icon">▾</span>
|
||||
</button>
|
||||
|
||||
<div class="trades-body" id="tradesAccordion">
|
||||
<div class="trades-inner">
|
||||
|
||||
<!-- Filter buttons swap only the tbody -->
|
||||
<div class="trades-filter" id="tradeFilters">
|
||||
<button class="filter-btn active"
|
||||
hx-get="/trade/fragment"
|
||||
hx-target="#tradesBody"
|
||||
hx-swap="innerHTML">All</button>
|
||||
</div>
|
||||
|
||||
<p class="trades-count"
|
||||
hx-get="/trade/count"
|
||||
hx-trigger="load from:#tradesAccordion"
|
||||
hx-swap="innerHTML">—</p>
|
||||
|
||||
<div class="trades-table-wrap">
|
||||
<table class="trades-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symbol</th><th>Asset</th><th>Currency</th>
|
||||
<th>Date</th><th>Dir</th><th>Qty</th><th>Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tradesBody"
|
||||
hx-get="/trade/fragment"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="innerHTML">
|
||||
<tr class="state-row"><td colspan="7">expand to load trades</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<a href="mailto:me@samantha42.xyz">me@samantha42.xyz</a>
|
||||
<p class="footer-copy">© 2026 — All rights reserved</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
138
internal/website/static/styles.css
Normal file
138
internal/website/static/styles.css
Normal file
@@ -0,0 +1,138 @@
|
||||
:root {
|
||||
--bg: #f4f4f4;
|
||||
--surface: #efefef;
|
||||
--surface2:#e0e0e0;
|
||||
--border: #8c8c8c;
|
||||
--border2: #acacac;
|
||||
--text: #000000;
|
||||
--muted: #5a5a68;
|
||||
--muted2: #3d3d4a;
|
||||
--accent: #c8a96e;
|
||||
--up: #5aad85;
|
||||
--down: #c96b6b;
|
||||
--mono: 'Space Mono', monospace;
|
||||
--serif: 'Fraunces', Georgia, serif;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { font-size: 15px; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.container { max-width: 1080px; margin: 0 auto; padding: 3rem 2rem 5rem; }
|
||||
|
||||
/* ── Header ── */
|
||||
header { margin-bottom: 3rem; border-bottom: 1px solid var(--border); padding-bottom: 2rem; }
|
||||
.back { display: inline-block; color: var(--muted); text-decoration: none; font-size: 0.72rem; letter-spacing: 0.05em; margin-bottom: 1.8rem; transition: color 0.15s; }
|
||||
.back:hover { color: var(--accent); }
|
||||
.tag { font-size: 0.68rem; color: var(--accent); letter-spacing: 0.12em; margin-bottom: 0.6rem; }
|
||||
h1 { font-family: var(--serif); font-size: clamp(2.4rem, 5vw, 3.6rem); font-weight: 300; line-height: 1.1; color: var(--text); margin-bottom: 0.75rem; }
|
||||
h1 span { font-style: italic; color: var(--accent); }
|
||||
.header-sub { font-size: 0.7rem; color: var(--muted); letter-spacing: 0.08em; }
|
||||
|
||||
/* ── Status bar ── */
|
||||
.status-bar {
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
font-size: 0.65rem; color: var(--muted);
|
||||
margin-bottom: 2rem; padding: 0.6rem 1rem;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 5px;
|
||||
}
|
||||
.status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--muted2); }
|
||||
.status-dot.ok { background: var(--up); }
|
||||
.status-dot.loading { background: var(--accent); animation: blink 1s ease-in-out infinite; }
|
||||
.status-dot.error { background: var(--down); }
|
||||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.2} }
|
||||
.status-sep { color: var(--muted2); margin: 0 0.25rem; }
|
||||
.status-spacer { flex: 1; }
|
||||
.refresh-btn {
|
||||
background: none; border: 1px solid var(--border2); border-radius: 3px;
|
||||
color: var(--muted); font-family: var(--mono); font-size: 0.62rem;
|
||||
padding: 2px 9px; cursor: pointer; letter-spacing: 0.05em; transition: all 0.15s;
|
||||
}
|
||||
.refresh-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
/* ── Summary cards ── */
|
||||
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; margin-bottom: 2.5rem; }
|
||||
.card { background: var(--surface); padding: 1.1rem 1.25rem; }
|
||||
.card-label { font-size: 0.62rem; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 0.45rem; }
|
||||
.card-value { font-family: var(--serif); font-size: 1.65rem; font-weight: 300; color: var(--text); }
|
||||
.card-value.dim { color: var(--muted2); font-size: 1rem; }
|
||||
|
||||
/* ── Section label ── */
|
||||
.section-label { font-size: 0.62rem; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 0.75rem; }
|
||||
.section-label span { color: var(--accent); }
|
||||
|
||||
/* ── Tables ── */
|
||||
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 6px; margin-bottom: 2.5rem; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.75rem; }
|
||||
thead tr { border-bottom: 1px solid var(--border2); background: var(--surface); }
|
||||
th { color: var(--muted); font-weight: 400; font-size: 0.6rem; letter-spacing: 0.1em; text-transform: uppercase; padding: 0.75rem 1rem; text-align: left; white-space: nowrap; }
|
||||
td { padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); color: var(--text); white-space: nowrap; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr:hover td { background: rgba(200,169,110,0.03); }
|
||||
|
||||
.ticker { font-weight: 700; font-size: 0.72rem; letter-spacing: 0.04em; color: var(--text); margin-right: 6px; }
|
||||
.currency-badge { display: inline-block; font-size: 0.58rem; padding: 1px 5px; background: var(--surface2); border: 1px solid var(--border2); border-radius: 3px; color: var(--muted); letter-spacing: 0.06em; vertical-align: middle; }
|
||||
|
||||
.weight-cell { min-width: 100px; }
|
||||
.weight-bar-track { height: 2px; background: var(--border2); border-radius: 1px; margin-top: 5px; }
|
||||
.weight-bar-fill { height: 2px; background: var(--accent); border-radius: 1px; }
|
||||
|
||||
.state-row td { color: var(--muted2); font-size: 0.7rem; text-align: center; padding: 2rem; border-bottom: none; }
|
||||
|
||||
/* ── Trades accordion ── */
|
||||
.trades-section { margin-top: 0; }
|
||||
.trades-toggle {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
cursor: pointer; background: none; border: none;
|
||||
border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);
|
||||
width: 100%; padding: 0.85rem 0; color: inherit;
|
||||
font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.1em;
|
||||
text-align: left; user-select: none; text-transform: uppercase;
|
||||
}
|
||||
.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(--muted2); font-size: 0.9rem; transition: transform 0.25s ease; }
|
||||
.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.4rem; flex-wrap: wrap; padding: 1rem 0 0.75rem; }
|
||||
.filter-btn {
|
||||
background: none; border: 1px solid var(--border2); border-radius: 3px;
|
||||
color: var(--muted); font-family: var(--mono); font-size: 0.62rem;
|
||||
letter-spacing: 0.08em; padding: 3px 10px; cursor: pointer;
|
||||
transition: all 0.15s; text-transform: uppercase;
|
||||
}
|
||||
.filter-btn:hover, .filter-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(200,169,110,0.06); }
|
||||
|
||||
.trades-count { font-size: 0.62rem; color: var(--muted2); letter-spacing: 0.06em; padding-bottom: 0.5rem; }
|
||||
|
||||
.trades-table-wrap { overflow-x: auto; padding-bottom: 1.5rem; border: 1px solid var(--border); border-radius: 6px; }
|
||||
.trades-table { width: 100%; border-collapse: collapse; font-size: 0.73rem; }
|
||||
.trades-table thead tr { border-bottom: 1px solid var(--border2); background: var(--surface); }
|
||||
.trades-table th { color: var(--muted); font-weight: 400; letter-spacing: 0.1em; font-size: 0.6rem; text-transform: uppercase; padding: 0.6rem 0.9rem; text-align: left; white-space: nowrap; }
|
||||
.trades-table td { padding: 0.6rem 0.9rem; border-bottom: 1px solid var(--border); white-space: nowrap; color: var(--text); }
|
||||
.trades-table tbody tr:last-child td { border-bottom: none; }
|
||||
.trades-table tbody tr:hover td { background: rgba(200,169,110,0.03); }
|
||||
.trades-table tr.hidden-row { display: none; }
|
||||
|
||||
.dir-buy { color: var(--up); }
|
||||
.dir-sell { color: var(--down); }
|
||||
|
||||
.trade-code { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 0.6rem; background: var(--surface2); border: 1px solid var(--border2); color: var(--muted); letter-spacing: 0.05em; }
|
||||
.trade-ticker { font-weight: 700; color: var(--text); letter-spacing: 0.03em; }
|
||||
|
||||
/* ── Footer ── */
|
||||
footer { margin-top: 4rem; padding-top: 1.5rem; border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
|
||||
footer a { color: var(--muted); font-size: 0.68rem; text-decoration: none; letter-spacing: 0.04em; transition: color 0.15s; }
|
||||
footer a:hover { color: var(--accent); }
|
||||
.footer-copy { font-size: 0.65rem; color: var(--muted2); letter-spacing: 0.06em; }
|
||||
Reference in New Issue
Block a user